|
| 1 | +# React & Render Performance |
| 2 | + |
| 3 | +Behavior-preserving performance idioms for components, hooks, and hot render paths. These are safe defaults — apply them freely. For the render-causing *effect/state* anti-patterns (derived state in effects, effect chains, state synced to a prop), use the dedicated skills: `/you-might-not-need-an-effect`, `/you-might-not-need-state`, `/you-might-not-need-a-memo`, `/you-might-not-need-a-callback`. Those refactors change render timing — verify them against the running UI, never mass-apply blind. |
| 4 | + |
| 5 | +## Lazy-init refs that hold objects |
| 6 | + |
| 7 | +`useRef(new Map())` / `useRef(new Set())` / `useRef({...})` allocates a fresh object on **every render** and throws it away — only the first is ever kept. Lazy-init instead so the allocation happens once. |
| 8 | + |
| 9 | +```typescript |
| 10 | +// ✗ Bad — allocates a new Map each render, discards all but the first |
| 11 | +const cacheRef = useRef<Map<string, string>>(new Map()) |
| 12 | + |
| 13 | +// ✓ Good — allocated once, stable identity thereafter |
| 14 | +const cacheRef = useRef<Map<string, string> | null>(null) |
| 15 | +cacheRef.current ??= new Map() |
| 16 | +``` |
| 17 | + |
| 18 | +Read `cacheRef.current` directly inside effects/handlers — refs are stable and never belong in a dependency array. A cheap primitive (`useRef(0)`, `useRef('')`, `useRef(null)`) needs no lazy init. |
| 19 | + |
| 20 | +## Hoist static values and closure-free functions to module scope |
| 21 | + |
| 22 | +A value or function declared inside a component is rebuilt every render. If it captures **nothing** from component scope (no props/state/refs), move it above the component at module scope. This skips the per-render allocation and keeps a stable identity so memoized children don't re-render. |
| 23 | + |
| 24 | +```typescript |
| 25 | +// ✗ Bad — rebuilt every render, new identity each time |
| 26 | +function Toolbar({ mode }: ToolbarProps) { |
| 27 | + const TITLES = { create: 'Add', edit: 'Configure' } as const |
| 28 | + const handleWheel = (e: React.WheelEvent) => e.currentTarget.scrollBy(e.deltaX, e.deltaY) |
| 29 | + // ... |
| 30 | +} |
| 31 | + |
| 32 | +// ✓ Good — allocated once at module load |
| 33 | +const TITLES = { create: 'Add', edit: 'Configure' } as const |
| 34 | +function handleWheel(e: React.WheelEvent) { |
| 35 | + e.currentTarget.scrollBy(e.deltaX, e.deltaY) |
| 36 | +} |
| 37 | +function Toolbar({ mode }: ToolbarProps) { /* ... */ } |
| 38 | +``` |
| 39 | + |
| 40 | +A closure-free function that IS wired through a ref sink or intentionally kept for stable identity may stay inline — hoisting a one-line `preventDefault` handler is churn, not a win. Hoist when it removes a real per-render allocation or unblocks child memoization. |
| 41 | + |
| 42 | +## Pre-index with Map/Set for repeated lookups |
| 43 | + |
| 44 | +`array.find()` / `array.includes()` / `array.indexOf()` scan the whole list each call. Inside a loop or a hot render path over a non-trivial list, that is O(n·m). Build a `Map` (for lookup-by-key) or `Set` (for membership) **once before** the loop, then look up in O(1). |
| 45 | + |
| 46 | +```typescript |
| 47 | +// ✗ Bad — find() re-scans outputs for every column |
| 48 | +for (const child of columns) { |
| 49 | + const output = group.outputs.find((o) => o.columnName === getColumnId(child)) |
| 50 | +} |
| 51 | + |
| 52 | +// ✓ Good — index once, then O(1) lookups |
| 53 | +const outputByName = new Map<string, Output>() |
| 54 | +for (const o of group.outputs) { |
| 55 | + if (!outputByName.has(o.columnName)) outputByName.set(o.columnName, o) // first wins, matches find() |
| 56 | +} |
| 57 | +for (const child of columns) { |
| 58 | + const output = outputByName.get(getColumnId(child)) |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +Preserve `.find()`'s **first-match** semantics when duplicate keys are possible: `new Map(arr.map(...))` keeps the *last* entry, so guard with `if (!map.has(key))` when replacing a `.find()`. Skip this for tiny, cold arrays (a handful of items in an event handler) where the Map build costs more than it saves. |
| 63 | + |
| 64 | +## Never mutate a shared array in place |
| 65 | + |
| 66 | +The real bug to avoid is `array.sort()` / `array.reverse()` on an array you don't own — sorting a React Query cache array in place corrupts shared state. Always sort a copy: |
| 67 | + |
| 68 | +```typescript |
| 69 | +// ✗ Bad — mutates the (possibly shared) source array in place |
| 70 | +return items.sort(compare) |
| 71 | + |
| 72 | +// ✓ Good — sorts a throwaway copy, source untouched |
| 73 | +return [...items].sort(compare) |
| 74 | +``` |
| 75 | + |
| 76 | +**Do NOT reach for `toSorted()` / `toReversed()` / `with()` / `toSpliced()` on client render paths.** They are ES2023 *runtime* methods — and a tsconfig `"lib": ["ES2023"]` only makes them **type-check**, it does not make them **run**. Next/SWC compiles syntax but does **not** polyfill prototype methods, and the default browserslist still includes browsers without them (`toSorted` landed in Safari 16 / iOS 16, so any device capped at iOS 15 throws `TypeError: x.toSorted is not a function` and crashes the page). The perf difference vs `[...arr].sort()` is negligible (both allocate one array), so the copy-then-sort form is the correct default everywhere client code runs. Only consider the immutable methods in Node-only code (server routes, scripts) on Node ≥20, where the runtime is known. |
| 77 | + |
| 78 | +## Run independent awaits in parallel |
| 79 | + |
| 80 | +Sequential `await`s that don't consume each other's result serialize latency for nothing — in an async Server Component or a route handler this directly delays the response. Kick them off together with `Promise.all` and destructure. |
| 81 | + |
| 82 | +```typescript |
| 83 | +// ✗ Bad — waits for params, then separately waits for searchParams |
| 84 | +const { id } = await params |
| 85 | +const { kbName } = await searchParams |
| 86 | + |
| 87 | +// ✓ Good — one combined wait |
| 88 | +const [{ id }, { kbName }] = await Promise.all([params, searchParams]) |
| 89 | +``` |
| 90 | + |
| 91 | +Only keep awaits sequential when a later call genuinely uses an earlier result, or when the ordering is deliberate (rate-limited batches, retry loops, write-then-read). |
| 92 | + |
| 93 | +## Local feature barrels are the convention — do not "fix" them |
| 94 | + |
| 95 | +Tooling (e.g. react-doctor's `no-barrel-import`) will flag imports from local `index.ts` barrels as a bundle cost. In this repo that is a **false positive**: barrel imports for 3+ export folders are mandated by `.claude/rules/sim-imports.md`. Leave them. |
0 commit comments