Keeping dependency references stable in useEffect, useCallback, and useMemo is crucial for avoiding subtle bugs, stale closures, and performance-killing re-renders.
The Core Concept: Referential Equality
React uses "shallow comparison" (===) to determine if a dependency has changed.
-
Primitives (strings, numbers, booleans): Compared by value.
5 === 5is alwaystrue. -
Objects, Arrays, and Functions: Compared by reference (memory address).
{}is never equal to another{}.
When you define an object or function inside a component, it gets a new reference on every render, triggering any hooks that depend on it.
1. The Easiest Fix: Move it Outside
If a function or object doesn't depend on props or state, move it outside the component. This gives it a static identity that never changes.
// β
BEST: Static identity, no hooks needed
const DEFAULT_OPTIONS = { debug: true };
const formatData = (data) => data.trim();
function MyComponent({ input }) {
useEffect(() => {
const cleanData = formatData(input);
console.log(cleanData, DEFAULT_OPTIONS);
}, [input]); // Only depends on 'input'
}
2. Stabilizing Functions (useCallback)
Problem: Unstable Identity
Functions created inside a component body change on every render, causing effects to re-run or React.memo children to re-render.
function MyButton() {
// π΄ UNSTABLE: New reference every render
const handleClick = () => console.log('Clicked');
useEffect(() => {
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, [handleClick]); // Runs on every render
}
Fix: useCallback + Functional Updates
Use useCallback to memoize the function. To keep the dependency array small, use functional updates for state.
function Counter() {
const [count, setCount] = useState(0);
// β
FIXED: Stable identity
// β
PRO TIP: Using (prev => prev + 1) removes 'count' from dependencies
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []);
return <button onClick={increment}>+</button>;
}
3. Stabilizing Objects and Arrays (useMemo)
Problem: New References
Creating objects or filtering arrays inline produces a new reference every time, even if the data is identical.
function DataFetcher({ userId, allItems }) {
// π΄ UNSTABLE: New object and new array on every render
const config = { method: 'POST', body: { id: userId } };
const activeItems = allItems.filter(item => item.active);
useEffect(() => {
fetch('/api', config);
}, [config]); // Re-runs every render
}
Fix: Memoize the Result
Wrap the creation logic in useMemo so the reference only changes when the underlying data changes.
function DataFetcher({ userId, allItems }) {
// β
FIXED: Identity only changes when userId changes
const config = useMemo(() => ({
method: 'POST',
body: { id: userId }
}), [userId]);
// β
FIXED: Identity only changes when allItems changes
const activeItems = useMemo(() => (
allItems.filter(item => item.active)
), [allItems]);
useEffect(() => {
fetch('/api', config);
}, [config]);
}
4. Avoiding Stale Closures
Problem: Missing Dependencies
If you wrap a function in useCallback but forget to include a variable it uses, that function will "capture" the value from the render when it was first created.
function Uploader({ folderId }) {
const [files, setFiles] = useState([]);
const upload = useCallback(() => {
// π΄ STALE: 'files' will always be the initial empty array
console.log(`Uploading to ${folderId}:`, files);
}, [folderId]); // Missing 'files'
}
Fix: Exhaustive Dependencies
Always include every reactive value (props, state, and functions derived from them) used inside the hook.
const upload = useCallback(() => {
// β
FRESH: Always has the latest values
console.log(`Uploading to ${folderId}:`, files);
}, [folderId, files]); // Includes all external references
5. The useRef Escape Hatch
Sometimes you need to access the latest value of a prop or state inside an effect, but you don't want the effect to re-run when that value changes.
function Timer({ onTick }) {
// Use a ref to keep track of the latest 'onTick' without triggering effects
const tickRef = useRef(onTick);
useEffect(() => {
tickRef.current = onTick;
}, [onTick]);
useEffect(() => {
const id = setInterval(() => {
tickRef.current(); // Calls the latest version
}, 1000);
return () => clearInterval(id);
}, []); // Effect only runs once on mount
}
Summary Checklist
- Can it be moved outside? If yes, do it.
- Is it a primitive? You can usually use it directly in dependencies.
-
Is it a function? Wrap in
useCallback. Use functional state updates to trim dependencies. -
Is it an object/array? Wrap in
useMemo. -
Does it need to be reactive? If you need the value but don't want to trigger a re-run, use
useRef.
Top comments (0)