Passing non-serializable values into useEffect dependency array

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

  1. Functions
   const fetchData = () => {...};
   useEffect(() => {...}, [fetchData]); // ❌ New function each render
  1. Class Instances
   const service = new ApiService();
   useEffect(() => {...}, [service]); // ❌
  1. 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

ApproachReruns Effect WhenPerformance
Object in depsEvery render❌ Poor
Primitives in depsWhen values change✅ Good
useMemo/useCallbackWhen deps change✅ Best

Key Takeaways

  1. Always prefer primitive values in dependency arrays
  2. Stabilize objects/functions with useMemo/useCallback
  3. For class instances, use refs or memoization
  4. Consider custom comparison for deep object changes
  5. The React ESLint plugin will warn about these issues

Proper dependency array management is crucial for both performance and correctness in React components.

Leave a Reply

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