Back to React
React
medium
mid

How do state updates and re renders work in React?

setState schedules a re-render; React reconciles the virtual DOM, diffs vs the previous render, and commits changes to the real DOM. Updates batch automatically (React 18+) across sync and async code. A component re-renders when its state, props, or subscribed context changes; parent re-render cascades to children unless React.memo + stable props short-circuit it. Updates from outside React (Redux, Zustand) need useSyncExternalStore for tearing-free concurrent rendering.

9 min read·~5 min to think through

React's render model is well-defined but easy to misuse. Understanding the lifecycle clarifies most perf and correctness questions.

The cycle

  1. Trigger: setState, a parent re-rendering, or a subscribed context/store updating.
  2. Render: React calls the component function. The function returns a virtual DOM tree.
  3. Reconcile: React diffs the new tree against the previous one.
  4. Commit: only the changed DOM nodes are updated.
  5. Effects: useEffect callbacks run after commit (synchronous layout effects via useLayoutEffect run before paint).

What causes a re-render

A component re-renders when:

  1. Its own useState/useReducer state changes.
  2. A parent re-renders (cascades down).
  3. A subscribed useContext value changes.
  4. An external store subscribed via useSyncExternalStore notifies a change.

Crucially: props changing doesn't trigger a re-render. Props change because the parent re-rendered — and the parent's re-render is what cascaded to the child.

Batching

React 18 batches setState calls across all paths — including timeouts, promises, and event handlers:

jsx
function onClick() {
  setA(1);
  setB(2);
  setC(3);
  // → one re-render
}

async function onLoad() {
  const data = await fetch();
  setA(data.a);
  setB(data.b);
  // → still one re-render in React 18+
}

In React 17, async setStates didn't batch — they fired separate renders. React 18 changed this; if you need to opt out, use flushSync.

Functional updates

jsx
setCount(prev => prev + 1);

Use the functional form when the new value depends on the previous. The plain form captures the value at the time of render — stale closures bite when setState is called inside intervals, callbacks, async work.

Strict Mode

Dev-only: React intentionally double-invokes:

  • Component bodies (renders).
  • useEffect setup + cleanup.
  • useState/useReducer initializer.

Goal: surface side effects in render and effects that aren't idempotent. Strict Mode behavior in dev = a bug in your code, not a React bug.

Reconciliation

React walks the new tree and the old tree side by side:

  • Same component type at same position → re-render that component (its function runs).
  • Different component type → unmount old subtree, mount new.
  • Same type, different key → unmount + remount.

Keys matter on lists. Without stable keys (using indices or randoms), React mis-matches items on reorder/insert/delete → unnecessary unmount/remount → state loss, focus loss, expensive children re-mounting.

Concurrent rendering

React 18 introduced concurrent rendering:

  • React can pause a render in progress, switch to a higher-priority update, then resume.
  • useTransition marks an update as non-urgent; React can interrupt it to handle user input.
  • useDeferredValue returns a value that lags behind the original; updates to the deferred value are interruptible.
jsx
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter);
const results = useMemo(() => slowFilter(items, deferredFilter), [items, deferredFilter]);

<input value={filter} onChange={e => setFilter(e.target.value)} />

Input updates are urgent (snappy typing); results re-compute against the deferred value, interruptible.

When children re-render

By default, a parent's re-render re-renders all children. This is fine if children are cheap. For expensive children, use React.memo:

jsx
const Expensive = React.memo(function Expensive({ data }) { … });

Now Expensive only re-renders if its props (shallow-compared) actually changed.

Catch: inline object/function/array literals from the parent are new references each render → memo doesn't help. Stabilize with useMemo/useCallback or hoist.

Effects

  • useEffect runs after commit.
  • useLayoutEffect runs after DOM mutations but before browser paint — use for measurements that need to influence layout before the user sees it.
  • Cleanup runs before the next effect fires AND before unmount.

Effect dep arrays matter:

  • [] — runs once on mount.
  • [a, b] — runs when a or b changes.
  • Omitted — runs on every render.

ESLint react-hooks/exhaustive-deps rule catches missing deps that cause stale closures.

External stores

Redux, Zustand, Apollo, etc. subscribe components via useSyncExternalStore. This API was added in React 18 to give external stores tearing-free concurrent rendering — all consumers see consistent state during a single render.

Mental model

State change → re-render → reconcile → commit. Parent re-renders cascade to children unless short-circuited. Strict mode reveals impurities. Concurrent features let you mark updates as non-urgent. External stores need useSyncExternalStore for correctness in concurrent mode.

Understanding this avoids the common confusions: "why is my useEffect firing twice?" (Strict Mode), "why does my memoized child still re-render?" (unstable prop ref), "why does my interval show a stale value?" (stale closure), "why did batching change?" (React 18 changed the default).

Follow-up questions

  • What did React 18 batching change?
  • Why does Strict Mode double-invoke?
  • What's the difference between useEffect and useLayoutEffect?
  • How does useTransition help with concurrent rendering?

Common mistakes

  • Stale closures from non-functional setState in async/intervals.
  • Effect dep arrays missing required values.
  • Side effects in render bodies.
  • Index keys in dynamic lists — wrong reconciliation.
  • Treating Strict Mode double-invocation as a React bug.
  • Inline object literals breaking React.memo.

Performance considerations

  • Most React perf wins are about *limiting the blast radius* of state changes (lift down, split context, memoize where it matters). Understanding the cycle is the foundation for picking the right optimization.

Edge cases

  • flushSync forces a synchronous render — escape hatch from batching.
  • useId for stable SSR-safe ids — avoid Math.random in render.
  • useSyncExternalStore for external store subscriptions.
  • Suspense + concurrent rendering can pause and discard renders — side effects in render are extra harmful.
  • RSC don't have state — server-rendered, no hooks.

Real-world examples

  • React DevTools Profiler visualizes the render lifecycle and why each component rendered.
  • React 19 Compiler auto-memoizes — but the underlying cycle is unchanged.
  • Concurrent React powers Suspense-based data fetching and transitions in modern frameworks.

Senior engineer discussion

Seniors articulate the cycle precisely, distinguish trigger from cascade, and pick optimizations based on the actual cause of a re-render (Profiler-confirmed, not guessed). They use useTransition/useDeferredValue for non-urgent updates and reach for external stores when fine-grained subscription is needed.

Related questions