Overview
useEffect is a React Hook for synchronizing a component with an external system after React renders and commits the UI. External systems include browser APIs, timers, subscriptions, network connections, third-party widgets, analytics, and imperative libraries.
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
return <h1>Room {roomId}</h1>;
}
The cleanup function undoes the setup. It runs before the effect is re-run with changed dependencies and when the component unmounts. In development Strict Mode, React may run setup, cleanup, and setup again to reveal missing cleanup bugs.
This topic matters in interviews because many React bugs come from using effects for the wrong job, missing dependencies, causing infinite loops, leaking subscriptions, failing to cancel stale async work, or writing cleanup that does not mirror setup.
The practical goal is to use effects only when needed, make dependencies honest, and write setup/cleanup code that remains correct across mount, update, unmount, and development re-checks.
Core Concepts
What useEffect Is For
useEffect is for synchronizing with systems outside React's render calculation.
Good effect use cases:
- Connecting to a WebSocket or chat server.
- Subscribing to browser events.
- Starting and clearing timers.
- Controlling a non-React widget.
- Fetching data in simple client-side components.
- Reporting analytics after a screen appears.
- Synchronizing with APIs such as
localStorage, when appropriate.
Example:
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return <p>{width}px</p>;
}
What useEffect Is Not For
Do not use effects for values that can be calculated during render.
Bad:
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
Good:
const fullName = `${firstName} ${lastName}`;
Do not use effects to handle user events that already have event handlers.
Bad:
useEffect(() => {
if (isSubmitted) {
submitForm();
}
}, [isSubmitted]);
Good:
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
submitForm();
}
Effects are escape hatches. If no external system is involved, you often do not need an effect.
Basic Effect Shape
An effect has setup logic and optional cleanup logic.
useEffect(() => {
setup();
return () => {
cleanup();
};
}, [dependencies]);
The dependency array tells React which reactive values the setup uses. Reactive values include props, state, and variables or functions declared inside the component.
function ChatRoom({ roomId, serverUrl }: Props) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);
}
If serverUrl or roomId changes, React cleans up the old connection and sets up the new one.
Effect Timing
Effects run after React commits the render to the DOM. That means effects do not block the render calculation.
This is why effects are suitable for synchronization after the UI has updated:
useEffect(() => {
document.title = `Inbox (${unreadCount})`;
}, [unreadCount]);
For visual work that must happen before the browser paints, such as measuring layout to avoid flicker, useLayoutEffect may be more appropriate. Most effects should use useEffect.
Effects do not run during server rendering. Code inside an effect is client-side behavior.
Cleanup
Cleanup is the function returned from an effect. It should undo whatever setup did.
useEffect(() => {
const id = window.setInterval(() => {
setTime(new Date());
}, 1000);
return () => {
window.clearInterval(id);
};
}, []);
Common cleanup tasks:
- Remove event listeners.
- Clear timers.
- Disconnect sockets.
- Unsubscribe from stores.
- Abort fetch requests or ignore stale results.
- Destroy third-party widgets.
Cleanup must match setup. If setup subscribes, cleanup unsubscribes. If setup starts a timer, cleanup clears it.
When Cleanup Runs
Cleanup runs:
- Before the effect runs again because dependencies changed.
- When the component unmounts.
- During development Strict Mode checks, after the first setup and before the second setup.
Example timeline:
useEffect(() => {
connect(roomId);
return () => disconnect(roomId);
}, [roomId]);
If roomId changes from "general" to "music":
- Cleanup disconnects
"general". - Setup connects
"music".
This keeps the component synchronized with the latest props and state.
Strict Mode Double Setup
In development, Strict Mode may run an extra setup-cleanup-setup cycle. This is not a production behavior, but it exposes effects that are missing cleanup.
If this effect has no cleanup:
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
}, [roomId]);
development may show duplicate connections.
Fix:
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
The goal is not to prevent the development re-check. The goal is to make the effect resilient to setup and cleanup happening multiple times.
Dependency Arrays
The dependency array should include every reactive value used by the effect.
function ChatRoom({ roomId }: { roomId: string }) {
const [serverUrl, setServerUrl] = useState("https://localhost:1234");
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);
}
Do not remove dependencies just to silence the linter. Missing dependencies create stale closure bugs, where an effect reads old props or state.
If a dependency changes too often, restructure the code:
- Move object creation inside the effect.
- Move functions inside the effect.
- Use primitive dependencies.
- Use
useCallbackonly when stable function identity is actually needed. - Split effects by responsibility.
No Dependency Array vs Empty Dependency Array
No dependency array:
useEffect(() => {
console.log("Runs after every render");
});
Empty dependency array:
useEffect(() => {
console.log("Runs after mount");
}, []);
Specific dependencies:
useEffect(() => {
console.log("Runs when roomId changes");
}, [roomId]);
An empty dependency array does not mean "ignore dependencies." It means the effect uses no reactive values from the component. If it does use props or state, the dependency list should include them.
Avoiding Object and Function Dependency Loops
Objects and functions created during render have new identity each render.
Problem:
function ChatRoom({ roomId }: { roomId: string }) {
const options = {
serverUrl: "https://localhost:1234",
roomId,
};
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]);
}
options is new every render, so the effect runs too often.
Better:
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const options = {
serverUrl: "https://localhost:1234",
roomId,
};
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
Move object creation inside the effect so dependencies represent real reactive inputs.
Fetching Data in Effects
Effects can fetch data in simple client-side components, but you must handle stale results.
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
let ignore = false;
async function loadUser() {
setUser(null);
const nextUser = await fetchUser(userId);
if (!ignore) {
setUser(nextUser);
}
}
loadUser();
return () => {
ignore = true;
};
}, [userId]);
return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}
The cleanup prevents an older request from updating state after userId changes or the component unmounts.
For production apps, framework data loading, route loaders, server components, or client data libraries often handle caching, deduplication, race conditions, and loading states better than manual effects.
AbortController
When using fetch, cleanup can abort an in-flight request.
useEffect(() => {
const controller = new AbortController();
async function load() {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await response.json();
setUser(data);
}
load().catch((error) => {
if (error.name !== "AbortError") {
setError("Failed to load user");
}
});
return () => {
controller.abort();
};
}, [userId]);
Aborting avoids unnecessary network work and prevents stale work from continuing after cleanup.
Subscriptions and External Stores
Subscriptions need cleanup.
useEffect(() => {
function handleChange() {
setSnapshot(store.getSnapshot());
}
const unsubscribe = store.subscribe(handleChange);
return () => {
unsubscribe();
};
}, [store]);
For external stores, React provides useSyncExternalStore, which is often better than hand-rolled subscription effects because it is designed for concurrent rendering and consistent snapshots.
Splitting Effects
Each effect should usually represent one synchronization process.
Bad:
useEffect(() => {
document.title = title;
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [title, roomId]);
Better:
useEffect(() => {
document.title = title;
}, [title]);
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
Splitting effects makes dependencies smaller and behavior easier to reason about.
Infinite Effect Loops
An infinite effect loop usually happens when:
- The effect updates state.
- That state update changes a dependency.
- The changed dependency causes the effect to run again.
Example:
useEffect(() => {
setOptions({ sort: "name" });
}, [options]);
Every setOptions creates a new object, which changes options, which runs the effect again.
Fix the data flow:
- Derive the value during render if possible.
- Initialize state directly.
- Remove unnecessary object dependencies.
- Use functional updates carefully.
- Split the effect or remove it.
Reading Latest Values Without Re-running Effects
Sometimes an effect needs latest data for an event or callback, but that data should not cause the subscription itself to restart. Common solutions include:
- Move non-reactive event logic into the event handler.
- Store mutable latest values in a ref when appropriate.
- Use React's newer effect-event patterns where available in your environment.
- Split the effect so the subscription and state reaction are separate.
Use this carefully. Do not hide real dependencies. If the effect's synchronization depends on a value, include it.
Common Mistakes
Common mistakes include:
- Using effects for derived state.
- Using effects for user event logic.
- Missing dependencies.
- Adding empty dependency arrays while reading props or state.
- Creating object or function dependencies that change every render.
- Forgetting cleanup for subscriptions, timers, and connections.
- Treating Strict Mode's development re-check as the bug instead of fixing cleanup.
- Fetching data without handling stale responses.
- Combining unrelated synchronization processes in one effect.
- Updating state in an effect after every render and causing loops.
Best Practices
Use these rules of thumb:
- Start by asking whether an external system is involved.
- If not, you probably do not need an effect.
- Keep render calculations in render.
- Keep user-triggered actions in event handlers.
- Include all reactive dependencies.
- Let the linter help with dependency correctness.
- Make cleanup mirror setup.
- Split effects by synchronization purpose.
- Handle stale async work with cleanup, ignore flags, or aborts.
- Treat effects as synchronization, not lifecycle methods.