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.
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.
// ✗ Bad — allocates a new Map each render, discards all but the first
const cacheRef = useRef<Map<string, string>>(new Map())
// ✓ Good — allocated once, stable identity thereafter
const cacheRef = useRef<Map<string, string> | null>(null)
cacheRef.current ??= new Map()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.
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.
// ✗ Bad — rebuilt every render, new identity each time
function Toolbar({ mode }: ToolbarProps) {
const TITLES = { create: 'Add', edit: 'Configure' } as const
const handleWheel = (e: React.WheelEvent) => e.currentTarget.scrollBy(e.deltaX, e.deltaY)
// ...
}
// ✓ Good — allocated once at module load
const TITLES = { create: 'Add', edit: 'Configure' } as const
function handleWheel(e: React.WheelEvent) {
e.currentTarget.scrollBy(e.deltaX, e.deltaY)
}
function Toolbar({ mode }: ToolbarProps) { /* ... */ }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.
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).
// ✗ Bad — find() re-scans outputs for every column
for (const child of columns) {
const output = group.outputs.find((o) => o.columnName === getColumnId(child))
}
// ✓ Good — index once, then O(1) lookups
const outputByName = new Map<string, Output>()
for (const o of group.outputs) {
if (!outputByName.has(o.columnName)) outputByName.set(o.columnName, o) // first wins, matches find()
}
for (const child of columns) {
const output = outputByName.get(getColumnId(child))
}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.
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:
// ✗ Bad — mutates the (possibly shared) source array in place
return items.sort(compare)
// ✓ Good — sorts a throwaway copy, source untouched
return [...items].sort(compare)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.
Sequential awaits 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.
// ✗ Bad — waits for params, then separately waits for searchParams
const { id } = await params
const { kbName } = await searchParams
// ✓ Good — one combined wait
const [{ id }, { kbName }] = await Promise.all([params, searchParams])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).
For long lists of dynamic destinations, do not viewport-prefetch every row and do not assume
router.prefetch() warms the full route: in Next 16 it uses the automatic/PPR strategy. Gate
<Link prefetch={true}> behind deliberate hover or keyboard focus, and prefetch destination
server state with the consumer's shared React Query options. A short, cancelable hover dwell
avoids drive-by downloads. Do not treat touchstart as intent because it also begins scrolling;
let the actual unmodified click start the data request.
If a continuity-focused surface intentionally omits loading.tsx so the current view remains
mounted until its peer is ready, the intent path must warm both the full route and its critical
data. Otherwise keep the loading boundary so dynamic navigation remains responsive.
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.