React useEffect Hook
Handle side effects, manage dependencies, and clean up after effects.
TL;DR
- 01useEffect runs code after render for side effects like fetching data.
- 02Dependency array controls when the effect runs.
- 03Return a cleanup function to prevent memory leaks.
Tips
- 01Always include all dependencies in the dependency array — ESLint's exhaustive-deps rule helps catch missing ones.
Warnings
- 01Forgetting cleanup functions causes memory leaks — always clean up subscriptions, timers, and listeners.
Basic useEffect
Run a side effect after every render by calling
useEffectwith no dependency array.useEffect(() => { console.log("Component rendered"); // runs after every render });Update the document title after each render as a common side-effect pattern.
useEffect(() => { document.title = `You clicked ${count} times`; // Runs after every render where count may have changed });Wrap async logic in an inner function because the effect callback itself cannot be async.
useEffect(() => { async function loadData() { const res = await fetch("/api/users"); const data = await res.json(); setUsers(data); } loadData(); // call the async function immediately }, []);Use
useEffectfor imperative side effects like logging, analytics, or third-party integrations.useEffect(() => { analytics.track("page_view", { page: pathname }); // fire-and-forget side effect }, [pathname]); // re-fires when the route changesKeep effect callbacks synchronous at the top level and wrap any async work inside.
// Wrong: async effect callback — cleanup return value is lost useEffect(async () => { await doSomething(); }, []); // Right: inner async function with explicit call useEffect(() => { async function run() { await doSomething(); } run(); }, []);
Dependency Comparison Rules
Know that React compares each dependency to its previous value with
Object.is, not a deep equality check — this is the exact mechanism that decides whether an effect re-runs.useEffect(() => { console.log("Count changed:", count); // Object.is(prevCount, count) === false triggers a re-run }, [count]);Expect object and array literals to fail
Object.isevery render because a new reference is created each time, even when the contents are identical.useEffect(() => { fetchUserData(filters); // filters = { status: "active" } looks "changed" every render }, [filters]); // new object reference each render → effect re-fires constantlyFix reference-instability by memoizing the object/array with
useMemoor by destructuring to primitive dependencies instead.useEffect(() => { fetchUserData(status, token); // primitives compare correctly with Object.is }, [status, token]); // both must appear in the deps arrayOmit the array entirely only when the effect must run after every single render — each item is still diffed with
Object.is, there's just no array to skip the check.useEffect(() => { // Runs after EVERY render — use only when you truly need this syncStateToExternalSystem(value); }); // no array = every renderEnable the ESLint
react-hooks/exhaustive-depsrule so it statically flags any variable read inside the effect that's missing from the array — it does not understandObject.issemantics, only static usage.# Install the rules-of-hooks plugin npm install eslint-plugin-react-hooks --save-dev # Add to .eslintrc: "react-hooks/exhaustive-deps": "warn" # Use the rule's quick-fix to auto-insert missing deps, then verify literals are memoizedAvoid silencing
exhaustive-depswith an inline disable comment — it almost always hides a stale-closure bug rather than a false positive.useEffect(() => { fetchUserData(userId); // eslint-disable-next-line react-hooks/exhaustive-deps // Tempting but dangerous: userId changes won't re-trigger this effect }, []);
Data Fetching
Fetch data when the component mounts by using an empty dependency array.
useEffect(() => { fetch("/api/data") .then((r) => r.json()) .then(setData); }, []); // runs once on mountTrack
loadinganderrorstate alongside the fetch for complete async UI handling.useEffect(() => { setLoading(true); fetch(url) .then((r) => r.json()) .then(setData) .catch(setError) .finally(() => setLoading(false)); }, [url]); // re-fetches when url changesCancel stale requests with
AbortControllerto prevent setting state after unmount.useEffect(() => { const controller = new AbortController(); fetch(url, { signal: controller.signal }) .then((r) => r.json()) .then(setData) .catch((err) => { if (err.name !== "AbortError") setError(err); // ignore intentional cancellation }); return () => controller.abort(); // cancel on unmount or url change }, [url]);Re-fetch automatically when a dependency like
userIdchanges by including it in the array.useEffect(() => { async function load() { const res = await fetch(`/api/users/${userId}`); setUser(await res.json()); } load(); }, [userId]); // re-runs every time userId changesUse SWR or React Query in production for caching, deduplication, and automatic revalidation.
import useSWR from "swr"; // SWR handles loading state, caching, and revalidation on focus const { data, error, isLoading } = useSWR("/api/user", fetcher);
Cleanup Functions
Return a function from the effect callback itself — this is the only way React knows it's a cleanup function, and it fires before unmount or before the next effect run.
useEffect(() => { const subscription = subscribe((data) => { setData(data); }); return () => subscription.unsubscribe(); // cleanup before unmount }, []);Remember that the cleanup function closes over the props/state values from the render that scheduled it, not the latest render — it always sees the OLD values, never the current ones.
useEffect(() => { const id = roomId; // captured by this closure connect(id); return () => disconnect(id); // disconnects the OLD roomId, even after roomId changes }, [roomId]);Picture the exact ordering on every dependency change: React runs the PREVIOUS effect's cleanup first, fully, before invoking the NEW effect's setup — never interleaved.
useEffect(() => { const timer = setInterval(() => setTime((t) => t + 1), 1000); return () => clearInterval(timer); // old timer fully cleared before a new one starts }, [intervalMs]); // change in intervalMs: cleanup(old) → setup(new), in that orderPair every subscription-style API call with its exact inverse in cleanup — addEventListener/removeEventListener, connect/disconnect, open/close — so each effect run leaves zero residue for the next one to build on.
useEffect(() => { const handleResize = () => setWidth(window.innerWidth); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); // exact inverse of setup }, []);Treat a missing cleanup return as a silent bug, not a no-op — React still re-runs the new effect on every dependency change, it just never undoes the old one first.
useEffect(() => { const ws = new WebSocket(`wss://example.com/${roomId}`); ws.onmessage = (e) => setMessages((prev) => [...prev, e.data]); return () => ws.close(); // without this, every roomId change leaks an open socket }, [roomId]);Re-subscribe correctly when a dependency like
userIdchanges by relying on cleanup to unsubscribe from the PREVIOUS value before the new effect subscribes to the current one.useEffect(() => { const sub = subscribe(userId); // subscribe to new user return () => sub.unsubscribe(); // unsubscribe from PREVIOUS user first }, [userId]); // runs cleanup on every userId change before re-subscribing
useEffect Patterns
Sync state with
localStorageby reading on mount and writing when state changes.useEffect(() => { const saved = localStorage.getItem("theme"); if (saved) setTheme(saved); // restore on mount }, []); useEffect(() => { localStorage.setItem("theme", theme); // persist on every change }, [theme]);Track the previous value of a variable by storing it in a ref before each update.
const prevCountRef = useRef<number | undefined>(undefined); useEffect(() => { prevCountRef.current = count; // store current as previous after each render }, [count]); const prevCount = prevCountRef.current; // previous value available during renderUse multiple separate
useEffectcalls for unrelated concerns to keep each effect focused.useEffect(() => { /* handle auth token refresh */ }, [token]); useEffect(() => { /* sync theme to body class */ }, [theme]); useEffect(() => { /* track window resize */ }, []); // Three concerns → three effects, each independently managedFocus an input or dialog element after it becomes visible in the DOM.
useEffect(() => { if (isOpen && inputRef.current) { inputRef.current.focus(); // focus runs after the DOM updates } }, [isOpen]); // re-runs whenever isOpen toggles to trueUpdate the document title reactively to reflect dynamic values like unread counts.
useEffect(() => { document.title = unreadCount > 0 ? `(${unreadCount}) My App` : "My App"; }, [unreadCount]); // updates whenever unreadCount changesTreat Strict Mode's extra dev-only mount cycle as a debugging aid, not a bug, when writing custom hooks or subscriptions.
function useChatRoom(roomId) { useEffect(() => { const connection = createConnection(roomId); connection.connect(); return () => connection.disconnect(); // must fully undo connect() }, [roomId]); // Strict Mode connects, disconnects, reconnects in dev to prove cleanup is correct }
FAQ
useEffect runs after the browser has painted the screen, not during render. This makes it safe for DOM mutations and async operations without blocking the UI.
In development, React 18+ Strict Mode intentionally runs setup, cleanup, then setup again for every effect on mount — even with an empty dependency array. This is not a bug: it simulates remounting to surface effects with missing or broken cleanup. Production builds always run the effect once. Fix the underlying issue (incomplete cleanup) rather than trying to suppress the double run.
An infinite loop usually means an object or array is listed as a dependency but gets recreated on every render, so Object.is sees a new reference each time. Move the value outside the component, memoize it with useMemo/useCallback, or restructure so only primitives are in the dependency array.
useLayoutEffect fires synchronously after DOM mutations but before the browser paints, so it blocks rendering — use it only when you need to measure or mutate the DOM before the user sees it. Prefer useEffect for everything else.
Use an AbortController: create it at the top of the effect, pass signal to fetch, and call controller.abort() in the cleanup function. This prevents setState from being called on an unmounted component when the request resolves late.