----------|-----------------|----------------------|------------------------|----------------------|
| useEffect (Passive) | Post-Commit | Asynchronous (after paint) | β Unsafe (causes layout flicker) | Macro Task (Scheduler) |
| useLayoutEffect (Layout) | Synchronous Commit | Synchronous (before paint) | β
Safe (prevents flicker) | Micro Task / Sync Block |
| componentDidMount (Legacy) | Synchronous Commit | Synchronous (before paint) | β
Safe | Sync Block (Class Fiber) |
Key Findings:
- Sweet Spot:
useLayoutEffect should be strictly reserved for DOM measurements and synchronous layout corrections. All other side effects (subscriptions, API calls, logging) belong in useEffect to preserve main-thread responsiveness.
- Execution Order Guarantee: React guarantees
useLayoutEffect callbacks fire synchronously in the Commit phase, while useEffect callbacks are deferred to a new macro task. This explains why render β layout effect β effect is the only valid execution order.
- Cleanup Determinism: Cleanup functions always execute before their corresponding new effect mounts (
commitPassiveUnmountEffects β commitPassiveMountEffects), ensuring no duplicate subscriptions or orphaned event listeners exist in the tree.
Core Solution
React's effect lifecycle is governed by a three-phase pipeline: Render β Commit β Effects. Understanding the exact boundary between these phases is critical for predictable side-effect management.
1. The Three-Phase Pipeline
- Render: React executes component functions, diffs the virtual tree, and determines mutations. No DOM writes occur. This is where
console.log('render') fires.
- Commit: React applies calculated mutations to the real DOM synchronously. This phase is uninterruptible.
- Effects: After DOM updates, React schedules passive effects. Layout effects run synchronously during Commit; passive effects run asynchronously after browser paint.
2. useEffect Execution Model
useEffect is not a direct callback. It is a declaration of deferred work. After Commit finishes, React queues the effect callbacks in the Scheduler's macro-task queue. The execution sequence is:
1. Render phase β your component functions run
2. Commit phase β DOM is updated
3. Browser paints the screen
4. Scheduler fires β useEffect callbacks run
Because steps 1-2 occupy one macro task and step 4 occupies a new one, the browser is guaranteed a paint opportunity between DOM mutation and effect execution.
3. Cleanup Lifecycle Determinism
React enforces strict cleanup ordering to prevent side-effect duplication:
useEffect(() => {
const subscription = subscribe(id);
return () => subscription.unsubscribe(); // cleanup
}, [id]);
When dependencies change or the component unmounts, React executes commitPassiveUnmountEffects (cleanup) before commitPassiveMountEffects (new effect). This runs in the same scheduled task, respecting tree traversal order (children before parents).
4. useLayoutEffect Synchronous Execution
useLayoutEffect bypasses the Scheduler and runs synchronously inside the Commit phase:
1. Render phase β component functions run
2. Commit phase β DOM is updated
3. useLayoutEffect callbacks run β here, synchronously, before paint
4. Browser paints
5. useEffect callbacks run β here, in next macro task
This is the only safe window for DOM measurements that affect layout:
// useLayoutEffect β correct for DOM measurements
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setTooltipPosition({ top: rect.bottom, left: rect.left });
});
// useEffect β would cause a visible flicker for measurements
// because the browser already painted before this runs
useEffect(() => {
const rect = ref.current.getBoundingClientRect();
setTooltipPosition({ top: rect.bottom, left: rect.left }); // too late
});
Pitfall Guide
- Using
useEffect for Layout Calculations: Measuring DOM elements or adjusting layout inside useEffect causes a visible flicker because the browser paints the unadjusted state first. Always use useLayoutEffect for synchronous layout corrections.
- Assuming Synchronous Post-Render Execution: Treating
useEffect as a direct continuation of the render function leads to race conditions. Effects are scheduled as macro tasks; state updates triggered inside them will queue a new render cycle, not update the current one.
- Omitting or Misordering Cleanup Functions: Forgetting to return a cleanup function, or attempting to run setup logic before cleanup, causes memory leaks and duplicate subscriptions. React guarantees cleanup runs first (
commitPassiveUnmountEffects), but developers must explicitly define it.
- Blocking the Commit Phase with Heavy Logic: Placing CPU-intensive operations inside
useLayoutEffect blocks the main thread and delays browser paint, causing jank. Reserve useLayoutEffect strictly for fast DOM reads/writes; offload heavy computation to useEffect or Web Workers.
- Relying on Cross-Component Effect Ordering: While React guarantees children run before parents within the same tree, effect execution order across independent component branches is not strictly deterministic. Design effects to be idempotent and independent of sibling execution timing.
Deliverables
- π Blueprint: React Effect Lifecycle Execution Map β A visual reference detailing the exact temporal boundaries between Render, Commit, Layout Effects, and Passive Effects, including macro-task scheduling boundaries and browser paint windows.
- β
Checklist: Effect Timing & Cleanup Verification β A 7-point validation checklist for code reviews: (1) DOM measurements use
useLayoutEffect, (2) API calls/subscriptions use useEffect, (3) Cleanup functions are explicitly returned, (4) No heavy synchronous logic in layout effects, (5) Dependency arrays match actual closure variables, (6) Idempotent effect design, (7) SSR compatibility verified (useLayoutEffect fallback).
- βοΈ Configuration Templates: Standardized Effect Setup/Cleanup Patterns β Production-ready boilerplate for common side effects (WebSocket subscriptions, IntersectionObserver, ResizeObserver, Timer management) with built-in cleanup guards, dependency validation, and SSR-safe wrappers.