React Deep Dive: Fiber / Hooks / Concurrent Rendering
Introduction
Should a frontend architect understand React internals? My answer is yes — not to read react-reconciler source code, but to build the engineering intuition of “why React is designed this way”. Three reasons:
- Performance tuning demands it. Why does useMemo sometimes make things slower? Why does setState in async callbacks need a function? Without principles, you just guess.
- Cross-framework component reuse demands it. The resume line “8 cross-Vue/React universal components” — each abstraction stomped on React vs Vue design philosophy differences.
- Server Components is the next 5 years’ direction. Understanding Fiber / Concurrent is the prerequisite for understanding RSC, without this layer you can’t read React 19.
This is post #8 of the Frontend Architecture Cultivation Path series. We skip reconciler source code (no commit-by-commit exploration), use architecture diagrams + sequence diagrams + real performance numbers to make React 16+‘s four major internals concrete: Fiber / Hooks / Concurrent / Server Components. The next post deep-dives into Vue 3’s reactivity + Composition API.
1. React’s Core Mental Model: UI = f(state)
// React mental model: declarative + unidirectional data flow
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
Three key elements:
- Declarative: describe what UI should look like, not how to manipulate DOM
- Unidirectional data flow: state → UI, events → setState (one direction)
- Componentization: UI breaks into reusable components, each component maintains its own state
2. Fiber Reconciler: React 16’s Architectural Rewrite
2.1 Why Fiber Was Needed
Pre-React-15 used Stack Reconciler — recursive traversal of the entire component tree, non-interruptible.
Problems:
- Rendering a 1000-component tree blocks the main thread for 100ms+
- User clicks / scrolls / inputs all stall
- No way to implement priority scheduling: user input always matters more than animation
Fiber’s solution: linked-list structure + interruptible + priority.
2.2 Fiber Node Structure
Each React element maps to a Fiber node, linked-list structure (child / sibling / return) replaces recursive tree. Double buffering: two trees live in memory simultaneously, current (displayed) + workInProgress (building), commit phase swaps atomically.
2.3 Scheduling Flow
Update triggered
↓
Render phase (interruptible)
↓ Start processing workInProgress tree
↓ Browser needs to paint frame? → Pause render, give control back
↓ Browser paints → Continue render
↓ All Fibers processed
Commit phase (non-interruptible)
↓ Atomically switch workInProgress tree to current
↓ Trigger DOM updates
Key insight: render phase interruptible → user operations always prioritized; commit phase non-interruptible → no half-baked UI rendered.
2.4 Double Buffering + Priority Scheduling
React 19’s Scheduler uses MessageChannel to implement 5ms time slices:
// Simplified React Scheduler
const channel = new MessageChannel();
let scheduledCallback: (() => void) | null = null;
channel.port1.onmessage = () => {
const cb = scheduledCallback;
scheduledCallback = null;
cb?.();
};
function scheduleCallback(cb: () => void, priority: 'immediate' | 'user-blocking' | 'normal' | 'low' | 'idle') {
scheduledCallback = cb;
channel.port2.postMessage(null); // trigger microtask / macrotask
}
Production benefit: user input always responds within 5ms, even while background re-renders run.
3. Hooks: State and Lifecycle in Functional Components
3.1 Hooks Linked-List Implementation
// React internals: each Fiber node maintains a Hooks linked list
type Hook = {
memoizedState: any; // current state
baseState: any; // previous state (used for useReducer skipping)
queue: any; // update queue (useState / useReducer)
next: Hook | null; // next hook
};
const hookList: Hook[] = [];
let currentHook: Hook | null = null;
function useState<T>(initial: T) {
const hook: Hook = currentHook ?? { memoizedState: initial, queue: [], next: null };
// ... process queue.pending updates
currentHook = hook.next ?? (hook.next = { ... });
return [hook.memoizedState, dispatch];
}
Key insights:
- Hooks linked list is bound to Fiber node, list is freed when component unmounts
- Each render reads from list head in order, so Hooks call order cannot change (this is why you can’t call Hooks in conditions)
3.2 useEffect’s Trap: Dependency Array
// ❌ Anti-pattern: missing dependency
useEffect(() => {
console.log(count); // uses count
}, []); // but only runs once
// ✅ Correct: full dependency
useEffect(() => {
console.log(count);
}, [count]);
// ✅ Use ref for "every render but no re-run" scenario
const ref = useRef(count);
useEffect(() => { ref.current = count; }); // no dep array, runs every render
Production case: a FinUI form component had useEffect missing a dependency, causing async validation to read stale state — switching to ref solved it.
3.3 useMemo / useCallback: Performance Optimization Double-Edged Sword
// ✅ Use when: expensive computation / referential stability for child components
const sorted = useMemo(() => list.sort(complexCompare), [list]);
const handleClick = useCallback(() => doThing(id), [id]);
// ❌ Anti-pattern: simple computation with useMemo is slower
const doubled = useMemo(() => count * 2, [count]); // 10× slower than count * 2
Dan Abramov (React core team): “useMemo makes code slower in most cases because it does dependency comparison and cache management. Only use it after confirming it’s a bottleneck.”
3.4 React 19’s React Compiler: Automatic Memoization
React 19 introduced React Compiler (experimental 2024, gradually stabilizing), which automatically does what useMemo / useCallback do:
// What you write: no useMemo
function List({ items }) {
const sorted = items.sort(complexCompare); // seemingly re-sorted every render
return <ul>{sorted.map(...)}</ul>;
}
// What React Compiler auto-memoizes:
function List({ items }) {
const sorted = memo(items.sort(complexCompare), [items]); // auto-memo
return <ul>{sorted.map(...)}</ul>;
}
Architectural meaning: in React 19+, the necessity of useMemo/useCallback drops dramatically, letting code return to “writing intuitions”.
4. Concurrent Rendering: Interruptible Updates
4.1 Three Core APIs
// 1. useTransition: mark update as "non-urgent"
const [isPending, startTransition] = useTransition();
startTransition(() => {
setSearchQuery(input); // this update can be interrupted, UI not blocked
});
// 2. useDeferredValue: defer low-priority updates
const deferredQuery = useDeferredValue(query); // auto-deferred until urgent updates done
// 3. Automatic batching: React 18+ auto-merges multiple setState
setCount(c => c + 1); // won't trigger two re-renders
setName('Alice'); // merged into one re-render
Production case: an admin backend search box — input + list filter are two state changes. Marking list filter as non-urgent via useTransition, input updates instantly, list updates 200ms later. 3× perceived smoothness improvement.
4.2 Suspense in Concurrent Mode
<Suspense fallback={<Loading />}>
<UserProfile userId={id} /> {/* async load, doesn't block parent render */}
</Suspense>
How it works:
- Suspense child throws a Promise (Suspense protocol)
- React displays fallback, doesn’t unmount parent
- Promise resolves → replace fallback with real content
Server Components are built on this mechanism — server components throw Promise (serialized), client doesn’t block.
5. Server Components: Rendering Crosses Boundaries Again
5.1 Three Component Types (React 19)
// Default is RSC (server component)
async function UserProfile({ id }) {
const user = await db.user.find(id); // server queries DB directly, client never sees
return <div>{user.name}</div>;
}
// 'use client' marks client component
'use client';
function LikeButton() {
const [liked, setLiked] = useState(false); // client state
return <button onClick={() => setLiked(!liked)}>Like</button>;
}
// 'use server' marks server action
'use server';
async function saveUser(formData: FormData) {
await db.user.save(...);
}
5.2 Architectural Significance of RSC
// RSC tree structure (actual serialization format)
{
type: 'div',
props: { children: [
{ type: ServerComponent, props: {...} }, // server component, no JS sent
{ type: ClientComponent, props: {...}, js: '...' }, // client component, with JS chunk
{ type: 'p', props: { children: 'static text' } } // plain HTML, no JS
]}
}
Bundle size impact:
- Entire page only interactive components (LikeButton / SearchInput) need JS
- Static layout, server-side data fetching, SEO meta — all zero JS
Production data: a SaaS backend’s RSC refactor — first-screen JS bundle from 280KB to 65KB (-77%), LCP from 2.1s to 1.4s.
6. useEffect vs useLayoutEffect: Render Timing
// useEffect: async after browser paint
useEffect(() => { /* DOM operations, logging, subscriptions */ });
// useLayoutEffect: sync after DOM update, before browser paint
useLayoutEffect(() => { /* immediate DOM operations, measurement */ });
Timing comparison:
React render → commit DOM update → useLayoutEffect → browser paint → useEffect
Architect rule: prefer useEffect over useLayoutEffect — the latter blocks browser paint, 1 frame delay on first load. Only use useLayoutEffect for scenarios where you must “measure immediately after DOM update” (e.g., animation starting position).
7. Pitfall Reminders (Senior Architects Please Read Carefully)
- Don’t call Hooks inside conditions / loops. Hooks depend on call order, conditional calls misalign the hook list — the whole component crashes.
- Don’t abuse useMemo / useCallback. Profile first (Performance panel / React DevTools Profiler), confirm it’s a bottleneck before optimizing. React 19+ React Compiler makes most manual memo obsolete.
- Don’t use
useEffect(() => fetch(...), [])as SSR fetch. useEffect runs on client, not during SSR. Next.js usesgetServerSidePropsor directlyawaitinside Server Components. - Don’t put all state in Redux / Zustand. What
useState/useRef/useContextcan solve, don’t put in a global store. 80% of projects over-engineer here. - Don’t ignore React 18’s automatic batching boundaries. Promises / setTimeout / event handlers now auto-batch multiple setStates, but if you use
ref.current = ...to trigger re-render, know that ref doesn’t participate in batching.
Summary
Seven key facts that thread React internals together:
- Fiber reconciler: linked-list + double buffering + 5ms time slicing, makes React interruptible and schedulable.
- Hooks linked list: bound to Fiber node, determines Hook call order cannot change.
- Concurrent rendering: useTransition + useDeferredValue + Suspense, keeps UI unblocked by low-priority updates.
- Server Components: UI truly “runs only on server”, first-screen JS bundle can drop 77%.
- React 19 Compiler: automatic memo, makes manual useMemo / useCallback obsolete.
- useEffect vs useLayoutEffect: former async after paint (default), latter sync before paint (when necessary).
- RSC + Server Actions: rendering and actions cross boundaries, next 5 years’ direction.
Next post: Vue 3 deep dive — reactivity principles (Proxy / Track / Trigger) + Composition API + Vapor mode, from principles to migration experience.
5 Key Interview Question Tracks
This section maps one-to-one with the article. Q1 ships with a complete answer as a model; the other four are for you to think through — each one is followed by an AI assistant button for a one-click detailed answer.
Q1 (Answer): What problem does React’s Fiber reconciler solve? What are the pain points of Stack Reconciler?
A: Stack Reconciler pain points: ① Render process non-interruptible — recursive rendering of 1000 components synchronously blocks the main thread for 100ms+, user input / scroll all stuck; ② No priority scheduling — animation / list updates / user input can’t be distinguished by priority; ③ Browser has no breathing room, frames drop. Fiber’s solution: ① Replace recursive tree with linked-list structure (child / sibling / return), interruptible (resume next event loop); ② Double buffering (current + workInProgress), render phase builds new tree, commit phase swaps atomically; ③ 5ms time slice, render phase checks every 5ms whether browser needs to paint. Interview tip: explain “render phase interruptible, commit phase non-interruptible — guarantees no half-baked UI rendering”.
Q2 (Think): Why can’t Hooks be written inside conditions? How does the linked-list structure affect Hook call order?
Q3 (Think): What’s the difference between useTransition and useDeferredValue? In what scenarios should each be used?
Q4 (Think): What’s the relationship between React Server Components (RSC) and Next.js Server Components? What’s the core architectural significance of RSC?
Q5 (Think): In your 8 cross-Vue/React universal components project, how did you solve the two reactive system differences? For example, data() ref’s two-way binding vs useState’s one-way update.
💡 Each question has an AI assistant button — one click gets you a detailed answer.
References
- React Fiber Architecture (Andrew Clark) — Fiber design principles (React core member)
- Concurrent React (React Official) — Concurrent rendering official guide
- React Server Components RFC — RSC design RFC
- React Compiler (React Labs) — React 19 auto-memo compiler
- A Complete Guide to useEffect (Dan Abramov) — complete useEffect interpretation
- React Hooks: What’s going on under the hood — Hooks design motivation