React Deep Dive: Fiber / Hooks / Concurrent Rendering

0 0

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:

  1. 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.
  2. 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.
  3. 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:

  1. Declarative: describe what UI should look like, not how to manipulate DOM
  2. Unidirectional data flow: state → UI, events → setState (one direction)
  3. 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

Figure 1: Fiber node + double-buffered trees

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:

  1. Suspense child throws a Promise (Suspense protocol)
  2. React displays fallback, doesn’t unmount parent
  3. 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)

Figure 2: React 19 three component types
// 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)

  1. Don’t call Hooks inside conditions / loops. Hooks depend on call order, conditional calls misalign the hook list — the whole component crashes.
  2. 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.
  3. Don’t use useEffect(() => fetch(...), []) as SSR fetch. useEffect runs on client, not during SSR. Next.js uses getServerSideProps or directly await inside Server Components.
  4. Don’t put all state in Redux / Zustand. What useState / useRef / useContext can solve, don’t put in a global store. 80% of projects over-engineer here.
  5. 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

🔗 Original Link Share to reach more people

Comments