How useEffect works in React?
useEffect is a React hook that runs side effects after a component renders, without blocking the browser from painting the screen.
Theory
TL;DR
useEffectruns after render, not during. The UI appears first, then the effect fires.- Empty
[]= runs once on mount. No deps = runs on every render.[value]= runs when that value changes. - Always return a cleanup function if your effect sets up a subscription, timer, or event listener.
- Need post-render work?
useEffect. Pure state logic?useStateor a reducer. - Think of it like a hotel "do not disturb" sign: flip it after checking in, and housekeeping (the side effect) only comes when you change the sign (deps).
Quick example
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`; // Runs after render
}, [count]); // Re-runs only when count changes
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
// Title updates after each click — not on every possible re-render.After the component renders, React updates the document title. If count did not change, the effect skips. Clean and predictable.
Key difference from class lifecycle methods
In class components, you had componentDidMount, componentDidUpdate, and componentWillUnmount as three separate methods. useEffect collapses all three into one hook. The return value handles cleanup (unmount), the deps array handles conditional re-runs (did update), and an empty array handles the mount-only case. One mental model instead of three.
When to use
- Fetch data on mount: use
[]as deps. - Re-fetch when a prop changes: put that prop in deps
[userId]. - Set up a subscription or timer: return a cleanup function.
- Sync something external (document title, localStorage, analytics): include the synced value in deps.
- Skip
useEffectfor pure calculations, derived state, or synchronous DOM reads before paint. For the last case, useuseLayoutEffect.
How React schedules useEffect
React runs effects after the browser has painted the screen. This happens during the commit phase of fiber reconciliation: React finishes DOM mutations, the browser paints, then your effect fires asynchronously via the React scheduler. That is different from useLayoutEffect, which runs synchronously after DOM mutations but before the paint.
Cleanup runs before the next effect when deps change, and again on unmount. React tracks effects per fiber node using a linked list of hooks, which is why hooks must be called in the same order on every render.
Common mistakes
Mistake 1: no deps array
useEffect(() => {
fetchData(); // Runs on every render
});Without a deps array, the effect fires after every single render. If fetchData triggers a state update, you get an infinite loop. Add [] or the specific values your effect actually reads.
Mistake 2: forgetting cleanup
useEffect(() => {
const timer = setInterval(tick, 1000);
// Missing: return () => clearInterval(timer);
}, []);The timer keeps running after the component unmounts. This is one of the most common memory leaks in React apps. Always clean up intervals, subscriptions, and event listeners.
Mistake 3: object in deps
const config = { id: 1 };
useEffect(() => {
api(config);
}, [config]); // New object reference on every renderObjects are compared by reference. { id: 1 } !== { id: 1 }, so the effect runs on every render no matter what. Depend on the primitive instead:
useEffect(() => {
api({ id: config.id });
}, [config.id]); // Stable primitiveMistake 4: the infinite loop trap
function BadCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // count in deps triggers re-render, which re-runs the effect
}, [count]);
}Setting state that is in your deps causes infinite re-renders. If you need a self-incrementing counter, use a functional update with no deps:
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // No stale closure, no deps needed
}, 1000);
return () => clearInterval(id);
}, []);Real-world usage
- React Query / TanStack Query: uses
useEffectinternally to trigger initial fetches before the query cache takes over. - Next.js:
useEffect+useRouterfor post-hydration redirects (router code cannot run on the server). - Framer Motion: DOM measurements happen inside
useEffectbefore animations start. - Redux Toolkit: syncing store state to
windowfocus events for tab visibility detection. - AbortController for cancellable fetches:
useEffect(() => {
const abort = new AbortController();
fetch(`/api/user/${userId}`, { signal: abort.signal })
.then(res => res.json())
.then(setUser);
return () => abort.abort();
}, [userId]);Follow-up questions
Q: When exactly does useEffect run relative to the render?
A: After the browser paints the screen. React commits DOM changes, the browser paints, then effects fire asynchronously. The user sees the updated UI before any effect runs.
Q: What is the difference between empty [] and no deps?
A: Empty [] runs the effect once after the first render. No deps runs it after every render. These are very different behaviors, and mixing them up causes bugs.
Q: How do you cancel a fetch when the component unmounts?
A: Use AbortController. Create it inside the effect, pass its signal to fetch, and call abort.abort() in the cleanup function. This prevents state updates on unmounted components.
Q: Why does the ESLint exhaustive-deps rule exist?
A: To catch stale closures. If your effect reads a variable but does not list it in deps, it captures the value from the first render and never updates. The linter forces you to be explicit about dependencies.
Q: In React 18 concurrent mode, how do effects behave during transitions?
A: Effects still run after commit, even when renders are interrupted by transitions. React may render a component multiple times before committing, but effects fire only once per commit. This is why React 18 Strict Mode invokes effects twice in development: to surface bugs in effects that are not idempotent.
Examples
Basic: sync document title to state
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}The effect fires once after mount (setting the title to "Count: 0"), then again each time the button is clicked. If the component re-renders for another reason without count changing, the effect skips.
Intermediate: fetch user data with cleanup
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const abort = new AbortController();
fetch(`/api/user/${userId}`, { signal: abort.signal })
.then(res => res.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => abort.abort(); // Cancel if userId changes before fetch completes
}, [userId]);
return <div>{user ? user.name : 'Loading...'}</div>;
}When userId changes, React runs cleanup first (aborting the in-flight request), then starts a new fetch. No race conditions, no state updates from stale responses.
Advanced: avoiding stale closures with useRef
I have seen this trip up experienced developers more than once. An effect captures the callback value at the time it runs, not at the time it was defined.
import { useState, useEffect, useRef } from 'react';
function SearchInput({ onSearch }) {
const [query, setQuery] = useState('');
const onSearchRef = useRef(onSearch);
// Keep the ref current on every render, no deps needed
useEffect(() => {
onSearchRef.current = onSearch;
});
useEffect(() => {
if (!query) return;
const timer = setTimeout(() => {
onSearchRef.current(query); // Always uses the latest callback
}, 300);
return () => clearTimeout(timer);
}, [query]); // Re-runs only when query changes
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}The first effect (no deps) keeps onSearchRef.current up to date on every render. The second effect reads from the ref, so it never becomes stale, but only re-runs when query changes.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.