A common React performance pitfall occurs when passing non-serializable values (like functions, class instances, or complex objects) into a useEffect dependency array. Here’s what you need to know:
The Core Problem
React uses shallow comparison (Object.is) to check dependency array changes. Non-serializable values often create new references on each render, causing:
function Component() {
const user = { id: 1, name: 'John' }; // New object each render
useEffect(() => {
fetchUserData(user);
}, [user]); // ❌ Re-runs every render (new object reference)
return <div>{user.name}</div>;
}
Types of Non-Serializable Values
- Functions
const fetchData = () => {...};
useEffect(() => {...}, [fetchData]); // ❌ New function each render
- Class Instances
const service = new ApiService();
useEffect(() => {...}, [service]); // ❌
- Objects/Arrays
const filters = { active: true };
useEffect(() => {...}, [filters]); // ❌ New object each render
Solutions and Best Practices
1. For Objects: Use Primitive Properties
const { id, name } = user;
useEffect(() => {
fetchUserData(user);
}, [id, name]); // ✅ Only reruns when id/name change
2. For Functions: useCallback
const fetchData = useCallback(() => {
// ...
}, []); // ✅ Stable function reference
useEffect(() => {
fetchData();
}, [fetchData]); // ✅
3. For Class Instances: useRef + useMemo
const service = useRef(new ApiService()).current;
// Or for props:
const service = useMemo(() => new ApiService(props), [props.x, props.y]);
4. For Complex State: Stringify or Custom Comparison
const [complexState] = useState({ a: 1, b: 2 });
const stateString = JSON.stringify(complexState);
useEffect(() => {
// Handle effect
}, [stateString]); // ✅
When You Must Use Non-Serializable Dependencies
For cases where you can’t avoid it (e.g., external library instances):
Option 1: Store in Ref
const externalService = useRef(thirdPartyService);
useEffect(() => {
externalService.current.doSomething();
}, []); // Empty deps since ref is stable
Option 2: Custom Comparison Hook
function useDeepCompareEffect(callback, dependencies) {
const prevDeps = useRef(dependencies);
if (!deepEqual(prevDeps.current, dependencies)) {
prevDeps.current = dependencies;
}
useEffect(callback, [prevDeps.current]);
}
Common Mistakes to Avoid
❌ Using inline objects
useEffect(() => {...}, [{ id: 1 }]); // ❌ Always different
❌ Using new instances in render
const service = props.service || new Service(); // ❌
useEffect(() => {...}, [service]);
✅ Better: Initialize outside or memoize
const service = useMemo(() => props.service || new Service(), [props.service]);
Real-World Example
Problem:
function UserProfile({ user }) {
const [data, setData] = useState(null);
useEffect(() => {
loadData(user.preferences); // preferences is object
}, [user.preferences]); // ❌ Re-runs every render
// ...
}
Solution:
function UserProfile({ user }) {
const [data, setData] = useState(null);
const prefString = JSON.stringify(user.preferences);
useEffect(() => {
loadData(user.preferences);
}, [prefString]); // ✅ Only when preferences actually change
// Or better - select specific fields:
const { theme, notifications } = user.preferences;
useEffect(() => {
loadData(user.preferences);
}, [theme, notifications]); // ✅
}
Performance Impact
| Approach | Reruns Effect When | Performance |
|---|---|---|
| Object in deps | Every render | ❌ Poor |
| Primitives in deps | When values change | ✅ Good |
| useMemo/useCallback | When deps change | ✅ Best |
Key Takeaways
- Always prefer primitive values in dependency arrays
- Stabilize objects/functions with
useMemo/useCallback - For class instances, use refs or memoization
- Consider custom comparison for deep object changes
- The React ESLint plugin will warn about these issues
Proper dependency array management is crucial for both performance and correctness in React components.
