Passing non-serializable values into useEffect dependency array

Loading

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:

  1. Objects/functions are recreated each render
  2. Shallow comparison fails (new reference ≠ old reference)
  3. Causes unnecessary effect re-runs
  4. 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

  1. Class instances:
   const service = new ApiService(); // ❌
   useEffect(() => {...}, [service]);
  1. Functions:
   const onSuccess = () => {...}; // ❌ New each render
   useEffect(() => {...}, [onSuccess]);
  1. Arrays/objects with methods:
   const config = { url: '/api', transform: data => data }; // ❌
  1. 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

  1. Prefer primitive dependencies (IDs, strings, numbers)
  2. Stabilize references with useMemo/useCallback
  3. Extract logic to custom hooks when complex
  4. Disable eslint only when absolutely necessary:
   useEffect(() => {
     // effect code
   }, [unstableValue]); // eslint-disable-line react-hooks/exhaustive-deps

Performance Impact

  1. Unstable deps cause effect re-runs
  2. Memory pressure from recreating objects
  3. Bug potential from stale closures
  4. 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]);

Leave a Reply

Your email address will not be published. Required fields are marked *