DEV Community

abhi
abhi

Posted on

React Hook Stability Cheat Sheet: Problem & Fixes

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 === 5 is always true.
  • 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'
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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]); 
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Summary Checklist

  1. Can it be moved outside? If yes, do it.
  2. Is it a primitive? You can usually use it directly in dependencies.
  3. Is it a function? Wrap in useCallback. Use functional state updates to trim dependencies.
  4. Is it an object/array? Wrap in useMemo.
  5. 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)