Skip to content

How useEffect works behind the scenes in React

What actually happens when React sees a useEffect call. A look at the Fiber architecture, the render and commit phases, and why effects run when they do.

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.

This post covers React's Fiber architecture, the render and commit phases, and why effects fire when they do instead of immediately.

The foundation: React Fiber architecture

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.

Think of a fiber node as a blueprint: it records where each piece of state lives and how the component's structure fits together, the way an architectural drawing records where the wiring and plumbing go.

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:

  • tag marks 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 to useEffect. 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.
  • deps is 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.

Render vs. commit: the two-step process

React's rendering happens in two distinct phases, and 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. That comparison is expensive, which is part of why React is careful about when effects run.

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.

How React registers effects: the hook list

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:

  1. React creates or updates the Effect object for that call.
  2. It retrieves the dependency array from the previous render.
  3. 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.

React also tracks, per render, whether this particular effect actually needs to run, based on that dependency comparison: an effect with no dependency array is always marked to run, while one with an array is only marked when a value in it changed.

When effects actually run

Once React finishes committing DOM updates, it moves into the post-commit phase, where effects execute.

  1. React flushes all pending DOM updates, so every visual change is applied before any effect runs.
  2. It collects every effect marked for execution across all components in the current render cycle.
  3. It runs cleanup functions from the previous round of effects first, before running the new ones.
  4. 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: how React handles teardown

If your effect returns a function, React stores it as the cleanup for that effect. Before running the next effect, or when the component unmounts, React calls that function.

useEffect(() => {
  const id = setInterval(fetchData, 5000);
  return () => clearInterval(id); // This cleanup function runs automatically
}, [data]);

This exists because 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:

  1. Before the next effect runs, if dependencies changed. This stops subscriptions or timers from overlapping.
  2. 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.

Effect execution order

React runs effects in a fixed, predictable order:

  1. Child effects run before parent effects, so a child's updates settle before the parent's do.
  2. Cleanup functions run before new effects, so side effects never overlap.
  3. 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.

Internal hook state and the rules of hooks

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.

Async behavior and its performance benefits

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.

Effect queue and scheduling priority

React maintains an effect queue and processes it in batches after commit. The scheduler assigns each batch a priority so the app stays responsive under load:

  • Discrete event priority, for direct interactions like clicks and keypresses.
  • Continuous event priority, for animation and scrolling.
  • Default event priority, for ordinary updates.
  • Idle priority, for background work.

This lets React interrupt lower-priority work to handle something urgent, then pick the lower-priority work back up once the urgent task is done. A click gets processed before a background data fetch, even if the fetch was queued first.

Memory management and optimization

A few optimizations keep effects cheap:

  • Effect reuse skips re-running an effect when its dependencies haven't changed.
  • Automatic cleanup tracks and runs cleanup functions so references don't pile up.
  • Batching runs every effect from one cycle together instead of one at a time.

Effect reuse isn't just a dependency check. React also reads the effect's tag: effects with no dependency array are marked to run on every render, while effects with one are marked to run only when it changes.

Common misunderstandings about 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.

Practical tips for using useEffect

  • 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 useEffect calls. It's easier to debug, and React can skip each one independently.
  • Reach for useLayoutEffect only when you need a DOM measurement or a synchronous layout update. useEffect is the right default otherwise.
  • Don't update state or run expensive work directly in render logic. If a value depends on props or other state, compute it in an effect instead.

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;
  };
}, []);

This avoids the "Can't perform a React state update on an unmounted component" warning, by making sure setState never fires after teardown. 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.