Passing non-serializable values into useEffect dependency array

Loading

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

  1. New Reference Each Render: Functions/objects get recreated
  2. Infinite Loops: Effect triggers on every render
  3. Performance Issues: Unnecessary effect executions
  4. 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

  1. Functions (unless memoized with useCallback)
  2. Class instances (new instances each render)
  3. Objects/arrays (new references unless memoized)
  4. Promises/async operations
  5. 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

  1. Prefer primitive dependencies (IDs, strings, numbers)
  2. Memoize what you can’t avoid with useCallback/useMemo
  3. Keep effects small and focused
  4. Consider custom hooks for complex dependencies
  5. 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.

Leave a Reply

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