You've probably called useEffect hundreds of times and wrestled with its dependency array at least once. But what does React actually do when it hits that call? If you've read how useState works under the hood, the fiber architecture here will look familiar, it's the same system powering both hooks.
React doesn't just run your effect function whenever it feels like it. There's scheduling, cleanup, and a few optimizations behind the scenes that decide exactly when your code runs.
So why does your effect run after the screen updates, and not the moment React reads it? This post answers that by walking through the Fiber architecture, the render and commit phases, and how React schedules and cleans up effects. If you've used useEffect but never looked at what React does with it, you have all the background you need.
Every useEffect call becomes an object on a fiber
Every component you write gets turned into a fiber node, React's internal data structure for tracking everything about that component. It's the same structure behind useState's state management.
When you call useEffect, React doesn't run your function immediately. It creates an Effect object and attaches it to the component's fiber. That object holds everything React needs to manage the effect's lifecycle.
Here's what's inside it:
-
tagmarks whether the effect should actually run, based on whether its dependencies changed. React checks this flag before deciding to execute anything. -
create()is your effect callback, the function you pass as the first argument touseEffect. It holds the actual side-effect logic: fetching data, setting up a subscription, touching the DOM. -
destroy()starts out null. If your effect returns a cleanup function, React stores it here and calls it later. -
depsis your dependency array. React compares it against the previous render's array to decide whether the effect needs to run again.
This is React's to-do list for side effects: what to run, and when to clean it up.
Effects wait until React has updated the DOM
React renders in two phases, render and commit, and effects only run once both are done. This split is the key to understanding when useEffect fires.
The render phase
During render, React runs your component function, diffs the virtual DOM, and figures out what needs to change. This is also where it registers your useEffect calls, it doesn't run them yet.
This phase is also where reconciliation happens: React compares the new virtual DOM tree against the previous one to figure out the minimal set of real DOM changes.
The commit phase
Once React knows what needs to change, it commits: it writes those changes to the real DOM. Only after that is done does React start running effects.
useEffect runs after the commit phase, not during it. That ordering matters. If effects ran during commit, every effect would have to finish before the user saw any visual update. Running them after commit means the screen updates first and effects catch up in the background.
This split also lets React batch effects together instead of running them one at a time as each component commits.
That's the general case, not a guarantee. If the effect was triggered by a direct interaction like a click, React may run it before the browser paints, so the effect's result stays observable to the event system.
Dependencies decide whether an effect runs again
On every render, React compares an effect's dependencies with the previous render's, and only a changed value marks the effect to run. Each useEffect call in your component creates an entry in that component's hook list, a linked list attached to its fiber.
Here's what happens on each render:
- React creates or updates the Effect object for that call.
- It retrieves the dependency array from the previous render.
- It compares the new dependencies against the old ones using
Object.is().
If any dependency changed, or if there's no dependency array at all, React marks the effect to run.
let hasChanged = true;
if (oldDeps) {
hasChanged = deps.some((dep, i) => !Object.is(dep, oldDeps[i]));
}
if (hasChanged) {
// Schedule this effect for execution
}
Object.is() matters here because it handles edge cases regular equality misses. Object.is(NaN, NaN) returns true; NaN === NaN returns false. That precision keeps dependency tracking correct.
Effects usually run after the browser paints
Once React finishes committing DOM updates, it moves into the post-commit phase, where effects execute, and it lets the browser paint before it starts.
- React flushes all pending DOM updates, so every visual change is applied before any effect runs.
- It collects every effect marked for execution across all components in the current render cycle.
- It runs cleanup functions from the previous round of effects first, before running the new ones.
- It runs the new effect callbacks asynchronously, through React's own Scheduler rather than a microtask.
That last step trips people up. queueMicrotask() looks like the obvious tool for "soon, but not right now," but it isn't what React uses here. Microtasks drain before the browser gets a chance to paint, so effects scheduled that way would fire before the screen updated instead of after, undoing the whole point of deferring them. React posts the flush through MessageChannel instead, the same mechanism its Scheduler package uses elsewhere, a macrotask-like hand-off that lets the browser paint first and gives effects the next turn after that.
Cleanup runs before the next effect and on unmount
If your effect returns a function, React stores it as the cleanup for that effect and calls it before the next run of that effect, or when the component unmounts.
Here's what happens without one. This effect starts polling a chat room, and restarts whenever roomId changes.
useEffect(() => {
setInterval(() => fetchMessages(roomId), 5000);
}, [roomId]);
Switch rooms twice and three intervals are running, one per room you've visited, each still fetching and holding on to its old roomId. Returning a cleanup fixes it.
useEffect(() => {
const id = setInterval(() => fetchMessages(roomId), 5000);
return () => clearInterval(id);
}, [roomId]);
Subscriptions, timers, and event listeners all hold references that keep objects alive in memory. Without cleanup, those references pile up and the app leaks memory over time.
React calls cleanup functions in two situations:
- Before the next effect runs, if dependencies changed. This stops subscriptions or timers from overlapping.
- When the component unmounts, so no reference outlives the component. That includes when an error boundary catches a render error and unmounts the failing subtree, so a render error doesn't skip cleanup and leak memory anyway.
You don't have to track any of this timing yourself. React handles it.
Children's effects run before their parent's
Before reading on, guess what this logs when Parent mounts.
function Child() {
useEffect(() => console.log('child'));
return null;
}
function Parent() {
useEffect(() => console.log('parent'));
return <Child />;
}
Most people expect parent first, since Parent renders first. It prints child, then parent. React renders top-down, but it runs effects bottom-up. React runs effects in a fixed, predictable order:
- Child effects run before parent effects, so a child's updates settle before the parent's do.
- Cleanup functions run before new effects, so side effects never overlap.
- Unmount cleanups always run, so nothing gets left behind when a component is removed.
This ordering isn't arbitrary. If a parent's effects ran before its children's, the parent could end up processing data the children haven't finished updating. And if new effects ran before old ones cleaned up, you could end up with two WebSocket connections open at once instead of one, causing duplicate data or race conditions.
Hooks are matched by call order
React keeps a linked list of hooks attached to each fiber. Every useEffect, useState, or useMemo call adds an entry to that list.
This is the reason the Rules of Hooks exist. React matches hook state between renders by call order, the same mechanism behind useState's hook registration. Change the order hooks are called in between renders, and that mapping breaks.
class Hook {
constructor() {
this.state = null; // Stores the effect object
this.next = null; // Pointer to the next hook
}
}
React walks this list in order on every render, matching each hook call to its entry. Reorder the calls, for instance by putting a hook inside a conditional, and React can end up handing a useEffect hook the state meant for a useState hook. That's why hooks can't live inside loops, conditions, or nested functions: React needs to know exactly how many hooks will run, and in what order, to keep the mapping straight.
useLayoutEffect is the one that blocks paint
useEffect usually lets the screen update first, while useLayoutEffect always holds it up. Running effects asynchronously buys a few things:
- Rendering never waits on an effect to finish.
- The browser paints before effects run, not after.
- Effects from one render cycle run together instead of one at a time.
useLayoutEffect is the exception: it runs synchronously, before paint. That's useful for DOM measurements or layout adjustments, but it can block the UI if the work takes too long.
| Hook type | Execution timing | Blocking? |
|---|---|---|
useEffect |
After paint (asynchronous) | No |
useLayoutEffect |
Before paint (synchronous) | Yes |
The async default matters most while a user is actively interacting, scrolling a list or typing into a field. If effects ran synchronously, they could stall the response to that interaction.
Four common misreadings of useEffect
"useEffect runs during render." It doesn't. React only schedules it during render; the actual call happens after commit.
"Dependencies cause re-renders." They don't. Dependencies decide whether the effect re-runs, not whether the component re-renders. The component can re-render for unrelated reasons, like a state or prop change, while the effect stays untouched.
"Cleanup runs instantly." It runs before the next effect, or on unmount, not the moment the effect finishes.
"Every dependency must be in the array, no exceptions." The lint rule is a safe default, not a law. React's own guidance is that an effect should synchronize with something outside React, not fire in response to every value it happens to read. If you're fighting the array just to keep a function or value "current" without wanting to restart the effect, that's usually a sign the logic belongs in a ref, an event handler, or shouldn't be an effect at all.
Keep effects small and their dependencies honest
- Keep dependency arrays accurate and minimal. Every extra dependency gets compared on every render; every missing one risks a stale closure.
- Split unrelated effects into separate
useEffectcalls. It's easier to debug, and React can skip each one independently. - Reach for
useLayoutEffectonly when you need a DOM measurement or a synchronous layout update.useEffectis the right default otherwise. - Don't use an effect to compute a value from props or state. Calculate it during render, with
useMemoif it's expensive. An effect would render once with the stale value, then again with the new one.
FAQ
Why does my useEffect run twice in development?
That's Strict Mode. In development, React intentionally mounts, unmounts, and remounts your component to check that your cleanup function actually works. In production, effects run once. Write effects so they behave correctly even when called more than once.
Can I use useEffect for data fetching?
Yes, with some care around cleanup and error handling. A common pattern uses a flag to skip setState calls after the component has unmounted:
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
try {
const response = await api.getData();
if (!cancelled) {
setData(response);
}
} catch (error) {
if (!cancelled) {
setError(error);
}
}
};
fetchData();
return () => {
cancelled = true;
};
}, []);
The flag makes sure setState never fires after teardown. Once the effect has dependencies, such as [userId], the same flag also stops a slow, older response from overwriting a newer one. React 18 removed the old "Can't perform a React state update on an unmounted component" warning, but ignoring stale results still matters. For anything more involved, a library like React Query handles the caching and race conditions for you.
What's the difference between useEffect and useLayoutEffect?
useEffect runs asynchronously, after the browser paints. useLayoutEffect runs synchronously, before paint. Use useLayoutEffect only when you need to measure the DOM or apply a layout change before the user sees anything, otherwise the extra synchronous work just delays the paint.
Why do I need to include functions in my dependency array?
Functions are objects in JavaScript, and a new one gets created on every render. If your effect uses a function from props or state and you leave it out of the dependency array, the effect keeps calling whatever version of that function existed when it first ran, a stale closure. The same issue shows up with stale state values in useState.
Can I skip the dependency array?
You can, but the effect will then run after every render, which usually isn't what you want and can cause a render loop. Include exactly the dependencies the effect reads.
How do I clean up subscriptions in useEffect?
Return a function from the effect. React calls it before the next effect runs and again when the component unmounts, which is where you clear intervals, cancel in-flight requests, or remove event listeners. Anything that keeps a reference alive after your component is gone needs to be released here, following the same lifecycle React uses to manage state cleanup.
Wrapping up
useEffect looks like a simple API, but underneath it's a fiber-based scheduling system deciding exactly when your side effects are allowed to run.
Knowing how dependency comparison, scheduling, and cleanup fit together makes your effects more predictable, and makes the bugs easier to track down when they happen. Paired with how useState manages state internally, that's most of what you need to know about how React's hook system actually works.
Originally published at Notes by Sachintha.
Top comments (0)