A common React performance pitfall is including non-serializable values in useEffect
dependency arrays, which can lead to infinite re-renders or broken memoization.
The Problem
function UserProfile() {
const user = {
id: 1,
name: 'John',
// ❌ Contains non-serializable data
update: () => console.log('Updating')
};
useEffect(() => {
fetchUserData(user.id);
}, [user]); // ❌ Bad - object with methods changes every render
return <div>{user.name}</div>;
}
Why this is problematic:
- Objects/functions are recreated each render
- Shallow comparison fails (new reference ≠ old reference)
- Causes unnecessary effect re-runs
- Can lead to infinite loops
Correct Solutions
1. Primitive Dependencies (Recommended)
function UserProfile({ userId }) {
useEffect(() => {
fetchUserData(userId);
}, [userId]); // ✅ Good - primitive value
}
2. Memoize Objects
function UserProfile() {
const user = useMemo(() => ({
id: 1,
name: 'John'
// ❌ Still avoid methods in memoized objects
}), []); // ✅ Empty deps = stable reference
useEffect(() => {
fetchUserData(user.id);
}, [user.id]); // ✅ Even better - use just the ID
}
3. Extract Functions Outside
// ✅ Move function outside component
function updateUser() {
console.log('Updating');
}
function UserProfile() {
const user = { id: 1, name: 'John' };
useEffect(() => {
updateUser();
}, []); // ✅ No dependency needed
}
4. Use Refs for Functions
function UserProfile() {
const updateRef = useRef(() => console.log('Updating'));
useEffect(() => {
updateRef.current();
}, []); // ✅ Ref is stable
}
Common Non-Serializable Values
- Class instances:
const service = new ApiService(); // ❌
useEffect(() => {...}, [service]);
- Functions:
const onSuccess = () => {...}; // ❌ New each render
useEffect(() => {...}, [onSuccess]);
- Arrays/objects with methods:
const config = { url: '/api', transform: data => data }; // ❌
- DOM elements:
const [element] = useState(document.body); // ❌
When Non-Serializable is Unavoidable
For cases like Redux actions or stable class instances:
function MyComponent() {
const dispatch = useDispatch();
const stableAction = useCallback(
() => dispatch({ type: 'ACTION' }),
[dispatch]
); useEffect(() => { stableAction(); }, [stableAction]); // ✅ useCallback makes it stable }
Best Practices
- Prefer primitive dependencies (IDs, strings, numbers)
- Stabilize references with
useMemo
/useCallback
- Extract logic to custom hooks when complex
- Disable eslint only when absolutely necessary:
useEffect(() => {
// effect code
}, [unstableValue]); // eslint-disable-line react-hooks/exhaustive-deps
Performance Impact
- Unstable deps cause effect re-runs
- Memory pressure from recreating objects
- Bug potential from stale closures
- Optimization breaks for
React.memo
Advanced Pattern: Custom Comparison
For complex objects where you control equality:
function useDeepCompareEffect(effect, deps) {
const ref = useRef(deps);
if (!deepEqual(deps, ref.current)) {
ref.current = deps;
}
useEffect(effect, ref.current);
}
// Usage (with caution)
useDeepCompareEffect(() => {
// effect
}, [complexObject]);