er can automate this, but explicit structure ensures predictable behavior across versions.
// components/InventoryRow.tsx
import type { Product } from '@/types';
interface InventoryRowProps {
product: Product;
onRestock: (id: string) => void;
}
export function InventoryRow({ product, onRestock }: InventoryRowProps) {
// Render phase: Pure computation.
// In React 19, the compiler will automatically cache this.
const displayPrice = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: product.currency
}).format(product.price);
const isLowStock = product.quantity < product.threshold;
return (
<tr data-testid={`product-${product.id}`} className={isLowStock ? 'bg-warning' : ''}>
<td>{product.sku}</td>
<td>{product.quantity}</td>
<td>{displayPrice}</td>
<td>
<button
type="button"
onClick={() => onRestock(product.id)}
disabled={!isLowStock}
>
Restock
</button>
</td>
</tr>
);
}
2. Stabilize Reconciliation with Domain Keys
Reconciliation matches children by identity, not position. Using array indices or non-unique fields forces React to destroy and recreate subtrees unnecessarily.
// components/InventoryTable.tsx
import { useState, useLayoutEffect, useEffect } from 'react';
import { InventoryRow } from './InventoryRow';
import type { Product } from '@/types';
export function InventoryTable({ initialCatalog }: { initialCatalog: Product[] }) {
const [catalog, setCatalog] = useState<Product[]>(initialCatalog);
const [lastRefresh, setLastRefresh] = useState<number>(Date.now());
// Commit phase: Synchronous DOM measurement before paint
useLayoutEffect(() => {
const container = document.getElementById('inventory-scroll-area');
if (container) {
const height = container.scrollHeight;
console.debug(`[Commit] Layout measured: ${height}px`);
}
}, [catalog]);
// Commit phase: Asynchronous side effect after paint
useEffect(() => {
const interval = setInterval(() => setLastRefresh(Date.now()), 10000);
return () => clearInterval(interval);
}, []);
const handleRestock = (id: string) => {
setCatalog(prev =>
prev.map(item =>
item.id === id ? { ...item, quantity: item.quantity + 25 } : item
)
);
};
return (
<div id="inventory-scroll-area" className="overflow-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th>SKU</th>
<th>Quantity</th>
<th>Price</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{catalog.map(product => (
<InventoryRow
key={product.id}
product={product}
onRestock={handleRestock}
/>
))}
</tbody>
</table>
</div>
);
}
Architecture Decisions & Rationale
- Why separate
useLayoutEffect and useEffect? The commit phase executes synchronously before the browser paints. useLayoutEffect runs in this window, making it safe for reading DOM metrics (like scroll height or element dimensions) without visual flicker. useEffect runs asynchronously after paint, ideal for data fetching, subscriptions, or logging. Mixing them causes layout thrashing or stale measurements.
- Why pass
onRestock directly instead of wrapping in useCallback? In React 19, the compiler automatically memoizes stable functions. Manually wrapping handlers in useCallback adds boilerplate and can interfere with compiler heuristics. The architecture relies on stable references through lexical scoping rather than explicit caching.
- Why use
product.id as the key? Reconciliation requires stable, unique identifiers that persist across renders. Domain-specific IDs ensure that when items reorder, React moves existing DOM nodes instead of destroying and recreating them. This drastically reduces commit-phase work.
- Why keep formatting inside the component? Modern JavaScript engines optimize
Intl.NumberFormat heavily. Extracting it into a custom hook or external utility often introduces unnecessary closure overhead. The render phase is cheap; the bottleneck is usually reconciliation or commit.
Pitfall Guide
1. The Memoization Mirage
Explanation: Applying React.memo to components that receive primitive props or rarely change. This adds reconciliation overhead (shallow comparison) without preventing any DOM updates.
Fix: Profile first. Only memoize when a parent re-renders frequently and passes identical props to a child with expensive render logic.
2. Key Collision in Dynamic Lists
Explanation: Using array indices, timestamps, or non-unique fields as keys. React matches by position, causing state leakage and unnecessary subtree destruction when items reorder or filter.
Fix: Always use stable, domain-specific identifiers. If IDs are unavailable, generate a deterministic hash from immutable fields (e.g., btoa(${sku}-${variant})).
3. Blocking the Commit Phase
Explanation: Running heavy DOM queries, synchronous loops, or layout calculations inside useLayoutEffect. This delays browser paint, causing visible jank.
Fix: Defer non-critical work to useEffect or requestIdleCallback. Reserve useLayoutEffect strictly for measurements that affect the next paint frame.
4. Misinterpreting "Props Changed" as a Trigger
Explanation: Attempting to prevent re-renders by manually checking prop equality or using shouldComponentUpdate patterns. Props change because the parent re-rendered; the prop update is a symptom, not the cause.
Fix: Colocate state. Move frequently changing data closer to where it's consumed. Use context sparingly, as context changes trigger re-renders in all subscribed descendants.
5. Effect Timing Confusion
Explanation: Reading layout-dependent values (like offsetWidth or getBoundingClientRect()) inside useEffect. The browser has already painted, so values may reflect the previous frame or cause double-render flicker.
Fix: Use useLayoutEffect for any DOM read that influences state or styling. Use useEffect for side effects that don't affect visual output.
6. Compiler Over-Reliance
Explanation: Assuming React 19's compiler automatically solves all performance issues. The compiler optimizes memoization and dependency tracking, but it cannot fix algorithmic complexity, unstable keys, or DOM thrashing.
Fix: Treat the compiler as an optimization layer, not an architectural fix. Maintain clean phase boundaries and stable keys regardless of compiler presence.
7. Virtual DOM Fallacy
Explanation: Believing the virtual DOM is inherently faster than direct DOM manipulation. The VDOM exists for orchestration and correctness, not raw speed. Direct DOM access is faster for isolated, high-frequency updates.
Fix: Use refs and direct DOM APIs only when profiling confirms commit-phase bottlenecks. Otherwise, rely on React's reconciliation for maintainability and correctness.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Parent re-renders frequently, child props unchanged | React.memo on child | Prevents unnecessary render-phase function calls | Low memory overhead, negligible CPU gain |
| List reorders frequently, keys use array index | Switch to stable domain IDs | Prevents subtree destruction during reconciliation | High performance gain, zero runtime cost |
| Commit phase shows mass DOM mutations | Implement virtualization | Reduces active DOM nodes, minimizes layout work | Moderate bundle size increase, major FPS improvement |
| Layout measurements cause flicker | Move to useLayoutEffect | Synchronizes reads with browser paint cycle | No performance cost, eliminates visual artifacts |
| Heavy formatting in render path | Rely on React 19 compiler or extract utility | Compiler caches pure computations automatically | Zero manual memoization, cleaner codebase |
Configuration Template
// vite.config.ts (React 19 Compiler Setup)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [
[
'babel-plugin-react-compiler',
{
target: '19',
environment: {
enableTreatRefLikeIdentifiersToBeReadable: true,
},
},
],
],
},
}),
],
});
// hooks/usePhaseProfiler.ts (Development Instrumentation)
import { useRef, useEffect } from 'react';
export function usePhaseProfiler(componentName: string) {
const renderStart = useRef(performance.now());
useEffect(() => {
const renderDuration = performance.now() - renderStart.current;
console.debug(
`[PhaseProfiler] ${componentName} | Render: ${renderDuration.toFixed(2)}ms | Commit: async`
);
});
return { renderStart };
}
Quick Start Guide
- Install React 19 & Compiler Plugin: Update
react and react-dom to ^19.0.0. Add babel-plugin-react-compiler to your build configuration using the template above.
- Audit List Keys: Search your codebase for
.map((item, index) => and replace index-based keys with stable identifiers (key={item.id}).
- Separate Effect Timing: Convert any
useEffect that reads DOM metrics to useLayoutEffect. Move data fetching and subscriptions back to useEffect.
- Profile Phase Latency: Open React DevTools, enable the Profiler, and interact with your app. Note whether yellow highlights (render) correlate with actual frame drops. If not, the bottleneck is in reconciliation or commit.
- Apply Targeted Fixes: Use the decision matrix to apply phase-specific optimizations. Avoid blanket memoization; let the compiler handle stable computations while you focus on key stability and DOM batching.