Handling Non-Serializable Values in useEffect Dependencies
A common React performance pitfall occurs when passing non-serializable values (like functions, class instances, or complex objects) into a useEffect
dependency array. This can lead to unnecessary re-renders or infinite loops.
The Problem: Non-Serializable Dependencies
function MyComponent() {
const [data, setData] = useState([]);
const fetchData = () => {
// Fetch implementation
};
useEffect(() => {
fetchData();
}, [fetchData]); // ❌ Function reference changes every render
return /* ... */;
}
Why This is Problematic
- New Reference Each Render: Functions/objects get recreated
- Infinite Loops: Effect triggers on every render
- Performance Issues: Unnecessary effect executions
- React Warnings: “The ‘fetchData’ function makes the dependencies of useEffect change on every render”
Solutions
1. Move Dependencies Inside Effect (Simple Cases)
useEffect(() => {
const fetchData = () => { /* ... */ };
fetchData();
}, []); // ✅ No dependency needed
2. useCallback for Functions
const fetchData = useCallback(() => {
// Fetch implementation
}, []); // ✅ Stable function reference
useEffect(() => {
fetchData();
}, [fetchData]); // ✅ Only changes if dependencies change
3. Memoize Objects with useMemo
const config = useMemo(() => ({
timeout: 1000,
retries: 3
}), []); // ✅ Stable object reference
useEffect(() => {
apiCall(config);
}, [config]); // ✅ Only changes if dependencies change
4. Use Primitive Values
const [user, setUser] = useState({ id: 1, name: 'John' });
useEffect(() => {
fetchUserDetails(user.id);
}, [user.id]); // ✅ Using primitive ID instead of whole object
5. For Class Instances: Use Refs
const service = useRef(new ApiService()).current;
useEffect(() => {
service.fetchData();
}, [service]); // ✅ Ref maintains stable reference
Common Non-Serializable Values
- Functions (unless memoized with
useCallback
) - Class instances (new instances each render)
- Objects/arrays (new references unless memoized)
- Promises/async operations
- DOM elements
When to Break the Rules
In rare cases with stable external systems:
useEffect(() => {
const handler = (e) => console.log(e);
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, [window]); // ✅ window object is stable
TypeScript Considerations
// Explicitly type dependencies
useEffect(() => {
// ...
}, [fetchData as DependencyList]); // Type assertion if needed
Best Practices
- Prefer primitive dependencies (IDs, strings, numbers)
- Memoize what you can’t avoid with
useCallback
/useMemo
- Keep effects small and focused
- Consider custom hooks for complex dependencies
- Disable linter carefully only when absolutely necessary
Real-World Example
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// ✅ Memoized fetch function
const fetchUser = useCallback(async () => {
const response = await fetch(`/users/${userId}`);
setUser(await response.json());
}, [userId]);
useEffect(() => {
fetchUser();
}, [fetchUser]); // ✅ Only changes when userId changes
// ✅ Memoized user settings object
const settings = useMemo(() => ({
theme: user?.preferences?.theme || 'light',
notifications: user?.preferences?.notifications ?? true
}), [user?.preferences]);
return /* ... */;
}
Remember: The React dependency array wants stable references between renders. For non-primitive values, you need to actively manage their stability with memoization techniques.