Problem Context
Pre-2017 React had a synchronous reconciler. Click a button, set state, and React would re-render the entire subtree in one uninterruptible pass. On a fast app this was fine. On a slow page with a heavy tree it would block the main thread for hundreds of milliseconds โ janky scrolling, dropped keystrokes, the "is it broken?" spinner.
React Fiber, shipped in React 16, rebuilt the reconciler around interruptible work. Fiber is what makes Concurrent Rendering, Suspense, useTransition, automatic batching, and Server Components possible. You don't write Fiber code directly, but understanding it explains why "why did this re-render?" questions have surprising answers.
- You wrote
setStatethree times in a handler and saw exactly one re-render โ and weren't sure why - You wrapped state in
useTransitionand the "urgent" updates still felt slow - You see "React detected an update during render" warnings and don't know what fiber phase that refers to
- You've heard "React 18 added concurrent rendering" but couldn't explain what changed
This guide explains Fiber as a mental model โ work units, phases, lanes โ without going into the source code.
Concept Explanation
A fiber is React's internal data structure representing one node in the component tree (a host element like div, a function component, a Suspense boundary, etc.). Each fiber holds props, state, hook list, child/sibling/return pointers, and alane mask describing the priority of pending work.
flowchart LR
A["State update"] --> B["Schedule on a Lane"]
B --> C{"Lane priority"}
C -->|Sync| D["Render immediately"]
C -->|Default| E["Render in next frame"]
C -->|Transition| F["Render when idle, can be interrupted"]
D --> G["Render Phase<br/>(pure, interruptible)"]
E --> G
F --> G
G --> H["Build work-in-progress fiber tree"]
H --> I["Diff vs current tree"]
I --> J["Commit Phase<br/>(synchronous, DOM mutations)"]
J --> K["Run effects (useEffect)"]
style C fill:#4f46e5,color:#fff,stroke:#4338ca
style G fill:#059669,color:#fff,stroke:#047857
style J fill:#dc2626,color:#fff,stroke:#b91c1c
Rendering happens in two phases:
Render Phase โ React calls your function components, runs hook updates, and builds a new work-in-progress fiber tree. This phase is pure, repeatable, and interruptible. React may discard the work and start over if a higher-priority update comes in. Side effects in render are forbidden because the render may run multiple times (Strict Mode mounts twice for this reason).
Commit Phase โ once a complete tree is ready, React commits it to the DOM in a synchronous, uninterruptiblesweep: insertions, mutations, deletions, then layout effects (useLayoutEffect), then passive effects (useEffect) on the next microtask.
Lanes โ priority without preemption
React 18 replaced the old "expiration time" scheduler with Lanes: a 31-bit bitmask where each bit represents a priority class. Sync events (clicks, keystrokes) get high-priority lanes. Transitions (startTransition) get a low-priority lane that can be interrupted by a sync update on the same fiber. Multiple updates of different priority can be processed in the same render pass.
Implementation
Step 1: Automatic batching โ one render per event
// React 18+ batches all of these into ONE re-render โ even across awaits
function handleClick() {
setCount(c => c + 1);
setFlag(true);
setTimeout(() => {
setName('Amit'); // pre-18: separate render. 18+: batched.
setEmail('a@b.com'); // batched with the line above.
}, 0);
}Step 2: useTransition โ mark updates as interruptible
'use client';
import { useState, useTransition } from 'react';
function SearchBar({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Item[]>(items);
const [isPending, startTransition] = useTransition();
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
// 1) Urgent: keep input responsive
setQuery(e.target.value);
// 2) Non-urgent: filter the (possibly large) list
startTransition(() => {
setResults(items.filter(i => i.name.includes(e.target.value)));
});
}
return (
<>
<input value={query} onChange={onChange} />
{isPending && <Spinner />}
<ResultList items={results} />
</>
);
}The keystroke that updates query commits in the next frame. The (potentially expensive) filter that updates results runs on a transition lane and may be interrupted if the user types again โ Fiber discards the partial work and re-starts with the latest input.
Step 3: useDeferredValue โ defer downstream work
function Search({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// deferredQuery lags behind query under load,
// letting the input stay responsive while ResultList re-renders.
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultList items={items} query={deferredQuery} />
</>
);
}Step 4: Render-phase pitfalls Fiber catches
// BAD โ setState during render of the SAME component creates an infinite loop
function Bad() {
const [n, setN] = useState(0);
setN(n + 1); // React throws: "Too many re-renders"
return <div>{n}</div>;
}
// OK โ setState during render of a DIFFERENT component is allowed (rare)
// React will discard the in-progress render of the parent and re-run.
// BAD โ Strict Mode runs render twice in dev. Side effects here run twice.
function Logger() {
console.log('rendered'); // logs twice in dev under StrictMode
fetch('/api/log'); // FIRES TWICE โ move into useEffect
return null;
}Pitfalls
1. Confusing render and commit."React rendered my component twice but only the DOM updated once" โ that's render phase running twice (Strict Mode dev, or interrupted concurrent work) and only one commit. Side effects belong in useEffect so they only run on commit, not on every render attempt.
2. Expecting transitions to make slow code fast. useTransitiondoesn't speed up rendering โ it lets the UI stay interactive while slow work happens. If filtering 100k items takes 800ms, it still takes 800ms; you just get a responsive input during it. For real speed, profile and reduce the work.
3. Reading refs during render. ref.current mutates outside the render lane. Reading it during render gives stale values relative to the WIP tree. Read refs in effects or event handlers, not in JSX.
4. Assuming hook order doesn't matter. Fiber matches hook calls to slots by call order. Conditional hooks (if (x) useState(...)) shift the slot indices and corrupt every hook that follows. The lint rule react-hooks/rules-of-hookscatches this โ never disable it.
5. Long-running work in the render phase.Even with concurrent rendering, a single component's render must finish before React can yield. A 200ms JSON.parse in a component still blocks for 200ms. Move it to a Worker, a Server Component, or split withSuspense.
Practical Takeaways
- The render phase is pure and may run more than once. Side effects always go in
useEffect. - The commit phase is synchronous and short. Don't put expensive work in
useLayoutEffect. - Lanes give you priority, not preemption. A long synchronous render still blocks; transitions only let other lanes interrupt at hook boundaries.
- Use
useTransitionwhen an update would block the UI but isn't urgent (filtering, route changes). - Use
useDeferredValuewhen you don't own the setter but want a downstream component to render on a lower priority. - Strict Mode's double-mount in dev is a feature: it surfaces effects that aren't cleanup-safe. Fix the effect, don't disable Strict Mode.
- If "why did this re-render?" is a recurring question, install React DevTools' Profiler โ it shows which fibers committed and why.

