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.
React's render model is well-defined but easy to misuse. Understanding the lifecycle clarifies most perf and correctness questions.
The cycle
- Trigger:
setState, a parent re-rendering, or a subscribed context/store updating. - Render: React calls the component function. The function returns a virtual DOM tree.
- Reconcile: React diffs the new tree against the previous one.
- Commit: only the changed DOM nodes are updated.
- Effects:
useEffectcallbacks run after commit (synchronous layout effects viauseLayoutEffectrun before paint).
What causes a re-render
A component re-renders when:
- Its own
useState/useReducerstate changes. - A parent re-renders (cascades down).
- A subscribed
useContextvalue changes. - An external store subscribed via
useSyncExternalStorenotifies 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:
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
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.
useTransitionmarks an update as non-urgent; React can interrupt it to handle user input.useDeferredValuereturns a value that lags behind the original; updates to the deferred value are interruptible.
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:
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
useEffectruns after commit.useLayoutEffectruns 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.