React Internals: How React Works Under the Hood
Deep dive into React's internal architecture, reconciliation algorithm, fiber architecture, and rendering pipeline.
Topics Covered:
Prerequisites:
- Advanced State Management Patterns
Overview
Understanding React's internals helps you write better code, debug issues, and make informed architectural decisions. This knowledge is essential for Staff-level engineers who need to optimize performance, debug complex issues, and make system-level decisions. In this tutorial, we'll explore React's fiber architecture, the reconciliation process, rendering phases, and how React schedules and prioritizes work.
Lesson 1: Understanding the Fiber Architecture
React 16 introduced the Fiber architecture, a complete rewrite of React's core algorithm. Understanding fibers is crucial for understanding how React works. What is a Fiber? A fiber is a JavaScript object that represents a unit of work. In React's virtual DOM, each component instance and DOM node corresponds to a fiber object. Key Properties of a Fiber: • type: The component type (function, class, or DOM element type) • key: Unique identifier for reconciliation • stateNode: Reference to the actual DOM node or component instance • child: First child fiber • sibling: Next sibling fiber • return: Parent fiber • alternate: Reference to the previous version of this fiber (for diffing) • effectTag: What needs to be done with this fiber (place, update, delete) • expirationTime: Priority level for this work Fiber Structure: The fiber tree mirrors the component tree, but React builds a linked list structure for efficient traversal.
// Simplified fiber structure
interface Fiber {
// Identity
type: Function | string | Class;
key: string | null;
stateNode: any;
// Tree structure
child: Fiber | null;
sibling: Fiber | null;
return: Fiber | null;
// Work
alternate: Fiber | null;
effectTag: number;
expirationTime: number;
updateQueue: any;
// Hook state (for function components)
memoizedState: any;
memoizedProps: any;
}
// Example: When you have this component tree:
function App() {
return (
<div>
<Header />
<Main />
</div>
);
}
// React creates a fiber tree like:
// App (fiber)
// - child: div (fiber)
// - child: Header (fiber)
// - sibling: Main (fiber)
// - return: div (fiber)
// - return: App (fiber)Each component and DOM element becomes a fiber. The tree structure allows React to traverse and update efficiently. The alternate property links to the previous version for comparison during reconciliation.
Lesson 2: How React Traverses the Fiber Tree
React doesn't use recursion for traversal. Instead, it uses an iterative approach with a linked list structure that allows it to pause, resume, and prioritize work. Traversal Process: 1. Begin at the root fiber 2. Visit the current fiber (work on it) 3. Move to child if exists 4. If no child, move to sibling 5. If no sibling, return to parent 6. Continue until all fibers are processed This iterative approach enables concurrent rendering - React can pause traversal, handle urgent updates, then resume.
Lesson 3: The Reconciliation Algorithm Step-by-Step
Reconciliation is React's diffing algorithm - comparing the new virtual DOM with the previous one to determine minimal changes. Step-by-Step Reconciliation Process: Step 1: Element Type Comparison React first compares the element type at the root: • Same type: Update props and continue diffing children • Different type: Tear down old tree, build new tree • Function vs Class: Treated as different types even if render same output Step 2: Props Diffing If types match, React compares props: • Old props vs new props • Identifies changed props • Prepares update operations Step 3: Recursive Children Diffing For each child: • Use keys to match old and new children • Compare matched children • Handle additions, removals, reordering Step 4: Generate Effect List React builds a list of effects (changes) to apply: • PLACEMENT: New nodes to insert • UPDATE: Existing nodes to modify • DELETION: Nodes to remove
// Example: Reconciliation scenario
// Before (previous render)
<div>
<Header />
<Main />
</div>
// After (current render)
<div>
<Header />
<Sidebar />
<Main />
</div>
// Reconciliation process:
// 1. Compare root <div>: Same type, continue
// 2. Compare children:
// - Header: Same type, same key (if any), update props if changed
// - Sidebar: NEW (no matching child), mark for PLACEMENT
// - Main: Same type, but position changed, mark for UPDATE/MOVE
// Effect list generated:
// [PLACEMENT: Sidebar, UPDATE: Main (position changed)]
// React then commits these changes efficientlyReact uses keys to efficiently match old and new children. Without keys, React might unnecessarily recreate components. With keys, React can track moves and reuses components when possible.
Lesson 4: Understanding Keys in Reconciliation
Keys are crucial for React's reconciliation efficiency. Let's understand why with a detailed example. Why Keys Matter: • Help React identify which items changed • Enable React to reuse components when items move • Prevent unnecessary recreations • Improve performance significantly Key Rules: • Must be unique among siblings • Should be stable (don't use array indices if items can reorder) • Should be predictable (not random) Common Mistakes: • Using array index when items can reorder • Using random values • Using non-unique keys
// ❌ BAD: Using index as key when items can reorder
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo, index) => (
<TodoItem key={index} todo={todo} />
))}
</ul>
);
}
// Problem: If todos reorder, React thinks items changed
// React will recreate components instead of moving them
// ✅ GOOD: Using stable, unique IDs
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
// React can now:
// 1. Match old and new items by key
// 2. Reuse component instances when items move
// 3. Only update props if data changed
// 4. Efficiently handle additions/removals
// When items reorder:
// Before: [A(id:1), B(id:2), C(id:3)]
// After: [C(id:3), A(id:1), B(id:2)]
// With keys, React knows:
// - C moved from position 2 to 0 (move, don't recreate)
// - A moved from 0 to 1 (move, don't recreate)
// - B moved from 1 to 2 (move, don't recreate)
// Without keys (using index), React would think:
// - Item at 0 changed from A to C (recreate)
// - Item at 1 changed from B to A (recreate)
// - Item at 2 changed from C to B (recreate)
// All components get destroyed and recreated!Keys enable React to efficiently track which items moved, were added, or removed. Without proper keys, React will unnecessarily recreate components, losing state and causing performance issues.
Lesson 5: The Two-Phase Rendering Model
React 16+ splits rendering into two distinct phases, enabling concurrent rendering capabilities. Phase 1: Render Phase (Can be Interrupted) • Pure computation - no side effects • Can be paused, aborted, or restarted • Builds the fiber tree • Determines what needs to change • Can be interrupted for higher priority work • Can be time-sliced across multiple frames Phase 2: Commit Phase (Synchronous) • Applies changes to DOM • Runs synchronously to prevent visual inconsistencies • Runs lifecycle methods • Runs effects (useEffect callbacks) • Cannot be interrupted • Happens in one batch
// Understanding the phases with code:
function Component() {
const [count, setCount] = useState(0);
// This runs during RENDER PHASE
console.log('Render phase - can be interrupted');
useEffect(() => {
// This runs during COMMIT PHASE
console.log('Commit phase - runs after DOM updates');
});
return <div>{count}</div>;
}
// React's internal process:
// RENDER PHASE (interruptible)
// 1. React calls Component()
// 2. Component returns JSX
// 3. React creates/updates fiber tree
// 4. React identifies changes needed
// - If urgent update arrives, can pause here
// 5. React completes render phase
// COMMIT PHASE (synchronous)
// 1. React applies all DOM updates
// 2. React calls componentDidMount/Update (class components)
// 3. React runs useEffect callbacks
// 4. User sees updates
// Why this matters:
// - Render phase can be time-sliced across frames
// - High priority updates can interrupt low priority work
// - Commit phase ensures UI consistencyThe separation of phases enables React's concurrent features. The render phase can be interrupted for urgent updates, while the commit phase ensures all DOM updates happen atomically.
Lesson 6: Work Priority and Time Slicing
React uses expiration times to prioritize work. Higher priority work can interrupt lower priority work. Priority Levels: • Immediate: User input, blocking transitions • High: Hover effects, urgent animations • Normal: Regular updates, data fetching results • Low: Offscreen updates, prefetching • Never: Background work Time Slicing: React breaks work into small units and can pause between units to check for higher priority work. This keeps the UI responsive even during heavy renders.
// Example: Priority interruption
// Low priority work starts
React starts rendering a large list (1000 items)
// After rendering 100 items, user clicks a button
// React:
// 1. Pauses the list rendering
// 2. Processes the button click (high priority)
// 3. Updates the button immediately
// 4. Resumes list rendering when possible
// Without time slicing:
// - Button click waits for list to finish
// - UI feels unresponsive
// - Poor user experience
// With time slicing:
// - Button responds immediately
// - List continues in background
// - Smooth, responsive UI
// Using useTransition for non-urgent updates
import { useTransition } from 'react';
function SearchResults({ query }) {
const [isPending, startTransition] = useTransition();
const handleInput = (e) => {
// Urgent: Update input immediately
setInputValue(e.target.value);
// Non-urgent: Search results can be interrupted
startTransition(() => {
setSearchResults(search(e.target.value));
});
};
return (
<div>
<input
value={inputValue}
onChange={handleInput}
/>
{isPending && <Spinner />}
<ResultsList results={searchResults} />
</div>
);
}Time slicing allows React to keep the UI responsive by breaking work into chunks and prioritizing user interactions. useTransition lets you mark updates as non-urgent so they don't block urgent updates.
Lesson 7: The Commit Phase Deep Dive
The commit phase happens synchronously and applies all changes to the DOM. Commit Phase Steps: 1. Before Mutation: - Call getSnapshotBeforeUpdate (class components) - Store snapshot values 2. Mutation: - Apply DOM updates - Call componentWillUnmount (class components) - Handle refs 3. Layout: - Call componentDidMount/Update (class components) - Run useLayoutEffect callbacks - Read layout properties if needed 4. Effect: - Run useEffect callbacks - Schedule passive effects
// Commit phase execution order
function Component() {
const ref = useRef(null);
useEffect(() => {
// Phase 4: Effect
console.log('useEffect runs here - after DOM updates');
});
useLayoutEffect(() => {
// Phase 3: Layout
console.log('useLayoutEffect runs here - synchronous, before paint');
// Can read layout and synchronously update
if (ref.current) {
ref.current.style.width = '100px';
}
});
return <div ref={ref}>Content</div>;
}
// Execution order:
// 1. Mutation: DOM updates applied
// 2. Layout: useLayoutEffect runs (synchronous)
// 3. Browser paints
// 4. Effect: useEffect runs (after paint)
// Why this matters:
// - useLayoutEffect: Need to read/write layout synchronously
// - useEffect: Side effects that don't need to block paintUnderstanding the commit phase order helps you choose between useEffect and useLayoutEffect. Layout effects run synchronously before paint, while regular effects run asynchronously after paint.
Lesson 8: Debugging with React Internals Knowledge
Understanding React's internals helps you debug complex issues. Debugging Techniques: • Use React DevTools Profiler to see render phases • Check fiber tree structure in DevTools • Monitor commit phases and effect timing • Identify unnecessary re-renders • Find reconciliation inefficiencies Common Issues and Solutions: • Performance problems: Check reconciliation, use keys properly • State updates not working: Understand render phases • Effects running too often: Understand dependency arrays • UI flickering: Understand commit phase timing
// Example: Debugging with React DevTools
// 1. Open React DevTools
// 2. Go to Profiler tab
// 3. Record a render
// 4. Analyze:
// You'll see:
// - Which components rendered
// - Render time
// - Commit time
// - Phase breakdown
// Common findings:
// Issue: Component re-rendering unnecessarily
// Solution: Use React.memo, useMemo, useCallback
function ExpensiveComponent({ data }) {
// Re-renders even when data hasn't changed
const expensiveValue = expensiveCalculation(data);
return <div>{expensiveValue}</div>;
}
// Fix:
const ExpensiveComponent = React.memo(function ExpensiveComponent({ data }) {
const expensiveValue = useMemo(
() => expensiveCalculation(data),
[data]
);
return <div>{expensiveValue}</div>;
});
// Issue: Reconciliation inefficient
// Solution: Use proper keys
// Issue: Effects running too often
// Solution: Fix dependency arrays
useEffect(() => {
fetchData();
}, [userId]); // Only runs when userId changes
// Use React DevTools to verify:
// - Component renders
// - Effect runs
// - Dependencies matchReact DevTools Profiler shows you exactly what React is doing. Use it to identify performance bottlenecks, unnecessary re-renders, and reconciliation inefficiencies.
Conclusion
Understanding React internals enables you to reason about performance, debug complex issues, and make architectural decisions. You now understand fibers, reconciliation, render phases, and how React prioritizes work. Use this knowledge to write more performant code and debug issues effectively.