React from Usage to Internals: Fiber Reconciliation, Diffing, Hooks and Concurrent Rendering
Key takeaways
A single deep guide connecting React usage with how Fiber reconciliation, virtual DOM diffing, hooks on fibers, the concurrent scheduler (batching, transitions, Suspense, streaming SSR) and production patterns actually fit together, including what changed in React 19.
What React is
React lets you describe UI as a function of state. Whenever state changes, it recomputes a React element tree, compares it with the previous tree, and applies the resulting changes to the underlying host tree (DOM, native views, Canvas, etc.). The core package is renderer-agnostic; react-dom, react-native and other renderers implement the host-specific commit operations.
This article pairs everyday API usage with what happens underneath: how the work loop traverses Fiber nodes, how list diffing uses keys, how hooks are stored on fibers, and how the scheduler prioritizes updates through automatic batching, transitions, Suspense and streaming SSR. Where React 19 changed the picture, the relevant section says so.
Project setup
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
Mount with createRoot:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
createRoot is not just a new spelling of the old API. The legacy ReactDOM.render root kept React 17 semantics—no automatic batching outside event handlers and no concurrent features—which is why a React 18 upgrade that only bumped the package version changed almost nothing. React 19 removed ReactDOM.render and ReactDOM.hydrate entirely, so code still using them has to move to createRoot / hydrateRoot from react-dom/client before it will run at all.
StrictMode intentionally double-invokes component bodies and, since React 18, mounts every component, runs its effects, unmounts it and mounts it again—in development only. It has no production runtime impact. The point is to surface effects without a proper cleanup: if a double mount opens two WebSocket connections or fires two analytics events, the effect is missing a teardown that would also bite you on fast route changes in production.
Components, JSX, and basic hooks
Function components use hooks for state and side effects:
import { useState, useEffect, useMemo } from 'react';
type User = { id: string; name: string };
export function UserPanel({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json() as Promise<User>)
.then(setUser)
.catch((err) => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, [userId]);
const title = useMemo(() => (user ? `Hello, ${user.name}` : 'Loading…'), [user]);
return <section aria-label={title}>{user?.name ?? ''}</section>;
}
- The dependency array is a contract: “re-run this effect when these values change.” Aborting in the cleanup avoids races when
userIdchanges quickly—the response for an old id can no longer overwrite the newer one—and it is exactly what makes the StrictMode double mount harmless. useMemo/useCallbackmatter when referential identity matters—e.g. children wrapped inReact.memo. Profile first; blanket memoization often hurts readability for no gain.- Other hooks added in React 18 fill specific gaps:
useIdgenerates ids that match between server and client HTML (forhtmlFor/aria-*pairs—never use it for list keys), anduseInsertionEffectruns before layout effects so CSS-in-JS libraries can inject style tags before anything reads layout. Application code rarely needs the latter.
Fiber reconciliation (deep dive)
React 16 replaced the stack reconciler with a Fiber reconciler. Goals: (1) chunk work so the main thread is not monopolized, (2) prioritize updates, and (3) support interruptible, resumable, discardable render passes—the foundation of concurrent features.
Render phase vs commit phase
- Render (reconciliation) phase: builds the
workInProgressFiber tree and computes what must change. It should be pure (derive from props/state only) and may be interrupted. - Commit phase: performs real host mutations (DOM updates) and layout-affecting work. It is synchronous and not interruptible.
That split is why you must not cause observable side effects during render: the render pass might run more than once or abort before commit (especially under StrictMode or concurrent rendering).
Fiber nodes and the linked-list tree
Each component instance / host node corresponds to a Fiber. Conceptually it carries:
child/sibling/return: pointers that encode a depth-first traversal without an explicit stack, making it easy to slice work into units.alternate: links the current tree and work-in-progress tree for double buffering. When work completes, trees swap at commit.memoizedProps/memoizedState: finalized props for this render and the head of the hook list (among other memoized values).
Think of a Fiber as both “what to show” and a schedulable work item.
workLoop and time slicing
In concurrent renders, the work loop processes Fibers one unit at a time and checks after each unit whether it should yield (roughly every 5 ms), so the browser can handle input and paint—this is time slicing. The reconciler is less “compare two giant trees in one shot” and more an incremental work loop over Fibers. Synchronous updates (the default for clicks and typing) still run the loop to completion without yielding; only transition-priority work is sliced.
beginWork / completeWork and child reconciliation
Roughly, each Fiber passes through beginWork (decide what to do, descend to children) and completeWork (bubble up and finalize). Host components (e.g. div) diff props then attach child Fibers; function/class components run the component to obtain child elements.
When reconciling children, React treats keyed and non-keyed children differently. Without keys, children are matched by position; with keys, React can build maps and match in roughly O(n) time. That is why stable keys on lists are a rule, not a style preference.
Effect lists and commit sub-phases
Commit is not atomic: mutate DOM → useLayoutEffect (sync, before paint) → paint → useEffect (after paint). Use useLayoutEffect only when you must read/write layout before paint; most subscriptions and logging belong in useEffect to avoid blocking the main thread. One nuance: when an update comes from a discrete input like a click, React 18+ flushes the resulting passive effects synchronously before the next discrete event, so effects are “after commit” rather than strictly “after paint.”
Virtual DOM and diffing (deep dive)
The virtual DOM is an in-memory tree of lightweight React elements. Each render constructs a fresh element tree; React compares it with the previous Fiber snapshot to produce a minimal change set.
Different element types
If the root type changes (div → section, or a different component function), React replaces the subtree. Internal state is usually lost, so swapping unrelated component types at the same position is a common footgun.
Same host element type
For the same intrinsic tag, React patches props in place—className, style, event handlers, etc.—instead of rebuilding everything.
Child lists and keys
Among siblings, key identifies stable identity across renders. React uses keys to match old and new children efficiently.
- Using array index as
keybreaks when items are reordered, inserted, or removed: the wrong instances may be reused, causing jumpy focus or state mis-attachment. - Prefer stable domain ids (database ids, natural keys).
Heuristics and cost
The diff is heuristic and tuned for typical UI patterns (O(n)-ish behavior). Still, deep, wide trees that fully re-render often are expensive. Production mitigations: narrow state, memoized subtrees where measured, virtualized lists (e.g. @tanstack/react-virtual, which renders only the rows in the viewport), and route-level code splitting to shrink the amount of work per update.
”Same position” rules and component identity
Reconciliation is keyed off child slots under a parent. If the same slot receives the same component function reference, React treats it as the same instance and preserves state. If you swap two different component types in the same slot, React sees a type change and drops state. To preserve state across different UIs, branch inside one component or split instances with an explicit key. The same rule explains a classic bug: defining a component function inside another component’s body creates a new function reference every render, so React sees a new type each time and remounts the subtree, wiping its state and focus.
Hooks implementation (deep dive)
Hooks are not magic—they are a linked list stored on the Fiber’s memoizedState. Each hook call creates a node; the next hook chains via next.
useState and dispatchers
useState stores the current value, a queue of updates, and links to the next hook. Calling the setter schedules an update on that Fiber, and the scheduler flushes a render when appropriate.
Why hook order must be fixed
Conditional or looped hook calls change the number of hook nodes per render, desynchronizing the list and pairing state with the wrong hook—this is the mechanical reason behind the Rules of Hooks. (React 19’s use() is the one exception: it is not backed by a hook node, so it may be called inside conditions and loops.)
useEffect queues
Effects run after commit, and dependencies decide whether to tear down and re-subscribe. For synchronous DOM reads/writes that must happen before paint, useLayoutEffect is the tool—use sparingly because it blocks painting.
Mount vs update dispatchers
Internally React maintains a current dispatcher per render phase. Built-in hooks like useState take different paths on first mount vs updates: mount initializes hook nodes; update enqueues changes on existing queues. Custom hooks must follow the same rules—conditional hooks inside a custom hook still break the hook list if call counts change.
useReducer and automatic batching
useReducer is ideal for event-heavy state machines. Batching decides how many renders a burst of updates costs. React 17 batched only inside React event handlers; two setters called in a setTimeout or after an await caused two renders. Under createRoot, React 18+ batches everywhere—timeouts, promises, native listeners:
// React 17 (or a legacy ReactDOM.render root): two renders
// React 18+ with createRoot: one render
setTimeout(() => {
setCount((c) => c + 1);
setFlag((f) => !f);
}, 1000);
This pairs well with splitting state without multiplying renders. The rare opt-out is flushSync, which forces the updates inside its callback to render and commit synchronously before it returns:
import { flushSync } from 'react-dom';
flushSync(() => {
setItems((prev) => [...prev, newItem]); // committed to the DOM before flushSync returns
});
listRef.current?.lastElementChild?.scrollIntoView(); // the new row exists now
setFlag((f) => !f); // an ordinary update again, batched as usual
Only the updates inside the callback are flushed; anything after it goes back to normal batching. Reach for it when imperative DOM code (scrolling, focus, measuring) needs the result right away, and treat each use as a performance cost, because it also forces any pending work to finish first.
A common upgrade trap with batching is code that quietly relied on the old behavior: a promise callback that called one setter and then read a DOM measurement or a ref updated during that render, expecting the render to have happened. After switching to createRoot the read sees stale values because the render is now deferred to the end of the batch. The fix is to move the read into an effect or a layout effect—flushSync works too, but it is the tool for the few cases that need it.
Concurrent rendering (deep dive)
createRoot enables concurrent features, but every update is still urgent by default. React models urgency with lanes (an internal priority system):
- Urgent updates: typing, clicks—need immediate feedback and render synchronously.
- Transition updates: wrapped in
startTransition—rendered in interruptible slices and abandoned if newer urgent input arrives.
Caveat: because renders may retry or abort, never put side effects in render. Fetch in effects or data libraries; write code that survives StrictMode’s development-only double invocation.
useTransition: marking your own updates as non-urgent
The standard case is an input that drives an expensive list. Keep the input value urgent and move only the expensive state into a transition:
import { useState, useTransition } from 'react';
function SearchList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [listQuery, setListQuery] = useState('');
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // urgent: the input must echo every keystroke
startTransition(() => {
setListQuery(value); // non-urgent: can be interrupted by the next keystroke
});
}
const results = filterItems(items, listQuery); // expensive
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<List items={results} />
</>
);
}
Two details make or break this. First, the controlled input’s own value must not be set inside the transition—if it is, the input can only update when the transition commits, and fast typing feels like the field is fighting you. Wrapping the single setQuery call of an existing search box in startTransition is a typical first attempt, and it makes the box worse than before: characters appear in bursts. Splitting into an urgent value and a deferred one is the whole trick. Second, the transition only helps if the expensive work actually happens during render in components React can interrupt. If the cost is a synchronous filterItems over 50,000 rows, each attempt still blocks for the full duration once it starts; the benefit is that stale attempts get discarded instead of queued.
Transitions also change how Suspense behaves on navigation. For tabs or route changes, wrapping the state change in startTransition tells React to keep showing the previous content (with isPending available for a dimmed style) instead of replacing an already-visible screen with a fallback:
function Tabs() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
return (
<>
<nav style={{ opacity: isPending ? 0.6 : 1 }}>
{['home', 'about', 'contact'].map((t) => (
<button key={t} onClick={() => startTransition(() => setTab(t))}>
{t}
</button>
))}
</nav>
<Suspense fallback={<Spinner />}>
<TabContent tab={tab} />
</Suspense>
</>
);
}
In React 18, the function passed to startTransition must be synchronous—state updates after an await inside it are not marked as transitions. React 19 accepts async functions (“Actions”): isPending stays true across the awaits, and errors surface to error boundaries. That is the base the new form hooks build on (see “Server Components, Server Functions and Actions” below).
useDeferredValue: deferring a value you receive
When you do not own the setter—the value comes in as a prop or from context—useDeferredValue gives you a copy that lags behind during heavy renders:
import { useState, useDeferredValue, memo } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<SearchResults query={deferredQuery} />
</div>
</>
);
}
const SearchResults = memo(function SearchResults({ query }: { query: string }) {
return <List items={expensiveFilter(items, query)} />;
});
The memo is not optional decoration here. React first re-renders with the old deferred value, then attempts a background render with the new one. Without memo, SearchResults re-renders during that first urgent pass anyway—because its parent re-rendered—and the deferral buys nothing. React 19 adds an optional second argument, useDeferredValue(value, initialValue), so the first render can use a cheap placeholder and fill in the real value in a background render.
Lanes and starvation
Updates are grouped into lanes (internal priorities). Urgent input can keep delaying a heavy transition; the scheduler still ensures long-waiting work eventually runs by expiring starved lanes. Even so, a massive transition (re-rendering a huge list) may need more than useDeferredValue—pair transitions with chunking and virtualization.
Suspense for data
A component “suspends” by throwing a promise during render; the nearest <Suspense> boundary shows its fallback until the promise resolves, then React retries the render. React 18 made this work for data, not just React.lazy, but deliberately left the fetching side to frameworks and libraries: Next.js, Relay, TanStack Query’s useSuspenseQuery, or SWR with suspense: true. React 19 adds the public use(promise) API for reading a promise inside render.
Boundaries define what loads together. Nested boundaries let independent regions resolve on their own schedule:
function Dashboard() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<OrdersTable />
</Suspense>
</Suspense>
);
}
Two pitfalls. First, Suspense only deduplicates what your data layer caches. If a component creates a fresh promise on every render—use(fetch(url).then(r => r.json())) directly in the body—each retry creates another request, which suspends again, and you get an endless fetch loop. The promise must come from a cache, a loader, or a Server Component that created it once. Second, parallel boundaries only load in parallel if the requests start in parallel. If RevenueChart fetches and then renders a child that fetches its own data, you still get a waterfall; the fix is to start both requests higher up (a route loader or a parent that kicks off both) and let the components read the pending promises.
React 19 also changed what happens when a component suspends: React now commits the nearest fallback immediately instead of first rendering the suspended component’s siblings, then “pre-warms” those siblings in the background. Fallbacks appear sooner, but fetches that used to be kicked off by rendering those siblings now start only in the pre-warm pass, a little later than before, so code relying on that pattern can get slower. That behavior change is a good reason to start fetches outside the render tree.
useSyncExternalStore and external stores
When wiring global stores (Redux, Zustand, browser APIs), useSyncExternalStore is the React 18+ pattern to avoid tearing—where different parts of the UI read inconsistent snapshots during concurrent rendering. It takes a subscribe function, a getSnapshot function, and an optional server snapshot:
import { useSyncExternalStore } from 'react';
function subscribe(onChange: () => void) {
window.addEventListener('resize', onChange);
return () => window.removeEventListener('resize', onChange);
}
export function useWindowWidth() {
return useSyncExternalStore(
subscribe,
() => window.innerWidth, // client snapshot: a primitive, so equal values compare equal
() => 0, // server snapshot used during SSR and hydration
);
}
The contract that trips people up is that getSnapshot must return the same value (by Object.is) when nothing has changed. A tempting version returns { width: window.innerWidth, height: window.innerHeight }—a new object on every call—and React responds with a warning that the result of getSnapshot should be cached, followed by an infinite re-render loop, because every check looks like a change. Return primitives, or cache the object and only replace it inside the event handler. Defining subscribe outside the component matters too: an inline arrow is a new function every render, so React unsubscribes and resubscribes each time.
Production React patterns
Boundaries and recovery
- Error boundaries catch render/lifecycle errors in descendants and show fallback UI (not errors inside event handlers—handle those explicitly). React 19 adds
onCaughtError/onUncaughtErrorroot options for reporting, and no longer logs each error twice. - Suspense defines loading boundaries; pair with routers and
React.lazyfor route-level splitting.
State architecture
- Separate server state (cache, invalidation) from UI state (modals, ephemeral form fields).
- Avoid premature global stores; colocate state at the lowest shared ancestor.
Performance habits
- Lists: stable
key, normalized stores (byId) when data is relational. - Memoization: apply
memo/useMemoto measured hot children; uselessmemoon components whose props change every render buys nothing. The React Compiler (stable since late 2025) inserts this memoization automatically at build time, which removes most hand-writtenuseMemo/useCallback—but only for components that follow the Rules of React, so code that mutates props or reads refs during render gets skipped rather than optimized. - Bundles: dynamic
import(), tree-shakeable imports, avoid pulling entire heavy libraries for one helper.
Accessibility and forms
Default to semantic HTML, focus management, and ARIA where needed. For complex forms, separate controlled vs uncontrolled concerns and map validation, client, and server errors consistently (libraries like React Hook Form help).
Composition, context, and performance
- Composition:
childrenor slot props reduce prop drilling while keeping types precise. - Context: putting a frequently changing value in a large context can re-render every consumer. Prefer small contexts or external stores with
useSyncExternalStorefor volatile data. - Render props / custom hooks: when only logic is shared, extract a custom hook—easier to test and reuse than nested HOCs.
Streaming SSR
The pre-18 server API, renderToString, had to wait for every piece of data before sending a single byte, and the client had to hydrate the whole page in one pass before anything was interactive. React 18’s renderToPipeableStream (Node) and renderToReadableStream (Web streams) send the shell—everything outside Suspense boundaries—as soon as it is ready, then stream each boundary’s HTML, with a small inline script that swaps it into place, when its data resolves. Hydration becomes selective too: boundaries hydrate independently, and one the user clicks on gets hydrated first.
import { renderToPipeableStream } from 'react-dom/server';
app.get('/', (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/client.js'],
onShellReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError() {
// the shell itself failed: nothing has been sent yet, so a full error page is safe
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html');
res.send('<h1>Something went wrong</h1>');
},
onError(error) {
didError = true;
console.error(error); // log only; streaming may already have started
},
});
setTimeout(() => abort(), 10_000); // give up on slow boundaries; they fall back to client rendering
});
The callback split is the part copy-pasted examples often get wrong. onError fires for errors anywhere, including inside a Suspense boundary after the shell has already been streamed. If that callback calls res.status(500).send(...), a failure in one late boundary becomes Express’s “Cannot set headers after they are sent to the client” error while the page is half-streamed, so one flaky widget can turn into failed responses. Put the error response in onShellError (before anything is sent), use onError for logging and choosing the status code, and let a failed boundary fall back to client rendering, which is what React does by default.
Keep in mind that the status code and headers are committed when the shell streams, so anything that must affect them—redirects, 404 for a missing product—has to be decided by code outside Suspense boundaries.
Server Components, Server Functions and Actions
With frameworks like the Next.js App Router, React Server Components run only on the server: they can be async, read databases and secrets directly, and ship no JavaScript to the client. Components marked with 'use client' (and everything they import) form the client bundle and can use state, effects and event handlers. The boundary has one hard rule: props crossing from a Server Component into a Client Component must be serializable—strings, numbers, arrays, plain objects, JSX, and in React 19 promises (which the client can read with use()). Plain functions and class instances cannot cross; functions only work if they are Server Functions.
Server Components and Server Functions (functions marked 'use server', formerly called Server Actions, callable from the client) became stable parts of React in 19. Combined with async transitions, React 19 adds form-oriented hooks: useActionState for pending state and results of an action, useOptimistic for showing an update before the server confirms it, and useFormStatus in react-dom for reading the parent form’s pending state from a nested button. <form action={fn}> accepts a function directly.
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createUser(formData: FormData) {
const name = String(formData.get('name') ?? '');
if (!name) return { error: 'Name is required' };
await db.user.create({ data: { name } });
revalidatePath('/users');
return { error: null };
}
Treat each Server Function as a public HTTP endpoint: the client can call it with any arguments, so validate input and check authorization inside it, exactly as you would in an API route. Other React 19 quality-of-life changes: ref is now a regular prop on function components (so forwardRef is no longer needed), <Context> can be rendered directly as a provider, and <title> / <meta> / <link> rendered anywhere are hoisted into <head>.
Testing and observability
- Unit: hooks and pure utils with Jest/Vitest; components with Testing Library (user-centric assertions).
- E2E: Playwright/Cypress for critical journeys.
- Production: error boundaries + logging (e.g. Sentry), Core Web Vitals, React DevTools Profiler to find render hotspots.
Which scheduling tool to reach for
| Tool | Reach for it when |
|---|---|
startTransition / useTransition | You own an update that triggers an expensive render (filters, tabs, navigation) |
useDeferredValue | An expensive subtree depends on a prop or context value you do not control |
Nested <Suspense> | Independent regions should load and reveal separately |
| Streaming SSR | Some server data is slow but the rest of the page should not wait |
useSyncExternalStore | Reading any store that lives outside React state |
flushSync | Imperative DOM code needs an update committed right now |
Reach for these only after a profile shows the render that is actually slow. A transition does not make a render cheaper; it only lets React keep the page responsive while that render happens, so a component tree that does too much work on every keystroke still needs narrower state or a smaller subtree.
Further reading
- React Hooks Pitfalls: Stale Closures, Effect Loops, Fetch Races and Useless Memoization goes further into effect patterns.
- Next.js App Router: SSR vs SSG vs ISR shows how Server Components and streaming are used in a real framework.
- Finding and Fixing Slow React Renders: memo, useMemo, useCallback, Code Splitting and Profiling covers measuring before memoizing.
- What TypeScript 5 Changed: Standard Decorators, satisfies, const Type Parameters and Migrating from 4 for typing component props and app-level modules.