useEffect synchronizes a component with something outside React: a subscription, a timer, a browser API, a network connection. It runs after React commits a render to the screen, first after mount and then after every render in which one of its dependencies changed.
Rendering must stay pure, so code that touches the outside world goes either into an event handler (caused by a user action) or into an effect (caused by the component being on screen).
An effect usually runs after the browser paints, so it does not delay the frame; useLayoutEffect exists for measuring layout before paint.
Effects do not run on the server: during SSR only rendering happens.
Think "keep this in sync with these values", not "run on mount": an effect is not a lifecycle method.
Fetching data in a raw effect works, but TanStack Query, router loaders or Server Components handle caching and races for you.
ReactMiddle
Effects & Refs
useEffect and its dependencies, cleanup, race conditions, when you do not need an effect, useRef, useLayoutEffect, StrictMode double run
Questions closed
0 / 19
- 0Review
- 9Asked often
Questions
With no array the effect runs after every render; with [] it runs once after mount, plus cleanup on unmount; with [a, b] it runs after mount and after every render in which a or b changed. React compares each dependency with its previous value using Object.is.
Every reactive value the effect reads (props, state, anything computed from them in the component body) belongs in the list; the react-hooks/exhaustive-deps lint rule checks it.
Dependencies describe what the effect reads, not when you want it to run: to run it less often, change the code so it reads fewer values.
Values from outside the component (module constants, imports) and setters from useState are stable and can be left out.
Objects, arrays and functions created during render are new every time, so as dependencies they re-run the effect after every render.
[] claims the effect uses nothing from render; if that is not true, the effect works with stale values.
The cleanup is the function you return from the effect; it undoes what the effect set up: unsubscribes, clears a timer, aborts a request. React calls it before running the effect again with new dependencies and once when the component unmounts.
Order on a dependency change: render with the new values, commit, cleanup with the old values, then the effect with the new ones.
The cleanup closes over the values of its own render, so it disconnects from the old roomId, not the new one.
Without cleanup you get leaks and duplicates: two intervals ticking, listeners firing after unmount, sockets left open.
A good test: mount, unmount and mount again must leave the same result as one mount; StrictMode does exactly this in development.
Not every effect needs one: setting document.title or logging has nothing to undo.
useRef(initial) returns a mutable object { current } that lives as long as the component and keeps the same identity across renders. Changing ref.current does not cause a re-render, so a ref holds values the screen does not show, while state holds the values it does.
Typical uses: DOM nodes, timer and interval ids, an AbortController, an instance of a non-React widget.
State is a snapshot per render; a ref is one shared box, so a callback created in an old render still reads the latest ref.current.
Do not read or write ref.current during render, except for lazy initialization: the result would depend on when React renders, which breaks concurrent rendering and the React Compiler's assumptions.
In React 19 types useRef requires an argument: useRef<number | null>(null) returns a RefObject with a writable current.
Create a ref with useRef<HTMLInputElement>(null) and pass it to the element's ref prop; after commit ref.current holds the DOM node, and on unmount it goes back to null. In React 19 a function component receives ref as an ordinary prop, so forwardRef is no longer needed.
Use DOM refs for what React has no prop for: focus, scrolling, measuring, media playback, integrating a non-React library.
ref.current is null during the first render; read it in effects and event handlers only.
Your component takes ref from its props and puts it on the inner element; React.ComponentProps<'input'> already includes it.
forwardRef still works and fills older code, but it is planned for deprecation.
Do not use refs to change what React renders (text, classes): that belongs to state and props.
In development <StrictMode> mounts every component, unmounts it and mounts it again, so each effect runs setup, cleanup, setup. It is a deliberate stress test: if the second run breaks something, the effect is missing a correct cleanup.
It happens only in development; production runs an effect once per mount.
The right fix is a cleanup that undoes the setup (disconnect, clear the timer, abort the request), not a useRef flag that skips the second run.
A doubled request in the Network tab is fine when the first response is ignored or aborted; with a data library its cache makes the second call free.
Code that must run once per app load (initializing analytics, reading a token) goes at module level or behind a module variable, outside components.
StrictMode also calls component bodies, state initializers and updaters twice to catch impure rendering, and in React 19 it runs ref callbacks twice on mount too.
When a dependency changes quickly (typing in a search box, switching tabs), several requests are in flight, and a slower old response can arrive last and overwrite the new one. The fix is a cleanup that ignores or aborts the request from the previous run.
An ignore flag set in the cleanup makes the stale then do nothing; it works with any promise.
AbortController also cancels the network request; catch the AbortError so it does not show up as a failure.
The same cleanup prevents setting state after the component has unmounted, for example after navigating away.
Production apps use TanStack Query, SWR, a router loader or Server Components, which handle races, caching and deduplication; a hand-written effect is fine for small cases and is what this question checks.
You do not need an effect when nothing outside React is involved: data you can compute from props or state is calculated during render, and work caused by a user action goes into the event handler. Effects are for synchronizing with external systems.
Derived data: const visible = todos.filter(...) during render, wrapped in useMemo only if it is slow. An effect plus state renders twice and shows stale data for a frame.
Reacting to an event: send the request, show the toast or navigate in onSubmit, not in an effect that watches a submitted flag.
Resetting state when a prop changes: give the component a key instead of an effect that clears state.
Notifying the parent: call onChange in the same handler that sets the state, not in an effect afterwards.
Chains of effects, where one effect sets state that triggers another: compute the whole next state in one handler.
App initialization: module-level code, not an effect with [].
filters is a new object on every render, so the dependency always differs by Object.is: the effect fetches, setProducts stores a new array, the component re-renders, creates another filters, and the cycle repeats.
Dependencies compare by reference: an object, array or function declared in the component body counts as changed on every render.
Fix: build the object inside the effect and depend on the primitive it comes from (category).
If the object has to be shared, memoize it with useMemo(() => ({ category, inStock: true }), [category]); the React Compiler does this automatically.
The fixed version also adds an ignore flag so a slow response for an old category cannot overwrite a newer one.
A classic variant of the same bug: an effect with no dependency array that sets state to a new object every time.
The effect callback is async, so it returns a promise, and React treats whatever an effect returns as its cleanup. When userId changes, React calls that promise as a function, gets TypeError: destroy is not a function, and without an error boundary the whole root unmounts.
What an effect may return: undefined or a cleanup function. An async function always returns a promise, hence the warning on every run.
When it breaks: the first load works; the crash comes with the first cleanup, when userId changes or the component unmounts. Under <StrictMode> in development the cleanup runs right after mount, so the data never appears.
No cleanup also means a race: a slow response for the previous userId could arrive last and overwrite the current one.
Fix: keep the effect synchronous, declare the async function inside it and call it, and return a real cleanup that sets an ignore flag (or aborts with AbortController). Give load() a .catch, or a failed request becomes an unhandled rejection.
TypeScript rejects the original: "Type 'Promise<void>' is not assignable to type 'void | Destructor'", so this bug ships from JavaScript files or through any.
On mount: render 0, effect 0. After the click: render 1, cleanup 0, effect 1. The render comes first, then the old effect is cleaned up with its own values, then the new effect runs.
An effect runs after its render has been committed, never during it.
The cleanup prints 0 because it closes over the render that created it, not over the current state.
On unmount only cleanup 1 would be printed.
With <StrictMode> in development the mount logs render 0 twice, then effect 0, cleanup 0, effect 0: components render twice, and effects run setup, cleanup, setup.
The classic version stores the value in a ref inside an effect, so during a render the ref still holds the value from the previous commit. It works, but it reads ref.current during render, and it returns the value from the last render, not the last different value.
Previous render, not previous value: if the component re-renders for another reason, the "previous" value becomes equal to the current one.
Refs during render: reading ref.current in render is what the React docs and the React Compiler lint rules warn about.
State-based alternative: keep the last seen value in state and update it during render when the input changes; React re-renders the component right away, before its children.
Often you need neither: compare old and new values in the event handler that causes the change.
The interval callback was created in the first render and closes over count = 0, so every tick calls setCount(0 + 1); after the first tick the state is already 1 and nothing changes.
This is a stale closure: [] tells React the effect uses nothing from render, which is false, and exhaustive-deps warns about it.
Adding count to the dependencies works, but it tears down and recreates the interval every second.
The updater form setCount((c) => c + 1) gets the latest state from React, so the effect really depends on nothing.
To read the latest props inside a long-lived callback without restarting it, use useEffectEvent or a ref.
useLayoutEffect runs synchronously after React updates the DOM but before the browser paints; useEffect usually runs after paint. Use the layout version only when you must measure the DOM and change it before the user sees a frame, such as positioning a tooltip.
With useEffect the user can see one frame in the wrong position and then a jump (flicker).
useLayoutEffect blocks painting, so slow code in it makes the UI feel stuck; useEffect stays the default.
State updates inside useLayoutEffect are processed synchronously, before paint.
It does not run during server rendering; render a fallback there until the client has measured.
useInsertionEffect runs even earlier, before layout effects, and exists for CSS-in-JS libraries that inject styles.
useImperativeHandle(ref, () => handle) sets what a parent's ref receives from your component: a small object of methods instead of the raw DOM node. Use it for imperative actions that props express badly, such as focus, scrollToItem or play.
In React 19 the ref arrives as a prop; older code wraps the component in forwardRef.
A narrow handle hides the DOM structure, so you can change the markup without breaking callers.
Do not use it for what props can do: an isOpen prop beats modalRef.current.open(), because the parent's state stays the source of truth.
Pass a dependency array as the third argument when the handle reads props or state.
While the dropdown is open, listen for pointerdown on document and close it when the event target is not inside the dropdown (!ref.current.contains(e.target)). Add the listener in an effect that depends on open, and remove it in the cleanup.
Put the ref on a wrapper that includes the toggle button: otherwise pressing the button counts as outside, the listener closes the menu and the button's onClick opens it again.
**pointerdown rather than click**: React runs the effects of a click update synchronously, before the click reaches document, so a click listener added there also receives the click that opened the menu; with the ref on the panel alone, the menu closes at once. pointerdown has already passed, covers mouse, touch and pen, and matches native menus, which close on press.
Portals: a menu rendered with createPortal lies outside the wrapper in the DOM, so clicks inside it look outside; check a second ref on the portal content.
Keyboard users: close on Escape and return focus to the button.
Ready-made options: the popover attribute gives light dismiss (outside click and Escape) natively, and Radix, Headless UI or Floating UI's useDismiss handle the edge cases.
Render an empty container with a ref, create the library instance in an effect, keep it in a ref and destroy it in the cleanup. React owns only the container; prop changes reach the instance through its own API in separate effects instead of recreating it.
Create after commit: the DOM node exists only once React has committed it, and rendering must stay pure, so new Chart(canvas, config) goes into an effect, never into the component body.
Destroy in the cleanup: chart.destroy(), map.remove(). StrictMode mounts, unmounts and mounts again in development, and without a cleanup Chart.js throws "Canvas is already in use" and Leaflet "Map container is already initialized".
Create once, update through the API: an effect with [] creates the instance, others push changes (chart.update(), map.setView(...)). Recreating it on every prop change flickers and loses zoom, pan and animations.
Keep React out of the container: render no children into a node the library manages; both would edit the same DOM, which ends in errors like "Failed to execute 'removeChild' on 'Node'".
Library events: subscribe in the effect and call the latest prop through useEffectEvent, so a new onMove callback does not recreate the map.
Server rendering: such libraries often touch window on import, so load them in the browser only, for example with import() inside the effect.
Move the non-reactive part into an effect event: const onConnected = useEffectEvent(() => showNotification(theme)). It always sees the latest props and state but is not a dependency, so the effect re-runs only for the values it truly reacts to.
Example: reconnecting to a chat room because theme changed is wrong; the connection depends on roomId, the notification only reads theme.
Call effect events only from inside effects, never during render or from handlers, and do not list them in dependencies; the lint rule knows about them.
useEffectEvent is stable since React 19.2. Before it, the workaround was a ref updated on every render (latest.current = theme) and read inside the effect.
Silencing exhaustive-deps with a comment is the wrong fix: the effect then reads stale values without any warning.
If a value is not reactive at all, move it out of the component.
A callback ref is a function passed to ref: React calls it with the DOM node when the node is attached and, before React 19, with null when it is detached. In React 19 the callback can return a cleanup function, which React calls on detach instead of passing null.
Use one when you need to react to a node appearing: measure it, attach a ResizeObserver, hand it to a non-React library, or collect refs for a dynamic list in a Map of id to node.
An inline arrow is a new function on every render, so React detaches and re-attaches it each time (cleanup, then setup); wrap it in useCallback when setup is costly, or let the React Compiler do it.
Return a cleanup or nothing: TypeScript now rejects implicit returns such as ref={(el) => (node = el)}, because any returned value is treated as a cleanup.
In <StrictMode> development React 19 runs ref callbacks setup, cleanup, setup on mount, the same way it does with effects.