← Back to the library
FrontendHow it works · Applied · 14 min

React rendering: triggers, reconciliation, memoization and transitions

What makes a React component render, what happens between setState and the pixels changing, and when is memoization worth it?

A render is React calling your component functions to compute a new UI description; a commit is React applying the difference to the DOM. Most performance work is about rendering less (structure, keys, memoization or the React Compiler) or rendering later (transitions and deferred values).

THE MENTAL MODEL

Treat a component as a pure function from props, state and context to JSX, and treat rendering as cheap until measured otherwise. A state update marks its component dirty; React batches updates, re-runs that component and, by default, every component it renders, then reconciles the new tree against the old one by position, element type and key. Only the differences reach the DOM in the commit phase, after which layout effects run, the browser paints and passive effects run. Rendering is therefore not the same as DOM work: an extra render that produces the same output costs CPU but not DOM writes. Memoization lets React skip subtrees whose inputs are referentially unchanged; transitions let React interrupt and postpone non-urgent renders so typing and clicks stay responsive.

HOW IT FITS TOGETHER

From a state update to effectsRender is interruptible for transition updates; commit is always synchronous. Effects run after the commit, never during render.
  1. TriggersetState or dispatch, an ancestor re-rendering, a consumed context value changing, or the initial root.render().
  2. Batch and scheduleUpdates queued in the same event, timeout or promise tick are batched (React 18+). Each update gets a priority lane: urgent (input) or transition.
  3. RenderReact calls the dirty component and everything it renders, unless a child bails out (same props under memo, or the compiler cached its JSX). Must be pure.
  4. ReconcileNew elements are matched to the previous tree by position, type and key. Same type and key: update in place, keep state. Otherwise: unmount and remount.
  5. CommitReact applies the minimal DOM mutations, then attaches refs. Synchronous and never interrupted, so the screen never shows half a tree.
  6. Layout effectsuseLayoutEffect runs synchronously before the browser paints. Use it only to measure and adjust layout; it blocks paint.
  7. PaintThe browser recalculates style and layout and paints the new frame.
  8. Passive effectsuseEffect setups run after paint (cleanups of the previous run first). Setting state here starts another cycle.
Memoization tools comparedAll of these trade memory and comparison work for skipped renders. The compiler applies the first three automatically at a finer grain.
CachesInvalidated whenReach for it whenWith React Compiler
React.memo(Component)A component's last rendered outputAny prop fails Object.is (shallow)An expensive child re-renders with unchanged propsUsually unnecessary; compiler skips unchanged children
useMemo(fn, deps)A computed valueAny dependency fails Object.isCostly derivation, or a stable object for memo or depsKeep only for values used as effect dependencies
useCallback(fn, deps)A function identityAny dependency fails Object.isPassing a handler to a memoized child or an effectUsually unnecessary; compiler memoizes closures
React Compiler 1.0JSX, values and callbacks per componentInputs it tracked changeDefault for new code on React 17+Opt out with "use no memo" while fixing a bug
Structural fixNothing; avoids the renderNot applicableMove state down or pass JSX as children firstStill the best fix; the compiler cannot move state

KEY TERMS

Render vs commit, and purity
Render calls components to produce elements; commit mutates the DOM. Because React may render without committing (an interrupted transition, a suspended tree, StrictMode), render must be pure: no mutation of props or outer variables, no subscriptions, no fetches. Side effects belong in event handlers or effects.
Reconciliation and keys
State belongs to a position in the tree. Same component type at the same position keeps state; a different type, or a different key, destroys the subtree and mounts a fresh one. In lists the key is the identity: index keys attach state to the wrong row after inserts or reorders. A changing key is also the idiomatic way to reset a component.
Batching
Since React 18 all updates queued in the same tick are batched into one render, including those in timeouts, promises and native handlers. Inside a handler state is a snapshot, so setCount(count + 1) three times adds one; use the updater form setCount(c => c + 1) when the next value depends on the previous. flushSync forces a synchronous commit and should be rare.
Memoization and the React Compiler
memo, useMemo and useCallback skip work when inputs are referentially equal; one new inline object or function as a prop defeats memo for the whole component. React Compiler 1.0 (stable since October 2025, works with React 17+) inserts this memoization automatically at build time, at finer granularity than hand-written hooks, and skips components that break the Rules of React.
Context re-renders
Every component that reads a context re-renders when the Provider's value changes by Object.is, and memo does not stop that. A value object built inline in the Provider's parent changes on every render. Split rarely and frequently changing values into separate contexts, memoize the value, or move fast-changing state into a store read with useSyncExternalStore and selectors.
StrictMode in development
StrictMode calls component bodies, state initializers, updaters, reducers and useMemo callbacks twice, and runs every effect and ref callback through an extra setup, cleanup, setup cycle on mount. It surfaces impure renders and missing cleanups. It does nothing in production builds; removing it hides the bug instead of fixing it.
Transitions and deferred values
startTransition marks updates as non-urgent: React renders them in the background, abandons the work if a newer urgent update arrives, and keeps showing the old UI (isPending is true) instead of a Suspense fallback. useDeferredValue does the same for a value you do not own, such as a prop. Neither makes a render faster; they only stop it from blocking input.

IN YOUR STACK

TypeScript · React 19 Client

// Stable keys: state stays with the row, not the index.
function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoRow key={todo.id} todo={todo} /> // not key={index}
      ))}
    </ul>
  );
}

// Changing key = new identity: the editor remounts with fresh state
// when the user switches contacts, instead of keeping the old draft.
function Inbox({ contact }: { contact: Contact }) {
  return <DraftEditor key={contact.id} contact={contact} />;
}

// Anti-pattern: a component defined inside another is a new type on
// every render, so its state and DOM are thrown away each time.
function Form() {
  // function Field() { const [v, setV] = useState(""); ... }  // don't
  return <Field />; // declare Field at module level
}
  • Keys only need to be unique among siblings, and they must come from the data, not Math.random() or crypto.randomUUID() during render.
  • Resetting state with a key is usually better than an effect that copies props into state when an ID changes.
React 19 documentation (opens in new tab)

TypeScript · React 19 Client

function Cart() {
  const [count, setCount] = useState(0);
  const [saving, setSaving] = useState(false);

  async function addThree() {
    setCount((c) => c + 1); // updaters chain: 0 -> 1 -> 2 -> 3
    setCount((c) => c + 1);
    setCount((c) => c + 1);
    setSaving(true);        // still one render for all four updates

    await saveCart();
    // After an await, updates are still batched together (React 18+).
    setSaving(false);
    setCount((c) => c - 1); // read the latest value, not the stale snapshot
  }

  return (
    <button onClick={addThree} disabled={saving}>
      Items: {count}
    </button>
  );
}
  • Inside a handler, count is the snapshot from the render that created the handler; setCount(count + 1) three times yields +1.
  • Separate user events are never batched together, so a click that disables a button prevents the next click from submitting twice.
  • Updaters run during render and are double-invoked in StrictMode, so keep them pure.
React 19 documentation (opens in new tab)

TypeScript · React 19 (no compiler) Client

// 1. Structural fix first: the state that changes often lives in a small
// component, and the expensive tree is passed in as children, so it is
// created by the parent and is the same element on every pointer move.
function Hover({ children }: { children: React.ReactNode }) {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  return <div onPointerMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
    <Cursor pos={pos} />
    {children}
  </div>;
}

// 2. Manual memo: only useful if every prop is stable.
const Chart = memo(function Chart({ points, onSelect }: ChartProps) {
  return <svg>{/* expensive */}</svg>;
});

function Dashboard({ rows, filter }: { rows: Row[]; filter: string }) {
  const points = useMemo(() => toPoints(rows, filter), [rows, filter]);
  const onSelect = useCallback((id: string) => track("select", id), []);
  return <Chart points={points} onSelect={onSelect} />;
  // <Chart points={toPoints(rows, filter)} /> would re-render every time
}
  • memo compares each prop with Object.is. One inline object, array or arrow function prop makes it re-render on every parent render.
  • Measure with the React DevTools Profiler on a production build before adding memoization; the comparisons are not free.
React 19 (no compiler) documentation (opens in new tab)

TypeScript · React Compiler 1.0 + Vite Client

// npm i -D @rolldown/plugin-babel   (needs @vitejs/plugin-react >= 6)
// vite.config.ts
import { defineConfig } from "vite";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";

export default defineConfig({
  plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});

// next.config.ts (Next.js 16+, after npm i -D babel-plugin-react-compiler)
// const nextConfig: NextConfig = { reactCompiler: true };

// Component code stays plain; the compiler caches derived values,
// callbacks and JSX between renders.
function Dashboard({ rows, filter }: { rows: Row[]; filter: string }) {
  const points = toPoints(rows, filter);
  return <Chart points={points} onSelect={(id) => track("select", id)} />;
}

function Legacy() {
  "use no memo"; // temporary opt-out while fixing a Rules of React violation
  return <Widget />;
}
  • Enable the compiler lint rules from eslint-plugin-react-hooks (recommended-latest preset); a component that violates the Rules of React is skipped, not miscompiled.
  • For React 17 or 18, set the compiler's target option and add react-compiler-runtime. Expo SDK 54+ enables the compiler by default; Vite and Next.js are opt-in.
  • Leave existing useMemo/useCallback in place in old code unless you test the removal; new code should rely on the compiler and keep manual hooks for effect dependencies only.
React Compiler 1.0 + Vite documentation (opens in new tab)

TypeScript · React 19 Client

// One context holding { user, theme, cursor } re-renders every consumer
// whenever the cursor moves. Split by change rate and memoize values.
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<ThemeApi | null>(null);

function Providers({ user, children }: { user: User | null; children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  // Without useMemo this object is new on every render of Providers.
  const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <UserContext value={user}>
      <ThemeContext value={themeValue}>{children}</ThemeContext>
    </UserContext>
  );
}

// High-frequency state belongs in an external store with a selector,
// so only components whose slice changed re-render.
function useCursorX() {
  return useSyncExternalStore(cursorStore.subscribe, () => cursorStore.get().x);
}
  • React 19 lets you render <Context> directly as the provider; <Context.Provider> still works.
  • memo does not block context updates: a memoized component that reads a context re-renders when that context changes.
  • useSyncExternalStore snapshots must be cached or primitive; returning a new object each call causes an infinite render loop.
React 19 documentation (opens in new tab)

TypeScript · React 19 Client

const SlowResults = memo(function SlowResults({ query }: { query: string }) {
  return <ul>{search(query).map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
});

function Search() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query); // lags behind while typing
  const stale = query !== deferredQuery;
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <div style={{ opacity: stale ? 0.6 : 1 }}>
        <SlowResults query={deferredQuery} />
      </div>
    </>
  );
}

function Tabs() {
  const [tab, setTab] = useState("home");
  const [isPending, startTransition] = useTransition();
  const select = (next: string) => startTransition(() => setTab(next));
  return <TabBar current={tab} pending={isPending} onSelect={select} />;
}
  • The input's own state must stay urgent; transitions cannot drive a controlled text input.
  • Without memo on SlowResults the urgent render re-renders it anyway and the deferral buys nothing.
  • In an async transition, state updates after an await need their own startTransition until AsyncContext lands.
React 19 documentation (opens in new tab)

TypeScript · React 19 StrictMode Client

// main.tsx
createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

// In development StrictMode runs setup -> cleanup -> setup on mount.
// Without the cleanup you would see two open connections.
function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect(); // makes the double run harmless
  }, [roomId]);

  // Bug StrictMode exposes: mutating a prop during render.
  // props.items.push(extra)   -> appears twice in development
  return <Messages roomId={roomId} />;
}
  • Double invocation only happens in development; it reveals effects without cleanup and impure renders that would also break under transitions, Suspense and Activity.
  • Fetching in an effect also runs twice in development; ignore stale responses with a flag or AbortController in the cleanup.
React 19 StrictMode documentation (opens in new tab)

WHEN TO REACH FOR WHAT

Restructure state and children

Use when A frequently changing piece of state sits high in the tree, or a wrapper owns state its children do not need.

Avoid when The state genuinely must be shared high up; then reach for a store with selectors.

React Compiler

Use when New or lint-clean code on React 17+ where you want memoization by default without hand-maintained dependency arrays.

Avoid when Code that mutates during render or breaks hook rules; fix those first or opt the component out.

Manual memo/useMemo/useCallback

Use when No compiler yet and the Profiler shows a costly subtree re-rendering with equal inputs, or you need a stable effect dependency.

Avoid when Cheap components, props that change anyway, or as a fix for a render loop whose cause you have not found.

useTransition / useDeferredValue

Use when An update is legitimately expensive and input feels laggy while it renders, such as filtering, tab switches or charts.

Avoid when The work is slow outside React (network, long tasks in handlers) or the render can be made cheap with virtualization.

WHERE IT BITES

  • Index keys in lists that insert, delete or reorder: input text, focus and local state attach to the wrong row.
  • Creating objects, arrays or arrow functions inline as props to a memoized child, or as a context value, so every render looks like a change.
  • Deriving state with an effect (useEffect(() => setFull(first + last))): an extra render per change and a flash of stale UI. Compute it during render.
  • Setting state unconditionally in an effect whose dependencies include that state or a new object each render, producing a render loop.
  • Disabling StrictMode because effects run twice instead of adding cleanup, which leaves the leak in production paths like remounts and route changes.
  • Wrapping everything in memo and useCallback without profiling; the comparisons and dependency arrays cost more than the renders they skip, and wrong deps create stale closures.

CLOSE THE AI. EXPLAIN THIS.

A search box lags on every keystroke and the results component re-renders even though you wrapped it in memo. Name three possible reasons the memo is not working, then explain how useDeferredValue changes when the expensive render happens without making it faster.

SOURCES

Explainer reviewed