useEffect Hook
Synchronize a component with an external system by running side effects after render.
Recipe
Quick-reference recipe card — copy-paste ready.
// Run after every render
useEffect(() => {
console.log("rendered");
});
// Run once on mount
useEffect(() => {
fetchData();
}, []);
// Run when dependencies change
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
// Cleanup on unmount or before re-running
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);When to reach for this: You need to synchronize with something outside React — fetching data, subscribing to events, manipulating the DOM, or setting up timers.
Working Example
"use client";
import { useEffect, useState } from "react";
export function WindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
handleResize(); // Set initial size
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<p className="text-sm font-mono">
Window: {size.width} × {size.height}
</p>
);
}What this demonstrates:
- Subscribing to a browser event (
resize) insideuseEffect - Returning a cleanup function to remove the listener on unmount
- Empty dependency array
[]so the effect runs only once on mount - Setting initial state inside the effect to avoid SSR hydration mismatch
Deep Dive
How It Works
useEffectruns after the browser paints the screen, not during render- React calls your setup function after the component mounts and after every re-render where dependencies changed
- Before re-running the effect, React calls the previous cleanup function (if provided)
- On unmount, React calls the cleanup function one final time
- In Strict Mode during development, React mounts, unmounts, and re-mounts every component to surface missing cleanup logic
Parameters & Return Values
| Parameter | Type | Description |
|---|---|---|
setup | () => void or () => () => void | Effect function; optionally returns a cleanup function |
dependencies | unknown[] or omitted | Array of reactive values the effect depends on |
| Return | Type | Description |
|---|---|---|
| (none) | void | useEffect does not return a value |
Variations
Data fetching with cleanup (abort controller):
useEffect(() => {
const controller = new AbortController();
async function load() {
const res = await fetch(`/api/users/${id}`, {
signal: controller.signal,
});
const data = await res.json();
setUser(data);
}
load();
return () => controller.abort();
}, [id]);Connecting to a WebSocket:
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (e) => setMessages((prev) => [...prev, JSON.parse(e.data)]);
return () => ws.close();
}, [url]);Syncing with localStorage:
useEffect(() => {
localStorage.setItem("theme", theme);
}, [theme]);TypeScript Notes
// Effect with async — must wrap because useEffect cannot return a Promise
useEffect(() => {
async function fetchData() {
const res = await fetch("/api/data");
const json: DataType = await res.json();
setData(json);
}
fetchData();
}, []);
// TypeScript catches missing cleanup return types automaticallyGotchas
-
Infinite loop — Updating state that is also a dependency causes the effect to re-run endlessly. Fix: Narrow your dependency array or move the state update behind a condition.
-
Missing dependencies — Omitting a value from the dependency array causes stale data inside the effect. Fix: Include all reactive values used inside the effect, or refactor to remove the dependency.
-
Async directly in useEffect — Returning a
Promiseinstead of a cleanup function produces a React warning. Fix: Define anasyncfunction inside the effect and call it immediately. -
Race conditions on fast re-renders — If
idchanges quickly, an older fetch may resolve after a newer one and overwrite correct data. Fix: Use anAbortControlleror a booleanignoreflag in cleanup. -
Running on the server —
useEffectdoes not run during SSR. Accessingwindowordocumentwithout guarding causes build errors. Fix: Access browser APIs only insideuseEffector behind atypeof window !== "undefined"check.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
useLayoutEffect | You need to measure or mutate the DOM before the browser paints | The effect does not touch layout or visual DOM |
| Event handler | Side effect is triggered by a specific user action (click, submit) | Effect must run on mount or on value change |
Server Component fetch | Data can be fetched at request time on the server | Data depends on client-side state or user interaction |
useSyncExternalStore | Subscribing to an external store (Redux, browser API) | One-time setup or data fetching |
Why not fetch in useEffect? For many apps, frameworks like Next.js offer server-side data fetching that avoids loading spinners and waterfalls. Reserve useEffect fetching for truly client-only data needs.
Real-World Example
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: Keyboard navigation for study cards
// File: src/hooks/use-card-keyboard.ts
import { useEffect } from "react";
interface UseCardKeyboardOptions {
isAutoplayOn: boolean;
cardNumber: number;
totalCards: number;
hasMoreCards: boolean;
onCardClick: () => void;
onNext: () => void;
onPrev: () => void;
}
export function useCardKeyboard({
isAutoplayOn, cardNumber, totalCards, hasMoreCards,
onCardClick, onNext, onPrev,
}: UseCardKeyboardOptions) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isAutoplayOn) return;
if (event.code === "Space" || event.key === " ") {
event.preventDefault();
onCardClick();
} else if (event.key === "ArrowLeft" || event.key === "a") {
event.preventDefault();
if (cardNumber > 1) onPrev();
} else if (event.key === "ArrowRight" || event.key === "d") {
event.preventDefault();
if (cardNumber < totalCards || hasMoreCards) onNext();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [cardNumber, totalCards, hasMoreCards, isAutoplayOn, onCardClick, onNext, onPrev]);
}What this demonstrates in production:
- Cleanup removes the
keydownlistener on unmount and whenever dependencies change, preventing stale handlers from accumulating. - Every referenced value inside the effect is included in the dependency array. Missing even one (like
cardNumber) would cause stale closure bugs where old values are captured. - The
onCardClick,onNext, andonPrevcallbacks should be wrapped inuseCallbackby the parent component. Without that, every parent re-render creates new function references, causing this effect to unsubscribe and resubscribe the listener unnecessarily. - The
isAutoplayOnguard at the top of the handler is a production pattern for disabling keyboard input during automated playback without removing the listener entirely. - Extracting this into a custom hook keeps the component clean and makes the keyboard behavior independently testable.
FAQs
What is the difference between an empty dependency array [] and no dependency array at all?
useEffect(() => { ... }, [])runs once on mount and cleans up on unmount.useEffect(() => { ... })runs after every single render.- Omitting the array is rarely intentional and often causes performance issues.
Why can't I pass an async function directly to useEffect?
useEffectexpects the callback to return eitherundefinedor a cleanup function.- An
asyncfunction returns aPromise, which React cannot use as a cleanup function. - Wrap the async logic in an inner function and call it immediately:
useEffect(() => {
async function fetchData() {
const res = await fetch("/api/data");
setData(await res.json());
}
fetchData();
}, []);How do I prevent race conditions when fetching data in useEffect?
- Use an
AbortControllerto cancel the previous request when dependencies change. - Alternatively, use a boolean
ignoreflag set totruein the cleanup function.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then(res => res.json())
.then(setUser);
return () => controller.abort();
}, [id]);Gotcha: Why does my useEffect run twice in development?
- In React 18+ Strict Mode, React mounts, unmounts, and re-mounts every component during development.
- This is intentional -- it surfaces missing cleanup logic (event listeners, subscriptions, timers).
- It does not happen in production builds.
When should I use useEffect vs an event handler for side effects?
- Use an event handler when the side effect is triggered by a specific user action (click, submit, keypress).
- Use
useEffectwhen you need to synchronize with an external system on mount or when a dependency changes. - Event handlers are simpler and avoid dependency-related bugs.
What does the cleanup function do and when does it run?
- The cleanup function runs before the effect re-runs (when dependencies change) and once when the component unmounts.
- It is used to unsubscribe from events, clear timers, or abort fetches.
- If you forget cleanup, you risk memory leaks and stale handlers.
Gotcha: Why does adding an object as a dependency cause my effect to run on every render?
- React compares dependencies with
Object.is, which checks reference equality. - A new object literal
{}or array[]created during render has a new reference every time. - Fix by memoizing the object with
useMemoor extracting primitive dependencies.
How do I type the cleanup function in TypeScript?
useEffect((): (() => void) => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
// TypeScript infers the cleanup type automatically
// in most cases -- explicit typing is rarely needed.How do I access window or document safely inside useEffect with SSR?
useEffectonly runs on the client, sowindowanddocumentare always available inside it.- The danger is accessing them outside
useEffectduring server-side rendering. - Keep all browser API access inside
useEffector behind atypeof window !== "undefined"guard.
Should I split one large useEffect into multiple smaller ones?
- Yes, if the effect handles unrelated concerns (e.g., fetching data and subscribing to resize events).
- Each effect should have its own dependency array matching its specific concern.
- This makes cleanup and dependency management simpler and less error-prone.
How does useEffect differ from useLayoutEffect?
useEffectruns after the browser paints the screen (asynchronous).useLayoutEffectruns synchronously after DOM mutations but before the browser paints.- Use
useLayoutEffectonly when you need to measure or mutate the DOM before the user sees it (e.g., tooltips, animations).
Related
- useState — state that your effects read and write
- useRef — mutable values that persist across renders without triggering effects
- useCallback — stabilize functions used as effect dependencies
- Custom Hooks — extract reusable effect logic into custom hooks