Skip to content

Back to Blog Listing

How React Skips Work

React can avoid rendering parts of the fiber tree when props, state, and context dependencies have not changed. Manual memoization, React Compiler, and React Developer Tools all help you control and measure that skipped work.

Ben Houston8 min read

This is the third article in the React Internals series.

Part 1: How React Renders covered the fiber tree, reconciliation, keys, and hook identity. Part 2: React Beyond the DOM covered lanes, transitions, Suspense, and host renderers.

Those pieces explain how React finds work and schedules it. This article asks a different question: when can React skip work?

Rendering Is Not the Same as Updating the DOM#

React developers often use "render" to mean several different things. A component function can run. React can compare new output with old output. The DOM renderer can mutate browser nodes. The browser can run layout and paint.

Those steps cost different amounts. A parent component might render while React reuses a child fiber without calling the child component. A child component might render and produce the same host output, which means the DOM does not change. A DOM update might change text without forcing expensive layout.

Optimization starts by separating these costs. Skipping a component render helps when the component or its children do expensive JavaScript work. It does not remove unrelated browser work, network work, or a slow effect.

Fiber tree showing state, parent props, and context updates entering render, with some branches bailing out

React already skips work during reconciliation. If a fiber has no relevant pending lanes, its props match the previous props, and its context dependencies still match, React can reuse the previous subtree. Internally, this kind of bailout lets React avoid walking children that cannot affect the next screen.

The practical version is simple: React needs proof that a subtree would produce the same result.

Why a Fiber Renders#

A fiber renders when React has a reason to revisit it. Common reasons include:

  • Its own state queue has an update.
  • Its parent renders and passes changed props.
  • A context value it reads has changed.
  • React needs to retry suspended or interrupted work.

The second case surprises people. By default, when a parent component renders, React calls its child components again. React does this because JavaScript functions can create different output each time they run:

function ProductPage({ product }: { product: Product }) {
  return <Details product={product} options={{ compact: true }} />;
}

ProductPage creates a new options object on every render. Even if product stayed the same, the child receives a new object identity. React cannot treat that prop as unchanged by shallow comparison.

memo Is About Prop Identity#

React.memo asks React to reuse a component's last rendered output when its props compare equal. By default, React compares each prop with Object.is.

const Details = memo(function Details({
  product,
  options,
}: {
  product: Product;
  options: { compact: boolean };
}) {
  return <ExpensiveDetails product={product} compact={options.compact} />;
});

This helps only if the props keep stable identities. A fresh object, array, or function breaks the shallow comparison. In that case, memo still runs the component because the props changed from React's point of view.

useMemo and useCallback exist to preserve identities across renders:

function ProductPage({ product }: { product: Product }) {
  const options = useMemo(() => ({ compact: true }), []);
  const onSelect = useCallback((id: string) => {
    console.log('selected', id);
  }, []);

  return <Details product={product} options={options} onSelect={onSelect} />;
}

useMemo stores a calculated value in a hook slot. useCallback stores a function identity. On the next render, React compares the dependency array with the previous committed dependency array. If each dependency matches with Object.is, React returns the cached value.

Props flowing through memoized and non-memoized paths, showing stable and unstable object and function identities

Memoization has a cost. React must store dependencies, compare them, and keep the cached value alive. For cheap components, that bookkeeping can cost more than rerendering. Use manual memoization when you have evidence: slow child renders, large lists, expensive derived data, or props passed into an already memoized child.

Context Crosses Memo Boundaries#

React.memo compares props. It does not hide a component from context changes.

When a component calls useContext(ThemeContext), React records that dependency on the component's fiber. During a later render, if the nearest provider's value changed, React treats the consumer as needing work even when its props compare equal.

const Row = memo(function Row({ item }: { item: Item }) {
  const theme = useContext(ThemeContext);
  return <div className={theme.rowClass}>{item.name}</div>;
});

If ThemeContext.Provider receives a new value, Row can render again because it reads that context. React compares provider values with Object.is, so a provider that creates a fresh object each render can wake up many consumers:

<ThemeContext.Provider value={{ rowClass, accentColor }}>
  {children}
</ThemeContext.Provider>

You can reduce that churn by stabilizing provider values, splitting contexts by update frequency, and moving context reads closer to the components that need them.

const themeValue = useMemo(
  () => ({ rowClass, accentColor }),
  [rowClass, accentColor],
);

return <ThemeContext.Provider value={themeValue}>{children}</ThemeContext.Provider>;

This does not make context free. It gives React a stable value when the actual theme did not change.

React Compiler Automates Many Caches#

Manual memoization works, but it asks humans to track identity throughout a tree. React Compiler moves much of that work into the build step.

This site enables the compiler through Vite:

plugins: [
  tanstackStart(),
  babel({ presets: [reactCompilerPreset()] }),
  react(),
  nitroV2Plugin(/* ... */),
  tailwindcss(),
],

React Compiler analyzes components and hooks, tracks data flow and mutation, then inserts cache checks where it can prove a value or JSX subtree can be reused. It can do this at a finer grain than a hand-written React.memo boundary because it sees values inside the component too.

For example, this source:

function ProductPage({ product }: { product: Product }) {
  return <Details product={product} compact />;
}

can compile to a shape like this simplified pseudocode:

function ProductPage({ product }) {
  const cache = getCompilerCache();

  if (cache[0] !== product) {
    cache[0] = product;
    cache[1] = <Details product={product} compact />;
  }

  return cache[1];
}

The real output differs, but the idea matters: React can reuse the JSX when the values it depends on did not change.

Source component passing through React Compiler analysis and producing cache slots that reuse JSX

The compiler does not change React's semantics. State updates still render. Context updates still reach consumers. Expensive DOM mutations still cost time. The compiler also depends on React's rules: pure rendering, stable hook order, and no mutation of values that React treats as immutable.

The "use memo" directive can force compilation in configurations that require annotations. The "use no memo" directive can opt a function out while you debug. Most applications using the compiler in its normal inferred mode should not need either directive often.

Measure With React Developer Tools#

The Chrome React Developer Tools extension helps you answer two separate questions.

First, Highlight updates when components render shows which parts of the React tree render during an interaction. This view is blunt, but useful. If typing in one input lights up a large page section, you have a place to investigate.

Second, the Profiler records commits and shows which components took time. Record the slow interaction, stop the recording, then inspect the flamegraph or ranked view. Look for components that render often or take a large share of a commit.

React Developer Tools can also show why a component rendered, such as changed props, state, hooks, or context. That signal matters more than the render count alone. A component that renders because its own state changed may be fine. A component that renders because a parent passes a new callback on every keystroke may have a fix.

Workflow from React DevTools highlight updates to Profiler evidence, one optimization, and a second profile run

Use Chrome DevTools Performance panel for the browser side of the story: scripting, style recalculation, layout, paint, and long tasks. Use React Developer Tools for the React side: which components rendered and how long React spent committing them.

The best workflow is mechanical:

  1. Reproduce the slow interaction.
  2. Record it in React Developer Tools.
  3. Identify repeated expensive renders.
  4. Change one boundary, value identity, or context shape.
  5. Record the same interaction again.

Do not optimize from vibes. A skipped render that saves 0.2 ms is not worth a confusing dependency array. A skipped render that removes 80 ms from each keystroke is worth attention.

The Useful Mental Model#

React skips work when it can prove that a fiber's output and dependencies have not changed.

Keys preserve identity across lists. Hook order preserves state slots. Lanes tell React which pending work matters. Memoization and the compiler help React reuse prior output. Context can invalidate that reuse because it is an input to render, even when props stay the same.

React Compiler changes the default ergonomics. You can write straightforward components and let the build step add many caches. React Developer Tools keeps you honest by showing which components still render and whether those renders matter.

That completes the client-side loop from a different angle: How React Renders explains the tree, React Beyond the DOM explains scheduling, and this article explains how React avoids work once the tree gets large.