React Fiber Under the Microscope: Scheduling, Priorities, and Cooperative Concurrency
React Fiber Under the Microscope: Scheduling, Priorities, and Cooperative Concurrency
React's Fiber architecture isn't just an implementation detail—it's the foundation that enables concurrent rendering, automatic batching, Suspense, and transitions. Understanding how Fiber scheduling actually works transforms how you architect large-scale UIs: when to use transitions, why certain patterns cause jank, and how to structure components for optimal responsiveness.
This is a deep dive into the internals: the work loop, priority lanes, time slicing, and the cooperative concurrency model that makes modern React possible.
The Problem Fiber Solves
┌─────────────────────────────────────────────────────────────────────────────┐
│ THE PRE-FIBER PROBLEM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ React 15 (Stack Reconciler) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ setState() │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ SYNCHRONOUS RECURSIVE RENDER │ │ │
│ │ │ │ │ │
│ │ │ render(App) │ │ │
│ │ │ → render(Header) │ │ │
│ │ │ → render(Main) │ │ │
│ │ │ → render(List) ← 10,000 items │ │ │
│ │ │ → render(Item) × 10,000 │ │ │
│ │ │ │ │ │
│ │ │ CANNOT STOP. CANNOT PAUSE. MUST COMPLETE. │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ │ 200ms later... │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Browser can finally: │ │ │
│ │ │ - Handle user input │ │ │
│ │ │ - Run animations │ │ │
│ │ │ - Paint to screen │ │ │
│ │ │ │ │ │
│ │ │ User sees: JANK. UI freeze. Dropped frames. │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ The fundamental issue: │
│ JavaScript is single-threaded. Long-running synchronous work │
│ blocks everything else, including user interactions. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Fiber's Solution: Cooperative Scheduling
┌─────────────────────────────────────────────────────────────────────────────┐
│ FIBER'S APPROACH │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ React 18+ (Fiber Reconciler) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ setState() │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ INTERRUPTIBLE INCREMENTAL RENDER │ │ │
│ │ │ │ │ │
│ │ │ Work Loop: │ │ │
│ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │
│ │ │ │Fiber1│→│Fiber2│→│Fiber3│→│Fiber4│→│Fiber5│→ ... │ │ │
│ │ │ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ ▼ ▼ ▼ │ │ │
│ │ │ "Should I "Still "Time's │ │ │
│ │ │ yield?" have up!" │ │ │
│ │ │ No ✓ time" ✓ ─────┐ │ │ │
│ │ │ │ │ │ │
│ │ │ ▼ │ │ │
│ │ │ ┌─────────────┐ │ │ │
│ │ │ │ YIELD TO │ │ │ │
│ │ │ │ BROWSER │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ • Input │ │ │ │
│ │ │ │ • Animation │ │ │ │
│ │ │ │ • Paint │ │ │ │
│ │ │ └──────┬──────┘ │ │ │
│ │ │ │ │ │ │
│ │ │ ▼ │ │ │
│ │ │ Resume work... │ │ │
│ │ │ ┌──────┐ ┌──────┐ │ │ │
│ │ │ │Fiber6│→│Fiber7│→ ... │ │ │
│ │ │ └──────┘ └──────┘ │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Key insight: Turn recursive tree traversal into an iterative │
│ linked-list walk that can pause at any fiber boundary. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Fiber Data Structure
Every React element becomes a Fiber—a JavaScript object representing a unit of work.
// Simplified Fiber structure (from React source)
interface Fiber {
// === IDENTITY ===
tag: WorkTag; // FunctionComponent, ClassComponent, HostComponent, etc.
key: string | null; // Reconciliation key
elementType: any; // The function/class/string
type: any; // Resolved type (after lazy loading)
// === TREE STRUCTURE ===
return: Fiber | null; // Parent fiber
child: Fiber | null; // First child
sibling: Fiber | null; // Next sibling
index: number; // Position among siblings
// === STATE ===
pendingProps: any; // Props for this render
memoizedProps: any; // Props from last render
memoizedState: any; // State from last render (hooks linked list)
updateQueue: UpdateQueue; // Pending state updates
// === EFFECTS ===
flags: Flags; // Side effects (Placement, Update, Deletion, etc.)
subtreeFlags: Flags; // Aggregated flags from children
deletions: Fiber[] | null; // Child fibers to delete
// === SCHEDULING ===
lanes: Lanes; // Priority lanes for this fiber
childLanes: Lanes; // Priority lanes in subtree
// === DOUBLE BUFFERING ===
alternate: Fiber | null; // The other version (current ↔ workInProgress)
// === HOST ===
stateNode: any; // DOM node, class instance, or null
}
// Work tags (what kind of fiber is this?)
const FunctionComponent = 0;
const ClassComponent = 1;
const IndeterminateComponent = 2; // Before we know if it's function or class
const HostRoot = 3; // Root of the tree
const HostPortal = 4; // React.createPortal
const HostComponent = 5; // DOM element (div, span, etc.)
const HostText = 6; // Text node
const Fragment = 7;
const Mode = 8; // StrictMode, ConcurrentMode
const ContextConsumer = 9;
const ContextProvider = 10;
const ForwardRef = 11;
const Profiler = 12;
const SuspenseComponent = 13;
const MemoComponent = 14;
const SimpleMemoComponent = 15;
const LazyComponent = 16;
const OffscreenComponent = 22; // For Suspense and hidden content
The Linked List Traversal
┌─────────────────────────────────────────────────────────────────────────────┐
│ FIBER TREE TRAVERSAL ORDER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Component Tree: │
│ │
│ <App> Fiber Tree: │
│ <Header> │
│ <Logo /> ┌─────┐ │
│ <Nav /> │ App │ ◄─── 1. beginWork │
│ </Header> └──┬──┘ │
│ <Main> │ child │
│ <Sidebar /> ▼ │
│ <Content /> ┌────────┐ sibling ┌──────┐ │
│ </Main> │ Header │──────────►│ Main │ │
│ </App> └───┬────┘ └──┬───┘ │
│ │ │ │
│ ┌───┴───┐ ┌───┴────┐ │
│ ▼ ▼ ▼ ▼ │
│ ┌────┐ ┌─────┐ ┌────────┐ ┌────────┐ │
│ │Logo│ │ Nav │ │Sidebar │ │Content │ │
│ └────┘ └─────┘ └────────┘ └────────┘ │
│ │
│ Traversal order (depth-first): │
│ │
│ 1. App beginWork (enter) │
│ 2. Header beginWork (enter) │
│ 3. Logo beginWork → completeWork (leaf) │
│ 4. Nav beginWork → completeWork (leaf, sibling of Logo) │
│ 5. Header completeWork (all children done) │
│ 6. Main beginWork (sibling of Header) │
│ 7. Sidebar beginWork → completeWork (leaf) │
│ 8. Content beginWork → completeWork (leaf) │
│ 9. Main completeWork │
│ 10. App completeWork (root done) │
│ │
│ Algorithm: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ while (workInProgress !== null) { │ │
│ │ // Process current fiber │ │
│ │ next = beginWork(current, workInProgress, lanes); │ │
│ │ │ │
│ │ if (next !== null) { │ │
│ │ // Has child, go deeper │ │
│ │ workInProgress = next; │ │
│ │ } else { │ │
│ │ // No child, complete this fiber │ │
│ │ completeUnitOfWork(workInProgress); │ │
│ │ // completeUnitOfWork handles sibling/return navigation │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Priority System: Lanes
React 18 uses a "lanes" model for priority. Each lane is a bit in a 31-bit integer, allowing efficient priority comparisons and merging.
// Lane definitions (from React source, simplified)
const TotalLanes = 31;
// Priority lanes (higher = more urgent)
const NoLanes = 0b0000000000000000000000000000000;
const NoLane = 0b0000000000000000000000000000000;
const SyncLane = 0b0000000000000000000000000000001; // Discrete events (click)
const InputContinuousLane = 0b0000000000000000000000000000010; // Continuous events (drag)
const DefaultLane = 0b0000000000000000000000000000100; // Normal updates
const TransitionLanes = 0b0000000011111111111111110000000; // startTransition
const RetryLanes = 0b0000011100000000000000000000000; // Suspense retries
const IdleLane = 0b0100000000000000000000000000000; // Idle callbacks
const OffscreenLane = 0b1000000000000000000000000000000; // Offscreen rendering
// Lane operations
function mergeLanes(a: Lanes, b: Lanes): Lanes {
return a | b; // Bitwise OR
}
function intersectLanes(a: Lanes, b: Lanes): Lanes {
return a & b; // Bitwise AND
}
function includesSomeLane(set: Lanes, subset: Lanes): boolean {
return (set & subset) !== NoLanes;
}
function isSubsetOfLanes(set: Lanes, subset: Lanes): boolean {
return (set & subset) === subset;
}
How Priorities Map to User Actions
┌─────────────────────────────────────────────────────────────────────────────┐
│ PRIORITY LANE MAPPING │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ User Action │ Lane │ Behavior │
│ ─────────────────────────┼────────────────────┼───────────────────────── │
│ Click handler │ SyncLane │ Synchronous, immediate │
│ setState in onClick │ │ Cannot be interrupted │
│ │ │ │
│ ─────────────────────────┼────────────────────┼───────────────────────── │
│ Input, scroll, drag │ InputContinuous │ High priority, can batch │
│ onChange, onScroll │ Lane │ Multiple events together │
│ │ │ │
│ ─────────────────────────┼────────────────────┼───────────────────────── │
│ useEffect setState │ DefaultLane │ Normal priority │
│ fetch().then(setState) │ │ Can be interrupted │
│ │ │ │
│ ─────────────────────────┼────────────────────┼───────────────────────── │
│ startTransition │ TransitionLanes │ Low priority │
│ useDeferredValue │ │ Interruptible by above │
│ │ │ Shows stale UI while │
│ │ │ rendering new │
│ │ │ │
│ ─────────────────────────┼────────────────────┼───────────────────────── │
│ Suspense retry │ RetryLanes │ When promise resolves │
│ │ │ Re-attempt rendering │
│ │ │ │
│ ─────────────────────────┼────────────────────┼───────────────────────── │
│ requestIdleCallback │ IdleLane │ Only when browser idle │
│ (internal use) │ │ Lowest priority │
│ │ │ │
│ ─────────────────────────┼────────────────────┼───────────────────────── │
│ Hidden content │ OffscreenLane │ Prerendering content │
│ <Offscreen> │ │ not yet visible │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Priority Entanglement
┌─────────────────────────────────────────────────────────────────────────────┐
│ LANE ENTANGLEMENT │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Problem: What if we have multiple pending updates at different │
│ priorities that affect the same component? │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Update 1: SyncLane (click) │ │
│ │ └─► Counter setState: count + 1 │ │
│ │ │ │
│ │ Update 2: TransitionLane (search) │ │
│ │ └─► SearchResults setState: newResults │ │
│ │ │ │
│ │ Update 3: DefaultLane (effect) │ │
│ │ └─► Counter setState: count + 1 (another) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ React processes lanes in priority order: │
│ │
│ Pass 1: Process SyncLane │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ • Counter: count 0 → 1 │ │
│ │ • SearchResults: unchanged (different lane) │ │
│ │ • Commit to screen │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Pass 2: Process DefaultLane │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ • Counter: count 1 → 2 │ │
│ │ • SearchResults: still unchanged │ │
│ │ • Commit to screen │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Pass 3: Process TransitionLane (can be interrupted) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ • Counter: already at 2 │ │
│ │ • SearchResults: render with newResults │ │
│ │ • If interrupted → restart later │ │
│ │ • If completes → commit to screen │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Entanglement ensures consistency: │
│ If two updates MUST be seen together, they get merged into same lane │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Work Loop
The work loop is the heart of React's scheduler. It's where time slicing and interruption happen.
// Simplified work loop (from ReactFiberWorkLoop.js)
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
function workLoopSync() {
// Perform work without checking if we need to yield
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork: Fiber): void {
// The current fiber (what's on screen) for this work
const current = unitOfWork.alternate;
// Render phase: Process this fiber and get next child
const next = beginWork(current, unitOfWork, renderLanes);
// Memoize the props for next comparison
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// No child. Complete this fiber and find next work.
completeUnitOfWork(unitOfWork);
} else {
// Has child. Continue down the tree.
workInProgress = next;
}
}
function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate;
const returnFiber = completedWork.return;
// Complete the work for this fiber
completeWork(current, completedWork, renderLanes);
// Check for sibling
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// Sibling exists, process it next
workInProgress = siblingFiber;
return;
}
// No sibling, go back to parent
completedWork = returnFiber;
workInProgress = completedWork;
} while (completedWork !== null);
// Reached the root
workInProgressRootExitStatus = RootCompleted;
}
Time Slicing: shouldYield()
// The magic function that enables cooperative scheduling
function shouldYield(): boolean {
const currentTime = getCurrentTime();
// Have we exceeded our time slice?
if (currentTime >= deadline) {
// Check if we need to yield to browser
if (needsPaint || scheduling.isInputPending()) {
// Browser needs to do work, yield
return true;
}
// No pending input/paint, but check if we've been running too long
// Default time slice is ~5ms
return currentTime >= maxYieldInterval;
}
return false;
}
// The scheduler determines the deadline
let deadline = 0;
const yieldInterval = 5; // ms - yield every 5ms to keep 60fps
function scheduleCallback(priorityLevel, callback) {
const currentTime = getCurrentTime();
// Calculate timeout based on priority
let timeout;
switch (priorityLevel) {
case ImmediatePriority:
timeout = -1; // ASAP
break;
case UserBlockingPriority:
timeout = 250;
break;
case NormalPriority:
timeout = 5000;
break;
case LowPriority:
timeout = 10000;
break;
case IdlePriority:
timeout = 1073741823; // Max int32
break;
}
const expirationTime = currentTime + timeout;
const newTask = {
callback,
priorityLevel,
expirationTime,
sortIndex: expirationTime,
};
// Add to priority queue (min-heap by expirationTime)
push(taskQueue, newTask);
// Request callback from browser
requestHostCallback(flushWork);
}
The Full Render Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ COMPLETE RENDER FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ setState() called │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 1. CREATE UPDATE │ │
│ │ • Create update object with new state │ │
│ │ • Assign lane based on context (click? effect? transition?) │ │
│ │ • Enqueue on fiber.updateQueue │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 2. SCHEDULE RENDER │ │
│ │ • Mark fiber and ancestors with lane (childLanes) │ │
│ │ • Schedule callback with Scheduler │ │
│ │ • Scheduler uses MessageChannel or setTimeout │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (when Scheduler calls back) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 3. RENDER PHASE (Interruptible) │ │
│ │ │ │
│ │ prepareFreshStack() │ │
│ │ └─► Create workInProgress root from current │ │
│ │ │ │
│ │ workLoopConcurrent() │ │
│ │ └─► while (workInProgress && !shouldYield()) │ │
│ │ performUnitOfWork(workInProgress) │ │
│ │ │ │
│ │ For each fiber: │ │
│ │ ┌───────────────────────────────────────────────────────────┐ │ │
│ │ │ beginWork(current, workInProgress, lanes) │ │ │
│ │ │ • Call function component / render method │ │ │
│ │ │ • Process hooks (useState, useEffect, etc.) │ │ │
│ │ │ • Reconcile children (diff old vs new) │ │ │
│ │ │ • Create child fibers if needed │ │ │
│ │ │ • Mark fiber with flags (Placement, Update, Deletion) │ │ │
│ │ └───────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────────────────┐ │ │
│ │ │ completeWork(current, workInProgress, lanes) │ │ │
│ │ │ • Create DOM nodes (for new host components) │ │ │
│ │ │ • Diff props (prepareUpdate) │ │ │
│ │ │ • Bubble subtreeFlags up to parent │ │ │
│ │ └───────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ (if interrupted: save progress, schedule continuation) │
│ │ (if completed: proceed to commit) │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 4. COMMIT PHASE (Synchronous, cannot interrupt) │ │
│ │ │ │
│ │ commitBeforeMutationEffects() │ │
│ │ └─► getSnapshotBeforeUpdate │ │
│ │ └─► Schedule useEffect cleanup/setup │ │
│ │ │ │
│ │ commitMutationEffects() │ │
│ │ └─► DOM insertions, updates, deletions │ │
│ │ └─► Ref detachments │ │
│ │ │ │
│ │ Swap trees: root.current = finishedWork │ │
│ │ │ │
│ │ commitLayoutEffects() │ │
│ │ └─► useLayoutEffect callbacks │ │
│ │ └─► componentDidMount/Update │ │
│ │ └─► Ref attachments │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (after browser paint) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 5. PASSIVE EFFECTS │ │
│ │ • useEffect cleanup (from previous render) │ │
│ │ • useEffect setup (from this render) │ │
│ │ • Scheduled asynchronously via Scheduler │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Concurrent Features in Practice
startTransition
import { startTransition, useTransition, useState } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
// Urgent: Update input immediately (SyncLane)
setQuery(value);
// Non-urgent: Update results later (TransitionLane)
startTransition(() => {
const filtered = filterResults(value); // Expensive!
setResults(filtered);
});
}
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultsList results={results} />
</div>
);
}
// What happens internally:
//
// 1. User types 'a'
// 2. setQuery('a') → SyncLane update
// 3. setResults([...]) → TransitionLane update
//
// 4. React starts SyncLane render
// - Input shows 'a' immediately
// - Commits to screen
//
// 5. React starts TransitionLane render
// - Rendering ResultsList with filtered data
// - User types 'b'...
//
// 6. New SyncLane update interrupts Transition
// - setQuery('ab') → SyncLane
// - setResults([...]) → new TransitionLane
// - Abort previous transition render
//
// 7. React processes new SyncLane
// - Input shows 'ab'
// - isPending is still true (transition pending)
//
// 8. Eventually, user stops typing
// - Transition render completes
// - Results update, isPending → false
useDeferredValue
import { useDeferredValue, useMemo } from 'react';
function SearchResults({ query }: { query: string }) {
// deferredQuery lags behind query during rapid updates
const deferredQuery = useDeferredValue(query);
// Expensive computation uses deferred value
const results = useMemo(
() => filterResults(deferredQuery),
[deferredQuery]
);
// Visual indicator that results are stale
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ResultsList results={results} />
</div>
);
}
// Difference from startTransition:
//
// startTransition: You control WHAT is low-priority
// useDeferredValue: You receive a lagging copy of a value
//
// Use startTransition when:
// - You have direct access to the setState call
// - You want to mark specific updates as transitions
//
// Use useDeferredValue when:
// - You receive a value as a prop
// - You can't modify where the value comes from
// - You want to show stale content while computing new
Suspense and Concurrent Rendering
import { Suspense, lazy } from 'react';
const Comments = lazy(() => import('./Comments'));
function Post({ postId }: { postId: string }) {
return (
<article>
<PostContent id={postId} />
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={postId} />
</Suspense>
</article>
);
}
// How Suspense works with Fiber:
//
// 1. Comments component throws a Promise (during fetch)
//
// 2. React catches the Promise in the Suspense boundary
//
// 3. Instead of committing partial tree:
// - Mark Suspense fiber with DidCapture flag
// - Render fallback instead of children
// - Commit fallback to screen
//
// 4. Attach listener to Promise
//
// 5. When Promise resolves:
// - Schedule render at RetryLane
// - Re-attempt rendering children
// - If successful, replace fallback with children
//
// 6. During concurrent render, Suspense enables:
// - Streaming SSR (send fallback, then content)
// - Selective hydration (hydrate visible parts first)
// - Transitions (keep showing old while loading new)
Architecting for Concurrent React
Pattern 1: Separate Urgent from Non-Urgent State
// ❌ BAD: All state changes are coupled
function FilterableList({ items }: { items: Item[] }) {
const [state, setState] = useState({
searchQuery: '',
sortOrder: 'asc',
filteredItems: items,
});
function handleSearch(query: string) {
// Everything updates together at same priority
setState({
...state,
searchQuery: query,
filteredItems: filterAndSort(items, query, state.sortOrder),
});
}
return (/* ... */);
}
// ✅ GOOD: Separate urgent and deferrable state
function FilterableList({ items }: { items: Item[] }) {
// Urgent: UI feedback
const [searchQuery, setSearchQuery] = useState('');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
// Deferred: Expensive computation
const deferredQuery = useDeferredValue(searchQuery);
const deferredSort = useDeferredValue(sortOrder);
// Expensive work uses deferred values
const filteredItems = useMemo(
() => filterAndSort(items, deferredQuery, deferredSort),
[items, deferredQuery, deferredSort]
);
const isStale = searchQuery !== deferredQuery || sortOrder !== deferredSort;
return (
<div>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
<div style={{ opacity: isStale ? 0.8 : 1, transition: 'opacity 0.2s' }}>
<List items={filteredItems} />
</div>
</div>
);
}
Pattern 2: Suspense Boundaries as Performance Boundaries
// Structure Suspense boundaries around independent data dependencies
function Dashboard() {
return (
<div className="dashboard">
{/* Each section loads independently */}
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
<div className="main-content">
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<ActivitySidebar />
</Suspense>
</div>
</div>
);
}
// Benefits:
// 1. Fast sections render immediately
// 2. Slow sections don't block fast ones
// 3. Each boundary can show loading state
// 4. With concurrent features, boundaries enable streaming
Pattern 3: Avoid Synchronous Layout Cascades
// ❌ BAD: Reading layout during render
function BadComponent() {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
// This forces synchronous layout
if (ref.current) {
const newWidth = ref.current.offsetWidth; // LAYOUT!
if (newWidth !== width) {
setWidth(newWidth); // STATE UPDATE DURING RENDER!
}
}
return <div ref={ref}>Width: {width}</div>;
}
// ✅ GOOD: Use effect for layout measurement
function GoodComponent() {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
if (ref.current) {
const observer = new ResizeObserver(([entry]) => {
setWidth(entry.contentRect.width);
});
observer.observe(ref.current);
return () => observer.disconnect();
}
}, []);
return <div ref={ref}>Width: {width}</div>;
}
// Why this matters for concurrent React:
// - Render phase must be pure (no side effects)
// - Layout reads force browser to calculate layout
// - Interrupting/resuming becomes unpredictable
// - Can cause "tearing" (inconsistent UI state)
Pattern 4: Key-Based State Reset for Transitions
import { useTransition } from 'react';
function TabPanel({ tabs }: { tabs: Tab[] }) {
const [activeTabId, setActiveTabId] = useState(tabs[0].id);
const [isPending, startTransition] = useTransition();
function handleTabChange(tabId: string) {
startTransition(() => {
setActiveTabId(tabId);
});
}
const activeTab = tabs.find((t) => t.id === activeTabId);
return (
<div>
<TabList
tabs={tabs}
activeId={activeTabId}
onChange={handleTabChange}
/>
<div className="tab-content" style={{ opacity: isPending ? 0.7 : 1 }}>
{/* Key forces fresh state when tab changes */}
<TabContent key={activeTabId} tab={activeTab} />
</div>
</div>
);
}
// The key prop ensures:
// - New tab content starts with fresh state
// - Previous tab's state doesn't "leak" during transition
// - Suspense boundaries reset properly
Debugging and Profiling
React DevTools Profiler
// Wrap components to track render reasons
import { Profiler } from 'react';
function onRenderCallback(
id: string, // Component name
phase: 'mount' | 'update', // Mount or update
actualDuration: number, // Time spent rendering
baseDuration: number, // Estimated time without memoization
startTime: number, // When React started rendering
commitTime: number, // When React committed
interactions: Set<any> // Interactions that triggered this
) {
// Log slow renders
if (actualDuration > 16) {
console.warn(`Slow render: ${id} took ${actualDuration}ms`);
}
// Track to analytics
analytics.track('react_render', {
component: id,
phase,
duration: actualDuration,
});
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
);
}
Identifying Priority Issues
// Detect when transitions are being starved
function useTransitionDebug(label: string) {
const [isPending, startTransition] = useTransition();
const startTimeRef = useRef<number>(0);
const wrappedStartTransition = useCallback((callback: () => void) => {
startTimeRef.current = performance.now();
startTransition(callback);
}, [startTransition]);
useEffect(() => {
if (!isPending && startTimeRef.current > 0) {
const duration = performance.now() - startTimeRef.current;
if (duration > 100) {
console.warn(
`[${label}] Transition took ${duration.toFixed(0)}ms - ` +
`consider splitting into smaller updates or using Suspense`
);
}
startTimeRef.current = 0;
}
}, [isPending, label]);
return [isPending, wrappedStartTransition] as const;
}
Scheduler Tracing (Development)
// Enable Scheduler tracing in development
if (process.env.NODE_ENV === 'development') {
// @ts-ignore
window.__REACT_DEVTOOLS_GLOBAL_HOOK__?.onScheduleRoot?.((root, lane) => {
console.log('Scheduled render:', { lane: getLaneName(lane) });
});
}
function getLaneName(lane: number): string {
if (lane === 0) return 'NoLane';
if (lane === 1) return 'SyncLane';
if (lane === 2) return 'InputContinuousLane';
if (lane === 4) return 'DefaultLane';
if (lane >= 8 && lane <= 32768) return 'TransitionLane';
if (lane >= 65536 && lane <= 262144) return 'RetryLane';
if (lane === 536870912) return 'IdleLane';
return `Lane(${lane})`;
}
Common Pitfalls
┌─────────────────────────────────────────────────────────────────────────────┐
│ CONCURRENT REACT PITFALLS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Mutations during render │
│ ──────────────────────────────────── │
│ ❌ let count = 0; │
│ ❌ function Counter() { return <span>{count++}</span>; } │
│ │
│ Why: Render can be called multiple times (interrupted/resumed) │
│ Fix: Use useState, keep renders pure │
│ │
│ ──────────────────────────────────────────────────────────────────────── │
│ │
│ 2. Assuming renders are sequential │
│ ──────────────────────────────────── │
│ ❌ Relying on render order for IDs: id={renderCount++} │
│ │
│ Why: Concurrent renders can be abandoned and restarted │
│ Fix: Use useId() or stable identifiers │
│ │
│ ──────────────────────────────────────────────────────────────────────── │
│ │
│ 3. External store tearing │
│ ──────────────────────────────────── │
│ ❌ Reading from external mutable store during render │
│ │
│ Why: Store can change mid-render, causing inconsistent UI │
│ Fix: Use useSyncExternalStore() │
│ │
│ ──────────────────────────────────────────────────────────────────────── │
│ │
│ 4. Heavy computation in render │
│ ──────────────────────────────────── │
│ ❌ function List() { return items.map(i => expensiveCompute(i)); } │
│ │
│ Why: Can't yield mid-computation, blocks time slice │
│ Fix: useMemo, move to worker, or virtualize │
│ │
│ ──────────────────────────────────────────────────────────────────────── │
│ │
│ 5. Over-using transitions │
│ ──────────────────────────────────── │
│ ❌ startTransition(() => { /* everything */ }); │
│ │
│ Why: User feedback is delayed, UI feels sluggish │
│ Fix: Only transition expensive, non-urgent updates │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Bottom Line
React Fiber's scheduling system is built on a few key insights:
-
Work is divisible. By representing the component tree as a linked list of Fibers, React can process one unit at a time and yield between units.
-
Not all updates are equal. The lane system encodes priority, allowing urgent updates (clicks) to interrupt less urgent ones (search results).
-
Double buffering enables safety. Building a work-in-progress tree means we can abandon work without affecting what's on screen.
-
Cooperative scheduling respects the browser. By yielding every ~5ms and checking for pending input, React keeps the UI responsive even during expensive renders.
For architects, this means:
- Separate urgent from non-urgent state. Use transitions and deferred values to keep the UI responsive.
- Structure Suspense boundaries strategically. They're not just for loading states—they're performance boundaries.
- Keep renders pure. Any mutation or side effect during render breaks the concurrent model.
- Measure with the Profiler. Understand where your time slices are going.
The concurrent features aren't magic—they're the logical extension of a scheduling system designed from the ground up to be interruptible. Understanding that system is the key to building UIs that stay fast at any scale.
What did you think?