References

useEffect Patterns

Side effects, cleanup, dependencies, and async operations

Core Patterns

  • When to Read This
  • Effect Dependency Patterns
  • Cleanup Patterns
  • Async Patterns

When to Read This

  • Implementing data fetching or subscriptions
  • Managing timers, intervals, or event listeners
  • Handling race conditions in async effects
  • Understanding cleanup functions
  • Debugging stale closure issues

Effect Dependency Patterns

✅ Mount-Only Effect (Empty Dependencies)

// Run once on mount, cleanup on unmount
useEffect(() => {
  console.log("Component mounted");

  return () => {
    console.log("Component unmounted");
  };
}, []); // Empty array = mount only

Use cases: Global event listeners, initializing third-party libraries, WebSocket connections, subscriptions that don’t depend on props/state.

✅ Reactive Effect (With Dependencies)

useEffect(() => {
  console.log(`Count changed to ${count}`);
}, [count]); // Runs when count changes

useEffect(() => {
  fetchData(userId, filter);
}, [userId, filter]); // Runs when either changes

Rules:

  • Include ALL values from component scope used inside effect
  • ESLint rule exhaustive-deps helps catch missing dependencies
  • If linter suggests adding dependency, add it (don’t disable)

✅ Conditional Execution Inside Effect

// ❌ WRONG: Conditional effect call
if (shouldFetch) {
  useEffect(() => fetchData(), []);
}

// ✅ CORRECT: Condition inside effect
useEffect(() => {
  if (shouldFetch) {
    fetchData();
  }
}, [shouldFetch]);

⚠️ Avoiding Dependency Hell

// ❌ PROBLEM: Object dependency causes re-run on every render
const options = { method: "GET", headers: {} };
useEffect(() => {
  fetchData(options); // Re-runs every render (new object)
}, [options]);

// ✅ SOLUTION 1: Destructure primitive values
useEffect(() => {
  fetchData({ method: "GET", headers: {} });
}, []); // If options are static

// ✅ SOLUTION 2: useMemo for object identity
const options = useMemo(() => ({ method: "GET", headers: {} }), []);
useEffect(() => {
  fetchData(options);
}, [options]);

// ✅ SOLUTION 3: Separate primitive dependencies
useEffect(() => {
  const options = { method, headers };
  fetchData(options);
}, [method, headers]); // Track primitives

Cleanup Patterns

✅ Cleanup for Subscriptions

useEffect(() => {
  const subscription = dataSource.subscribe((data) => {
    setData(data);
  });

  return () => {
    subscription.unsubscribe();
  };
}, [dataSource]);

✅ Cleanup for Timers

useEffect(() => {
  const timeout = setTimeout(() => {
    console.log("Delayed action");
  }, 1000);

  return () => clearTimeout(timeout);
}, []);

useEffect(() => {
  const interval = setInterval(() => {
    setCount((c) => c + 1);
  }, 1000);

  return () => clearInterval(interval);
}, []);

✅ Cleanup for Event Listeners

useEffect(() => {
  const handleResize = () => {
    setWindowWidth(window.innerWidth);
  };

  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

✅ Cleanup for DOM Mutations

useEffect(() => {
  document.body.classList.add("modal-open");

  return () => {
    document.body.classList.remove("modal-open");
  };
}, []);

Async Patterns

✅ Async Data Fetching

useEffect(() => {
  let cancelled = false;

  async function fetchData() {
    setLoading(true);
    try {
      const result = await api.getData(userId);
      if (!cancelled) {
        setData(result);
      }
    } catch (error) {
      if (!cancelled) {
        setError(error);
      }
    } finally {
      if (!cancelled) {
        setLoading(false);
      }
    }
  }

  fetchData();

  return () => {
    cancelled = true; // Prevent state updates after unmount
  };
}, [userId]);

✅ AbortController for Fetch Requests

useEffect(() => {
  const controller = new AbortController();

  async function fetchData() {
    try {
      const response = await fetch(`/api/users/${userId}`, {
        signal: controller.signal,
      });
      const data = await response.json();
      setData(data);
    } catch (error) {
      if (error.name === "AbortError") {
        console.log("Fetch aborted");
      } else {
        setError(error);
      }
    }
  }

  fetchData();

  return () => {
    controller.abort(); // Cancel ongoing request
  };
}, [userId]);

❌ NEVER: Async Effect Function Directly

// ❌ WRONG: async effect function
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

// ✅ CORRECT: async function inside effect
useEffect(() => {
  async function fetch() {
    const data = await fetchData();
    setData(data);
  }
  fetch();
}, []);

Race Condition Handling

⚠️ The Problem

// ❌ PROBLEM: Race condition
useEffect(() => {
  fetchUser(userId).then((user) => {
    setUser(user); // May set stale data if userId changed
  });
}, [userId]);

// Scenario: userId changes from 1 → 2 → 3
// Requests: R1, R2, R3
// Responses arrive: R3, R1, R2 (out of order)
// Result: Shows user 2 instead of user 3

✅ Solution 1: Cancellation Flag

useEffect(() => {
  let cancelled = false;

  fetchUser(userId).then((user) => {
    if (!cancelled) {
      setUser(user);
    }
  });

  return () => {
    cancelled = true;
  };
}, [userId]);

✅ Solution 2: AbortController

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then((user) => setUser(user))
    .catch((err) => {
      if (err.name !== "AbortError") {
        setError(err);
      }
    });

  return () => controller.abort();
}, [userId]);

✅ Solution 3: Latest Request Tracking

useEffect(() => {
  let latestRequest = userId;

  fetchUser(userId).then((user) => {
    if (latestRequest === userId) {
      setUser(user);
    }
  });

  return () => {
    latestRequest = null;
  };
}, [userId]);

Debouncing & Throttling

✅ Debounced Effect

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

function SearchInput() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearch = useDebounce(searchTerm, 500);

  useEffect(() => {
    if (debouncedSearch) {
      searchAPI(debouncedSearch).then(setResults);
    }
  }, [debouncedSearch]);

  return <input value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />;
}

✅ Throttled Effect

function useThrottle<T>(value: T, limit: number): T {
  const [throttledValue, setThrottledValue] = useState(value);
  const lastRan = useRef(Date.now());

  useEffect(() => {
    const handler = setTimeout(
      () => {
        if (Date.now() - lastRan.current >= limit) {
          setThrottledValue(value);
          lastRan.current = Date.now();
        }
      },
      limit - (Date.now() - lastRan.current),
    );

    return () => clearTimeout(handler);
  }, [value, limit]);

  return throttledValue;
}

Complex Effect Patterns

✅ Multiple Independent Effects

// ✅ CORRECT: Separate effects for separate concerns
function UserProfile({ userId }) {
  // Effect 1: Fetch user data
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  // Effect 2: Track analytics
  useEffect(() => {
    analytics.track("profile_view", { userId });
  }, [userId]);

  // Effect 3: Update document title
  useEffect(() => {
    document.title = `Profile: ${user?.name}`;
  }, [user?.name]);
}

// ❌ WRONG: One effect for everything (harder to maintain)
useEffect(() => {
  fetchUser(userId).then(setUser);
  analytics.track("profile_view", { userId });
  document.title = `Profile: ${user?.name}`;
}, [userId, user?.name]); // Complex dependencies

✅ Effect with Multiple Cleanup Actions

useEffect(() => {
  const ws = new WebSocket(url);
  const interval = setInterval(() => ping(), 30000);

  window.addEventListener("online", reconnect);

  return () => {
    ws.close();
    clearInterval(interval);
    window.removeEventListener("online", reconnect);
  };
}, [url]);

✅ Conditional Effect Execution

useEffect(() => {
  if (!isAuthenticated) return;
  if (!userId) return;

  const subscription = subscribeToUser(userId);
  return () => subscription.unsubscribe();
}, [isAuthenticated, userId]);

Stale Closure Solutions

⚠️ Problem: Stale Closure

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // Always logs 0 (stale)
    }, 1000);
    return () => clearInterval(id);
  }, []); // count not in dependencies

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

✅ Solution 1: Functional Update

useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => {
      console.log(c); // Always current
      return c + 1;
    });
  }, 1000);
  return () => clearInterval(id);
}, []); // No dependencies needed

✅ Solution 2: Ref for Latest Value

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count; // Keep ref updated
  }, [count]);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(countRef.current); // Always current
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

✅ Solution 3: Include Dependency

useEffect(() => {
  const id = setInterval(() => {
    console.log(count); // Current value
  }, 1000);
  return () => clearInterval(id);
}, [count]); // Re-creates interval when count changes

Effect Execution Order

Component Lifecycle

function Component() {
  console.log('1. Render');

  useEffect(() => {
    console.log('3. Effect (after DOM update)');

    return () => {
      console.log('4. Cleanup (before next effect or unmount)');
    };
  });

  console.log('2. Render complete');

  return <div>Component</div>;
}

// Output on mount:
// 1. Render
// 2. Render complete
// 3. Effect (after DOM update)

// Output on unmount:
// 4. Cleanup (before next effect or unmount)

useLayoutEffect vs useEffect

// useLayoutEffect: Runs synchronously after DOM mutations, before paint
useLayoutEffect(() => {
  // Measure DOM, synchronous updates
  const height = elementRef.current.offsetHeight;
  setHeight(height);
}, []);

// useEffect: Runs asynchronously after paint
useEffect(() => {
  // Data fetching, subscriptions, async operations
  fetchData().then(setData);
}, []);

Use useLayoutEffect when: Measuring DOM elements, synchronous DOM mutations to prevent flicker, animations that need to be in sync with render.

Use useEffect for: Data fetching, subscriptions, event listeners, most side effects.


References