Two files, one repository
nota-america/forgecat-agent-profiles ships 3 formats across 40 indexed files. The question worth asking is whether the second one says anything the first does not.
| Dimension | Shared | Only in A | Only in B | Overlap |
|---|---|---|---|---|
| Sections | 2 | 58 | 58 | 2% |
| Commands | 0 | 1 | 2 | 0% |
| Section tags | 4 | 1 | 6 | 36% |
What each file covers
Sections
2 shared · 58 only in A · 58 only in B- − React View Transitions
- − When to Animate
- − Choosing Animation Style
- − Availability
- − Core Concepts
- − The `<ViewTransition>` Component
- − Animation Triggers
- − Critical Placement Rule
- − Styling with View Transition Classes
- − CSS Pseudo-Elements
- − Transition Types
- − `router.back()` and Browser Back Button
- − Types and Suspense
- − Shared Element Transitions
- − Common Patterns
- − Enter/Exit
- − List Reorder
- − Composing Shared Elements with List Identity
- − Force Re-Enter with `key`
- − Suspense Fallback to Content
- − How Multiple VTs Interact
- − Use `default="none"` Liberally
- − Two Patterns Coexist
- − Nested VT Limitation
- − Next.js Integration
- − Accessibility
- − Implementation Workflow
- − Step 1: Audit the App
- − Step 2: Add CSS Recipes
- − Step 3: Isolate Persistent Elements
- − Step 4: Add Directional Page Transitions
- − Step 5: Add Suspense Reveals
- − Step 6: Add Shared Element Transitions
- − Step 7: Verify Each Navigation Path
- − Common Mistakes
- − Patterns and Guidelines
- − Searchable Grid with `useDeferredValue`
- − Card Expand/Collapse with `startTransition`
- − Cross-Fade Without Remount
- − Isolate Elements from Parent Animations
- − Shared Controls Between Skeleton and Content
- − Reusable Animated Collapse
- − Preserve State with Activity
- − Exclude Elements with `useOptimistic`
- − View Transition Events
- − Animation Timing
- − Troubleshooting
- − CSS Animation Recipes
- − Timing Variables
- − Shared Keyframes
- − Fade
- − Slide (Vertical)
- − Directional Navigation
- − Single-Class Approach
- − Separate Enter/Exit Classes
- − Shared Element Morph
- − Text Morph
- − Scale
- + React Best Practices
- + 1. Eliminating Waterfalls
- + 1.1 Check Cheap Conditions Before Async Flags
- + 1.2 Defer Await Until Needed
- + 1.3 Dependency-Based Parallelization
- + 1.4 Prevent Waterfall Chains in API Routes
- + 1.5 Promise.all() for Independent Operations
- + 1.6 Strategic Suspense Boundaries
- + 2. Bundle Size Optimization
- + 2.1 Avoid Barrel File Imports
- + 2.2 Conditional Module Loading
- + 2.3 Defer Non-Critical Third-Party Libraries
- + 2.4 Dynamic Imports for Heavy Components
- + 2.5 Prefer Statically Analyzable Paths
- + 2.6 Preload Based on User Intent
- + 3. Server-Side Performance
- + 3.1 Authenticate Server Actions Like API Routes
- + 3.2 Avoid Duplicate Serialization in RSC Props
- + 3.3 Avoid Shared Module State for Request Data
- + 3.4 Cross-Request LRU Caching
- + 3.5 Hoist Static I/O to Module Level
- + 3.6 Minimize Serialization at RSC Boundaries
- + 3.7 Parallel Data Fetching with Component Composition
- + 3.8 Parallel Nested Data Fetching
- + 3.9 Per-Request Deduplication with React.cache()
- + 3.10 Use after() for Non-Blocking Operations
- + 4. Client-Side Data Fetching
- + 4.1 Deduplicate Global Event Listeners
- + 4.2 Use Passive Event Listeners for Scrolling Performance
- + 4.3 Use SWR for Automatic Deduplication
- + 4.4 Version and Minimize localStorage Data
- + 5. Re-render Optimization
- + 5.1 Calculate Derived State During Rendering
- + 5.2 Defer State Reads to Usage Point
- + 5.3 Do not wrap a simple expression with a primitive result type in useMemo
- + 5.4 Don't Define Components Inside Components
- + 5.5 Extract Default Non-primitive Parameter Value from Memoized Component to Constant
- + 5.6 Extract to Memoized Components
- + 5.7 Narrow Effect Dependencies
- + 5.8 Put Interaction Logic in Event Handlers
- + 5.9 Split Combined Hook Computations
- + 5.10 Subscribe to Derived State
- + 5.11 Use Functional setState Updates
- + 5.12 Use Lazy State Initialization
- + 5.13 Use Transitions for Non-Urgent Updates
- + 5.14 Use useDeferredValue for Expensive Derived Renders
- + 5.15 Use useRef for Transient Values
- + 6. Rendering Performance
- + 6.1 Animate SVG Wrapper Instead of SVG Element
- + 6.2 CSS content-visibility for Long Lists
- + 6.3 Hoist Static JSX Elements
- + 6.4 Optimize SVG Precision
- + 6.5 Prevent Hydration Mismatch Without Flickering
- + 6.6 Suppress Expected Hydration Mismatches
- + 6.7 Use Activity Component for Show/Hide
- + 6.8 Use defer or async on Script Tags
- + 6.9 Use Explicit Conditional Rendering
- + 6.10 Use React DOM Resource Hints
- Abstract
- Table of Contents
Commands
0 shared · 1 only in A · 2 only in B- − npm ls react
- + node.style.transform = `translateX(${e.clientX}px)`
- + npx svgo --precision=1 --multipass icon.svg
Section tags
4 shared · 1 only in A · 6 only in B- − agent-behaviour
- + build
- + security
- + dependencies
- + api
- + performance
- + deployment
- code-style
- types
- ui
- do-not
Line diff
nota-america/forgecat-agent-profiles · profiles/vercel-labs/agent-skills/vercel-labs_agent-skills_react-view-transitions/for-codex/AGENTS.md
@@ −1 @@
1<!-- forgecat:@forgecat/vercel-labs_agent-skills_react-view-transitions:react-view-transitions-agents:start -->
2# React View Transitions
3
4**Version 1.0.0**
5Vercel Engineering
6March 2026
7
8> **Note:**
9> This document is mainly for agents and LLMs to follow when implementing
10> view transitions in React applications. Humans may also find it useful,
11> but guidance here is optimized for automation and consistency by
12> AI-assisted workflows.
13
14---
15
16## Abstract
17
18Guide for implementing smooth, native-feeling animations using React's View Transition API. Covers the `<ViewTransition>` component, `addTransitionType`, CSS view transition pseudo-elements, shared element transitions, Suspense reveals, list reorder, directional navigation, and Next.js integration. Includes a step-by-step implementation workflow, ready-to-use CSS animation recipes, and common mistake warnings.
19
20---
21
22## Table of Contents
23
241. [Core Reference](#when-to-animate)
25 - [When to Animate](#when-to-animate)
26 - [Availability](#availability)
27 - [Core Concepts](#core-concepts)
28 - [Styling with View Transition Classes](#styling-with-view-transition-classes)
29 - [Transition Types](#transition-types)
30 - [Shared Element Transitions](#shared-element-transitions)
31 - [Common Patterns](#common-patterns)
32 - [How Multiple VTs Interact](#how-multiple-vts-interact)
33 - [Next.js Integration](#nextjs-integration)
34 - [Accessibility](#accessibility)
352. [Implementation Workflow](#implementation-workflow)
36 - [Step 1: Audit the App](#step-1-audit-the-app)
37 - [Step 2: Add CSS Recipes](#step-2-add-css-recipes)
38 - [Step 3: Isolate Persistent Elements](#step-3-isolate-persistent-elements)
39 - [Step 4: Add Directional Page Transitions](#step-4-add-directional-page-transitions)
40 - [Step 5: Add Suspense Reveals](#step-5-add-suspense-reveals)
41 - [Step 6: Add Shared Element Transitions](#step-6-add-shared-element-transitions)
42 - [Step 7: Verify Each Navigation Path](#step-7-verify-each-navigation-path)
43 - [Common Mistakes](#common-mistakes)
443. [Patterns and Guidelines](#patterns-and-guidelines)
454. [CSS Animation Recipes](#css-animation-recipes)
465. [View Transitions in Next.js](#view-transitions-in-nextjs)
47
48---
49
50Animate between UI states using the browser's native `document.startViewTransition`. Declare *what* with `<ViewTransition>`, trigger *when* with `startTransition` / `useDeferredValue` / `Suspense`, control *how* with CSS classes. Unsupported browsers skip animations gracefully.
51
52## When to Animate
53
54Every `<ViewTransition>` should communicate a spatial relationship or continuity. If you can't articulate what it communicates, don't add it.
55
56Implement **all** applicable patterns from this list, in this order:
57
58| Priority | Pattern | What it communicates |
59|----------|---------|---------------------|
60| 1 | **Shared element** (`name`) | "Same thing — going deeper" |
61| 2 | **Suspense reveal** | "Data loaded" |
62| 3 | **List identity** (per-item `key`) | "Same items, new arrangement" |
63| 4 | **State change** (`enter`/`exit`) | "Something appeared/disappeared" |
64| 5 | **Route change** (layout-level) | "Going to a new place" |
65
66This is an implementation order, not a "pick one" list. Implement every pattern that fits the app. Only skip a pattern if the app has no use case for it.
67
68### Choosing Animation Style
69
70| Context | Animation | Why |
71|---------|-----------|-----|
72| Hierarchical navigation (list → detail) | Type-keyed `nav-forward` / `nav-back` | Communicates spatial depth |
73| Lateral navigation (tab-to-tab) | Bare `<ViewTransition>` (fade) or `default="none"` | No depth to communicate |
74| Suspense reveal | `enter`/`exit` string props | Content arriving |
75| Revalidation / background refresh | `default="none"` | Silent — no animation needed |
76
77Reserve directional slides for hierarchical navigation (list → detail) and ordered sequences (prev/next photo, carousel, paginated results). For ordered sequences, the direction communicates position: "next" slides from right, "previous" from left. Lateral/unordered navigation (tab-to-tab) should not use directional slides — it falsely implies spatial depth.
78
79---
80
81## Availability
82
83- **Next.js:** Do **not** install `react@canary` — the App Router already bundles React canary internally. `ViewTransition` works out of the box. `npm ls react` may show a stable-looking version; this is expected.
84- **Without Next.js:** Install `react@canary react-dom@canary` (`ViewTransition` is not in stable React).
85- Browser support: Chromium 111+, Firefox 144+, Safari 18.2+. Graceful degradation.
86
87---
88
89## Core Concepts
90
91### The `<ViewTransition>` Component
92
93```jsx
94import { ViewTransition } from 'react';
95
96<ViewTransition>
97 <Component />
98</ViewTransition>
99```
100
101React auto-assigns a unique `view-transition-name` and calls `document.startViewTransition` behind the scenes. Never call `startViewTransition` yourself.
102
103### Animation Triggers
104
105| Trigger | When it fires |
106|---------|--------------|
107| **enter** | VT first inserted during a Transition |
108| **exit** | VT first removed during a Transition |
109| **update** | DOM mutations inside a VT. With nested VTs, mutation applies to the innermost one |
110| **share** | Named VT unmounts and another with same `name` mounts in same Transition |
111
112Only `startTransition`, `useDeferredValue`, or `Suspense` activate VTs. Regular `setState` does not animate.
113
114### Critical Placement Rule
115
116VT only activates enter/exit if it appears **before any DOM nodes**:
117
118```jsx
119// Works
120<ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition>
121
122// Broken — div wraps the VT
123<div><ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition></div>
124```
125
126---
127
128## Styling with View Transition Classes
129
130Values: `"auto"` (browser cross-fade), `"none"` (disabled), `"class-name"` (custom CSS), or `{ [type]: value }` for type-specific animations.
131
132```jsx
133<ViewTransition default="none" enter="slide-in" exit="slide-out" share="morph" />
134```
135
136If `default` is `"none"`, all triggers are off unless explicitly listed.
137
138### CSS Pseudo-Elements
139
140- `::view-transition-old(.class)` — outgoing snapshot
141- `::view-transition-new(.class)` — incoming snapshot
142- `::view-transition-group(.class)` — container
143- `::view-transition-image-pair(.class)` — old + new pair
144
145---
146
147## Transition Types
148
149Tag transitions with `addTransitionType` so VTs can pick different animations. Call it multiple times to stack types — different VTs in the tree react to different types:
150
151```jsx
152startTransition(() => {
153 addTransitionType('nav-forward');
154 addTransitionType('select-item');
155 router.push('/detail/1');
156});
157```
158
159Map types to CSS classes. Works on `enter`, `exit`, **and** `share`:
160
161```jsx
162<ViewTransition
163 enter={{ 'nav-forward': 'slide-from-right', 'nav-back': 'slide-from-left', default: 'none' }}
164 exit={{ 'nav-forward': 'slide-to-left', 'nav-back': 'slide-to-right', default: 'none' }}
165 share={{ 'nav-forward': 'morph-forward', 'nav-back': 'morph-back', default: 'morph' }}
166 default="none"
167>
168 <Page />
169</ViewTransition>
170```
171
172`enter` and `exit` don't have to be symmetric. For example, fade in but slide out directionally:
173
174```jsx
175<ViewTransition
176 enter={{ 'nav-forward': 'fade-in', 'nav-back': 'fade-in', default: 'none' }}
177 exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
178 default="none"
179>
180```
181
182**TypeScript:** `ViewTransitionClassPerType` requires a `default` key.
183
184### `router.back()` and Browser Back Button
185
186`router.back()` and the browser's back/forward buttons do **not** trigger view transitions (`popstate` is synchronous, incompatible with `startViewTransition`). Use `router.push()` with an explicit URL instead.
187
188### Types and Suspense
189
190Types are available during navigation but **not** during subsequent Suspense reveals (separate transitions, no type). Use type maps for page-level enter/exit; use simple string props for Suspense reveals.
191
192---
193
194## Shared Element Transitions
195
196Same `name` on two VTs — one unmounting, one mounting — creates a shared element morph:
197
198```jsx
199<ViewTransition name="hero-image">
200 <img src="/thumb.jpg" onClick={() => startTransition(() => onSelect())} />
201</ViewTransition>
202
203// Other view — same name
204<ViewTransition name="hero-image">
205 <img src="/full.jpg" />
206</ViewTransition>
207```
208
209- Only one VT with a given `name` can be mounted at a time — use unique names. Watch for reusable components: if a component with a named VT is rendered in both a modal/popover *and* a page, both mount simultaneously and break the morph. Either make the name conditional (via a prop) or move the named VT out of the shared component into the specific consumer.
210- `share` takes precedence over `enter`/`exit`. Think through each navigation path: when no pair forms, `enter`/`exit` fires instead. Consider whether the element needs a fallback animation for those paths.
211- Never use fade-out exit on pages with shared morphs — use directional slide.
212
213---
214
215## Common Patterns
216
217### Enter/Exit
218
219```jsx
220{show && (
221 <ViewTransition enter="fade-in" exit="fade-out"><Panel /></ViewTransition>
222)}
223```
224
225### List Reorder
226
227```jsx
228{items.map(item => (
229 <ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>
230))}
231```
232
233Trigger inside `startTransition`. Avoid wrapper `<div>`s between list and VT.
234
235### Composing Shared Elements with List Identity
236
237Shared elements and list identity are independent concerns — don't confuse one for the other. When a list item contains a shared element, use two nested `<ViewTransition>` boundaries:
238
239```jsx
240{items.map(item => (
241 <ViewTransition key={item.id}> {/* list identity */}
242 <Link href={`/items/${item.id}`}>
243 <ViewTransition name={`item-image-${item.id}`} share="morph"> {/* shared element */}
244 <Image src={item.image} />
245 </ViewTransition>
246 <p>{item.name}</p>
247 </Link>
248 </ViewTransition>
249))}
250```
251
252The outer VT handles list reorder/enter. The inner VT handles cross-route shared element morph. Missing either layer means that animation silently doesn't happen.
253
254### Force Re-Enter with `key`
255
256```jsx
257<ViewTransition key={searchParams.toString()} enter="slide-up" default="none">
258 <ResultsGrid />
259</ViewTransition>
260```
261
262**Caution:** Wrapping `<Suspense>` with key remounts the boundary and refetches.
263
264### Suspense Fallback to Content
265
266Simple cross-fade:
267```jsx
268<ViewTransition>
269 <Suspense fallback={<Skeleton />}><Content /></Suspense>
270</ViewTransition>
271```
272
273Directional reveal:
274```jsx
275<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
276 <ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>
277</Suspense>
278```
279
280---
281
282## How Multiple VTs Interact
283
284Every VT matching the trigger fires simultaneously in a single `document.startViewTransition`. VTs in **different** transitions don't compete.
285
286### Use `default="none"` Liberally
287
288Without it, every VT fires the browser cross-fade on **every** transition. Always use `default="none"` and explicitly enable only desired triggers.
289
290### Two Patterns Coexist
291
292**Pattern A — Directional slides:** Type-keyed VT on each page, fires during navigation.
293**Pattern B — Suspense reveals:** Simple string props, fires when data loads (no type).
294
295They coexist because they fire at different moments. `default="none"` on both prevents cross-interference. Always pair `enter` with `exit`. Place directional VTs in page components, not layouts.
296
297### Nested VT Limitation
298
299When a parent VT exits, nested VTs inside it do **not** fire their own enter/exit — only the outermost VT animates. Per-item staggered animations during page navigation are not possible today. See [react#36135](https://github.com/facebook/react/pull/36135) for an experimental opt-in fix.
300
301---
302
303## Next.js Integration
304
305See the [View Transitions in Next.js](#view-transitions-in-nextjs) section below.
306
307---
308
309## Accessibility
310
311Always add reduced motion CSS to your global stylesheet:
312
313```css
314@media (prefers-reduced-motion: reduce) {
315 ::view-transition-old(*),
316 ::view-transition-new(*),
317 ::view-transition-group(*) {
318 animation-duration: 0s !important;
319 animation-delay: 0s !important;
320 }
321}
322```
323
324---
325
326# Implementation Workflow
327
328**Follow these steps in order.** Start with the audit — do not skip it. Copy the CSS recipes from the CSS Recipes section below — do not write your own animation CSS.
329
330## Step 1: Audit the App
331
332Before writing any code, scan the codebase thoroughly. Search for:
333
334- **Every `<Link>` and `router.push`** — open every file that contains one
335- **Every `<Suspense>` boundary** — check what its fallback renders
336- **Every page/route component** — each needs a VT placement decision
337- **Persistent elements** (headers, navbars, sidebars) — need `viewTransitionName` isolation
338- **Shared visual elements** on both source and target views
339- **Skeleton-to-content control pairs** — if a fallback renders a control that also exists in the real content, both need a matching `viewTransitionName`
340
341Then classify every navigation and produce a navigation map:
342
343```
344| Route | Navigates to | Direction | VT pattern |
345|-----------------|----------------------|--------------|-----------------------|
346| / | /detail/[id] | forward | directional slide |
347| /detail/[id] | / | back | directional slide |
348| /detail/[id] | /detail/[other] | sequential | directional slide (ordered prev/next) or key+share crossfade |
349| /tab/[a] | /tab/[b] | lateral | key+share crossfade |
350| (Suspense) | (content loads) | — | slide-up reveal |
351```
352
353For each shared element (`name` prop), note where a pair forms and where it doesn't — this determines whether you need `enter`/`exit` as a fallback alongside `share`.
354
355## Step 2: Add CSS Recipes
356
357Copy the **complete** CSS recipe set from the CSS Animation Recipes section below into your global stylesheet. Don't write your own — the recipes handle staggered timing, motion blur, and reduced motion.
358
359## Step 3: Isolate Persistent Elements
360
361```jsx
362<header style={{ viewTransitionName: "site-header" }}>...</header>
363```
364
365```css
366::view-transition-group(site-header) {
367 animation: none;
368 z-index: 100;
369}
370```
371
372For `backdrop-blur`/`backdrop-filter`, use the backdrop-blur workaround instead.
373
374## Step 4: Add Directional Page Transitions
375
376```jsx
377startTransition(() => {
378 addTransitionType('nav-forward');
379 router.push('/detail/1');
380});
381```
382
383Wrap each **page component** (not layout) in a type-keyed VT:
384
385```jsx
386<ViewTransition
387 enter={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}
388 exit={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}
389 default="none"
390>
391 <div>...page content...</div>
392</ViewTransition>
393```
394
395Extract into a reusable component so every page doesn't repeat the type map:
396
397```jsx
398export function DirectionalTransition({ children }: { children: React.ReactNode }) {
399 return (
400 <ViewTransition
401 enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
402 exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
403 default="none"
404 >
405 {children}
406 </ViewTransition>
407 );
408}
409```
410
411**Rules:** Always pair `enter` with `exit`. Always include `default: "none"`. Place in page components, not layouts. Only use directional slides for hierarchical navigation or ordered sequences (prev/next).
412
413## Step 5: Add Suspense Reveals
414
415```jsx
416<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
417 <ViewTransition enter="slide-up" default="none"><AsyncContent /></ViewTransition>
418</Suspense>
419```
420
421Use `default="none"` on content VT. Use simple string props (not type maps) — Suspense resolves have no type.
422
423## Step 6: Add Shared Element Transitions
424
425```jsx
426// Source view
427<ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
428 <Image src={photo.src} ... />
429</ViewTransition>
430
431// Target view — same name
432<ViewTransition name={`photo-${photo.id}`} share="morph">
433 <Image src={photo.src} ... />
434</ViewTransition>
435```
436
437When list items contain shared elements, compose both patterns — two independent layers:
438
439```jsx
440{items.map(item => (
441 <ViewTransition key={item.id}> {/* list identity */}
442 <Link href={`/detail/${item.id}`}>
443 <ViewTransition name={`item-${item.id}`} share="morph" default="none"> {/* shared element */}
444 <Image src={item.image} ... />
445 </ViewTransition>
446 </Link>
447 </ViewTransition>
448))}
449```
450
451The outer VT handles list reorder/enter. The inner VT handles cross-route shared element morph. Missing either layer means that animation silently doesn't happen.
452
453**Rules:** Names must be globally unique. Add `default="none"` on list-side shared elements.
454
455## Step 7: Verify Each Navigation Path
456
457Walk through every row in the navigation map from Step 1:
458
459- Does the VT mount/unmount, or stay mounted (same-route)?
460- For named VTs: does a shared pair form? If not, does `enter`/`exit` provide a fallback?
461- Does `default="none"` block an animation you actually want?
462- Do persistent elements stay static?
463- Do Suspense reveals animate independently from directional navigations?
464
465---
466
467## Common Mistakes
468
469- **Bare VT without `default="none"`** — fires cross-fade on every transition
470- **Directional VT in a layout** — layouts persist, enter/exit won't fire on route changes
471- **Fade-out exit with shared morphs** — conflicts with morph, use directional slide
472- **Writing custom animation CSS** — use the recipes
473- **Missing `default: "none"` in type-keyed objects** — TypeScript requires it, fallback is `"auto"`
474- **Type maps on Suspense reveals** — Suspense resolves have no type, use string props
475- **Raw `viewTransitionName` CSS to trigger animations** — React only starts view transitions when `<ViewTransition>` components are in the tree. Bare `viewTransitionName` is for isolating elements, not triggering animations.
476- **`update` trigger for same-route navigations** — nested VTs steal the mutation from the parent. Use `key` + `name` + `share` instead.
477- **Named VT in a reusable component** — if a component with a named VT is rendered in both a modal/popover *and* a page, both mount simultaneously and break the morph. Make the name conditional or move it to the specific consumer.
478- **`router.back()` for back navigation** — `router.back()` triggers synchronous `popstate`, incompatible with view transitions. Use `router.push()` with an explicit URL.
479
480For Next.js-specific steps, see the Next.js section below.
481
482---
483
484# Patterns and Guidelines
485
486## Searchable Grid with `useDeferredValue`
487
488```tsx
489'use client';
490
491import { useDeferredValue, useState, ViewTransition, Suspense } from 'react';
492
493export default function SearchableGrid({ itemsPromise }) {
494 const [search, setSearch] = useState('');
495 const deferredSearch = useDeferredValue(search);
496
497 return (
498 <>
499 <input value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
500 <ViewTransition>
501 <Suspense fallback={<GridSkeleton />}>
502 <ItemGrid itemsPromise={itemsPromise} search={deferredSearch} />
503 </Suspense>
504 </ViewTransition>
505 </>
506 );
507}
508```
509
510Per-item named VTs in deferred lists trigger cross-fades on every keystroke. Fix with `default="none"`.
511
512## Card Expand/Collapse with `startTransition`
513
514```tsx
515'use client';
516
517import { useState, useRef, startTransition, ViewTransition } from 'react';
518
519export default function ItemGrid({ items }) {
520 const [expandedId, setExpandedId] = useState(null);
521 const scrollRef = useRef(0);
522
523 return expandedId ? (
524 <ViewTransition enter="slide-in" name={`item-${expandedId}`}>
525 <ItemDetail
526 item={items.find(i => i.id === expandedId)}
527 onClose={() => {
528 startTransition(() => {
529 setExpandedId(null);
530 setTimeout(() => window.scrollTo({ behavior: 'smooth', top: scrollRef.current }), 100);
531 });
532 }}
533 />
534 </ViewTransition>
535 ) : (
536 <div className="grid grid-cols-3 gap-4">
537 {items.map(item => (
538 <ViewTransition key={item.id} name={`item-${item.id}`}>
539 <ItemCard
540 item={item}
541 onSelect={() => {
542 scrollRef.current = window.scrollY;
543 startTransition(() => setExpandedId(item.id));
544 }}
545 />
546 </ViewTransition>
547 ))}
548 </div>
549 );
550}
551```
552
553## Cross-Fade Without Remount
554
555Omit `key` to trigger update (cross-fade) instead of exit + enter. Avoids Suspense remount:
556
557```jsx
558<ViewTransition><TabPanel tab={activeTab} /></ViewTransition>
559```
560
561## Isolate Elements from Parent Animations
562
563Persistent elements get captured in page's transition snapshot. Fix with `viewTransitionName`:
564
565```jsx
566<nav style={{ viewTransitionName: "persistent-nav" }}>{/* ... */}</nav>
567```
568
569```css
570::view-transition-group(persistent-nav) { animation: none; z-index: 100; }
571```
572
573Same for floating elements (popovers, tooltips). Global fix: `::view-transition-group(*) { z-index: 100; }`
574
575## Shared Controls Between Skeleton and Content
576
577Give matching controls the same `viewTransitionName`. Don't put manual `viewTransitionName` on root DOM node inside `<ViewTransition>`.
578
579## Reusable Animated Collapse
580
581```jsx
582function AnimatedCollapse({ open, children }) {
583 if (!open) return null;
584 return <ViewTransition enter="expand-in" exit="collapse-out">{children}</ViewTransition>;
585}
586```
587
588## Preserve State with Activity
589
590```jsx
591<Activity mode={isVisible ? 'visible' : 'hidden'}>
592 <ViewTransition enter="slide-in" exit="slide-out"><Sidebar /></ViewTransition>
593</Activity>
594```
595
596## Exclude Elements with `useOptimistic`
597
598`useOptimistic` values update before snapshot, excluding them from animation. Use for controls; use committed state for animated content.
599
600---
601
602## View Transition Events
603
604Imperative control via `onEnter`, `onExit`, `onUpdate`, `onShare`. Always return cleanup. `onShare` takes precedence.
605
606```jsx
607<ViewTransition
608 onEnter={(instance, types) => {
609 const anim = instance.new.animate(
610 [{ transform: 'scale(0.8)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],
611 { duration: 300, easing: 'ease-out' }
612 );
613 return () => anim.cancel();
614 }}
615>
616 <Component />
617</ViewTransition>
618```
619
620`instance`: `.old`, `.new`, `.group`, `.imagePair`, `.name`
621
622---
623
624## Animation Timing
625
626| Interaction | Duration |
627|------------|----------|
628| Direct toggle | 100–200ms |
629| Route transition | 150–250ms |
630| Suspense reveal | 200–400ms |
631| Shared element morph | 300–500ms |
632
633---
634
635## Troubleshooting
636
637**VT not activating:** Ensure VT comes before any DOM node. Ensure `startTransition`.
638
639**"Two VTs with same name":** Names must be globally unique. Use IDs.
640
641**`router.back()` and browser back/forward skip animation:** Use `router.push()` with an explicit URL instead.
642
643**Only updates animate:** Without `<Suspense>`, React treats swaps as updates. Conditionally render the VT itself, or wrap in `<Suspense>`.
644
645**Layout VT prevents page VTs from animating:** Nested VTs never fire enter/exit inside a parent VT. If your layout has a VT wrapping `{children}`, page-level enter/exit will silently not work. Remove the layout VT.
646
647**TS error "Property 'default' is missing":** Type-keyed objects require a `default` key.
648
649**Backdrop-blur flickers:** `::view-transition-old(name) { display: none }` + `::view-transition-new(name) { animation: none }`.
650
651**`border-radius` lost:** Apply `border-radius` directly to captured element.
652
653**Batching:** Multiple updates during animation are batched (A→B→C→D becomes B→D).
654
655---
656
657# CSS Animation Recipes
658
659Ready-to-use CSS for `<ViewTransition>` props. Copy into global stylesheet.
660
661## Timing Variables
662
663```css
664:root {
665 --duration-exit: 150ms;
666 --duration-enter: 210ms;
667 --duration-move: 400ms;
668}
669```
670
671### Shared Keyframes
672
673```css
674@keyframes fade {
675 from { filter: blur(3px); opacity: 0; }
676 to { filter: blur(0); opacity: 1; }
677}
678
679@keyframes slide {
680 from { translate: var(--slide-offset); }
681 to { translate: 0; }
682}
683
684@keyframes slide-y {
685 from { transform: translateY(var(--slide-y-offset, 10px)); }
686 to { transform: translateY(0); }
687}
688```
689
690## Fade
691
692```css
693::view-transition-old(.fade-out) {
694 animation: var(--duration-exit) ease-in fade reverse;
695}
696::view-transition-new(.fade-in) {
697 animation: var(--duration-enter) ease-out var(--duration-exit) both fade;
698}
699```
700
701## Slide (Vertical)
702
703```css
704::view-transition-old(.slide-down) {
705 animation:
706 var(--duration-exit) ease-out both fade reverse,
707 var(--duration-exit) ease-out both slide-y reverse;
708}
709::view-transition-new(.slide-up) {
710 animation:
711 var(--duration-enter) ease-in var(--duration-exit) both fade,
712 var(--duration-move) ease-in both slide-y;
713}
714```
715
716## Directional Navigation
717
718### Single-Class Approach
719
720```css
721::view-transition-old(.nav-forward) {
722 --slide-offset: -60px;
723 animation:
724 var(--duration-exit) ease-in both fade reverse,
725 var(--duration-move) ease-in-out both slide reverse;
726}
727::view-transition-new(.nav-forward) {
728 --slide-offset: 60px;
729 animation:
730 var(--duration-enter) ease-out var(--duration-exit) both fade,
731 var(--duration-move) ease-in-out both slide;
732}
733
734::view-transition-old(.nav-back) {
735 --slide-offset: 60px;
736 animation:
737 var(--duration-exit) ease-in both fade reverse,
738 var(--duration-move) ease-in-out both slide reverse;
739}
740::view-transition-new(.nav-back) {
741 --slide-offset: -60px;
742 animation:
743 var(--duration-enter) ease-out var(--duration-exit) both fade,
744 var(--duration-move) ease-in-out both slide;
745}
746```
747
748### Separate Enter/Exit Classes
749
750```css
751::view-transition-new(.slide-from-right) {
752 --slide-offset: 60px;
753 animation:
754 var(--duration-enter) ease-out var(--duration-exit) both fade,
755 var(--duration-move) ease-in-out both slide;
756}
757::view-transition-old(.slide-to-left) {
758 --slide-offset: -60px;
759 animation:
760 var(--duration-exit) ease-in both fade reverse,
761 var(--duration-move) ease-in-out both slide reverse;
762}
763
764::view-transition-new(.slide-from-left) {
765 --slide-offset: -60px;
766 animation:
767 var(--duration-enter) ease-out var(--duration-exit) both fade,
768 var(--duration-move) ease-in-out both slide;
769}
770::view-transition-old(.slide-to-right) {
771 --slide-offset: 60px;
772 animation:
773 var(--duration-exit) ease-in both fade reverse,
774 var(--duration-move) ease-in-out both slide reverse;
775}
776```
777
778## Shared Element Morph
779
780```css
781::view-transition-group(.morph) {
782 animation-duration: var(--duration-move);
783}
784::view-transition-image-pair(.morph) {
785 animation-name: via-blur;
786}
787@keyframes via-blur {
788 30% { filter: blur(3px); }
789}
790```
791
792**Note:** Shared element transitions take raster snapshots. For text with significant size differences (e.g., `<h3>` → `<h1>`), the old snapshot gets scaled up, producing a visible ghost artifact. Use `text-morph` for text shared elements.
793
794## Text Morph
795
796Avoids raster scaling artifacts on text by hiding the old snapshot and showing the new text at full resolution:
797
798```css
799::view-transition-group(.text-morph) {
800 animation-duration: var(--duration-move);
801}
802::view-transition-old(.text-morph) {
803 display: none;
804}
805::view-transition-new(.text-morph) {
806 animation: none;
807 object-fit: none;
808 object-position: left top;
809}
810```
811
812## Scale
813
814```css
815::view-transition-old(.scale-out) {
816 animation: var(--duration-exit) ease-in scale-down;
817}
818::view-transition-new(.scale-in) {
819 animation: var(--duration-enter) ease-out var(--duration-exit) both scale-up;
820}
821@keyframes scale-down {
822 from { transform: scale(1); opacity: 1; }
823 to { transform: scale(0.85); opacity: 0; }
824}
825@keyframes scale-up {
826 from { transform: scale(0.85); opacity: 0; }
827 to { transform: scale(1); opacity: 1; }
828}
829```
830
831## Persistent Element Isolation
832
833```css
834::view-transition-group(persistent-nav) {
835 animation: none;
836 z-index: 100;
837}
838```
839
840### Backdrop-Blur Workaround
841
842```css
843::view-transition-old(persistent-nav) { display: none; }
844::view-transition-new(persistent-nav) { animation: none; }
845```
846
847## Reduced Motion
848
849```css
850@media (prefers-reduced-motion: reduce) {
851 ::view-transition-old(*),
852 ::view-transition-new(*),
853 ::view-transition-group(*) {
854 animation-duration: 0s !important;
855 animation-delay: 0s !important;
856 }
857}
858```
859
860---
861
862# View Transitions in Next.js
863
864## Setup
865
866```js
867// next.config.js
868experimental: { viewTransition: true }
869```
870
871Wraps every `<Link>` navigation in `document.startViewTransition`. Use `default="none"` to prevent competing animations. Do **not** install `react@canary` — the App Router already bundles it.
872
873## Next.js Implementation Additions
874
875**After Step 2:** Enable the experimental flag.
876
877**Step 4:** Use `transitionTypes` on `<Link>` (if available — see availability note below):
878```tsx
879<Link href="/photo/1" transitionTypes={["nav-forward"]}>View</Link>
880<Link href="/" transitionTypes={["nav-back"]}>Back</Link>
881```
882
883**After Step 6:** For same-route dynamic segments, use `key` + `name` + `share` pattern.
884
885## Layout-Level ViewTransition
886
887Don't add a layout-level VT wrapping `{children}` if pages have their own VTs — nested VTs never fire enter/exit inside a parent VT, so page-level enter/exit will silently not work. Remove the layout VT entirely. A bare VT in layout works only if pages have no VTs of their own. Layouts persist across navigations — don't use type-keyed maps in layouts.
888
889## The `transitionTypes` Prop
890
891Works in Server Components, no wrapper needed:
892```tsx
893<Link href="/products/1" transitionTypes={['nav-forward']}>View</Link>
894```
895
896**Availability:** Requires `experimental.viewTransition: true`. Available in Next.js 15+ canary builds and Next.js 16+. If unavailable, use `startTransition` + `addTransitionType` + `router.push()`. To check: `grep -r "transitionTypes" node_modules/next/dist/`. Reserve manual `startTransition` for non-link interactions.
897
898## `loading.tsx` as Suspense Boundary
899
900Next.js `loading.tsx` files are implicit `<Suspense>` boundaries. Wrap the skeleton in `<ViewTransition exit="...">` in `loading.tsx`, and the content in `<ViewTransition enter="..." default="none">` in the page. This is the Next.js-idiomatic equivalent of explicit `<Suspense fallback={...}>`. Same rules apply: use simple string props (not type maps) since Suspense reveals fire without transition types.
901
902## Server-Side Filtering with `router.replace`
903
904For search/sort/filter that re-renders on the server (via URL params), use `startTransition` + `router.replace`. VTs activate because the update is inside `startTransition`. List items wrapped in `<ViewTransition key={item.id}>` animate reorder. This is the server-component alternative to the client-side `useDeferredValue` pattern.
905
906## Two-Layer Pattern (Directional + Suspense)
907
908Directional slides + Suspense reveals coexist because they fire at different moments. Place the directional VT in the **page component** (not layout):
909
910```tsx
911<ViewTransition
912 enter={{ "nav-forward": "slide-from-right", default: "none" }}
913 exit={{ "nav-forward": "slide-to-left", default: "none" }}
914 default="none"
915>
916 <div>
917 <Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
918 <ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>
919 </Suspense>
920 </div>
921</ViewTransition>
922```
923
924## Shared Elements Across Routes
925
926```tsx
927// List page
928<Link href={`/products/${product.id}`} transitionTypes={['nav-forward']}>
929 <ViewTransition name={`product-${product.id}`}>
930 <Image src={product.image} alt={product.name} width={400} height={300} />
931 </ViewTransition>
932</Link>
933
934// Detail page — same name
935<ViewTransition name={`product-${product.id}`}>
936 <Image src={product.image} alt={product.name} width={800} height={600} />
937</ViewTransition>
938```
939
940## Same-Route Dynamic Segment Transitions
941
942Page stays mounted on dynamic segment change — enter/exit never fire. Use `key` + `name` + `share`:
943
944```tsx
945<Suspense fallback={<Skeleton />}>
946 <ViewTransition key={slug} name={`collection-${slug}`} share="auto" default="none">
947 <Content slug={slug} />
948 </ViewTransition>
949</Suspense>
950```
951
952## Server Components
953
954- `<ViewTransition>` works in Server and Client Components
955- `<Link transitionTypes>` works in Server Components
956- `addTransitionType` and programmatic nav require Client Components
957<!-- forgecat:@forgecat/vercel-labs_agent-skills_react-view-transitions:react-view-transitions-agents:end -->
958
nota-america/forgecat-agent-profiles · profiles/vercel-labs/agent-skills/vercel-labs_agent-skills_react-best-practices/for-forgecat/AGENTS.md
@@ +1 @@
1# React Best Practices
2
3**Version 1.0.0**
4Vercel Engineering
5January 2026
6
7> **Note:**
8> This document is mainly for agents and LLMs to follow when maintaining,
9> generating, or refactoring React and Next.js codebases. Humans
10> may also find it useful, but guidance here is optimized for automation
11> and consistency by AI-assisted workflows.
12
13---
14
15## Abstract
16
17Comprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
18
19---
20
21## Table of Contents
22
231. [Eliminating Waterfalls](#1-eliminating-waterfalls) — **CRITICAL**
24 - 1.1 [Check Cheap Conditions Before Async Flags](#11-check-cheap-conditions-before-async-flags)
25 - 1.2 [Defer Await Until Needed](#12-defer-await-until-needed)
26 - 1.3 [Dependency-Based Parallelization](#13-dependency-based-parallelization)
27 - 1.4 [Prevent Waterfall Chains in API Routes](#14-prevent-waterfall-chains-in-api-routes)
28 - 1.5 [Promise.all() for Independent Operations](#15-promiseall-for-independent-operations)
29 - 1.6 [Strategic Suspense Boundaries](#16-strategic-suspense-boundaries)
302. [Bundle Size Optimization](#2-bundle-size-optimization) — **CRITICAL**
31 - 2.1 [Avoid Barrel File Imports](#21-avoid-barrel-file-imports)
32 - 2.2 [Conditional Module Loading](#22-conditional-module-loading)
33 - 2.3 [Defer Non-Critical Third-Party Libraries](#23-defer-non-critical-third-party-libraries)
34 - 2.4 [Dynamic Imports for Heavy Components](#24-dynamic-imports-for-heavy-components)
35 - 2.5 [Prefer Statically Analyzable Paths](#25-prefer-statically-analyzable-paths)
36 - 2.6 [Preload Based on User Intent](#26-preload-based-on-user-intent)
373. [Server-Side Performance](#3-server-side-performance) — **HIGH**
38 - 3.1 [Authenticate Server Actions Like API Routes](#31-authenticate-server-actions-like-api-routes)
39 - 3.2 [Avoid Duplicate Serialization in RSC Props](#32-avoid-duplicate-serialization-in-rsc-props)
40 - 3.3 [Avoid Shared Module State for Request Data](#33-avoid-shared-module-state-for-request-data)
41 - 3.4 [Cross-Request LRU Caching](#34-cross-request-lru-caching)
42 - 3.5 [Hoist Static I/O to Module Level](#35-hoist-static-io-to-module-level)
43 - 3.6 [Minimize Serialization at RSC Boundaries](#36-minimize-serialization-at-rsc-boundaries)
44 - 3.7 [Parallel Data Fetching with Component Composition](#37-parallel-data-fetching-with-component-composition)
45 - 3.8 [Parallel Nested Data Fetching](#38-parallel-nested-data-fetching)
46 - 3.9 [Per-Request Deduplication with React.cache()](#39-per-request-deduplication-with-reactcache)
47 - 3.10 [Use after() for Non-Blocking Operations](#310-use-after-for-non-blocking-operations)
484. [Client-Side Data Fetching](#4-client-side-data-fetching) — **MEDIUM-HIGH**
49 - 4.1 [Deduplicate Global Event Listeners](#41-deduplicate-global-event-listeners)
50 - 4.2 [Use Passive Event Listeners for Scrolling Performance](#42-use-passive-event-listeners-for-scrolling-performance)
51 - 4.3 [Use SWR for Automatic Deduplication](#43-use-swr-for-automatic-deduplication)
52 - 4.4 [Version and Minimize localStorage Data](#44-version-and-minimize-localstorage-data)
535. [Re-render Optimization](#5-re-render-optimization) — **MEDIUM**
54 - 5.1 [Calculate Derived State During Rendering](#51-calculate-derived-state-during-rendering)
55 - 5.2 [Defer State Reads to Usage Point](#52-defer-state-reads-to-usage-point)
56 - 5.3 [Do not wrap a simple expression with a primitive result type in useMemo](#53-do-not-wrap-a-simple-expression-with-a-primitive-result-type-in-usememo)
57 - 5.4 [Don't Define Components Inside Components](#54-dont-define-components-inside-components)
58 - 5.5 [Extract Default Non-primitive Parameter Value from Memoized Component to Constant](#55-extract-default-non-primitive-parameter-value-from-memoized-component-to-constant)
59 - 5.6 [Extract to Memoized Components](#56-extract-to-memoized-components)
60 - 5.7 [Narrow Effect Dependencies](#57-narrow-effect-dependencies)
61 - 5.8 [Put Interaction Logic in Event Handlers](#58-put-interaction-logic-in-event-handlers)
62 - 5.9 [Split Combined Hook Computations](#59-split-combined-hook-computations)
63 - 5.10 [Subscribe to Derived State](#510-subscribe-to-derived-state)
64 - 5.11 [Use Functional setState Updates](#511-use-functional-setstate-updates)
65 - 5.12 [Use Lazy State Initialization](#512-use-lazy-state-initialization)
66 - 5.13 [Use Transitions for Non-Urgent Updates](#513-use-transitions-for-non-urgent-updates)
67 - 5.14 [Use useDeferredValue for Expensive Derived Renders](#514-use-usedeferredvalue-for-expensive-derived-renders)
68 - 5.15 [Use useRef for Transient Values](#515-use-useref-for-transient-values)
696. [Rendering Performance](#6-rendering-performance) — **MEDIUM**
70 - 6.1 [Animate SVG Wrapper Instead of SVG Element](#61-animate-svg-wrapper-instead-of-svg-element)
71 - 6.2 [CSS content-visibility for Long Lists](#62-css-content-visibility-for-long-lists)
72 - 6.3 [Hoist Static JSX Elements](#63-hoist-static-jsx-elements)
73 - 6.4 [Optimize SVG Precision](#64-optimize-svg-precision)
74 - 6.5 [Prevent Hydration Mismatch Without Flickering](#65-prevent-hydration-mismatch-without-flickering)
75 - 6.6 [Suppress Expected Hydration Mismatches](#66-suppress-expected-hydration-mismatches)
76 - 6.7 [Use Activity Component for Show/Hide](#67-use-activity-component-for-showhide)
77 - 6.8 [Use defer or async on Script Tags](#68-use-defer-or-async-on-script-tags)
78 - 6.9 [Use Explicit Conditional Rendering](#69-use-explicit-conditional-rendering)
79 - 6.10 [Use React DOM Resource Hints](#610-use-react-dom-resource-hints)
80 - 6.11 [Use useTransition Over Manual Loading States](#611-use-usetransition-over-manual-loading-states)
817. [JavaScript Performance](#7-javascript-performance) — **LOW-MEDIUM**
82 - 7.1 [Avoid Layout Thrashing](#71-avoid-layout-thrashing)
83 - 7.2 [Build Index Maps for Repeated Lookups](#72-build-index-maps-for-repeated-lookups)
84 - 7.3 [Cache Property Access in Loops](#73-cache-property-access-in-loops)
85 - 7.4 [Cache Repeated Function Calls](#74-cache-repeated-function-calls)
86 - 7.5 [Cache Storage API Calls](#75-cache-storage-api-calls)
87 - 7.6 [Combine Multiple Array Iterations](#76-combine-multiple-array-iterations)
88 - 7.7 [Defer Non-Critical Work with requestIdleCallback](#77-defer-non-critical-work-with-requestidlecallback)
89 - 7.8 [Early Length Check for Array Comparisons](#78-early-length-check-for-array-comparisons)
90 - 7.9 [Early Return from Functions](#79-early-return-from-functions)
91 - 7.10 [Hoist RegExp Creation](#710-hoist-regexp-creation)
92 - 7.11 [Use flatMap to Map and Filter in One Pass](#711-use-flatmap-to-map-and-filter-in-one-pass)
93 - 7.12 [Use Loop for Min/Max Instead of Sort](#712-use-loop-for-minmax-instead-of-sort)
94 - 7.13 [Use Set/Map for O(1) Lookups](#713-use-setmap-for-o1-lookups)
95 - 7.14 [Use toSorted() Instead of sort() for Immutability](#714-use-tosorted-instead-of-sort-for-immutability)
968. [Advanced Patterns](#8-advanced-patterns) — **LOW**
97 - 8.1 [Do Not Put Effect Events in Dependency Arrays](#81-do-not-put-effect-events-in-dependency-arrays)
98 - 8.2 [Initialize App Once, Not Per Mount](#82-initialize-app-once-not-per-mount)
99 - 8.3 [Store Event Handlers in Refs](#83-store-event-handlers-in-refs)
100 - 8.4 [useEffectEvent for Stable Callback Refs](#84-useeffectevent-for-stable-callback-refs)
101
102---
103
104## 1. Eliminating Waterfalls
105
106**Impact: CRITICAL**
107
108Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
109
110### 1.1 Check Cheap Conditions Before Async Flags
111
112**Impact: HIGH (avoids unnecessary async work when a synchronous guard already fails)**
113
114When a branch uses `await` for a flag or remote value and also requires a **cheap synchronous** condition (local props, request metadata, already-loaded state), evaluate the cheap condition **first**. Otherwise you pay for the async call even when the compound condition can never be true.
115
116This is a specialization of [Defer Await Until Needed](./async-defer-await.md) for `flag && cheapCondition` style checks.
117
118**Incorrect:**
119
120```typescript
121const someFlag = await getFlag()
122
123if (someFlag && someCondition) {
124 // ...
125}
126```
127
128**Correct:**
129
130```typescript
131if (someCondition) {
132 const someFlag = await getFlag()
133 if (someFlag) {
134 // ...
135 }
136}
137```
138
139This matters when `getFlag` hits the network, a feature-flag service, or `React.cache` / DB work: skipping it when `someCondition` is false removes that cost on the cold path.
140
141Keep the original order if `someCondition` is expensive, depends on the flag, or you must run side effects in a fixed order.
142
143### 1.2 Defer Await Until Needed
144
145**Impact: HIGH (avoids blocking unused code paths)**
146
147Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
148
149**Incorrect: blocks both branches**
150
151```typescript
152async function handleRequest(userId: string, skipProcessing: boolean) {
153 const userData = await fetchUserData(userId)
154
155 if (skipProcessing) {
156 // Returns immediately but still waited for userData
157 return { skipped: true }
158 }
159
160 // Only this branch uses userData
161 return processUserData(userData)
162}
163```
164
165**Correct: only blocks when needed**
166
167```typescript
168async function handleRequest(userId: string, skipProcessing: boolean) {
169 if (skipProcessing) {
170 // Returns immediately without waiting
171 return { skipped: true }
172 }
173
174 // Fetch only when needed
175 const userData = await fetchUserData(userId)
176 return processUserData(userData)
177}
178```
179
180**Another example: early return optimization**
181
182```typescript
183// Incorrect: always fetches permissions
184async function updateResource(resourceId: string, userId: string) {
185 const permissions = await fetchPermissions(userId)
186 const resource = await getResource(resourceId)
187
188 if (!resource) {
189 return { error: 'Not found' }
190 }
191
192 if (!permissions.canEdit) {
193 return { error: 'Forbidden' }
194 }
195
196 return await updateResourceData(resource, permissions)
197}
198
199// Correct: fetches only when needed
200async function updateResource(resourceId: string, userId: string) {
201 const resource = await getResource(resourceId)
202
203 if (!resource) {
204 return { error: 'Not found' }
205 }
206
207 const permissions = await fetchPermissions(userId)
208
209 if (!permissions.canEdit) {
210 return { error: 'Forbidden' }
211 }
212
213 return await updateResourceData(resource, permissions)
214}
215```
216
217This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
218
219For `await getFlag()` combined with a cheap synchronous guard (`flag && someCondition`), see [Check Cheap Conditions Before Async Flags](./async-cheap-condition-before-await.md).
220
221### 1.3 Dependency-Based Parallelization
222
223**Impact: CRITICAL (2-10× improvement)**
224
225For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
226
227**Incorrect: profile waits for config unnecessarily**
228
229```typescript
230const [user, config] = await Promise.all([
231 fetchUser(),
232 fetchConfig()
233])
234const profile = await fetchProfile(user.id)
235```
236
237**Correct: config and profile run in parallel**
238
239```typescript
240import { all } from 'better-all'
241
242const { user, config, profile } = await all({
243 async user() { return fetchUser() },
244 async config() { return fetchConfig() },
245 async profile() {
246 return fetchProfile((await this.$.user).id)
247 }
248})
249```
250
251**Alternative without extra dependencies:**
252
253```typescript
254const userPromise = fetchUser()
255const profilePromise = userPromise.then(user => fetchProfile(user.id))
256
257const [user, config, profile] = await Promise.all([
258 userPromise,
259 fetchConfig(),
260 profilePromise
261])
262```
263
264We can also create all the promises first, and do `Promise.all()` at the end.
265
266Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
267
268### 1.4 Prevent Waterfall Chains in API Routes
269
270**Impact: CRITICAL (2-10× improvement)**
271
272In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
273
274**Incorrect: config waits for auth, data waits for both**
275
276```typescript
277export async function GET(request: Request) {
278 const session = await auth()
279 const config = await fetchConfig()
280 const data = await fetchData(session.user.id)
281 return Response.json({ data, config })
282}
283```
284
285**Correct: auth and config start immediately**
286
287```typescript
288export async function GET(request: Request) {
289 const sessionPromise = auth()
290 const configPromise = fetchConfig()
291 const session = await sessionPromise
292 const [config, data] = await Promise.all([
293 configPromise,
294 fetchData(session.user.id)
295 ])
296 return Response.json({ data, config })
297}
298```
299
300For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
301
302### 1.5 Promise.all() for Independent Operations
303
304**Impact: CRITICAL (2-10× improvement)**
305
306When async operations have no interdependencies, execute them concurrently using `Promise.all()`.
307
308**Incorrect: sequential execution, 3 round trips**
309
310```typescript
311const user = await fetchUser()
312const posts = await fetchPosts()
313const comments = await fetchComments()
314```
315
316**Correct: parallel execution, 1 round trip**
317
318```typescript
319const [user, posts, comments] = await Promise.all([
320 fetchUser(),
321 fetchPosts(),
322 fetchComments()
323])
324```
325
326### 1.6 Strategic Suspense Boundaries
327
328**Impact: HIGH (faster initial paint)**
329
330Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
331
332**Incorrect: wrapper blocked by data fetching**
333
334```tsx
335async function Page() {
336 const data = await fetchData() // Blocks entire page
337
338 return (
339 <div>
340 <div>Sidebar</div>
341 <div>Header</div>
342 <div>
343 <DataDisplay data={data} />
344 </div>
345 <div>Footer</div>
346 </div>
347 )
348}
349```
350
351The entire layout waits for data even though only the middle section needs it.
352
353**Correct: wrapper shows immediately, data streams in**
354
355```tsx
356function Page() {
357 return (
358 <div>
359 <div>Sidebar</div>
360 <div>Header</div>
361 <div>
362 <Suspense fallback={<Skeleton />}>
363 <DataDisplay />
364 </Suspense>
365 </div>
366 <div>Footer</div>
367 </div>
368 )
369}
370
371async function DataDisplay() {
372 const data = await fetchData() // Only blocks this component
373 return <div>{data.content}</div>
374}
375```
376
377Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
378
379**Alternative: share promise across components**
380
381```tsx
382function Page() {
383 // Start fetch immediately, but don't await
384 const dataPromise = fetchData()
385
386 return (
387 <div>
388 <div>Sidebar</div>
389 <div>Header</div>
390 <Suspense fallback={<Skeleton />}>
391 <DataDisplay dataPromise={dataPromise} />
392 <DataSummary dataPromise={dataPromise} />
393 </Suspense>
394 <div>Footer</div>
395 </div>
396 )
397}
398
399function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
400 const data = use(dataPromise) // Unwraps the promise
401 return <div>{data.content}</div>
402}
403
404function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
405 const data = use(dataPromise) // Reuses the same promise
406 return <div>{data.summary}</div>
407}
408```
409
410Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.
411
412**When NOT to use this pattern:**
413
414- Critical data needed for layout decisions (affects positioning)
415
416- SEO-critical content above the fold
417
418- Small, fast queries where suspense overhead isn't worth it
419
420- When you want to avoid layout shift (loading → content jump)
421
422**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.
423
424---
425
426## 2. Bundle Size Optimization
427
428**Impact: CRITICAL**
429
430Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
431
432### 2.1 Avoid Barrel File Imports
433
434**Impact: CRITICAL (200-800ms import cost, slow builds)**
435
436Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`).
437
438Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts.
439
440**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.
441
442**Incorrect: imports entire library**
443
444```tsx
445import { Check, X, Menu } from 'lucide-react'
446// Loads 1,583 modules, takes ~2.8s extra in dev
447// Runtime cost: 200-800ms on every cold start
448
449import { Button, TextField } from '@mui/material'
450// Loads 2,225 modules, takes ~4.2s extra in dev
451```
452
453**Correct - Next.js 13.5+ (recommended):**
454
455```tsx
456// Keep the standard imports - Next.js transforms them to direct imports
457import { Check, X, Menu } from 'lucide-react'
458// Full TypeScript support, no manual path wrangling
459```
460
461This is the recommended approach because it preserves TypeScript type safety and editor autocompletion while still eliminating the barrel import cost.
462
463**Correct - Direct imports (non-Next.js projects):**
464
465```tsx
466import Button from '@mui/material/Button'
467import TextField from '@mui/material/TextField'
468// Loads only what you use
469```
470
471> **TypeScript warning:** Some libraries (notably `lucide-react`) don't ship `.d.ts` files for their deep import paths. Importing from `lucide-react/dist/esm/icons/check` resolves to an implicit `any` type, causing errors under `strict` or `noImplicitAny`. Prefer `optimizePackageImports` when available, or verify the library exports types for its subpaths before using direct imports.
472
473These optimizations provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.
474
475Libraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`.
476
477Reference: [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
478
479### 2.2 Conditional Module Loading
480
481**Impact: HIGH (loads large data only when needed)**
482
483Load large data or modules only when a feature is activated.
484
485**Example: lazy-load animation frames**
486
487```tsx
488function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) {
489 const [frames, setFrames] = useState<Frame[] | null>(null)
490
491 useEffect(() => {
492 if (enabled && !frames && typeof window !== 'undefined') {
493 import('./animation-frames.js')
494 .then(mod => setFrames(mod.frames))
495 .catch(() => setEnabled(false))
496 }
497 }, [enabled, frames, setEnabled])
498
499 if (!frames) return <Skeleton />
500 return <Canvas frames={frames} />
501}
502```
503
504The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.
505
506### 2.3 Defer Non-Critical Third-Party Libraries
507
508**Impact: MEDIUM (loads after hydration)**
509
510Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
511
512**Incorrect: blocks initial bundle**
513
514```tsx
515import { Analytics } from '@vercel/analytics/react'
516
517export default function RootLayout({ children }) {
518 return (
519 <html>
520 <body>
521 {children}
522 <Analytics />
523 </body>
524 </html>
525 )
526}
527```
528
529**Correct: loads after hydration**
530
531```tsx
532import dynamic from 'next/dynamic'
533
534const Analytics = dynamic(
535 () => import('@vercel/analytics/react').then(m => m.Analytics),
536 { ssr: false }
537)
538
539export default function RootLayout({ children }) {
540 return (
541 <html>
542 <body>
543 {children}
544 <Analytics />
545 </body>
546 </html>
547 )
548}
549```
550
551### 2.4 Dynamic Imports for Heavy Components
552
553**Impact: CRITICAL (directly affects TTI and LCP)**
554
555Use `next/dynamic` to lazy-load large components not needed on initial render.
556
557**Incorrect: Monaco bundles with main chunk ~300KB**
558
559```tsx
560import { MonacoEditor } from './monaco-editor'
561
562function CodePanel({ code }: { code: string }) {
563 return <MonacoEditor value={code} />
564}
565```
566
567**Correct: Monaco loads on demand**
568
569```tsx
570import dynamic from 'next/dynamic'
571
572const MonacoEditor = dynamic(
573 () => import('./monaco-editor').then(m => m.MonacoEditor),
574 { ssr: false }
575)
576
577function CodePanel({ code }: { code: string }) {
578 return <MonacoEditor value={code} />
579}
580```
581
582### 2.5 Prefer Statically Analyzable Paths
583
584**Impact: HIGH (avoids accidental broad bundles and file traces)**
585
586Build tools work best when import and file-system paths are obvious at build time. If you hide the real path inside a variable or compose it too dynamically, the tool either has to include a broad set of possible files, warn that it cannot analyze the import, or widen file tracing to stay safe.
587
588Prefer explicit maps or literal paths so the set of reachable files stays narrow and predictable. This is the same rule whether you are choosing modules with `import()` or reading files in server/build code.
589
590When analysis becomes too broad, the cost is real:
591
592- Larger server bundles
593
594- Slower builds
595
596- Worse cold starts
597
598- More memory use
599
600**Incorrect: the bundler cannot tell what may be imported**
601
602```ts
603const PAGE_MODULES = {
604 home: './pages/home',
605 settings: './pages/settings',
606} as const
607
608const Page = await import(PAGE_MODULES[pageName])
609```
610
611**Correct: use an explicit map of allowed modules**
612
613```ts
614const PAGE_MODULES = {
615 home: () => import('./pages/home'),
616 settings: () => import('./pages/settings'),
617} as const
618
619const Page = await PAGE_MODULES[pageName]()
620```
621
622**Incorrect: a 2-value enum still hides the final path from static analysis**
623
624```ts
625const baseDir = path.join(process.cwd(), 'content/' + contentKind)
626```
627
628**Correct: make each final path literal at the callsite**
629
630```ts
631const baseDir =
632 kind === ContentKind.Blog
633 ? path.join(process.cwd(), 'content/blog')
634 : path.join(process.cwd(), 'content/docs')
635```
636
637In Next.js server code, this matters for output file tracing too. `path.join(process.cwd(), someVar)` can widen the traced file set because Next.js statically analyze `import`, `require`, and `fs` usage.
638
639Reference: [https://nextjs.org/docs/app/api-reference/config/next-config-js/output](https://nextjs.org/docs/app/api-reference/config/next-config-js/output), [https://nextjs.org/learn/seo/dynamic-imports](https://nextjs.org/learn/seo/dynamic-imports), [https://vite.dev/guide/features.html](https://vite.dev/guide/features.html), [https://esbuild.github.io/api/](https://esbuild.github.io/api/), [https://www.npmjs.com/package/@rollup/plugin-dynamic-import-vars](https://www.npmjs.com/package/@rollup/plugin-dynamic-import-vars), [https://webpack.js.org/guides/dependency-management/](https://webpack.js.org/guides/dependency-management/)
640
641### 2.6 Preload Based on User Intent
642
643**Impact: MEDIUM (reduces perceived latency)**
644
645Preload heavy bundles before they're needed to reduce perceived latency.
646
647**Example: preload on hover/focus**
648
649```tsx
650function EditorButton({ onClick }: { onClick: () => void }) {
651 const preload = () => {
652 if (typeof window !== 'undefined') {
653 void import('./monaco-editor')
654 }
655 }
656
657 return (
658 <button
659 onMouseEnter={preload}
660 onFocus={preload}
661 onClick={onClick}
662 >
663 Open Editor
664 </button>
665 )
666}
667```
668
669**Example: preload when feature flag is enabled**
670
671```tsx
672function FlagsProvider({ children, flags }: Props) {
673 useEffect(() => {
674 if (flags.editorEnabled && typeof window !== 'undefined') {
675 void import('./monaco-editor').then(mod => mod.init())
676 }
677 }, [flags.editorEnabled])
678
679 return <FlagsContext.Provider value={flags}>
680 {children}
681 </FlagsContext.Provider>
682}
683```
684
685The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.
686
687---
688
689## 3. Server-Side Performance
690
691**Impact: HIGH**
692
693Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times.
694
695### 3.1 Authenticate Server Actions Like API Routes
696
697**Impact: CRITICAL (prevents unauthorized access to server mutations)**
698
699Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly.
700
701Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation."
702
703**Incorrect: no authentication check**
704
705```typescript
706'use server'
707
708export async function deleteUser(userId: string) {
709 // Anyone can call this! No auth check
710 await db.user.delete({ where: { id: userId } })
711 return { success: true }
712}
713```
714
715**Correct: authentication inside the action**
716
717```typescript
718'use server'
719
720import { verifySession } from '@/lib/auth'
721import { unauthorized } from '@/lib/errors'
722
723export async function deleteUser(userId: string) {
724 // Always check auth inside the action
725 const session = await verifySession()
726
727 if (!session) {
728 throw unauthorized('Must be logged in')
729 }
730
731 // Check authorization too
732 if (session.user.role !== 'admin' && session.user.id !== userId) {
733 throw unauthorized('Cannot delete other users')
734 }
735
736 await db.user.delete({ where: { id: userId } })
737 return { success: true }
738}
739```
740
741**With input validation:**
742
743```typescript
744'use server'
745
746import { verifySession } from '@/lib/auth'
747import { z } from 'zod'
748
749const updateProfileSchema = z.object({
750 userId: z.string().uuid(),
751 name: z.string().min(1).max(100),
752 email: z.string().email()
753})
754
755export async function updateProfile(data: unknown) {
756 // Validate input first
757 const validated = updateProfileSchema.parse(data)
758
759 // Then authenticate
760 const session = await verifySession()
761 if (!session) {
762 throw new Error('Unauthorized')
763 }
764
765 // Then authorize
766 if (session.user.id !== validated.userId) {
767 throw new Error('Can only update own profile')
768 }
769
770 // Finally perform the mutation
771 await db.user.update({
772 where: { id: validated.userId },
773 data: {
774 name: validated.name,
775 email: validated.email
776 }
777 })
778
779 return { success: true }
780}
781```
782
783Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)
784
785### 3.2 Avoid Duplicate Serialization in RSC Props
786
787**Impact: LOW (reduces network payload by avoiding duplicate serialization)**
788
789RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server.
790
791**Incorrect: duplicates array**
792
793```tsx
794// RSC: sends 6 strings (2 arrays × 3 items)
795<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />
796```
797
798**Correct: sends 3 strings**
799
800```tsx
801// RSC: send once
802<ClientList usernames={usernames} />
803
804// Client: transform there
805'use client'
806const sorted = useMemo(() => [...usernames].sort(), [usernames])
807```
808
809**Nested deduplication behavior:**
810
811```tsx
812// string[] - duplicates everything
813usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings
814
815// object[] - duplicates array structure only
816users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)
817```
818
819Deduplication works recursively. Impact varies by data type:
820
821- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated
822
823- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference
824
825**Operations breaking deduplication: create new references**
826
827- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`
828
829- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`
830
831**More examples:**
832
833```tsx
834// ❌ Bad
835<C users={users} active={users.filter(u => u.active)} />
836<C product={product} productName={product.name} />
837
838// ✅ Good
839<C users={users} />
840<C product={product} />
841// Do filtering/destructuring in client
842```
843
844**Exception:** Pass derived data when transformation is expensive or client doesn't need original.
845
846### 3.3 Avoid Shared Module State for Request Data
847
848**Impact: HIGH (prevents concurrency bugs and request data leaks)**
849
850For React Server Components and client components rendered during SSR, avoid using mutable module-level variables to share request-scoped data. Server renders can run concurrently in the same process. If one render writes to shared module state and another render reads it, you can get race conditions, cross-request contamination, and security bugs where one user's data appears in another user's response.
851
852Treat module scope on the server as process-wide shared memory, not request-local state.
853
854**Incorrect: request data leaks across concurrent renders**
855
856```tsx
857let currentUser: User | null = null
858
859export default async function Page() {
860 currentUser = await auth()
861 return <Dashboard />
862}
863
864async function Dashboard() {
865 return <div>{currentUser?.name}</div>
866}
867```
868
869If two requests overlap, request A can set `currentUser`, then request B overwrites it before request A finishes rendering `Dashboard`.
870
871**Correct: keep request data local to the render tree**
872
873```tsx
874export default async function Page() {
875 const user = await auth()
876 return <Dashboard user={user} />
877}
878
879function Dashboard({ user }: { user: User | null }) {
880 return <div>{user?.name}</div>
881}
882```
883
884Safe exceptions:
885
886- Immutable static assets or config loaded once at module scope
887
888- Shared caches intentionally designed for cross-request reuse and keyed correctly
889
890- Process-wide singletons that do not store request- or user-specific mutable data
891
892For static assets and config, see [Hoist Static I/O to Module Level](./server-hoist-static-io.md).
893
894### 3.4 Cross-Request LRU Caching
895
896**Impact: HIGH (caches across requests)**
897
898`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
899
900**Implementation:**
901
902```typescript
903import { LRUCache } from 'lru-cache'
904
905const cache = new LRUCache<string, any>({
906 max: 1000,
907 ttl: 5 * 60 * 1000 // 5 minutes
908})
909
910export async function getUser(id: string) {
911 const cached = cache.get(id)
912 if (cached) return cached
913
914 const user = await db.user.findUnique({ where: { id } })
915 cache.set(id, user)
916 return user
917}
918
919// Request 1: DB query, result cached
920// Request 2: cache hit, no DB query
921```
922
923Use when sequential user actions hit multiple endpoints needing the same data within seconds.
924
925**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis.
926
927**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.
928
929Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
930
931### 3.5 Hoist Static I/O to Module Level
932
933**Impact: HIGH (avoids repeated file/network I/O per request)**
934
935When loading static assets (fonts, logos, images, config files) in route handlers or server functions, hoist the I/O operation to module level. Module-level code runs once when the module is first imported, not on every request. This eliminates redundant file system reads or network fetches that would otherwise run on every invocation.
936
937**Incorrect: reads font file on every request**
938
939```typescript
940// app/api/og/route.tsx
941import { ImageResponse } from 'next/og'
942
943export async function GET(request: Request) {
944 // Runs on EVERY request - expensive!
945 const fontData = await fetch(
946 new URL('./fonts/Inter.ttf', import.meta.url)
947 ).then(res => res.arrayBuffer())
948
949 const logoData = await fetch(
950 new URL('./images/logo.png', import.meta.url)
951 ).then(res => res.arrayBuffer())
952
953 return new ImageResponse(
954 <div style={{ fontFamily: 'Inter' }}>
955 <img src={logoData} />
956 Hello World
957 </div>,
958 { fonts: [{ name: 'Inter', data: fontData }] }
959 )
960}
961```
962
963**Correct: loads once at module initialization**
964
965```typescript
966// app/api/og/route.tsx
967import { ImageResponse } from 'next/og'
968
969// Module-level: runs ONCE when module is first imported
970const fontData = fetch(
971 new URL('./fonts/Inter.ttf', import.meta.url)
972).then(res => res.arrayBuffer())
973
974const logoData = fetch(
975 new URL('./images/logo.png', import.meta.url)
976).then(res => res.arrayBuffer())
977
978export async function GET(request: Request) {
979 // Await the already-started promises
980 const [font, logo] = await Promise.all([fontData, logoData])
981
982 return new ImageResponse(
983 <div style={{ fontFamily: 'Inter' }}>
984 <img src={logo} />
985 Hello World
986 </div>,
987 { fonts: [{ name: 'Inter', data: font }] }
988 )
989}
990```
991
992**Correct: synchronous fs at module level**
993
994```typescript
995// app/api/og/route.tsx
996import { ImageResponse } from 'next/og'
997import { readFileSync } from 'fs'
998import { join } from 'path'
999
1000// Synchronous read at module level - blocks only during module init
1001const fontData = readFileSync(
1002 join(process.cwd(), 'public/fonts/Inter.ttf')
1003)
1004
1005const logoData = readFileSync(
1006 join(process.cwd(), 'public/images/logo.png')
1007)
1008
1009export async function GET(request: Request) {
1010 return new ImageResponse(
1011 <div style={{ fontFamily: 'Inter' }}>
1012 <img src={logoData} />
1013 Hello World
1014 </div>,
1015 { fonts: [{ name: 'Inter', data: fontData }] }
1016 )
1017}
1018```
1019
1020**Incorrect: reads config on every call**
1021
1022```typescript
1023import fs from 'node:fs/promises'
1024
1025export async function processRequest(data: Data) {
1026 const config = JSON.parse(
1027 await fs.readFile('./config.json', 'utf-8')
1028 )
1029 const template = await fs.readFile('./template.html', 'utf-8')
1030
1031 return render(template, data, config)
1032}
1033```
1034
1035**Correct: hoists config and template to module level**
1036
1037```typescript
1038import fs from 'node:fs/promises'
1039
1040const configPromise = fs
1041 .readFile('./config.json', 'utf-8')
1042 .then(JSON.parse)
1043const templatePromise = fs.readFile('./template.html', 'utf-8')
1044
1045export async function processRequest(data: Data) {
1046 const [config, template] = await Promise.all([
1047 configPromise,
1048 templatePromise,
1049 ])
1050
1051 return render(template, data, config)
1052}
1053```
1054
1055When to use this pattern:
1056
1057- Loading fonts for OG image generation
1058
1059- Loading static logos, icons, or watermarks
1060
1061- Reading configuration files that don't change at runtime
1062
1063- Loading email templates or other static templates
1064
1065- Any static asset that's the same across all requests
1066
1067When not to use this pattern:
1068
1069- Assets that vary per request or user
1070
1071- Files that may change during runtime (use caching with TTL instead)
1072
1073- Large files that would consume too much memory if kept loaded
1074
1075- Sensitive data that shouldn't persist in memory
1076
1077With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute), module-level caching is especially effective because multiple concurrent requests share the same function instance. The static assets stay loaded in memory across requests without cold start penalties.
1078
1079In traditional serverless, each cold start re-executes module-level code, but subsequent warm invocations reuse the loaded assets until the instance is recycled.
1080
1081### 3.6 Minimize Serialization at RSC Boundaries
1082
1083**Impact: HIGH (reduces data transfer size)**
1084
1085The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses.
1086
1087**Incorrect: serializes all 50 fields**
1088
1089```tsx
1090async function Page() {
1091 const user = await fetchUser() // 50 fields
1092 return <Profile user={user} />
1093}
1094
1095'use client'
1096function Profile({ user }: { user: User }) {
1097 return <div>{user.name}</div> // uses 1 field
1098}
1099```
1100
1101**Correct: serializes only 1 field**
1102
1103```tsx
1104async function Page() {
1105 const user = await fetchUser()
1106 return <Profile name={user.name} />
1107}
1108
1109'use client'
1110function Profile({ name }: { name: string }) {
1111 return <div>{name}</div>
1112}
1113```
1114
1115### 3.7 Parallel Data Fetching with Component Composition
1116
1117**Impact: CRITICAL (eliminates server-side waterfalls)**
1118
1119React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
1120
1121**Incorrect: Sidebar waits for Page's fetch to complete**
1122
1123```tsx
1124export default async function Page() {
1125 const header = await fetchHeader()
1126 return (
1127 <div>
1128 <div>{header}</div>
1129 <Sidebar />
1130 </div>
1131 )
1132}
1133
1134async function Sidebar() {
1135 const items = await fetchSidebarItems()
1136 return <nav>{items.map(renderItem)}</nav>
1137}
1138```
1139
1140**Correct: both fetch simultaneously**
1141
1142```tsx
1143async function Header() {
1144 const data = await fetchHeader()
1145 return <div>{data}</div>
1146}
1147
1148async function Sidebar() {
1149 const items = await fetchSidebarItems()
1150 return <nav>{items.map(renderItem)}</nav>
1151}
1152
1153export default function Page() {
1154 return (
1155 <div>
1156 <Header />
1157 <Sidebar />
1158 </div>
1159 )
1160}
1161```
1162
1163**Alternative with children prop:**
1164
1165```tsx
1166async function Header() {
1167 const data = await fetchHeader()
1168 return <div>{data}</div>
1169}
1170
1171async function Sidebar() {
1172 const items = await fetchSidebarItems()
1173 return <nav>{items.map(renderItem)}</nav>
1174}
1175
1176function Layout({ children }: { children: ReactNode }) {
1177 return (
1178 <div>
1179 <Header />
1180 {children}
1181 </div>
1182 )
1183}
1184
1185export default function Page() {
1186 return (
1187 <Layout>
1188 <Sidebar />
1189 </Layout>
1190 )
1191}
1192```
1193
1194### 3.8 Parallel Nested Data Fetching
1195
1196**Impact: CRITICAL (eliminates server-side waterfalls)**
1197
1198When fetching nested data in parallel, chain dependent fetches within each item's promise so a slow item doesn't block the rest.
1199
1200**Incorrect: a single slow item blocks all nested fetches**
1201
1202```tsx
1203const chats = await Promise.all(
1204 chatIds.map(id => getChat(id))
1205)
1206
1207const chatAuthors = await Promise.all(
1208 chats.map(chat => getUser(chat.author))
1209)
1210```
1211
1212If one `getChat(id)` out of 100 is extremely slow, the authors of the other 99 chats can't start loading even though their data is ready.
1213
1214**Correct: each item chains its own nested fetch**
1215
1216```tsx
1217const chatAuthors = await Promise.all(
1218 chatIds.map(id => getChat(id).then(chat => getUser(chat.author)))
1219)
1220```
1221
1222Each item independently chains `getChat` → `getUser`, so a slow chat doesn't block author fetches for the others.
1223
1224### 3.9 Per-Request Deduplication with React.cache()
1225
1226**Impact: MEDIUM (deduplicates within request)**
1227
1228Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.
1229
1230**Usage:**
1231
1232```typescript
1233import { cache } from 'react'
1234
1235export const getCurrentUser = cache(async () => {
1236 const session = await auth()
1237 if (!session?.user?.id) return null
1238 return await db.user.findUnique({
1239 where: { id: session.user.id }
1240 })
1241})
1242```
1243
1244Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
1245
1246**Avoid inline objects as arguments:**
1247
1248`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits.
1249
1250**Incorrect: always cache miss**
1251
1252```typescript
1253const getUser = cache(async (params: { uid: number }) => {
1254 return await db.user.findUnique({ where: { id: params.uid } })
1255})
1256
1257// Each call creates new object, never hits cache
1258getUser({ uid: 1 })
1259getUser({ uid: 1 }) // Cache miss, runs query again
1260```
1261
1262**Correct: cache hit**
1263
1264```typescript
1265const params = { uid: 1 }
1266getUser(params) // Query runs
1267getUser(params) // Cache hit (same reference)
1268```
1269
1270If you must pass objects, pass the same reference:
1271
1272**Next.js-Specific Note:**
1273
1274In Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks:
1275
1276- Database queries (Prisma, Drizzle, etc.)
1277
1278- Heavy computations
1279
1280- Authentication checks
1281
1282- File system operations
1283
1284- Any non-fetch async work
1285
1286Use `React.cache()` to deduplicate these operations across your component tree.
1287
1288Reference: [https://react.dev/reference/react/cache](https://react.dev/reference/react/cache)
1289
1290### 3.10 Use after() for Non-Blocking Operations
1291
1292**Impact: MEDIUM (faster response times)**
1293
1294Use Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response.
1295
1296**Incorrect: blocks response**
1297
1298```tsx
1299import { logUserAction } from '@/app/utils'
1300
1301export async function POST(request: Request) {
1302 // Perform mutation
1303 await updateDatabase(request)
1304
1305 // Logging blocks the response
1306 const userAgent = request.headers.get('user-agent') || 'unknown'
1307 await logUserAction({ userAgent })
1308
1309 return new Response(JSON.stringify({ status: 'success' }), {
1310 status: 200,
1311 headers: { 'Content-Type': 'application/json' }
1312 })
1313}
1314```
1315
1316**Correct: non-blocking**
1317
1318```tsx
1319import { after } from 'next/server'
1320import { headers, cookies } from 'next/headers'
1321import { logUserAction } from '@/app/utils'
1322
1323export async function POST(request: Request) {
1324 // Perform mutation
1325 await updateDatabase(request)
1326
1327 // Log after response is sent
1328 after(async () => {
1329 const userAgent = (await headers()).get('user-agent') || 'unknown'
1330 const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
1331
1332 logUserAction({ sessionCookie, userAgent })
1333 })
1334
1335 return new Response(JSON.stringify({ status: 'success' }), {
1336 status: 200,
1337 headers: { 'Content-Type': 'application/json' }
1338 })
1339}
1340```
1341
1342The response is sent immediately while logging happens in the background.
1343
1344**Common use cases:**
1345
1346- Analytics tracking
1347
1348- Audit logging
1349
1350- Sending notifications
1351
1352- Cache invalidation
1353
1354- Cleanup tasks
1355
1356**Important notes:**
1357
1358- `after()` runs even if the response fails or redirects
1359
1360- Works in Server Actions, Route Handlers, and Server Components
1361
1362Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after)
1363
1364---
1365
1366## 4. Client-Side Data Fetching
1367
1368**Impact: MEDIUM-HIGH**
1369
1370Automatic deduplication and efficient data fetching patterns reduce redundant network requests.
1371
1372### 4.1 Deduplicate Global Event Listeners
1373
1374**Impact: LOW (single listener for N components)**
1375
1376Use `useSWRSubscription()` to share global event listeners across component instances.
1377
1378**Incorrect: N instances = N listeners**
1379
1380```tsx
1381function useKeyboardShortcut(key: string, callback: () => void) {
1382 useEffect(() => {
1383 const handler = (e: KeyboardEvent) => {
1384 if (e.metaKey && e.key === key) {
1385 callback()
1386 }
1387 }
1388 window.addEventListener('keydown', handler)
1389 return () => window.removeEventListener('keydown', handler)
1390 }, [key, callback])
1391}
1392```
1393
1394When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener.
1395
1396**Correct: N instances = 1 listener**
1397
1398```tsx
1399import useSWRSubscription from 'swr/subscription'
1400
1401// Module-level Map to track callbacks per key
1402const keyCallbacks = new Map<string, Set<() => void>>()
1403
1404function useKeyboardShortcut(key: string, callback: () => void) {
1405 // Register this callback in the Map
1406 useEffect(() => {
1407 if (!keyCallbacks.has(key)) {
1408 keyCallbacks.set(key, new Set())
1409 }
1410 keyCallbacks.get(key)!.add(callback)
1411
1412 return () => {
1413 const set = keyCallbacks.get(key)
1414 if (set) {
1415 set.delete(callback)
1416 if (set.size === 0) {
1417 keyCallbacks.delete(key)
1418 }
1419 }
1420 }
1421 }, [key, callback])
1422
1423 useSWRSubscription('global-keydown', () => {
1424 const handler = (e: KeyboardEvent) => {
1425 if (e.metaKey && keyCallbacks.has(e.key)) {
1426 keyCallbacks.get(e.key)!.forEach(cb => cb())
1427 }
1428 }
1429 window.addEventListener('keydown', handler)
1430 return () => window.removeEventListener('keydown', handler)
1431 })
1432}
1433
1434function Profile() {
1435 // Multiple shortcuts will share the same listener
1436 useKeyboardShortcut('p', () => { /* ... */ })
1437 useKeyboardShortcut('k', () => { /* ... */ })
1438 // ...
1439}
1440```
1441
1442### 4.2 Use Passive Event Listeners for Scrolling Performance
1443
1444**Impact: MEDIUM (eliminates scroll delay caused by event listeners)**
1445
1446Add `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay.
1447
1448**Incorrect:**
1449
1450```typescript
1451useEffect(() => {
1452 const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
1453 const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
1454
1455 document.addEventListener('touchstart', handleTouch)
1456 document.addEventListener('wheel', handleWheel)
1457
1458 return () => {
1459 document.removeEventListener('touchstart', handleTouch)
1460 document.removeEventListener('wheel', handleWheel)
1461 }
1462}, [])
1463```
1464
1465**Correct:**
1466
1467```typescript
1468useEffect(() => {
1469 const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
1470 const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
1471
1472 document.addEventListener('touchstart', handleTouch, { passive: true })
1473 document.addEventListener('wheel', handleWheel, { passive: true })
1474
1475 return () => {
1476 document.removeEventListener('touchstart', handleTouch)
1477 document.removeEventListener('wheel', handleWheel)
1478 }
1479}, [])
1480```
1481
1482**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.
1483
1484**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`.
1485
1486### 4.3 Use SWR for Automatic Deduplication
1487
1488**Impact: MEDIUM-HIGH (automatic deduplication)**
1489
1490SWR enables request deduplication, caching, and revalidation across component instances.
1491
1492**Incorrect: no deduplication, each instance fetches**
1493
1494```tsx
1495function UserList() {
1496 const [users, setUsers] = useState([])
1497 useEffect(() => {
1498 fetch('/api/users')
1499 .then(r => r.json())
1500 .then(setUsers)
1501 }, [])
1502}
1503```
1504
1505**Correct: multiple instances share one request**
1506
1507```tsx
1508import useSWR from 'swr'
1509
1510function UserList() {
1511 const { data: users } = useSWR('/api/users', fetcher)
1512}
1513```
1514
1515**For immutable data:**
1516
1517```tsx
1518import { useImmutableSWR } from '@/lib/swr'
1519
1520function StaticContent() {
1521 const { data } = useImmutableSWR('/api/config', fetcher)
1522}
1523```
1524
1525**For mutations:**
1526
1527```tsx
1528import { useSWRMutation } from 'swr/mutation'
1529
1530function UpdateButton() {
1531 const { trigger } = useSWRMutation('/api/user', updateUser)
1532 return <button onClick={() => trigger()}>Update</button>
1533}
1534```
1535
1536Reference: [https://swr.vercel.app](https://swr.vercel.app)
1537
1538### 4.4 Version and Minimize localStorage Data
1539
1540**Impact: MEDIUM (prevents schema conflicts, reduces storage size)**
1541
1542Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data.
1543
1544**Incorrect:**
1545
1546```typescript
1547// No version, stores everything, no error handling
1548localStorage.setItem('userConfig', JSON.stringify(fullUserObject))
1549const data = localStorage.getItem('userConfig')
1550```
1551
1552**Correct:**
1553
1554```typescript
1555const VERSION = 'v2'
1556
1557function saveConfig(config: { theme: string; language: string }) {
1558 try {
1559 localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))
1560 } catch {
1561 // Throws in incognito/private browsing, quota exceeded, or disabled
1562 }
1563}
1564
1565function loadConfig() {
1566 try {
1567 const data = localStorage.getItem(`userConfig:${VERSION}`)
1568 return data ? JSON.parse(data) : null
1569 } catch {
1570 return null
1571 }
1572}
1573
1574// Migration from v1 to v2
1575function migrate() {
1576 try {
1577 const v1 = localStorage.getItem('userConfig:v1')
1578 if (v1) {
1579 const old = JSON.parse(v1)
1580 saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang })
1581 localStorage.removeItem('userConfig:v1')
1582 }
1583 } catch {}
1584}
1585```
1586
1587**Store minimal fields from server responses:**
1588
1589```typescript
1590// User object has 20+ fields, only store what UI needs
1591function cachePrefs(user: FullUser) {
1592 try {
1593 localStorage.setItem('prefs:v1', JSON.stringify({
1594 theme: user.preferences.theme,
1595 notifications: user.preferences.notifications
1596 }))
1597 } catch {}
1598}
1599```
1600
1601**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled.
1602
1603**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags.
1604
1605---
1606
1607## 5. Re-render Optimization
1608
1609**Impact: MEDIUM**
1610
1611Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.
1612
1613### 5.1 Calculate Derived State During Rendering
1614
1615**Impact: MEDIUM (avoids redundant renders and state drift)**
1616
1617If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead.
1618
1619**Incorrect: redundant state and effect**
1620
1621```tsx
1622function Form() {
1623 const [firstName, setFirstName] = useState('First')
1624 const [lastName, setLastName] = useState('Last')
1625 const [fullName, setFullName] = useState('')
1626
1627 useEffect(() => {
1628 setFullName(firstName + ' ' + lastName)
1629 }, [firstName, lastName])
1630
1631 return <p>{fullName}</p>
1632}
1633```
1634
1635**Correct: derive during render**
1636
1637```tsx
1638function Form() {
1639 const [firstName, setFirstName] = useState('First')
1640 const [lastName, setLastName] = useState('Last')
1641 const fullName = firstName + ' ' + lastName
1642
1643 return <p>{fullName}</p>
1644}
1645```
1646
1647Reference: [https://react.dev/learn/you-might-not-need-an-effect](https://react.dev/learn/you-might-not-need-an-effect)
1648
1649### 5.2 Defer State Reads to Usage Point
1650
1651**Impact: MEDIUM (avoids unnecessary subscriptions)**
1652
1653Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
1654
1655**Incorrect: subscribes to all searchParams changes**
1656
1657```tsx
1658function ShareButton({ chatId }: { chatId: string }) {
1659 const searchParams = useSearchParams()
1660
1661 const handleShare = () => {
1662 const ref = searchParams.get('ref')
1663 shareChat(chatId, { ref })
1664 }
1665
1666 return <button onClick={handleShare}>Share</button>
1667}
1668```
1669
1670**Correct: reads on demand, no subscription**
1671
1672```tsx
1673function ShareButton({ chatId }: { chatId: string }) {
1674 const handleShare = () => {
1675 const params = new URLSearchParams(window.location.search)
1676 const ref = params.get('ref')
1677 shareChat(chatId, { ref })
1678 }
1679
1680 return <button onClick={handleShare}>Share</button>
1681}
1682```
1683
1684### 5.3 Do not wrap a simple expression with a primitive result type in useMemo
1685
1686**Impact: LOW-MEDIUM (wasted computation on every render)**
1687
1688When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`.
1689
1690Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.
1691
1692**Incorrect:**
1693
1694```tsx
1695function Header({ user, notifications }: Props) {
1696 const isLoading = useMemo(() => {
1697 return user.isLoading || notifications.isLoading
1698 }, [user.isLoading, notifications.isLoading])
1699
1700 if (isLoading) return <Skeleton />
1701 // return some markup
1702}
1703```
1704
1705**Correct:**
1706
1707```tsx
1708function Header({ user, notifications }: Props) {
1709 const isLoading = user.isLoading || notifications.isLoading
1710
1711 if (isLoading) return <Skeleton />
1712 // return some markup
1713}
1714```
1715
1716### 5.4 Don't Define Components Inside Components
1717
1718**Impact: HIGH (prevents remount on every render)**
1719
1720Defining a component inside another component creates a new component type on every render. React sees a different component each time and fully remounts it, destroying all state and DOM.
1721
1722A common reason developers do this is to access parent variables without passing props. Always pass props instead.
1723
1724**Incorrect: remounts on every render**
1725
1726```tsx
1727function UserProfile({ user, theme }) {
1728 // Defined inside to access `theme` - BAD
1729 const Avatar = () => (
1730 <img
1731 src={user.avatarUrl}
1732 className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
1733 />
1734 )
1735
1736 // Defined inside to access `user` - BAD
1737 const Stats = () => (
1738 <div>
1739 <span>{user.followers} followers</span>
1740 <span>{user.posts} posts</span>
1741 </div>
1742 )
1743
1744 return (
1745 <div>
1746 <Avatar />
1747 <Stats />
1748 </div>
1749 )
1750}
1751```
1752
1753Every time `UserProfile` renders, `Avatar` and `Stats` are new component types. React unmounts the old instances and mounts new ones, losing any internal state, running effects again, and recreating DOM nodes.
1754
1755**Correct: pass props instead**
1756
1757```tsx
1758function Avatar({ src, theme }: { src: string; theme: string }) {
1759 return (
1760 <img
1761 src={src}
1762 className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
1763 />
1764 )
1765}
1766
1767function Stats({ followers, posts }: { followers: number; posts: number }) {
1768 return (
1769 <div>
1770 <span>{followers} followers</span>
1771 <span>{posts} posts</span>
1772 </div>
1773 )
1774}
1775
1776function UserProfile({ user, theme }) {
1777 return (
1778 <div>
1779 <Avatar src={user.avatarUrl} theme={theme} />
1780 <Stats followers={user.followers} posts={user.posts} />
1781 </div>
1782 )
1783}
1784```
1785
1786**Symptoms of this bug:**
1787
1788- Input fields lose focus on every keystroke
1789
1790- Animations restart unexpectedly
1791
1792- `useEffect` cleanup/setup runs on every parent render
1793
1794- Scroll position resets inside the component
1795
1796### 5.5 Extract Default Non-primitive Parameter Value from Memoized Component to Constant
1797
1798**Impact: MEDIUM (restores memoization by using a constant for default value)**
1799
1800When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`.
1801
1802To address this issue, extract the default value into a constant.
1803
1804**Incorrect: `onClick` has different values on every rerender**
1805
1806```tsx
1807const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
1808 // ...
1809})
1810
1811// Used without optional onClick
1812<UserAvatar />
1813```
1814
1815**Correct: stable default value**
1816
1817```tsx
1818const NOOP = () => {};
1819
1820const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
1821 // ...
1822})
1823
1824// Used without optional onClick
1825<UserAvatar />
1826```
1827
1828### 5.6 Extract to Memoized Components
1829
1830**Impact: MEDIUM (enables early returns)**
1831
1832Extract expensive work into memoized components to enable early returns before computation.
1833
1834**Incorrect: computes avatar even when loading**
1835
1836```tsx
1837function Profile({ user, loading }: Props) {
1838 const avatar = useMemo(() => {
1839 const id = computeAvatarId(user)
1840 return <Avatar id={id} />
1841 }, [user])
1842
1843 if (loading) return <Skeleton />
1844 return <div>{avatar}</div>
1845}
1846```
1847
1848**Correct: skips computation when loading**
1849
1850```tsx
1851const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
1852 const id = useMemo(() => computeAvatarId(user), [user])
1853 return <Avatar id={id} />
1854})
1855
1856function Profile({ user, loading }: Props) {
1857 if (loading) return <Skeleton />
1858 return (
1859 <div>
1860 <UserAvatar user={user} />
1861 </div>
1862 )
1863}
1864```
1865
1866**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders.
1867
1868### 5.7 Narrow Effect Dependencies
1869
1870**Impact: LOW (minimizes effect re-runs)**
1871
1872Specify primitive dependencies instead of objects to minimize effect re-runs.
1873
1874**Incorrect: re-runs on any user field change**
1875
1876```tsx
1877useEffect(() => {
1878 console.log(user.id)
1879}, [user])
1880```
1881
1882**Correct: re-runs only when id changes**
1883
1884```tsx
1885useEffect(() => {
1886 console.log(user.id)
1887}, [user.id])
1888```
1889
1890**For derived state, compute outside effect:**
1891
1892```tsx
1893// Incorrect: runs on width=767, 766, 765...
1894useEffect(() => {
1895 if (width < 768) {
1896 enableMobileMode()
1897 }
1898}, [width])
1899
1900// Correct: runs only on boolean transition
1901const isMobile = width < 768
1902useEffect(() => {
1903 if (isMobile) {
1904 enableMobileMode()
1905 }
1906}, [isMobile])
1907```
1908
1909### 5.8 Put Interaction Logic in Event Handlers
1910
1911**Impact: MEDIUM (avoids effect re-runs and duplicate side effects)**
1912
1913If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action.
1914
1915**Incorrect: event modeled as state + effect**
1916
1917```tsx
1918function Form() {
1919 const [submitted, setSubmitted] = useState(false)
1920 const theme = useContext(ThemeContext)
1921
1922 useEffect(() => {
1923 if (submitted) {
1924 post('/api/register')
1925 showToast('Registered', theme)
1926 }
1927 }, [submitted, theme])
1928
1929 return <button onClick={() => setSubmitted(true)}>Submit</button>
1930}
1931```
1932
1933**Correct: do it in the handler**
1934
1935```tsx
1936function Form() {
1937 const theme = useContext(ThemeContext)
1938
1939 function handleSubmit() {
1940 post('/api/register')
1941 showToast('Registered', theme)
1942 }
1943
1944 return <button onClick={handleSubmit}>Submit</button>
1945}
1946```
1947
1948Reference: [https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)
1949
1950### 5.9 Split Combined Hook Computations
1951
1952**Impact: MEDIUM (avoids recomputing independent steps)**
1953
1954When a hook contains multiple independent tasks with different dependencies, split them into separate hooks. A combined hook reruns all tasks when any dependency changes, even if some tasks don't use the changed value.
1955
1956**Incorrect: changing `sortOrder` recomputes filtering**
1957
1958```tsx
1959const sortedProducts = useMemo(() => {
1960 const filtered = products.filter((p) => p.category === category)
1961 const sorted = filtered.toSorted((a, b) =>
1962 sortOrder === "asc" ? a.price - b.price : b.price - a.price
1963 )
1964 return sorted
1965}, [products, category, sortOrder])
1966```
1967
1968**Correct: filtering only recomputes when products or category change**
1969
1970```tsx
1971const filteredProducts = useMemo(
1972 () => products.filter((p) => p.category === category),
1973 [products, category]
1974)
1975
1976const sortedProducts = useMemo(
1977 () =>
1978 filteredProducts.toSorted((a, b) =>
1979 sortOrder === "asc" ? a.price - b.price : b.price - a.price
1980 ),
1981 [filteredProducts, sortOrder]
1982)
1983```
1984
1985This pattern also applies to `useEffect` when combining unrelated side effects:
1986
1987**Incorrect: both effects run when either dependency changes**
1988
1989```tsx
1990useEffect(() => {
1991 analytics.trackPageView(pathname)
1992 document.title = `${pageTitle} | My App`
1993}, [pathname, pageTitle])
1994```
1995
1996**Correct: effects run independently**
1997
1998```tsx
1999useEffect(() => {
2000 analytics.trackPageView(pathname)
2001}, [pathname])
2002
2003useEffect(() => {
2004 document.title = `${pageTitle} | My App`
2005}, [pageTitle])
2006```
2007
2008**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, it automatically optimizes dependency tracking and may handle some of these cases for you.
2009
2010### 5.10 Subscribe to Derived State
2011
2012**Impact: MEDIUM (reduces re-render frequency)**
2013
2014Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
2015
2016**Incorrect: re-renders on every pixel change**
2017
2018```tsx
2019function Sidebar() {
2020 const width = useWindowWidth() // updates continuously
2021 const isMobile = width < 768
2022 return <nav className={isMobile ? 'mobile' : 'desktop'} />
2023}
2024```
2025
2026**Correct: re-renders only when boolean changes**
2027
2028```tsx
2029function Sidebar() {
2030 const isMobile = useMediaQuery('(max-width: 767px)')
2031 return <nav className={isMobile ? 'mobile' : 'desktop'} />
2032}
2033```
2034
2035### 5.11 Use Functional setState Updates
2036
2037**Impact: MEDIUM (prevents stale closures and unnecessary callback recreations)**
2038
2039When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.
2040
2041**Incorrect: requires state as dependency**
2042
2043```tsx
2044function TodoList() {
2045 const [items, setItems] = useState(initialItems)
2046
2047 // Callback must depend on items, recreated on every items change
2048 const addItems = useCallback((newItems: Item[]) => {
2049 setItems([...items, ...newItems])
2050 }, [items]) // ❌ items dependency causes recreations
2051
2052 // Risk of stale closure if dependency is forgotten
2053 const removeItem = useCallback((id: string) => {
2054 setItems(items.filter(item => item.id !== id))
2055 }, []) // ❌ Missing items dependency - will use stale items!
2056
2057 return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
2058}
2059```
2060
2061The first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value.
2062
2063**Correct: stable callbacks, no stale closures**
2064
2065```tsx
2066function TodoList() {
2067 const [items, setItems] = useState(initialItems)
2068
2069 // Stable callback, never recreated
2070 const addItems = useCallback((newItems: Item[]) => {
2071 setItems(curr => [...curr, ...newItems])
2072 }, []) // ✅ No dependencies needed
2073
2074 // Always uses latest state, no stale closure risk
2075 const removeItem = useCallback((id: string) => {
2076 setItems(curr => curr.filter(item => item.id !== id))
2077 }, []) // ✅ Safe and stable
2078
2079 return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
2080}
2081```
2082
2083**Benefits:**
2084
20851. **Stable callback references** - Callbacks don't need to be recreated when state changes
2086
20872. **No stale closures** - Always operates on the latest state value
2088
20893. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
2090
20914. **Prevents bugs** - Eliminates the most common source of React closure bugs
2092
2093**When to use functional updates:**
2094
2095- Any setState that depends on the current state value
2096
2097- Inside useCallback/useMemo when state is needed
2098
2099- Event handlers that reference state
2100
2101- Async operations that update state
2102
2103**When direct updates are fine:**
2104
2105- Setting state to a static value: `setCount(0)`
2106
2107- Setting state from props/arguments only: `setName(newName)`
2108
2109- State doesn't depend on previous value
2110
2111**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.
2112
2113### 5.12 Use Lazy State Initialization
2114
2115**Impact: MEDIUM (wasted computation on every render)**
2116
2117Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
2118
2119**Incorrect: runs on every render**
2120
2121```tsx
2122function FilteredList({ items }: { items: Item[] }) {
2123 // buildSearchIndex() runs on EVERY render, even after initialization
2124 const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
2125 const [query, setQuery] = useState('')
2126
2127 // When query changes, buildSearchIndex runs again unnecessarily
2128 return <SearchResults index={searchIndex} query={query} />
2129}
2130
2131function UserProfile() {
2132 // JSON.parse runs on every render
2133 const [settings, setSettings] = useState(
2134 JSON.parse(localStorage.getItem('settings') || '{}')
2135 )
2136
2137 return <SettingsForm settings={settings} onChange={setSettings} />
2138}
2139```
2140
2141**Correct: runs only once**
2142
2143```tsx
2144function FilteredList({ items }: { items: Item[] }) {
2145 // buildSearchIndex() runs ONLY on initial render
2146 const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
2147 const [query, setQuery] = useState('')
2148
2149 return <SearchResults index={searchIndex} query={query} />
2150}
2151
2152function UserProfile() {
2153 // JSON.parse runs only on initial render
2154 const [settings, setSettings] = useState(() => {
2155 const stored = localStorage.getItem('settings')
2156 return stored ? JSON.parse(stored) : {}
2157 })
2158
2159 return <SettingsForm settings={settings} onChange={setSettings} />
2160}
2161```
2162
2163Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
2164
2165For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
2166
2167### 5.13 Use Transitions for Non-Urgent Updates
2168
2169**Impact: MEDIUM (maintains UI responsiveness)**
2170
2171Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
2172
2173**Incorrect: blocks UI on every scroll**
2174
2175```tsx
2176function ScrollTracker() {
2177 const [scrollY, setScrollY] = useState(0)
2178 useEffect(() => {
2179 const handler = () => setScrollY(window.scrollY)
2180 window.addEventListener('scroll', handler, { passive: true })
2181 return () => window.removeEventListener('scroll', handler)
2182 }, [])
2183}
2184```
2185
2186**Correct: non-blocking updates**
2187
2188```tsx
2189import { startTransition } from 'react'
2190
2191function ScrollTracker() {
2192 const [scrollY, setScrollY] = useState(0)
2193 useEffect(() => {
2194 const handler = () => {
2195 startTransition(() => setScrollY(window.scrollY))
2196 }
2197 window.addEventListener('scroll', handler, { passive: true })
2198 return () => window.removeEventListener('scroll', handler)
2199 }, [])
2200}
2201```
2202
2203### 5.14 Use useDeferredValue for Expensive Derived Renders
2204
2205**Impact: MEDIUM (keeps input responsive during heavy computation)**
2206
2207When user input triggers expensive computations or renders, use `useDeferredValue` to keep the input responsive. The deferred value lags behind, allowing React to prioritize the input update and render the expensive result when idle.
2208
2209**Incorrect: input feels laggy while filtering**
2210
2211```tsx
2212function Search({ items }: { items: Item[] }) {
2213 const [query, setQuery] = useState('')
2214 const filtered = items.filter(item => fuzzyMatch(item, query))
2215
2216 return (
2217 <>
2218 <input value={query} onChange={e => setQuery(e.target.value)} />
2219 <ResultsList results={filtered} />
2220 </>
2221 )
2222}
2223```
2224
2225**Correct: input stays snappy, results render when ready**
2226
2227```tsx
2228function Search({ items }: { items: Item[] }) {
2229 const [query, setQuery] = useState('')
2230 const deferredQuery = useDeferredValue(query)
2231 const filtered = useMemo(
2232 () => items.filter(item => fuzzyMatch(item, deferredQuery)),
2233 [items, deferredQuery]
2234 )
2235 const isStale = query !== deferredQuery
2236
2237 return (
2238 <>
2239 <input value={query} onChange={e => setQuery(e.target.value)} />
2240 <div style={{ opacity: isStale ? 0.7 : 1 }}>
2241 <ResultsList results={filtered} />
2242 </div>
2243 </>
2244 )
2245}
2246```
2247
2248**When to use:**
2249
2250- Filtering/searching large lists
2251
2252- Expensive visualizations (charts, graphs) reacting to input
2253
2254- Any derived state that causes noticeable render delays
2255
2256**Note:** Wrap the expensive computation in `useMemo` with the deferred value as a dependency, otherwise it still runs on every render.
2257
2258Reference: [https://react.dev/reference/react/useDeferredValue](https://react.dev/reference/react/useDeferredValue)
2259
2260### 5.15 Use useRef for Transient Values
2261
2262**Impact: MEDIUM (avoids unnecessary re-renders on frequent updates)**
2263
2264When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.
2265
2266**Incorrect: renders every update**
2267
2268```tsx
2269function Tracker() {
2270 const [lastX, setLastX] = useState(0)
2271
2272 useEffect(() => {
2273 const onMove = (e: MouseEvent) => setLastX(e.clientX)
2274 window.addEventListener('mousemove', onMove)
2275 return () => window.removeEventListener('mousemove', onMove)
2276 }, [])
2277
2278 return (
2279 <div
2280 style={{
2281 position: 'fixed',
2282 top: 0,
2283 left: lastX,
2284 width: 8,
2285 height: 8,
2286 background: 'black',
2287 }}
2288 />
2289 )
2290}
2291```
2292
2293**Correct: no re-render for tracking**
2294
2295```tsx
2296function Tracker() {
2297 const lastXRef = useRef(0)
2298 const dotRef = useRef<HTMLDivElement>(null)
2299
2300 useEffect(() => {
2301 const onMove = (e: MouseEvent) => {
2302 lastXRef.current = e.clientX
2303 const node = dotRef.current
2304 if (node) {
2305 node.style.transform = `translateX(${e.clientX}px)`
2306 }
2307 }
2308 window.addEventListener('mousemove', onMove)
2309 return () => window.removeEventListener('mousemove', onMove)
2310 }, [])
2311
2312 return (
2313 <div
2314 ref={dotRef}
2315 style={{
2316 position: 'fixed',
2317 top: 0,
2318 left: 0,
2319 width: 8,
2320 height: 8,
2321 background: 'black',
2322 transform: 'translateX(0px)',
2323 }}
2324 />
2325 )
2326}
2327```
2328
2329---
2330
2331## 6. Rendering Performance
2332
2333**Impact: MEDIUM**
2334
2335Optimizing the rendering process reduces the work the browser needs to do.
2336
2337### 6.1 Animate SVG Wrapper Instead of SVG Element
2338
2339**Impact: LOW (enables hardware acceleration)**
2340
2341Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.
2342
2343**Incorrect: animating SVG directly - no hardware acceleration**
2344
2345```tsx
2346function LoadingSpinner() {
2347 return (
2348 <svg
2349 className="animate-spin"
2350 width="24"
2351 height="24"
2352 viewBox="0 0 24 24"
2353 >
2354 <circle cx="12" cy="12" r="10" stroke="currentColor" />
2355 </svg>
2356 )
2357}
2358```
2359
2360**Correct: animating wrapper div - hardware accelerated**
2361
2362```tsx
2363function LoadingSpinner() {
2364 return (
2365 <div className="animate-spin">
2366 <svg
2367 width="24"
2368 height="24"
2369 viewBox="0 0 24 24"
2370 >
2371 <circle cx="12" cy="12" r="10" stroke="currentColor" />
2372 </svg>
2373 </div>
2374 )
2375}
2376```
2377
2378This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.
2379
2380### 6.2 CSS content-visibility for Long Lists
2381
2382**Impact: HIGH (faster initial render)**
2383
2384Apply `content-visibility: auto` to defer off-screen rendering.
2385
2386**CSS:**
2387
2388```css
2389.message-item {
2390 content-visibility: auto;
2391 contain-intrinsic-size: 0 80px;
2392}
2393```
2394
2395**Example:**
2396
2397```tsx
2398function MessageList({ messages }: { messages: Message[] }) {
2399 return (
2400 <div className="overflow-y-auto h-screen">
2401 {messages.map(msg => (
2402 <div key={msg.id} className="message-item">
2403 <Avatar user={msg.author} />
2404 <div>{msg.content}</div>
2405 </div>
2406 ))}
2407 </div>
2408 )
2409}
2410```
2411
2412For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
2413
2414### 6.3 Hoist Static JSX Elements
2415
2416**Impact: LOW (avoids re-creation)**
2417
2418Extract static JSX outside components to avoid re-creation.
2419
2420**Incorrect: recreates element every render**
2421
2422```tsx
2423function LoadingSkeleton() {
2424 return <div className="animate-pulse h-20 bg-gray-200" />
2425}
2426
2427function Container() {
2428 return (
2429 <div>
2430 {loading && <LoadingSkeleton />}
2431 </div>
2432 )
2433}
2434```
2435
2436**Correct: reuses same element**
2437
2438```tsx
2439const loadingSkeleton = (
2440 <div className="animate-pulse h-20 bg-gray-200" />
2441)
2442
2443function Container() {
2444 return (
2445 <div>
2446 {loading && loadingSkeleton}
2447 </div>
2448 )
2449}
2450```
2451
2452This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
2453
2454**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary.
2455
2456### 6.4 Optimize SVG Precision
2457
2458**Impact: LOW (reduces file size)**
2459
2460Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
2461
2462**Incorrect: excessive precision**
2463
2464```svg
2465<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
2466```
2467
2468**Correct: 1 decimal place**
2469
2470```svg
2471<path d="M 10.3 20.8 L 30.9 40.2" />
2472```
2473
2474**Automate with SVGO:**
2475
2476```bash
2477npx svgo --precision=1 --multipass icon.svg
2478```
2479
2480### 6.5 Prevent Hydration Mismatch Without Flickering
2481
2482**Impact: MEDIUM (avoids visual flicker and hydration errors)**
2483
2484When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.
2485
2486**Incorrect: breaks SSR**
2487
2488```tsx
2489function ThemeWrapper({ children }: { children: ReactNode }) {
2490 // localStorage is not available on server - throws error
2491 const theme = localStorage.getItem('theme') || 'light'
2492
2493 return (
2494 <div className={theme}>
2495 {children}
2496 </div>
2497 )
2498}
2499```
2500
2501Server-side rendering will fail because `localStorage` is undefined.
2502
2503**Incorrect: visual flickering**
2504
2505```tsx
2506function ThemeWrapper({ children }: { children: ReactNode }) {
2507 const [theme, setTheme] = useState('light')
2508
2509 useEffect(() => {
2510 // Runs after hydration - causes visible flash
2511 const stored = localStorage.getItem('theme')
2512 if (stored) {
2513 setTheme(stored)
2514 }
2515 }, [])
2516
2517 return (
2518 <div className={theme}>
2519 {children}
2520 </div>
2521 )
2522}
2523```
2524
2525Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
2526
2527**Correct: no flicker, no hydration mismatch**
2528
2529```tsx
2530function ThemeWrapper({ children }: { children: ReactNode }) {
2531 return (
2532 <>
2533 <div id="theme-wrapper">
2534 {children}
2535 </div>
2536 <script
2537 dangerouslySetInnerHTML={{
2538 __html: `
2539 (function() {
2540 try {
2541 var theme = localStorage.getItem('theme') || 'light';
2542 var el = document.getElementById('theme-wrapper');
2543 if (el) el.className = theme;
2544 } catch (e) {}
2545 })();
2546 `,
2547 }}
2548 />
2549 </>
2550 )
2551}
2552```
2553
2554The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
2555
2556This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
2557
2558### 6.6 Suppress Expected Hydration Mismatches
2559
2560**Impact: LOW-MEDIUM (avoids noisy hydration warnings for known differences)**
2561
2562In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it.
2563
2564**Incorrect: known mismatch warnings**
2565
2566```tsx
2567function Timestamp() {
2568 return <span>{new Date().toLocaleString()}</span>
2569}
2570```
2571
2572**Correct: suppress expected mismatch only**
2573
2574```tsx
2575function Timestamp() {
2576 return (
2577 <span suppressHydrationWarning>
2578 {new Date().toLocaleString()}
2579 </span>
2580 )
2581}
2582```
2583
2584### 6.7 Use Activity Component for Show/Hide
2585
2586**Impact: MEDIUM (preserves state/DOM)**
2587
2588Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.
2589
2590**Usage:**
2591
2592```tsx
2593import { Activity } from 'react'
2594
2595function Dropdown({ isOpen }: Props) {
2596 return (
2597 <Activity mode={isOpen ? 'visible' : 'hidden'}>
2598 <ExpensiveMenu />
2599 </Activity>
2600 )
2601}
2602```
2603
2604Avoids expensive re-renders and state loss.
2605
2606### 6.8 Use defer or async on Script Tags
2607
2608**Impact: HIGH (eliminates render-blocking)**
2609
2610Script tags without `defer` or `async` block HTML parsing while the script downloads and executes. This delays First Contentful Paint and Time to Interactive.
2611
2612- **`defer`**: Downloads in parallel, executes after HTML parsing completes, maintains execution order
2613
2614- **`async`**: Downloads in parallel, executes immediately when ready, no guaranteed order
2615
2616Use `defer` for scripts that depend on DOM or other scripts. Use `async` for independent scripts like analytics.
2617
2618**Incorrect: blocks rendering**
2619
2620```tsx
2621export default function Document() {
2622 return (
2623 <html>
2624 <head>
2625 <script src="https://example.com/analytics.js" />
2626 <script src="/scripts/utils.js" />
2627 </head>
2628 <body>{/* content */}</body>
2629 </html>
2630 )
2631}
2632```
2633
2634**Correct: non-blocking**
2635
2636```tsx
2637import Script from 'next/script'
2638
2639export default function Page() {
2640 return (
2641 <>
2642 <Script src="https://example.com/analytics.js" strategy="afterInteractive" />
2643 <Script src="/scripts/utils.js" strategy="beforeInteractive" />
2644 </>
2645 )
2646}
2647```
2648
2649**Note:** In Next.js, prefer the `next/script` component with `strategy` prop instead of raw script tags:
2650
2651Reference: [https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer)
2652
2653### 6.9 Use Explicit Conditional Rendering
2654
2655**Impact: LOW (prevents rendering 0 or NaN)**
2656
2657Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
2658
2659**Incorrect: renders "0" when count is 0**
2660
2661```tsx
2662function Badge({ count }: { count: number }) {
2663 return (
2664 <div>
2665 {count && <span className="badge">{count}</span>}
2666 </div>
2667 )
2668}
2669
2670// When count = 0, renders: <div>0</div>
2671// When count = 5, renders: <div><span class="badge">5</span></div>
2672```
2673
2674**Correct: renders nothing when count is 0**
2675
2676```tsx
2677function Badge({ count }: { count: number }) {
2678 return (
2679 <div>
2680 {count > 0 ? <span className="badge">{count}</span> : null}
2681 </div>
2682 )
2683}
2684
2685// When count = 0, renders: <div></div>
2686// When count = 5, renders: <div><span class="badge">5</span></div>
2687```
2688
2689### 6.10 Use React DOM Resource Hints
2690
2691**Impact: HIGH (reduces load time for critical resources)**
2692
2693React DOM provides APIs to hint the browser about resources it will need. These are especially useful in server components to start loading resources before the client even receives the HTML.
2694
2695- **`prefetchDNS(href)`**: Resolve DNS for a domain you expect to connect to
2696
2697- **`preconnect(href)`**: Establish connection (DNS + TCP + TLS) to a server
2698
2699- **`preload(href, options)`**: Fetch a resource (stylesheet, font, script, image) you'll use soon
2700
2701- **`preloadModule(href)`**: Fetch an ES module you'll use soon
2702
2703- **`preinit(href, options)`**: Fetch and evaluate a stylesheet or script
2704
2705- **`preinitModule(href)`**: Fetch and evaluate an ES module
2706
2707**Example: preconnect to third-party APIs**
2708
2709```tsx
2710import { preconnect, prefetchDNS } from 'react-dom'
2711
2712export default function App() {
2713 prefetchDNS('https://analytics.example.com')
2714 preconnect('https://api.example.com')
2715
2716 return <main>{/* content */}</main>
2717}
2718```
2719
2720**Example: preload critical fonts and styles**
2721
2722```tsx
2723import { preload, preinit } from 'react-dom'
2724
2725export default function RootLayout({ children }) {
2726 // Preload font file
2727 preload('/fonts/inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' })
2728
2729 // Fetch and apply critical stylesheet immediately
2730 preinit('/styles/critical.css', { as: 'style' })
2731
2732 return (
2733 <html>
2734 <body>{children}</body>
2735 </html>
2736 )
2737}
2738```
2739
2740**Example: preload modules for code-split routes**
2741
2742```tsx
2743import { preloadModule, preinitModule } from 'react-dom'
2744
2745function Navigation() {
2746 const preloadDashboard = () => {
2747 preloadModule('/dashboard.js', { as: 'script' })
2748 }
2749
2750 return (
2751 <nav>
2752 <a href="/dashboard" onMouseEnter={preloadDashboard}>
2753 Dashboard
2754 </a>
2755 </nav>
2756 )
2757}
2758```
2759
2760**When to use each:**
2761
2762| API | Use case |
2763
2764|-----|----------|
2765
2766| `prefetchDNS` | Third-party domains you'll connect to later |
2767
2768| `preconnect` | APIs or CDNs you'll fetch from immediately |
2769
2770| `preload` | Critical resources needed for current page |
2771
2772| `preloadModule` | JS modules for likely next navigation |
2773
2774| `preinit` | Stylesheets/scripts that must execute early |
2775
2776| `preinitModule` | ES modules that must execute early |
2777
2778Reference: [https://react.dev/reference/react-dom#resource-preloading-apis](https://react.dev/reference/react-dom#resource-preloading-apis)
2779
2780### 6.11 Use useTransition Over Manual Loading States
2781
2782**Impact: LOW (reduces re-renders and improves code clarity)**
2783
2784Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.
2785
2786**Incorrect: manual loading state**
2787
2788```tsx
2789function SearchResults() {
2790 const [query, setQuery] = useState('')
2791 const [results, setResults] = useState([])
2792 const [isLoading, setIsLoading] = useState(false)
2793
2794 const handleSearch = async (value: string) => {
2795 setIsLoading(true)
2796 setQuery(value)
2797 const data = await fetchResults(value)
2798 setResults(data)
2799 setIsLoading(false)
2800 }
2801
2802 return (
2803 <>
2804 <input onChange={(e) => handleSearch(e.target.value)} />
2805 {isLoading && <Spinner />}
2806 <ResultsList results={results} />
2807 </>
2808 )
2809}
2810```
2811
2812**Correct: useTransition with built-in pending state**
2813
2814```tsx
2815import { useTransition, useState } from 'react'
2816
2817function SearchResults() {
2818 const [query, setQuery] = useState('')
2819 const [results, setResults] = useState([])
2820 const [isPending, startTransition] = useTransition()
2821
2822 const handleSearch = (value: string) => {
2823 setQuery(value) // Update input immediately
2824
2825 startTransition(async () => {
2826 // Fetch and update results
2827 const data = await fetchResults(value)
2828 setResults(data)
2829 })
2830 }
2831
2832 return (
2833 <>
2834 <input onChange={(e) => handleSearch(e.target.value)} />
2835 {isPending && <Spinner />}
2836 <ResultsList results={results} />
2837 </>
2838 )
2839}
2840```
2841
2842**Benefits:**
2843
2844- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`
2845
2846- **Error resilience**: Pending state correctly resets even if the transition throws
2847
2848- **Better responsiveness**: Keeps the UI responsive during updates
2849
2850- **Interrupt handling**: New transitions automatically cancel pending ones
2851
2852Reference: [https://react.dev/reference/react/useTransition](https://react.dev/reference/react/useTransition)
2853
2854---
2855
2856## 7. JavaScript Performance
2857
2858**Impact: LOW-MEDIUM**
2859
2860Micro-optimizations for hot paths can add up to meaningful improvements.
2861
2862### 7.1 Avoid Layout Thrashing
2863
2864**Impact: MEDIUM (prevents forced synchronous layouts and reduces performance bottlenecks)**
2865
2866Avoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow.
2867
2868**This is OK: browser batches style changes**
2869
2870```typescript
2871function updateElementStyles(element: HTMLElement) {
2872 // Each line invalidates style, but browser batches the recalculation
2873 element.style.width = '100px'
2874 element.style.height = '200px'
2875 element.style.backgroundColor = 'blue'
2876 element.style.border = '1px solid black'
2877}
2878```
2879
2880**Incorrect: interleaved reads and writes force reflows**
2881
2882```typescript
2883function layoutThrashing(element: HTMLElement) {
2884 element.style.width = '100px'
2885 const width = element.offsetWidth // Forces reflow
2886 element.style.height = '200px'
2887 const height = element.offsetHeight // Forces another reflow
2888}
2889```
2890
2891**Correct: batch writes, then read once**
2892
2893```typescript
2894function updateElementStyles(element: HTMLElement) {
2895 // Batch all writes together
2896 element.style.width = '100px'
2897 element.style.height = '200px'
2898 element.style.backgroundColor = 'blue'
2899 element.style.border = '1px solid black'
2900
2901 // Read after all writes are done (single reflow)
2902 const { width, height } = element.getBoundingClientRect()
2903}
2904```
2905
2906**Correct: batch reads, then writes**
2907
2908```typescript
2909function updateElementStyles(element: HTMLElement) {
2910 element.classList.add('highlighted-box')
2911
2912 const { width, height } = element.getBoundingClientRect()
2913}
2914```
2915
2916**Better: use CSS classes**
2917
2918**React example:**
2919
2920```tsx
2921// Incorrect: interleaving style changes with layout queries
2922function Box({ isHighlighted }: { isHighlighted: boolean }) {
2923 const ref = useRef<HTMLDivElement>(null)
2924
2925 useEffect(() => {
2926 if (ref.current && isHighlighted) {
2927 ref.current.style.width = '100px'
2928 const width = ref.current.offsetWidth // Forces layout
2929 ref.current.style.height = '200px'
2930 }
2931 }, [isHighlighted])
2932
2933 return <div ref={ref}>Content</div>
2934}
2935
2936// Correct: toggle class
2937function Box({ isHighlighted }: { isHighlighted: boolean }) {
2938 return (
2939 <div className={isHighlighted ? 'highlighted-box' : ''}>
2940 Content
2941 </div>
2942 )
2943}
2944```
2945
2946Prefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain.
2947
2948See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.
2949
2950### 7.2 Build Index Maps for Repeated Lookups
2951
2952**Impact: LOW-MEDIUM (1M ops to 2K ops)**
2953
2954Multiple `.find()` calls by the same key should use a Map.
2955
2956**Incorrect (O(n) per lookup):**
2957
2958```typescript
2959function processOrders(orders: Order[], users: User[]) {
2960 return orders.map(order => ({
2961 ...order,
2962 user: users.find(u => u.id === order.userId)
2963 }))
2964}
2965```
2966
2967**Correct (O(1) per lookup):**
2968
2969```typescript
2970function processOrders(orders: Order[], users: User[]) {
2971 const userById = new Map(users.map(u => [u.id, u]))
2972
2973 return orders.map(order => ({
2974 ...order,
2975 user: userById.get(order.userId)
2976 }))
2977}
2978```
2979
2980Build map once (O(n)), then all lookups are O(1).
2981
2982For 1000 orders × 1000 users: 1M ops → 2K ops.
2983
2984### 7.3 Cache Property Access in Loops
2985
2986**Impact: LOW-MEDIUM (reduces lookups)**
2987
2988Cache object property lookups in hot paths.
2989
2990**Incorrect: 3 lookups × N iterations**
2991
2992```typescript
2993for (let i = 0; i < arr.length; i++) {
2994 process(obj.config.settings.value)
2995}
2996```
2997
2998**Correct: 1 lookup total**
2999
3000```typescript
3001const value = obj.config.settings.value
3002const len = arr.length
3003for (let i = 0; i < len; i++) {
3004 process(value)
3005}
3006```
3007
3008### 7.4 Cache Repeated Function Calls
3009
3010**Impact: MEDIUM (avoid redundant computation)**
3011
3012Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.
3013
3014**Incorrect: redundant computation**
3015
3016```typescript
3017function ProjectList({ projects }: { projects: Project[] }) {
3018 return (
3019 <div>
3020 {projects.map(project => {
3021 // slugify() called 100+ times for same project names
3022 const slug = slugify(project.name)
3023
3024 return <ProjectCard key={project.id} slug={slug} />
3025 })}
3026 </div>
3027 )
3028}
3029```
3030
3031**Correct: cached results**
3032
3033```typescript
3034// Module-level cache
3035const slugifyCache = new Map<string, string>()
3036
3037function cachedSlugify(text: string): string {
3038 if (slugifyCache.has(text)) {
3039 return slugifyCache.get(text)!
3040 }
3041 const result = slugify(text)
3042 slugifyCache.set(text, result)
3043 return result
3044}
3045
3046function ProjectList({ projects }: { projects: Project[] }) {
3047 return (
3048 <div>
3049 {projects.map(project => {
3050 // Computed only once per unique project name
3051 const slug = cachedSlugify(project.name)
3052
3053 return <ProjectCard key={project.id} slug={slug} />
3054 })}
3055 </div>
3056 )
3057}
3058```
3059
3060**Simpler pattern for single-value functions:**
3061
3062```typescript
3063let isLoggedInCache: boolean | null = null
3064
3065function isLoggedIn(): boolean {
3066 if (isLoggedInCache !== null) {
3067 return isLoggedInCache
3068 }
3069
3070 isLoggedInCache = document.cookie.includes('auth=')
3071 return isLoggedInCache
3072}
3073
3074// Clear cache when auth changes
3075function onAuthChange() {
3076 isLoggedInCache = null
3077}
3078```
3079
3080Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
3081
3082Reference: [https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
3083
3084### 7.5 Cache Storage API Calls
3085
3086**Impact: LOW-MEDIUM (reduces expensive I/O)**
3087
3088`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.
3089
3090**Incorrect: reads storage on every call**
3091
3092```typescript
3093function getTheme() {
3094 return localStorage.getItem('theme') ?? 'light'
3095}
3096// Called 10 times = 10 storage reads
3097```
3098
3099**Correct: Map cache**
3100
3101```typescript
3102const storageCache = new Map<string, string | null>()
3103
3104function getLocalStorage(key: string) {
3105 if (!storageCache.has(key)) {
3106 storageCache.set(key, localStorage.getItem(key))
3107 }
3108 return storageCache.get(key)
3109}
3110
3111function setLocalStorage(key: string, value: string) {
3112 localStorage.setItem(key, value)
3113 storageCache.set(key, value) // keep cache in sync
3114}
3115```
3116
3117Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
3118
3119**Cookie caching:**
3120
3121```typescript
3122let cookieCache: Record<string, string> | null = null
3123
3124function getCookie(name: string) {
3125 if (!cookieCache) {
3126 cookieCache = Object.fromEntries(
3127 document.cookie.split('; ').map(c => c.split('='))
3128 )
3129 }
3130 return cookieCache[name]
3131}
3132```
3133
3134**Important: invalidate on external changes**
3135
3136```typescript
3137window.addEventListener('storage', (e) => {
3138 if (e.key) storageCache.delete(e.key)
3139})
3140
3141document.addEventListener('visibilitychange', () => {
3142 if (document.visibilityState === 'visible') {
3143 storageCache.clear()
3144 }
3145})
3146```
3147
3148If storage can change externally (another tab, server-set cookies), invalidate cache:
3149
3150### 7.6 Combine Multiple Array Iterations
3151
3152**Impact: LOW-MEDIUM (reduces iterations)**
3153
3154Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.
3155
3156**Incorrect: 3 iterations**
3157
3158```typescript
3159const admins = users.filter(u => u.isAdmin)
3160const testers = users.filter(u => u.isTester)
3161const inactive = users.filter(u => !u.isActive)
3162```
3163
3164**Correct: 1 iteration**
3165
3166```typescript
3167const admins: User[] = []
3168const testers: User[] = []
3169const inactive: User[] = []
3170
3171for (const user of users) {
3172 if (user.isAdmin) admins.push(user)
3173 if (user.isTester) testers.push(user)
3174 if (!user.isActive) inactive.push(user)
3175}
3176```
3177
3178### 7.7 Defer Non-Critical Work with requestIdleCallback
3179
3180**Impact: MEDIUM (keeps UI responsive during background tasks)**
3181
3182Use `requestIdleCallback()` to schedule non-critical work during browser idle periods. This keeps the main thread free for user interactions and animations, reducing jank and improving perceived performance.
3183
3184**Incorrect: blocks main thread during user interaction**
3185
3186```typescript
3187function handleSearch(query: string) {
3188 const results = searchItems(query)
3189 setResults(results)
3190
3191 // These block the main thread immediately
3192 analytics.track('search', { query })
3193 saveToRecentSearches(query)
3194 prefetchTopResults(results.slice(0, 3))
3195}
3196```
3197
3198**Correct: defers non-critical work to idle time**
3199
3200```typescript
3201function handleSearch(query: string) {
3202 const results = searchItems(query)
3203 setResults(results)
3204
3205 // Defer non-critical work to idle periods
3206 requestIdleCallback(() => {
3207 analytics.track('search', { query })
3208 })
3209
3210 requestIdleCallback(() => {
3211 saveToRecentSearches(query)
3212 })
3213
3214 requestIdleCallback(() => {
3215 prefetchTopResults(results.slice(0, 3))
3216 })
3217}
3218```
3219
3220**With timeout for required work:**
3221
3222```typescript
3223// Ensure analytics fires within 2 seconds even if browser stays busy
3224requestIdleCallback(
3225 () => analytics.track('page_view', { path: location.pathname }),
3226 { timeout: 2000 }
3227)
3228```
3229
3230**Chunking large tasks:**
3231
3232```typescript
3233function processLargeDataset(items: Item[]) {
3234 let index = 0
3235
3236 function processChunk(deadline: IdleDeadline) {
3237 // Process items while we have idle time (aim for <50ms chunks)
3238 while (index < items.length && deadline.timeRemaining() > 0) {
3239 processItem(items[index])
3240 index++
3241 }
3242
3243 // Schedule next chunk if more items remain
3244 if (index < items.length) {
3245 requestIdleCallback(processChunk)
3246 }
3247 }
3248
3249 requestIdleCallback(processChunk)
3250}
3251```
3252
3253**With fallback for unsupported browsers:**
3254
3255```typescript
3256const scheduleIdleWork = window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1))
3257
3258scheduleIdleWork(() => {
3259 // Non-critical work
3260})
3261```
3262
3263**When to use:**
3264
3265- Analytics and telemetry
3266
3267- Saving state to localStorage/IndexedDB
3268
3269- Prefetching resources for likely next actions
3270
3271- Processing non-urgent data transformations
3272
3273- Lazy initialization of non-critical features
3274
3275**When NOT to use:**
3276
3277- User-initiated actions that need immediate feedback
3278
3279- Rendering updates the user is waiting for
3280
3281- Time-sensitive operations
3282
3283### 7.8 Early Length Check for Array Comparisons
3284
3285**Impact: MEDIUM-HIGH (avoids expensive operations when lengths differ)**
3286
3287When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
3288
3289In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
3290
3291**Incorrect: always runs expensive comparison**
3292
3293```typescript
3294function hasChanges(current: string[], original: string[]) {
3295 // Always sorts and joins, even when lengths differ
3296 return current.sort().join() !== original.sort().join()
3297}
3298```
3299
3300Two O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings.
3301
3302**Correct (O(1) length check first):**
3303
3304```typescript
3305function hasChanges(current: string[], original: string[]) {
3306 // Early return if lengths differ
3307 if (current.length !== original.length) {
3308 return true
3309 }
3310 // Only sort when lengths match
3311 const currentSorted = current.toSorted()
3312 const originalSorted = original.toSorted()
3313 for (let i = 0; i < currentSorted.length; i++) {
3314 if (currentSorted[i] !== originalSorted[i]) {
3315 return true
3316 }
3317 }
3318 return false
3319}
3320```
3321
3322This new approach is more efficient because:
3323
3324- It avoids the overhead of sorting and joining the arrays when lengths differ
3325
3326- It avoids consuming memory for the joined strings (especially important for large arrays)
3327
3328- It avoids mutating the original arrays
3329
3330- It returns early when a difference is found
3331
3332### 7.9 Early Return from Functions
3333
3334**Impact: LOW-MEDIUM (avoids unnecessary computation)**
3335
3336Return early when result is determined to skip unnecessary processing.
3337
3338**Incorrect: processes all items even after finding answer**
3339
3340```typescript
3341function validateUsers(users: User[]) {
3342 let hasError = false
3343 let errorMessage = ''
3344
3345 for (const user of users) {
3346 if (!user.email) {
3347 hasError = true
3348 errorMessage = 'Email required'
3349 }
3350 if (!user.name) {
3351 hasError = true
3352 errorMessage = 'Name required'
3353 }
3354 // Continues checking all users even after error found
3355 }
3356
3357 return hasError ? { valid: false, error: errorMessage } : { valid: true }
3358}
3359```
3360
3361**Correct: returns immediately on first error**
3362
3363```typescript
3364function validateUsers(users: User[]) {
3365 for (const user of users) {
3366 if (!user.email) {
3367 return { valid: false, error: 'Email required' }
3368 }
3369 if (!user.name) {
3370 return { valid: false, error: 'Name required' }
3371 }
3372 }
3373
3374 return { valid: true }
3375}
3376```
3377
3378### 7.10 Hoist RegExp Creation
3379
3380**Impact: LOW-MEDIUM (avoids recreation)**
3381
3382Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.
3383
3384**Incorrect: new RegExp every render**
3385
3386```tsx
3387function Highlighter({ text, query }: Props) {
3388 const regex = new RegExp(`(${query})`, 'gi')
3389 const parts = text.split(regex)
3390 return <>{parts.map((part, i) => ...)}</>
3391}
3392```
3393
3394**Correct: memoize or hoist**
3395
3396```tsx
3397const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
3398
3399function Highlighter({ text, query }: Props) {
3400 const regex = useMemo(
3401 () => new RegExp(`(${escapeRegex(query)})`, 'gi'),
3402 [query]
3403 )
3404 const parts = text.split(regex)
3405 return <>{parts.map((part, i) => ...)}</>
3406}
3407```
3408
3409**Warning: global regex has mutable state**
3410
3411```typescript
3412const regex = /foo/g
3413regex.test('foo') // true, lastIndex = 3
3414regex.test('foo') // false, lastIndex = 0
3415```
3416
3417Global regex (`/g`) has mutable `lastIndex` state:
3418
3419### 7.11 Use flatMap to Map and Filter in One Pass
3420
3421**Impact: LOW-MEDIUM (eliminates intermediate array)**
3422
3423Chaining `.map().filter(Boolean)` creates an intermediate array and iterates twice. Use `.flatMap()` to transform and filter in a single pass.
3424
3425**Incorrect: 2 iterations, intermediate array**
3426
3427```typescript
3428const userNames = users
3429 .map(user => user.isActive ? user.name : null)
3430 .filter(Boolean)
3431```
3432
3433**Correct: 1 iteration, no intermediate array**
3434
3435```typescript
3436const userNames = users.flatMap(user =>
3437 user.isActive ? [user.name] : []
3438)
3439```
3440
3441**More examples:**
3442
3443```typescript
3444// Extract valid emails from responses
3445// Before
3446const emails = responses
3447 .map(r => r.success ? r.data.email : null)
3448 .filter(Boolean)
3449
3450// After
3451const emails = responses.flatMap(r =>
3452 r.success ? [r.data.email] : []
3453)
3454
3455// Parse and filter valid numbers
3456// Before
3457const numbers = strings
3458 .map(s => parseInt(s, 10))
3459 .filter(n => !isNaN(n))
3460
3461// After
3462const numbers = strings.flatMap(s => {
3463 const n = parseInt(s, 10)
3464 return isNaN(n) ? [] : [n]
3465})
3466```
3467
3468**When to use:**
3469
3470- Transforming items while filtering some out
3471
3472- Conditional mapping where some inputs produce no output
3473
3474- Parsing/validating where invalid inputs should be skipped
3475
3476### 7.12 Use Loop for Min/Max Instead of Sort
3477
3478**Impact: LOW (O(n) instead of O(n log n))**
3479
3480Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.
3481
3482**Incorrect (O(n log n) - sort to find latest):**
3483
3484```typescript
3485interface Project {
3486 id: string
3487 name: string
3488 updatedAt: number
3489}
3490
3491function getLatestProject(projects: Project[]) {
3492 const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
3493 return sorted[0]
3494}
3495```
3496
3497Sorts the entire array just to find the maximum value.
3498
3499**Incorrect (O(n log n) - sort for oldest and newest):**
3500
3501```typescript
3502function getOldestAndNewest(projects: Project[]) {
3503 const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
3504 return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
3505}
3506```
3507
3508Still sorts unnecessarily when only min/max are needed.
3509
3510**Correct (O(n) - single loop):**
3511
3512```typescript
3513function getLatestProject(projects: Project[]) {
3514 if (projects.length === 0) return null
3515
3516 let latest = projects[0]
3517
3518 for (let i = 1; i < projects.length; i++) {
3519 if (projects[i].updatedAt > latest.updatedAt) {
3520 latest = projects[i]
3521 }
3522 }
3523
3524 return latest
3525}
3526
3527function getOldestAndNewest(projects: Project[]) {
3528 if (projects.length === 0) return { oldest: null, newest: null }
3529
3530 let oldest = projects[0]
3531 let newest = projects[0]
3532
3533 for (let i = 1; i < projects.length; i++) {
3534 if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
3535 if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
3536 }
3537
3538 return { oldest, newest }
3539}
3540```
3541
3542Single pass through the array, no copying, no sorting.
3543
3544**Alternative: Math.min/Math.max for small arrays**
3545
3546```typescript
3547const numbers = [5, 2, 8, 1, 9]
3548const min = Math.min(...numbers)
3549const max = Math.max(...numbers)
3550```
3551
3552This works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability.
3553
3554### 7.13 Use Set/Map for O(1) Lookups
3555
3556**Impact: LOW-MEDIUM (O(n) to O(1))**
3557
3558Convert arrays to Set/Map for repeated membership checks.
3559
3560**Incorrect (O(n) per check):**
3561
3562```typescript
3563const allowedIds = ['a', 'b', 'c', ...]
3564items.filter(item => allowedIds.includes(item.id))
3565```
3566
3567**Correct (O(1) per check):**
3568
3569```typescript
3570const allowedIds = new Set(['a', 'b', 'c', ...])
3571items.filter(item => allowedIds.has(item.id))
3572```
3573
3574### 7.14 Use toSorted() Instead of sort() for Immutability
3575
3576**Impact: MEDIUM-HIGH (prevents mutation bugs in React state)**
3577
3578`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation.
3579
3580**Incorrect: mutates original array**
3581
3582```typescript
3583function UserList({ users }: { users: User[] }) {
3584 // Mutates the users prop array!
3585 const sorted = useMemo(
3586 () => users.sort((a, b) => a.name.localeCompare(b.name)),
3587 [users]
3588 )
3589 return <div>{sorted.map(renderUser)}</div>
3590}
3591```
3592
3593**Correct: creates new array**
3594
3595```typescript
3596function UserList({ users }: { users: User[] }) {
3597 // Creates new sorted array, original unchanged
3598 const sorted = useMemo(
3599 () => users.toSorted((a, b) => a.name.localeCompare(b.name)),
3600 [users]
3601 )
3602 return <div>{sorted.map(renderUser)}</div>
3603}
3604```
3605
3606**Why this matters in React:**
3607
36081. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only
3609
36102. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
3611
3612**Browser support: fallback for older browsers**
3613
3614```typescript
3615// Fallback for older browsers
3616const sorted = [...items].sort((a, b) => a.value - b.value)
3617```
3618
3619`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
3620
3621**Other immutable array methods:**
3622
3623- `.toSorted()` - immutable sort
3624
3625- `.toReversed()` - immutable reverse
3626
3627- `.toSpliced()` - immutable splice
3628
3629- `.with()` - immutable element replacement
3630
3631---
3632
3633## 8. Advanced Patterns
3634
3635**Impact: LOW**
3636
3637Advanced patterns for specific cases that require careful implementation.
3638
3639### 8.1 Do Not Put Effect Events in Dependency Arrays
3640
3641**Impact: LOW (avoids unnecessary effect re-runs and lint errors)**
3642
3643Effect Event functions do not have a stable identity. Their identity intentionally changes on every render. Do not include the function returned by `useEffectEvent` in a `useEffect` dependency array. Keep the actual reactive values as dependencies and call the Effect Event from inside the effect body or subscriptions created by that effect.
3644
3645**Incorrect: Effect Event added as a dependency**
3646
3647```tsx
3648import { useEffect, useEffectEvent } from 'react'
3649
3650function ChatRoom({ roomId, onConnected }: {
3651 roomId: string
3652 onConnected: () => void
3653}) {
3654 const handleConnected = useEffectEvent(onConnected)
3655
3656 useEffect(() => {
3657 const connection = createConnection(roomId)
3658 connection.on('connected', handleConnected)
3659 connection.connect()
3660
3661 return () => connection.disconnect()
3662 }, [roomId, handleConnected])
3663}
3664```
3665
3666Including the Effect Event in dependencies makes the effect re-run every render and triggers the React Hooks lint rule.
3667
3668**Correct: depend on reactive values, not the Effect Event**
3669
3670```tsx
3671import { useEffect, useEffectEvent } from 'react'
3672
3673function ChatRoom({ roomId, onConnected }: {
3674 roomId: string
3675 onConnected: () => void
3676}) {
3677 const handleConnected = useEffectEvent(onConnected)
3678
3679 useEffect(() => {
3680 const connection = createConnection(roomId)
3681 connection.on('connected', handleConnected)
3682 connection.connect()
3683
3684 return () => connection.disconnect()
3685 }, [roomId])
3686}
3687```
3688
3689Reference: [https://react.dev/reference/react/useEffectEvent#effect-event-in-deps](https://react.dev/reference/react/useEffectEvent#effect-event-in-deps)
3690
3691### 8.2 Initialize App Once, Not Per Mount
3692
3693**Impact: LOW-MEDIUM (avoids duplicate init in development)**
3694
3695Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.
3696
3697**Incorrect: runs twice in dev, re-runs on remount**
3698
3699```tsx
3700function Comp() {
3701 useEffect(() => {
3702 loadFromStorage()
3703 checkAuthToken()
3704 }, [])
3705
3706 // ...
3707}
3708```
3709
3710**Correct: once per app load**
3711
3712```tsx
3713let didInit = false
3714
3715function Comp() {
3716 useEffect(() => {
3717 if (didInit) return
3718 didInit = true
3719 loadFromStorage()
3720 checkAuthToken()
3721 }, [])
3722
3723 // ...
3724}
3725```
3726
3727Reference: [https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
3728
3729### 8.3 Store Event Handlers in Refs
3730
3731**Impact: LOW (stable subscriptions)**
3732
3733Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
3734
3735**Incorrect: re-subscribes on every render**
3736
3737```tsx
3738function useWindowEvent(event: string, handler: (e) => void) {
3739 useEffect(() => {
3740 window.addEventListener(event, handler)
3741 return () => window.removeEventListener(event, handler)
3742 }, [event, handler])
3743}
3744```
3745
3746**Correct: stable subscription**
3747
3748```tsx
3749import { useEffectEvent } from 'react'
3750
3751function useWindowEvent(event: string, handler: (e) => void) {
3752 const onEvent = useEffectEvent(handler)
3753
3754 useEffect(() => {
3755 window.addEventListener(event, onEvent)
3756 return () => window.removeEventListener(event, onEvent)
3757 }, [event])
3758}
3759```
3760
3761**Alternative: use `useEffectEvent` if you're on latest React:**
3762
3763`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
3764
3765### 8.4 useEffectEvent for Stable Callback Refs
3766
3767**Impact: LOW (prevents effect re-runs)**
3768
3769Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
3770
3771**Incorrect: effect re-runs on every callback change**
3772
3773```tsx
3774function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
3775 const [query, setQuery] = useState('')
3776
3777 useEffect(() => {
3778 const timeout = setTimeout(() => onSearch(query), 300)
3779 return () => clearTimeout(timeout)
3780 }, [query, onSearch])
3781}
3782```
3783
3784**Correct: using React's useEffectEvent**
3785
3786```tsx
3787import { useEffectEvent } from 'react';
3788
3789function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
3790 const [query, setQuery] = useState('')
3791 const onSearchEvent = useEffectEvent(onSearch)
3792
3793 useEffect(() => {
3794 const timeout = setTimeout(() => onSearchEvent(query), 300)
3795 return () => clearTimeout(timeout)
3796 }, [query])
3797}
3798```
3799
3800---
3801
3802## References
3803
38041. [https://react.dev](https://react.dev)
38052. [https://nextjs.org](https://nextjs.org)
38063. [https://swr.vercel.app](https://swr.vercel.app)
38074. [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
38085. [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
38096. [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
38107. [https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
3811
@@ −1 +1 @@
1−<!-- forgecat:@forgecat/vercel-labs_agent-skills_react-view-transitions:react-view-transitions-agents:start -->
2−# React View Transitions
3−
4−**Version 1.0.0**
5−Vercel Engineering
6−March 2026
7−
8−> **Note:**
9−> This document is mainly for agents and LLMs to follow when implementing
10−> view transitions in React applications. Humans may also find it useful,
11−> but guidance here is optimized for automation and consistency by
12−> AI-assisted workflows.
13−
14−---
15−
16−## Abstract
17−
18−Guide for implementing smooth, native-feeling animations using React's View Transition API. Covers the `<ViewTransition>` component, `addTransitionType`, CSS view transition pseudo-elements, shared element transitions, Suspense reveals, list reorder, directional navigation, and Next.js integration. Includes a step-by-step implementation workflow, ready-to-use CSS animation recipes, and common mistake warnings.
19−
20−---
21−
22−## Table of Contents
23−
24−1. [Core Reference](#when-to-animate)
25− - [When to Animate](#when-to-animate)
26− - [Availability](#availability)
27− - [Core Concepts](#core-concepts)
28− - [Styling with View Transition Classes](#styling-with-view-transition-classes)
29− - [Transition Types](#transition-types)
30− - [Shared Element Transitions](#shared-element-transitions)
31− - [Common Patterns](#common-patterns)
32− - [How Multiple VTs Interact](#how-multiple-vts-interact)
33− - [Next.js Integration](#nextjs-integration)
34− - [Accessibility](#accessibility)
35−2. [Implementation Workflow](#implementation-workflow)
36− - [Step 1: Audit the App](#step-1-audit-the-app)
37− - [Step 2: Add CSS Recipes](#step-2-add-css-recipes)
38− - [Step 3: Isolate Persistent Elements](#step-3-isolate-persistent-elements)
39− - [Step 4: Add Directional Page Transitions](#step-4-add-directional-page-transitions)
40− - [Step 5: Add Suspense Reveals](#step-5-add-suspense-reveals)
41− - [Step 6: Add Shared Element Transitions](#step-6-add-shared-element-transitions)
42− - [Step 7: Verify Each Navigation Path](#step-7-verify-each-navigation-path)
43− - [Common Mistakes](#common-mistakes)
44−3. [Patterns and Guidelines](#patterns-and-guidelines)
45−4. [CSS Animation Recipes](#css-animation-recipes)
46−5. [View Transitions in Next.js](#view-transitions-in-nextjs)
47−
48−---
49−
50−Animate between UI states using the browser's native `document.startViewTransition`. Declare *what* with `<ViewTransition>`, trigger *when* with `startTransition` / `useDeferredValue` / `Suspense`, control *how* with CSS classes. Unsupported browsers skip animations gracefully.
51−
52−## When to Animate
53−
54−Every `<ViewTransition>` should communicate a spatial relationship or continuity. If you can't articulate what it communicates, don't add it.
55−
56−Implement **all** applicable patterns from this list, in this order:
57−
58−| Priority | Pattern | What it communicates |
59−|----------|---------|---------------------|
60−| 1 | **Shared element** (`name`) | "Same thing — going deeper" |
61−| 2 | **Suspense reveal** | "Data loaded" |
62−| 3 | **List identity** (per-item `key`) | "Same items, new arrangement" |
63−| 4 | **State change** (`enter`/`exit`) | "Something appeared/disappeared" |
64−| 5 | **Route change** (layout-level) | "Going to a new place" |
65−
66−This is an implementation order, not a "pick one" list. Implement every pattern that fits the app. Only skip a pattern if the app has no use case for it.
67−
68−### Choosing Animation Style
69−
70−| Context | Animation | Why |
71−|---------|-----------|-----|
72−| Hierarchical navigation (list → detail) | Type-keyed `nav-forward` / `nav-back` | Communicates spatial depth |
73−| Lateral navigation (tab-to-tab) | Bare `<ViewTransition>` (fade) or `default="none"` | No depth to communicate |
74−| Suspense reveal | `enter`/`exit` string props | Content arriving |
75−| Revalidation / background refresh | `default="none"` | Silent — no animation needed |
76−
77−Reserve directional slides for hierarchical navigation (list → detail) and ordered sequences (prev/next photo, carousel, paginated results). For ordered sequences, the direction communicates position: "next" slides from right, "previous" from left. Lateral/unordered navigation (tab-to-tab) should not use directional slides — it falsely implies spatial depth.
78−
79−---
80−
81−## Availability
82−
83−- **Next.js:** Do **not** install `react@canary` — the App Router already bundles React canary internally. `ViewTransition` works out of the box. `npm ls react` may show a stable-looking version; this is expected.
84−- **Without Next.js:** Install `react@canary react-dom@canary` (`ViewTransition` is not in stable React).
85−- Browser support: Chromium 111+, Firefox 144+, Safari 18.2+. Graceful degradation.
86−
87−---
88−
89−## Core Concepts
90−
91−### The `<ViewTransition>` Component
92−
93−```jsx
94−import { ViewTransition } from 'react';
95−
96−<ViewTransition>
97− <Component />
98−</ViewTransition>
99−```
100−
101−React auto-assigns a unique `view-transition-name` and calls `document.startViewTransition` behind the scenes. Never call `startViewTransition` yourself.
102−
103−### Animation Triggers
104−
105−| Trigger | When it fires |
106−|---------|--------------|
107−| **enter** | VT first inserted during a Transition |
108−| **exit** | VT first removed during a Transition |
109−| **update** | DOM mutations inside a VT. With nested VTs, mutation applies to the innermost one |
110−| **share** | Named VT unmounts and another with same `name` mounts in same Transition |
111−
112−Only `startTransition`, `useDeferredValue`, or `Suspense` activate VTs. Regular `setState` does not animate.
113−
114−### Critical Placement Rule
115−
116−VT only activates enter/exit if it appears **before any DOM nodes**:
117−
118−```jsx
119−// Works
120−<ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition>
121−
122−// Broken — div wraps the VT
123−<div><ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition></div>
124−```
125−
126−---
127−
128−## Styling with View Transition Classes
129−
130−Values: `"auto"` (browser cross-fade), `"none"` (disabled), `"class-name"` (custom CSS), or `{ [type]: value }` for type-specific animations.
131−
132−```jsx
133−<ViewTransition default="none" enter="slide-in" exit="slide-out" share="morph" />
134−```
135−
136−If `default` is `"none"`, all triggers are off unless explicitly listed.
137−
138−### CSS Pseudo-Elements
139−
140−- `::view-transition-old(.class)` — outgoing snapshot
141−- `::view-transition-new(.class)` — incoming snapshot
142−- `::view-transition-group(.class)` — container
143−- `::view-transition-image-pair(.class)` — old + new pair
144−
145−---
146−
147−## Transition Types
148−
149−Tag transitions with `addTransitionType` so VTs can pick different animations. Call it multiple times to stack types — different VTs in the tree react to different types:
150−
151−```jsx
152−startTransition(() => {
153− addTransitionType('nav-forward');
154− addTransitionType('select-item');
155− router.push('/detail/1');
156−});
157−```
158−
159−Map types to CSS classes. Works on `enter`, `exit`, **and** `share`:
160−
161−```jsx
162−<ViewTransition
163− enter={{ 'nav-forward': 'slide-from-right', 'nav-back': 'slide-from-left', default: 'none' }}
164− exit={{ 'nav-forward': 'slide-to-left', 'nav-back': 'slide-to-right', default: 'none' }}
165− share={{ 'nav-forward': 'morph-forward', 'nav-back': 'morph-back', default: 'morph' }}
166− default="none"
167−>
168− <Page />
169−</ViewTransition>
170−```
171−
172−`enter` and `exit` don't have to be symmetric. For example, fade in but slide out directionally:
173−
174−```jsx
175−<ViewTransition
176− enter={{ 'nav-forward': 'fade-in', 'nav-back': 'fade-in', default: 'none' }}
177− exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
178− default="none"
179−>
180−```
181−
182−**TypeScript:** `ViewTransitionClassPerType` requires a `default` key.
183−
184−### `router.back()` and Browser Back Button
185−
186−`router.back()` and the browser's back/forward buttons do **not** trigger view transitions (`popstate` is synchronous, incompatible with `startViewTransition`). Use `router.push()` with an explicit URL instead.
187−
188−### Types and Suspense
189−
190−Types are available during navigation but **not** during subsequent Suspense reveals (separate transitions, no type). Use type maps for page-level enter/exit; use simple string props for Suspense reveals.
191−
192−---
193−
194−## Shared Element Transitions
195−
196−Same `name` on two VTs — one unmounting, one mounting — creates a shared element morph:
197−
198−```jsx
199−<ViewTransition name="hero-image">
200− <img src="/thumb.jpg" onClick={() => startTransition(() => onSelect())} />
201−</ViewTransition>
202−
203−// Other view — same name
204−<ViewTransition name="hero-image">
205− <img src="/full.jpg" />
206−</ViewTransition>
207−```
208−
209−- Only one VT with a given `name` can be mounted at a time — use unique names. Watch for reusable components: if a component with a named VT is rendered in both a modal/popover *and* a page, both mount simultaneously and break the morph. Either make the name conditional (via a prop) or move the named VT out of the shared component into the specific consumer.
210−- `share` takes precedence over `enter`/`exit`. Think through each navigation path: when no pair forms, `enter`/`exit` fires instead. Consider whether the element needs a fallback animation for those paths.
211−- Never use fade-out exit on pages with shared morphs — use directional slide.
212−
213−---
214−
215−## Common Patterns
216−
217−### Enter/Exit
218−
219−```jsx
220−{show && (
221− <ViewTransition enter="fade-in" exit="fade-out"><Panel /></ViewTransition>
222−)}
223−```
224−
225−### List Reorder
226−
227−```jsx
228−{items.map(item => (
229− <ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>
230−))}
231−```
232−
233−Trigger inside `startTransition`. Avoid wrapper `<div>`s between list and VT.
234−
235−### Composing Shared Elements with List Identity
236−
237−Shared elements and list identity are independent concerns — don't confuse one for the other. When a list item contains a shared element, use two nested `<ViewTransition>` boundaries:
238−
239−```jsx
240−{items.map(item => (
241− <ViewTransition key={item.id}> {/* list identity */}
242− <Link href={`/items/${item.id}`}>
243− <ViewTransition name={`item-image-${item.id}`} share="morph"> {/* shared element */}
244− <Image src={item.image} />
245− </ViewTransition>
246− <p>{item.name}</p>
247− </Link>
248− </ViewTransition>
249−))}
250−```
251−
252−The outer VT handles list reorder/enter. The inner VT handles cross-route shared element morph. Missing either layer means that animation silently doesn't happen.
253−
254−### Force Re-Enter with `key`
255−
256−```jsx
257−<ViewTransition key={searchParams.toString()} enter="slide-up" default="none">
258− <ResultsGrid />
259−</ViewTransition>
260−```
261−
262−**Caution:** Wrapping `<Suspense>` with key remounts the boundary and refetches.
263−
264−### Suspense Fallback to Content
265−
266−Simple cross-fade:
267−```jsx
268−<ViewTransition>
269− <Suspense fallback={<Skeleton />}><Content /></Suspense>
270−</ViewTransition>
271−```
272−
273−Directional reveal:
274−```jsx
275−<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
276− <ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>
277−</Suspense>
278−```
279−
280−---
281−
282−## How Multiple VTs Interact
283−
284−Every VT matching the trigger fires simultaneously in a single `document.startViewTransition`. VTs in **different** transitions don't compete.
285−
286−### Use `default="none"` Liberally
287−
288−Without it, every VT fires the browser cross-fade on **every** transition. Always use `default="none"` and explicitly enable only desired triggers.
289−
290−### Two Patterns Coexist
291−
292−**Pattern A — Directional slides:** Type-keyed VT on each page, fires during navigation.
293−**Pattern B — Suspense reveals:** Simple string props, fires when data loads (no type).
294−
295−They coexist because they fire at different moments. `default="none"` on both prevents cross-interference. Always pair `enter` with `exit`. Place directional VTs in page components, not layouts.
296−
297−### Nested VT Limitation
298−
299−When a parent VT exits, nested VTs inside it do **not** fire their own enter/exit — only the outermost VT animates. Per-item staggered animations during page navigation are not possible today. See [react#36135](https://github.com/facebook/react/pull/36135) for an experimental opt-in fix.
300−
301−---
302−
303−## Next.js Integration
304−
305−See the [View Transitions in Next.js](#view-transitions-in-nextjs) section below.
306−
307−---
308−
309−## Accessibility
310−
311−Always add reduced motion CSS to your global stylesheet:
312−
313−```css
314−@media (prefers-reduced-motion: reduce) {
315− ::view-transition-old(*),
316− ::view-transition-new(*),
317− ::view-transition-group(*) {
318− animation-duration: 0s !important;
319− animation-delay: 0s !important;
320− }
321−}
322−```
323−
324−---
325−
326−# Implementation Workflow
327−
328−**Follow these steps in order.** Start with the audit — do not skip it. Copy the CSS recipes from the CSS Recipes section below — do not write your own animation CSS.
329−
330−## Step 1: Audit the App
331−
332−Before writing any code, scan the codebase thoroughly. Search for:
333−
334−- **Every `<Link>` and `router.push`** — open every file that contains one
335−- **Every `<Suspense>` boundary** — check what its fallback renders
336−- **Every page/route component** — each needs a VT placement decision
337−- **Persistent elements** (headers, navbars, sidebars) — need `viewTransitionName` isolation
338−- **Shared visual elements** on both source and target views
339−- **Skeleton-to-content control pairs** — if a fallback renders a control that also exists in the real content, both need a matching `viewTransitionName`
340−
341−Then classify every navigation and produce a navigation map:
342−
343−```
344−| Route | Navigates to | Direction | VT pattern |
345−|-----------------|----------------------|--------------|-----------------------|
346−| / | /detail/[id] | forward | directional slide |
347−| /detail/[id] | / | back | directional slide |
348−| /detail/[id] | /detail/[other] | sequential | directional slide (ordered prev/next) or key+share crossfade |
349−| /tab/[a] | /tab/[b] | lateral | key+share crossfade |
350−| (Suspense) | (content loads) | — | slide-up reveal |
351−```
352−
353−For each shared element (`name` prop), note where a pair forms and where it doesn't — this determines whether you need `enter`/`exit` as a fallback alongside `share`.
354−
355−## Step 2: Add CSS Recipes
356−
357−Copy the **complete** CSS recipe set from the CSS Animation Recipes section below into your global stylesheet. Don't write your own — the recipes handle staggered timing, motion blur, and reduced motion.
358−
359−## Step 3: Isolate Persistent Elements
360−
361−```jsx
362−<header style={{ viewTransitionName: "site-header" }}>...</header>
363−```
364−
365−```css
366−::view-transition-group(site-header) {
367− animation: none;
368− z-index: 100;
369−}
370−```
371−
372−For `backdrop-blur`/`backdrop-filter`, use the backdrop-blur workaround instead.
373−
374−## Step 4: Add Directional Page Transitions
375−
376−```jsx
377−startTransition(() => {
378− addTransitionType('nav-forward');
379− router.push('/detail/1');
380−});
381−```
382−
383−Wrap each **page component** (not layout) in a type-keyed VT:
384−
385−```jsx
386−<ViewTransition
387− enter={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}
388− exit={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}
389− default="none"
390−>
391− <div>...page content...</div>
392−</ViewTransition>
393−```
394−
395−Extract into a reusable component so every page doesn't repeat the type map:
396−
397−```jsx
398−export function DirectionalTransition({ children }: { children: React.ReactNode }) {
399− return (
400− <ViewTransition
401− enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
402− exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
403− default="none"
404− >
405− {children}
406− </ViewTransition>
407− );
408−}
409−```
410−
411−**Rules:** Always pair `enter` with `exit`. Always include `default: "none"`. Place in page components, not layouts. Only use directional slides for hierarchical navigation or ordered sequences (prev/next).
412−
413−## Step 5: Add Suspense Reveals
414−
415−```jsx
416−<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
417− <ViewTransition enter="slide-up" default="none"><AsyncContent /></ViewTransition>
418−</Suspense>
419−```
420−
421−Use `default="none"` on content VT. Use simple string props (not type maps) — Suspense resolves have no type.
422−
423−## Step 6: Add Shared Element Transitions
424−
425−```jsx
426−// Source view
427−<ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
428− <Image src={photo.src} ... />
429−</ViewTransition>
430−
431−// Target view — same name
432−<ViewTransition name={`photo-${photo.id}`} share="morph">
433− <Image src={photo.src} ... />
434−</ViewTransition>
435−```
436−
437−When list items contain shared elements, compose both patterns — two independent layers:
438−
439−```jsx
440−{items.map(item => (
441− <ViewTransition key={item.id}> {/* list identity */}
442− <Link href={`/detail/${item.id}`}>
443− <ViewTransition name={`item-${item.id}`} share="morph" default="none"> {/* shared element */}
444− <Image src={item.image} ... />
445− </ViewTransition>
446− </Link>
447− </ViewTransition>
448−))}
449−```
450−
451−The outer VT handles list reorder/enter. The inner VT handles cross-route shared element morph. Missing either layer means that animation silently doesn't happen.
452−
453−**Rules:** Names must be globally unique. Add `default="none"` on list-side shared elements.
454−
455−## Step 7: Verify Each Navigation Path
456−
457−Walk through every row in the navigation map from Step 1:
458−
459−- Does the VT mount/unmount, or stay mounted (same-route)?
460−- For named VTs: does a shared pair form? If not, does `enter`/`exit` provide a fallback?
461−- Does `default="none"` block an animation you actually want?
462−- Do persistent elements stay static?
463−- Do Suspense reveals animate independently from directional navigations?
464−
465−---
466−
467−## Common Mistakes
468−
469−- **Bare VT without `default="none"`** — fires cross-fade on every transition
470−- **Directional VT in a layout** — layouts persist, enter/exit won't fire on route changes
471−- **Fade-out exit with shared morphs** — conflicts with morph, use directional slide
472−- **Writing custom animation CSS** — use the recipes
473−- **Missing `default: "none"` in type-keyed objects** — TypeScript requires it, fallback is `"auto"`
474−- **Type maps on Suspense reveals** — Suspense resolves have no type, use string props
475−- **Raw `viewTransitionName` CSS to trigger animations** — React only starts view transitions when `<ViewTransition>` components are in the tree. Bare `viewTransitionName` is for isolating elements, not triggering animations.
476−- **`update` trigger for same-route navigations** — nested VTs steal the mutation from the parent. Use `key` + `name` + `share` instead.
477−- **Named VT in a reusable component** — if a component with a named VT is rendered in both a modal/popover *and* a page, both mount simultaneously and break the morph. Make the name conditional or move it to the specific consumer.
478−- **`router.back()` for back navigation** — `router.back()` triggers synchronous `popstate`, incompatible with view transitions. Use `router.push()` with an explicit URL.
479−
480−For Next.js-specific steps, see the Next.js section below.
481−
482−---
483−
484−# Patterns and Guidelines
485−
486−## Searchable Grid with `useDeferredValue`
487−
488−```tsx
489−'use client';
490−
491−import { useDeferredValue, useState, ViewTransition, Suspense } from 'react';
492−
493−export default function SearchableGrid({ itemsPromise }) {
494− const [search, setSearch] = useState('');
495− const deferredSearch = useDeferredValue(search);
496−
497− return (
498− <>
499− <input value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
500− <ViewTransition>
501− <Suspense fallback={<GridSkeleton />}>
502− <ItemGrid itemsPromise={itemsPromise} search={deferredSearch} />
503− </Suspense>
504− </ViewTransition>
505− </>
506− );
507−}
508−```
509−
510−Per-item named VTs in deferred lists trigger cross-fades on every keystroke. Fix with `default="none"`.
511−
512−## Card Expand/Collapse with `startTransition`
513−
514−```tsx
515−'use client';
516−
517−import { useState, useRef, startTransition, ViewTransition } from 'react';
518−
519−export default function ItemGrid({ items }) {
520− const [expandedId, setExpandedId] = useState(null);
521− const scrollRef = useRef(0);
522−
523− return expandedId ? (
524− <ViewTransition enter="slide-in" name={`item-${expandedId}`}>
525− <ItemDetail
526− item={items.find(i => i.id === expandedId)}
527− onClose={() => {
528− startTransition(() => {
529− setExpandedId(null);
530− setTimeout(() => window.scrollTo({ behavior: 'smooth', top: scrollRef.current }), 100);
531− });
532− }}
533− />
534− </ViewTransition>
535− ) : (
536− <div className="grid grid-cols-3 gap-4">
537− {items.map(item => (
538− <ViewTransition key={item.id} name={`item-${item.id}`}>
539− <ItemCard
540− item={item}
541− onSelect={() => {
542− scrollRef.current = window.scrollY;
543− startTransition(() => setExpandedId(item.id));
544− }}
545− />
546− </ViewTransition>
547− ))}
548− </div>
549− );
550−}
551−```
552−
553−## Cross-Fade Without Remount
554−
555−Omit `key` to trigger update (cross-fade) instead of exit + enter. Avoids Suspense remount:
556−
557−```jsx
558−<ViewTransition><TabPanel tab={activeTab} /></ViewTransition>
559−```
560−
561−## Isolate Elements from Parent Animations
562−
563−Persistent elements get captured in page's transition snapshot. Fix with `viewTransitionName`:
564−
565−```jsx
566−<nav style={{ viewTransitionName: "persistent-nav" }}>{/* ... */}</nav>
567−```
568−
569−```css
570−::view-transition-group(persistent-nav) { animation: none; z-index: 100; }
571−```
572−
573−Same for floating elements (popovers, tooltips). Global fix: `::view-transition-group(*) { z-index: 100; }`
574−
575−## Shared Controls Between Skeleton and Content
576−
577−Give matching controls the same `viewTransitionName`. Don't put manual `viewTransitionName` on root DOM node inside `<ViewTransition>`.
578−
579−## Reusable Animated Collapse
580−
581−```jsx
582−function AnimatedCollapse({ open, children }) {
583− if (!open) return null;
584− return <ViewTransition enter="expand-in" exit="collapse-out">{children}</ViewTransition>;
585−}
586−```
587−
588−## Preserve State with Activity
589−
590−```jsx
591−<Activity mode={isVisible ? 'visible' : 'hidden'}>
592− <ViewTransition enter="slide-in" exit="slide-out"><Sidebar /></ViewTransition>
593−</Activity>
594−```
595−
596−## Exclude Elements with `useOptimistic`
597−
598−`useOptimistic` values update before snapshot, excluding them from animation. Use for controls; use committed state for animated content.
599−
600−---
601−
602−## View Transition Events
603−
604−Imperative control via `onEnter`, `onExit`, `onUpdate`, `onShare`. Always return cleanup. `onShare` takes precedence.
605−
606−```jsx
607−<ViewTransition
608− onEnter={(instance, types) => {
609− const anim = instance.new.animate(
610− [{ transform: 'scale(0.8)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],
611− { duration: 300, easing: 'ease-out' }
612− );
613− return () => anim.cancel();
614− }}
615−>
616− <Component />
617−</ViewTransition>
618−```
619−
620−`instance`: `.old`, `.new`, `.group`, `.imagePair`, `.name`
621−
622−---
623−
624−## Animation Timing
625−
626−| Interaction | Duration |
627−|------------|----------|
628−| Direct toggle | 100–200ms |
629−| Route transition | 150–250ms |
630−| Suspense reveal | 200–400ms |
631−| Shared element morph | 300–500ms |
632−
633−---
634−
635−## Troubleshooting
636−
637−**VT not activating:** Ensure VT comes before any DOM node. Ensure `startTransition`.
638−
639−**"Two VTs with same name":** Names must be globally unique. Use IDs.
640−
641−**`router.back()` and browser back/forward skip animation:** Use `router.push()` with an explicit URL instead.
642−
643−**Only updates animate:** Without `<Suspense>`, React treats swaps as updates. Conditionally render the VT itself, or wrap in `<Suspense>`.
644−
645−**Layout VT prevents page VTs from animating:** Nested VTs never fire enter/exit inside a parent VT. If your layout has a VT wrapping `{children}`, page-level enter/exit will silently not work. Remove the layout VT.
646−
647−**TS error "Property 'default' is missing":** Type-keyed objects require a `default` key.
648−
649−**Backdrop-blur flickers:** `::view-transition-old(name) { display: none }` + `::view-transition-new(name) { animation: none }`.
650−
651−**`border-radius` lost:** Apply `border-radius` directly to captured element.
652−
653−**Batching:** Multiple updates during animation are batched (A→B→C→D becomes B→D).
654−
655−---
656−
657−# CSS Animation Recipes
658−
659−Ready-to-use CSS for `<ViewTransition>` props. Copy into global stylesheet.
660−
661−## Timing Variables
662−
663−```css
664−:root {
665− --duration-exit: 150ms;
666− --duration-enter: 210ms;
667− --duration-move: 400ms;
668−}
669−```
670−
671−### Shared Keyframes
672−
673−```css
674−@keyframes fade {
675− from { filter: blur(3px); opacity: 0; }
676− to { filter: blur(0); opacity: 1; }
677−}
678−
679−@keyframes slide {
680− from { translate: var(--slide-offset); }
681− to { translate: 0; }
682−}
683−
684−@keyframes slide-y {
685− from { transform: translateY(var(--slide-y-offset, 10px)); }
686− to { transform: translateY(0); }
687−}
688−```
689−
690−## Fade
691−
692−```css
693−::view-transition-old(.fade-out) {
694− animation: var(--duration-exit) ease-in fade reverse;
695−}
696−::view-transition-new(.fade-in) {
697− animation: var(--duration-enter) ease-out var(--duration-exit) both fade;
698−}
699−```
700−
701−## Slide (Vertical)
702−
703−```css
704−::view-transition-old(.slide-down) {
705− animation:
706− var(--duration-exit) ease-out both fade reverse,
707− var(--duration-exit) ease-out both slide-y reverse;
708−}
709−::view-transition-new(.slide-up) {
710− animation:
711− var(--duration-enter) ease-in var(--duration-exit) both fade,
712− var(--duration-move) ease-in both slide-y;
713−}
714−```
715−
716−## Directional Navigation
717−
718−### Single-Class Approach
719−
720−```css
721−::view-transition-old(.nav-forward) {
722− --slide-offset: -60px;
723− animation:
724− var(--duration-exit) ease-in both fade reverse,
725− var(--duration-move) ease-in-out both slide reverse;
726−}
727−::view-transition-new(.nav-forward) {
728− --slide-offset: 60px;
729− animation:
730− var(--duration-enter) ease-out var(--duration-exit) both fade,
731− var(--duration-move) ease-in-out both slide;
732−}
733−
734−::view-transition-old(.nav-back) {
735− --slide-offset: 60px;
736− animation:
737− var(--duration-exit) ease-in both fade reverse,
738− var(--duration-move) ease-in-out both slide reverse;
739−}
740−::view-transition-new(.nav-back) {
741− --slide-offset: -60px;
742− animation:
743− var(--duration-enter) ease-out var(--duration-exit) both fade,
744− var(--duration-move) ease-in-out both slide;
745−}
746−```
747−
748−### Separate Enter/Exit Classes
749−
750−```css
751−::view-transition-new(.slide-from-right) {
752− --slide-offset: 60px;
753− animation:
754− var(--duration-enter) ease-out var(--duration-exit) both fade,
755− var(--duration-move) ease-in-out both slide;
756−}
757−::view-transition-old(.slide-to-left) {
758− --slide-offset: -60px;
759− animation:
760− var(--duration-exit) ease-in both fade reverse,
761− var(--duration-move) ease-in-out both slide reverse;
762−}
763−
764−::view-transition-new(.slide-from-left) {
765− --slide-offset: -60px;
766− animation:
767− var(--duration-enter) ease-out var(--duration-exit) both fade,
768− var(--duration-move) ease-in-out both slide;
769−}
770−::view-transition-old(.slide-to-right) {
771− --slide-offset: 60px;
772− animation:
773− var(--duration-exit) ease-in both fade reverse,
774− var(--duration-move) ease-in-out both slide reverse;
775−}
776−```
777−
778−## Shared Element Morph
779−
780−```css
781−::view-transition-group(.morph) {
782− animation-duration: var(--duration-move);
783−}
784−::view-transition-image-pair(.morph) {
785− animation-name: via-blur;
786−}
787−@keyframes via-blur {
788− 30% { filter: blur(3px); }
789−}
790−```
791−
792−**Note:** Shared element transitions take raster snapshots. For text with significant size differences (e.g., `<h3>` → `<h1>`), the old snapshot gets scaled up, producing a visible ghost artifact. Use `text-morph` for text shared elements.
793−
794−## Text Morph
795−
796−Avoids raster scaling artifacts on text by hiding the old snapshot and showing the new text at full resolution:
797−
798−```css
799−::view-transition-group(.text-morph) {
800− animation-duration: var(--duration-move);
801−}
802−::view-transition-old(.text-morph) {
803− display: none;
804−}
805−::view-transition-new(.text-morph) {
806− animation: none;
807− object-fit: none;
808− object-position: left top;
809−}
810−```
811−
812−## Scale
813−
814−```css
815−::view-transition-old(.scale-out) {
816− animation: var(--duration-exit) ease-in scale-down;
817−}
818−::view-transition-new(.scale-in) {
819− animation: var(--duration-enter) ease-out var(--duration-exit) both scale-up;
820−}
821−@keyframes scale-down {
822− from { transform: scale(1); opacity: 1; }
823− to { transform: scale(0.85); opacity: 0; }
824−}
825−@keyframes scale-up {
826− from { transform: scale(0.85); opacity: 0; }
827− to { transform: scale(1); opacity: 1; }
828−}
829−```
830−
831−## Persistent Element Isolation
832−
833−```css
834−::view-transition-group(persistent-nav) {
835− animation: none;
836− z-index: 100;
837−}
838−```
839−
840−### Backdrop-Blur Workaround
841−
842−```css
843−::view-transition-old(persistent-nav) { display: none; }
844−::view-transition-new(persistent-nav) { animation: none; }
845−```
846−
847−## Reduced Motion
848−
849−```css
850−@media (prefers-reduced-motion: reduce) {
851− ::view-transition-old(*),
852− ::view-transition-new(*),
853− ::view-transition-group(*) {
854− animation-duration: 0s !important;
855− animation-delay: 0s !important;
856− }
857−}
858−```
859−
860−---
861−
862−# View Transitions in Next.js
863−
864−## Setup
865−
866−```js
867−// next.config.js
868−experimental: { viewTransition: true }
869−```
870−
871−Wraps every `<Link>` navigation in `document.startViewTransition`. Use `default="none"` to prevent competing animations. Do **not** install `react@canary` — the App Router already bundles it.
872−
873−## Next.js Implementation Additions
874−
875−**After Step 2:** Enable the experimental flag.
876−
877−**Step 4:** Use `transitionTypes` on `<Link>` (if available — see availability note below):
878−```tsx
879−<Link href="/photo/1" transitionTypes={["nav-forward"]}>View</Link>
880−<Link href="/" transitionTypes={["nav-back"]}>Back</Link>
881−```
882−
883−**After Step 6:** For same-route dynamic segments, use `key` + `name` + `share` pattern.
884−
885−## Layout-Level ViewTransition
886−
887−Don't add a layout-level VT wrapping `{children}` if pages have their own VTs — nested VTs never fire enter/exit inside a parent VT, so page-level enter/exit will silently not work. Remove the layout VT entirely. A bare VT in layout works only if pages have no VTs of their own. Layouts persist across navigations — don't use type-keyed maps in layouts.
888−
889−## The `transitionTypes` Prop
890−
891−Works in Server Components, no wrapper needed:
892−```tsx
893−<Link href="/products/1" transitionTypes={['nav-forward']}>View</Link>
894−```
895−
896−**Availability:** Requires `experimental.viewTransition: true`. Available in Next.js 15+ canary builds and Next.js 16+. If unavailable, use `startTransition` + `addTransitionType` + `router.push()`. To check: `grep -r "transitionTypes" node_modules/next/dist/`. Reserve manual `startTransition` for non-link interactions.
897−
898−## `loading.tsx` as Suspense Boundary
899−
900−Next.js `loading.tsx` files are implicit `<Suspense>` boundaries. Wrap the skeleton in `<ViewTransition exit="...">` in `loading.tsx`, and the content in `<ViewTransition enter="..." default="none">` in the page. This is the Next.js-idiomatic equivalent of explicit `<Suspense fallback={...}>`. Same rules apply: use simple string props (not type maps) since Suspense reveals fire without transition types.
901−
902−## Server-Side Filtering with `router.replace`
903−
904−For search/sort/filter that re-renders on the server (via URL params), use `startTransition` + `router.replace`. VTs activate because the update is inside `startTransition`. List items wrapped in `<ViewTransition key={item.id}>` animate reorder. This is the server-component alternative to the client-side `useDeferredValue` pattern.
905−
906−## Two-Layer Pattern (Directional + Suspense)
907−
908−Directional slides + Suspense reveals coexist because they fire at different moments. Place the directional VT in the **page component** (not layout):
909−
910−```tsx
911−<ViewTransition
912− enter={{ "nav-forward": "slide-from-right", default: "none" }}
913− exit={{ "nav-forward": "slide-to-left", default: "none" }}
914− default="none"
915−>
916− <div>
917− <Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>
918− <ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>
919− </Suspense>
920− </div>
921−</ViewTransition>
922−```
923−
924−## Shared Elements Across Routes
925−
926−```tsx
927−// List page
928−<Link href={`/products/${product.id}`} transitionTypes={['nav-forward']}>
929− <ViewTransition name={`product-${product.id}`}>
930− <Image src={product.image} alt={product.name} width={400} height={300} />
931− </ViewTransition>
932−</Link>
933−
934−// Detail page — same name
935−<ViewTransition name={`product-${product.id}`}>
936− <Image src={product.image} alt={product.name} width={800} height={600} />
937−</ViewTransition>
938−```
939−
940−## Same-Route Dynamic Segment Transitions
941−
942−Page stays mounted on dynamic segment change — enter/exit never fire. Use `key` + `name` + `share`:
943−
944−```tsx
945−<Suspense fallback={<Skeleton />}>
946− <ViewTransition key={slug} name={`collection-${slug}`} share="auto" default="none">
947− <Content slug={slug} />
948− </ViewTransition>
949−</Suspense>
950−```
951−
952−## Server Components
953−
954−- `<ViewTransition>` works in Server and Client Components
955−- `<Link transitionTypes>` works in Server Components
956−- `addTransitionType` and programmatic nav require Client Components
957−<!-- forgecat:@forgecat/vercel-labs_agent-skills_react-view-transitions:react-view-transitions-agents:end -->
1+# React Best Practices
2+
3+**Version 1.0.0**
4+Vercel Engineering
5+January 2026
6+
7+> **Note:**
8+> This document is mainly for agents and LLMs to follow when maintaining,
9+> generating, or refactoring React and Next.js codebases. Humans
10+> may also find it useful, but guidance here is optimized for automation
11+> and consistency by AI-assisted workflows.
12+
13+---
14+
15+## Abstract
16+
17+Comprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
18+
19+---
20+
21+## Table of Contents
22+
23+1. [Eliminating Waterfalls](#1-eliminating-waterfalls) — **CRITICAL**
24+ - 1.1 [Check Cheap Conditions Before Async Flags](#11-check-cheap-conditions-before-async-flags)
25+ - 1.2 [Defer Await Until Needed](#12-defer-await-until-needed)
26+ - 1.3 [Dependency-Based Parallelization](#13-dependency-based-parallelization)
27+ - 1.4 [Prevent Waterfall Chains in API Routes](#14-prevent-waterfall-chains-in-api-routes)
28+ - 1.5 [Promise.all() for Independent Operations](#15-promiseall-for-independent-operations)
29+ - 1.6 [Strategic Suspense Boundaries](#16-strategic-suspense-boundaries)
30+2. [Bundle Size Optimization](#2-bundle-size-optimization) — **CRITICAL**
31+ - 2.1 [Avoid Barrel File Imports](#21-avoid-barrel-file-imports)
32+ - 2.2 [Conditional Module Loading](#22-conditional-module-loading)
33+ - 2.3 [Defer Non-Critical Third-Party Libraries](#23-defer-non-critical-third-party-libraries)
34+ - 2.4 [Dynamic Imports for Heavy Components](#24-dynamic-imports-for-heavy-components)
35+ - 2.5 [Prefer Statically Analyzable Paths](#25-prefer-statically-analyzable-paths)
36+ - 2.6 [Preload Based on User Intent](#26-preload-based-on-user-intent)
37+3. [Server-Side Performance](#3-server-side-performance) — **HIGH**
38+ - 3.1 [Authenticate Server Actions Like API Routes](#31-authenticate-server-actions-like-api-routes)
39+ - 3.2 [Avoid Duplicate Serialization in RSC Props](#32-avoid-duplicate-serialization-in-rsc-props)
40+ - 3.3 [Avoid Shared Module State for Request Data](#33-avoid-shared-module-state-for-request-data)
41+ - 3.4 [Cross-Request LRU Caching](#34-cross-request-lru-caching)
42+ - 3.5 [Hoist Static I/O to Module Level](#35-hoist-static-io-to-module-level)
43+ - 3.6 [Minimize Serialization at RSC Boundaries](#36-minimize-serialization-at-rsc-boundaries)
44+ - 3.7 [Parallel Data Fetching with Component Composition](#37-parallel-data-fetching-with-component-composition)
45+ - 3.8 [Parallel Nested Data Fetching](#38-parallel-nested-data-fetching)
46+ - 3.9 [Per-Request Deduplication with React.cache()](#39-per-request-deduplication-with-reactcache)
47+ - 3.10 [Use after() for Non-Blocking Operations](#310-use-after-for-non-blocking-operations)
48+4. [Client-Side Data Fetching](#4-client-side-data-fetching) — **MEDIUM-HIGH**
49+ - 4.1 [Deduplicate Global Event Listeners](#41-deduplicate-global-event-listeners)
50+ - 4.2 [Use Passive Event Listeners for Scrolling Performance](#42-use-passive-event-listeners-for-scrolling-performance)
51+ - 4.3 [Use SWR for Automatic Deduplication](#43-use-swr-for-automatic-deduplication)
52+ - 4.4 [Version and Minimize localStorage Data](#44-version-and-minimize-localstorage-data)
53+5. [Re-render Optimization](#5-re-render-optimization) — **MEDIUM**
54+ - 5.1 [Calculate Derived State During Rendering](#51-calculate-derived-state-during-rendering)
55+ - 5.2 [Defer State Reads to Usage Point](#52-defer-state-reads-to-usage-point)
56+ - 5.3 [Do not wrap a simple expression with a primitive result type in useMemo](#53-do-not-wrap-a-simple-expression-with-a-primitive-result-type-in-usememo)
57+ - 5.4 [Don't Define Components Inside Components](#54-dont-define-components-inside-components)
58+ - 5.5 [Extract Default Non-primitive Parameter Value from Memoized Component to Constant](#55-extract-default-non-primitive-parameter-value-from-memoized-component-to-constant)
59+ - 5.6 [Extract to Memoized Components](#56-extract-to-memoized-components)
60+ - 5.7 [Narrow Effect Dependencies](#57-narrow-effect-dependencies)
61+ - 5.8 [Put Interaction Logic in Event Handlers](#58-put-interaction-logic-in-event-handlers)
62+ - 5.9 [Split Combined Hook Computations](#59-split-combined-hook-computations)
63+ - 5.10 [Subscribe to Derived State](#510-subscribe-to-derived-state)
64+ - 5.11 [Use Functional setState Updates](#511-use-functional-setstate-updates)
65+ - 5.12 [Use Lazy State Initialization](#512-use-lazy-state-initialization)
66+ - 5.13 [Use Transitions for Non-Urgent Updates](#513-use-transitions-for-non-urgent-updates)
67+ - 5.14 [Use useDeferredValue for Expensive Derived Renders](#514-use-usedeferredvalue-for-expensive-derived-renders)
68+ - 5.15 [Use useRef for Transient Values](#515-use-useref-for-transient-values)
69+6. [Rendering Performance](#6-rendering-performance) — **MEDIUM**
70+ - 6.1 [Animate SVG Wrapper Instead of SVG Element](#61-animate-svg-wrapper-instead-of-svg-element)
71+ - 6.2 [CSS content-visibility for Long Lists](#62-css-content-visibility-for-long-lists)
72+ - 6.3 [Hoist Static JSX Elements](#63-hoist-static-jsx-elements)
73+ - 6.4 [Optimize SVG Precision](#64-optimize-svg-precision)
74+ - 6.5 [Prevent Hydration Mismatch Without Flickering](#65-prevent-hydration-mismatch-without-flickering)
75+ - 6.6 [Suppress Expected Hydration Mismatches](#66-suppress-expected-hydration-mismatches)
76+ - 6.7 [Use Activity Component for Show/Hide](#67-use-activity-component-for-showhide)
77+ - 6.8 [Use defer or async on Script Tags](#68-use-defer-or-async-on-script-tags)
78+ - 6.9 [Use Explicit Conditional Rendering](#69-use-explicit-conditional-rendering)
79+ - 6.10 [Use React DOM Resource Hints](#610-use-react-dom-resource-hints)
80+ - 6.11 [Use useTransition Over Manual Loading States](#611-use-usetransition-over-manual-loading-states)
81+7. [JavaScript Performance](#7-javascript-performance) — **LOW-MEDIUM**
82+ - 7.1 [Avoid Layout Thrashing](#71-avoid-layout-thrashing)
83+ - 7.2 [Build Index Maps for Repeated Lookups](#72-build-index-maps-for-repeated-lookups)
84+ - 7.3 [Cache Property Access in Loops](#73-cache-property-access-in-loops)
85+ - 7.4 [Cache Repeated Function Calls](#74-cache-repeated-function-calls)
86+ - 7.5 [Cache Storage API Calls](#75-cache-storage-api-calls)
87+ - 7.6 [Combine Multiple Array Iterations](#76-combine-multiple-array-iterations)
88+ - 7.7 [Defer Non-Critical Work with requestIdleCallback](#77-defer-non-critical-work-with-requestidlecallback)
89+ - 7.8 [Early Length Check for Array Comparisons](#78-early-length-check-for-array-comparisons)
90+ - 7.9 [Early Return from Functions](#79-early-return-from-functions)
91+ - 7.10 [Hoist RegExp Creation](#710-hoist-regexp-creation)
92+ - 7.11 [Use flatMap to Map and Filter in One Pass](#711-use-flatmap-to-map-and-filter-in-one-pass)
93+ - 7.12 [Use Loop for Min/Max Instead of Sort](#712-use-loop-for-minmax-instead-of-sort)
94+ - 7.13 [Use Set/Map for O(1) Lookups](#713-use-setmap-for-o1-lookups)
95+ - 7.14 [Use toSorted() Instead of sort() for Immutability](#714-use-tosorted-instead-of-sort-for-immutability)
96+8. [Advanced Patterns](#8-advanced-patterns) — **LOW**
97+ - 8.1 [Do Not Put Effect Events in Dependency Arrays](#81-do-not-put-effect-events-in-dependency-arrays)
98+ - 8.2 [Initialize App Once, Not Per Mount](#82-initialize-app-once-not-per-mount)
99+ - 8.3 [Store Event Handlers in Refs](#83-store-event-handlers-in-refs)
100+ - 8.4 [useEffectEvent for Stable Callback Refs](#84-useeffectevent-for-stable-callback-refs)
101+
102+---
103+
104+## 1. Eliminating Waterfalls
105+
106+**Impact: CRITICAL**
107+
108+Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
109+
110+### 1.1 Check Cheap Conditions Before Async Flags
111+
112+**Impact: HIGH (avoids unnecessary async work when a synchronous guard already fails)**
113+
114+When a branch uses `await` for a flag or remote value and also requires a **cheap synchronous** condition (local props, request metadata, already-loaded state), evaluate the cheap condition **first**. Otherwise you pay for the async call even when the compound condition can never be true.
115+
116+This is a specialization of [Defer Await Until Needed](./async-defer-await.md) for `flag && cheapCondition` style checks.
117+
118+**Incorrect:**
119+
120+```typescript
121+const someFlag = await getFlag()
122+
123+if (someFlag && someCondition) {
124+ // ...
125+}
126+```
127+
128+**Correct:**
129+
130+```typescript
131+if (someCondition) {
132+ const someFlag = await getFlag()
133+ if (someFlag) {
134+ // ...
135+ }
136+}
137+```
138+
139+This matters when `getFlag` hits the network, a feature-flag service, or `React.cache` / DB work: skipping it when `someCondition` is false removes that cost on the cold path.
140+
141+Keep the original order if `someCondition` is expensive, depends on the flag, or you must run side effects in a fixed order.
142+
143+### 1.2 Defer Await Until Needed
144+
145+**Impact: HIGH (avoids blocking unused code paths)**
146+
147+Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
148+
149+**Incorrect: blocks both branches**
150+
151+```typescript
152+async function handleRequest(userId: string, skipProcessing: boolean) {
153+ const userData = await fetchUserData(userId)
154+
155+ if (skipProcessing) {
156+ // Returns immediately but still waited for userData
157+ return { skipped: true }
158+ }
159+
160+ // Only this branch uses userData
161+ return processUserData(userData)
162+}
163+```
164+
165+**Correct: only blocks when needed**
166+
167+```typescript
168+async function handleRequest(userId: string, skipProcessing: boolean) {
169+ if (skipProcessing) {
170+ // Returns immediately without waiting
171+ return { skipped: true }
172+ }
173+
174+ // Fetch only when needed
175+ const userData = await fetchUserData(userId)
176+ return processUserData(userData)
177+}
178+```
179+
180+**Another example: early return optimization**
181+
182+```typescript
183+// Incorrect: always fetches permissions
184+async function updateResource(resourceId: string, userId: string) {
185+ const permissions = await fetchPermissions(userId)
186+ const resource = await getResource(resourceId)
187+
188+ if (!resource) {
189+ return { error: 'Not found' }
190+ }
191+
192+ if (!permissions.canEdit) {
193+ return { error: 'Forbidden' }
194+ }
195+
196+ return await updateResourceData(resource, permissions)
197+}
198+
199+// Correct: fetches only when needed
200+async function updateResource(resourceId: string, userId: string) {
201+ const resource = await getResource(resourceId)
202+
203+ if (!resource) {
204+ return { error: 'Not found' }
205+ }
206+
207+ const permissions = await fetchPermissions(userId)
208+
209+ if (!permissions.canEdit) {
210+ return { error: 'Forbidden' }
211+ }
212+
213+ return await updateResourceData(resource, permissions)
214+}
215+```
216+
217+This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
218+
219+For `await getFlag()` combined with a cheap synchronous guard (`flag && someCondition`), see [Check Cheap Conditions Before Async Flags](./async-cheap-condition-before-await.md).
220+
221+### 1.3 Dependency-Based Parallelization
222+
223+**Impact: CRITICAL (2-10× improvement)**
224+
225+For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
226+
227+**Incorrect: profile waits for config unnecessarily**
228+
229+```typescript
230+const [user, config] = await Promise.all([
231+ fetchUser(),
232+ fetchConfig()
233+])
234+const profile = await fetchProfile(user.id)
235+```
236+
237+**Correct: config and profile run in parallel**
238+
239+```typescript
240+import { all } from 'better-all'
241+
242+const { user, config, profile } = await all({
243+ async user() { return fetchUser() },
244+ async config() { return fetchConfig() },
245+ async profile() {
246+ return fetchProfile((await this.$.user).id)
247+ }
248+})
249+```
250+
251+**Alternative without extra dependencies:**
252+
253+```typescript
254+const userPromise = fetchUser()
255+const profilePromise = userPromise.then(user => fetchProfile(user.id))
256+
257+const [user, config, profile] = await Promise.all([
258+ userPromise,
259+ fetchConfig(),
260+ profilePromise
261+])
262+```
263+
264+We can also create all the promises first, and do `Promise.all()` at the end.
265+
266+Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
267+
268+### 1.4 Prevent Waterfall Chains in API Routes
269+
270+**Impact: CRITICAL (2-10× improvement)**
271+
272+In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
273+
274+**Incorrect: config waits for auth, data waits for both**
275+
276+```typescript
277+export async function GET(request: Request) {
278+ const session = await auth()
279+ const config = await fetchConfig()
280+ const data = await fetchData(session.user.id)
281+ return Response.json({ data, config })
282+}
283+```
284+
285+**Correct: auth and config start immediately**
286+
287+```typescript
288+export async function GET(request: Request) {
289+ const sessionPromise = auth()
290+ const configPromise = fetchConfig()
291+ const session = await sessionPromise
292+ const [config, data] = await Promise.all([
293+ configPromise,
294+ fetchData(session.user.id)
295+ ])
296+ return Response.json({ data, config })
297+}
298+```
299+
300+For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
301+
302+### 1.5 Promise.all() for Independent Operations
303+
304+**Impact: CRITICAL (2-10× improvement)**
305+
306+When async operations have no interdependencies, execute them concurrently using `Promise.all()`.
307+
308+**Incorrect: sequential execution, 3 round trips**
309+
310+```typescript
311+const user = await fetchUser()
312+const posts = await fetchPosts()
313+const comments = await fetchComments()
314+```
315+
316+**Correct: parallel execution, 1 round trip**
317+
318+```typescript
319+const [user, posts, comments] = await Promise.all([
320+ fetchUser(),
321+ fetchPosts(),
322+ fetchComments()
323+])
324+```
325+
326+### 1.6 Strategic Suspense Boundaries
327+
328+**Impact: HIGH (faster initial paint)**
329+
330+Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
331+
332+**Incorrect: wrapper blocked by data fetching**
333+
334+```tsx
335+async function Page() {
336+ const data = await fetchData() // Blocks entire page
337+
338+ return (
339+ <div>
340+ <div>Sidebar</div>
341+ <div>Header</div>
342+ <div>
343+ <DataDisplay data={data} />
344+ </div>
345+ <div>Footer</div>
346+ </div>
347+ )
348+}
349+```
350+
351+The entire layout waits for data even though only the middle section needs it.
352+
353+**Correct: wrapper shows immediately, data streams in**
354+
355+```tsx
356+function Page() {
357+ return (
358+ <div>
359+ <div>Sidebar</div>
360+ <div>Header</div>
361+ <div>
362+ <Suspense fallback={<Skeleton />}>
363+ <DataDisplay />
364+ </Suspense>
365+ </div>
366+ <div>Footer</div>
367+ </div>
368+ )
369+}
370+
371+async function DataDisplay() {
372+ const data = await fetchData() // Only blocks this component
373+ return <div>{data.content}</div>
374+}
375+```
376+
377+Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
378+
379+**Alternative: share promise across components**
380+
381+```tsx
382+function Page() {
383+ // Start fetch immediately, but don't await
384+ const dataPromise = fetchData()
385+
386+ return (
387+ <div>
388+ <div>Sidebar</div>
389+ <div>Header</div>
390+ <Suspense fallback={<Skeleton />}>
391+ <DataDisplay dataPromise={dataPromise} />
392+ <DataSummary dataPromise={dataPromise} />
393+ </Suspense>
394+ <div>Footer</div>
395+ </div>
396+ )
397+}
398+
399+function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
400+ const data = use(dataPromise) // Unwraps the promise
401+ return <div>{data.content}</div>
402+}
403+
404+function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
405+ const data = use(dataPromise) // Reuses the same promise
406+ return <div>{data.summary}</div>
407+}
408+```
409+
410+Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.
411+
412+**When NOT to use this pattern:**
413+
414+- Critical data needed for layout decisions (affects positioning)
415+
416+- SEO-critical content above the fold
417+
418+- Small, fast queries where suspense overhead isn't worth it
419+
420+- When you want to avoid layout shift (loading → content jump)
421+
422+**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.
423+
424+---
425+
426+## 2. Bundle Size Optimization
427+
428+**Impact: CRITICAL**
429+
430+Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
431+
432+### 2.1 Avoid Barrel File Imports
433+
434+**Impact: CRITICAL (200-800ms import cost, slow builds)**
435+
436+Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`).
437+
438+Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts.
439+
440+**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.
441+
442+**Incorrect: imports entire library**
443+
444+```tsx
445+import { Check, X, Menu } from 'lucide-react'
446+// Loads 1,583 modules, takes ~2.8s extra in dev
447+// Runtime cost: 200-800ms on every cold start
448+
449+import { Button, TextField } from '@mui/material'
450+// Loads 2,225 modules, takes ~4.2s extra in dev
451+```
452+
453+**Correct - Next.js 13.5+ (recommended):**
454+
455+```tsx
456+// Keep the standard imports - Next.js transforms them to direct imports
457+import { Check, X, Menu } from 'lucide-react'
458+// Full TypeScript support, no manual path wrangling
459+```
460+
461+This is the recommended approach because it preserves TypeScript type safety and editor autocompletion while still eliminating the barrel import cost.
462+
463+**Correct - Direct imports (non-Next.js projects):**
464+
465+```tsx
466+import Button from '@mui/material/Button'
467+import TextField from '@mui/material/TextField'
468+// Loads only what you use
469+```
470+
471+> **TypeScript warning:** Some libraries (notably `lucide-react`) don't ship `.d.ts` files for their deep import paths. Importing from `lucide-react/dist/esm/icons/check` resolves to an implicit `any` type, causing errors under `strict` or `noImplicitAny`. Prefer `optimizePackageImports` when available, or verify the library exports types for its subpaths before using direct imports.
472+
473+These optimizations provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.
474+
475+Libraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`.
476+
477+Reference: [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
478+
479+### 2.2 Conditional Module Loading
480+
481+**Impact: HIGH (loads large data only when needed)**
482+
483+Load large data or modules only when a feature is activated.
484+
485+**Example: lazy-load animation frames**
486+
487+```tsx
488+function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) {
489+ const [frames, setFrames] = useState<Frame[] | null>(null)
490+
491+ useEffect(() => {
492+ if (enabled && !frames && typeof window !== 'undefined') {
493+ import('./animation-frames.js')
494+ .then(mod => setFrames(mod.frames))
495+ .catch(() => setEnabled(false))
496+ }
497+ }, [enabled, frames, setEnabled])
498+
499+ if (!frames) return <Skeleton />
500+ return <Canvas frames={frames} />
501+}
502+```
503+
504+The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.
505+
506+### 2.3 Defer Non-Critical Third-Party Libraries
507+
508+**Impact: MEDIUM (loads after hydration)**
509+
510+Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
511+
512+**Incorrect: blocks initial bundle**
513+
514+```tsx
515+import { Analytics } from '@vercel/analytics/react'
516+
517+export default function RootLayout({ children }) {
518+ return (
519+ <html>
520+ <body>
521+ {children}
522+ <Analytics />
523+ </body>
524+ </html>
525+ )
526+}
527+```
528+
529+**Correct: loads after hydration**
530+
531+```tsx
532+import dynamic from 'next/dynamic'
533+
534+const Analytics = dynamic(
535+ () => import('@vercel/analytics/react').then(m => m.Analytics),
536+ { ssr: false }
537+)
538+
539+export default function RootLayout({ children }) {
540+ return (
541+ <html>
542+ <body>
543+ {children}
544+ <Analytics />
545+ </body>
546+ </html>
547+ )
548+}
549+```
550+
551+### 2.4 Dynamic Imports for Heavy Components
552+
553+**Impact: CRITICAL (directly affects TTI and LCP)**
554+
555+Use `next/dynamic` to lazy-load large components not needed on initial render.
556+
557+**Incorrect: Monaco bundles with main chunk ~300KB**
558+
559+```tsx
560+import { MonacoEditor } from './monaco-editor'
561+
562+function CodePanel({ code }: { code: string }) {
563+ return <MonacoEditor value={code} />
564+}
565+```
566+
567+**Correct: Monaco loads on demand**
568+
569+```tsx
570+import dynamic from 'next/dynamic'
571+
572+const MonacoEditor = dynamic(
573+ () => import('./monaco-editor').then(m => m.MonacoEditor),
574+ { ssr: false }
575+)
576+
577+function CodePanel({ code }: { code: string }) {
578+ return <MonacoEditor value={code} />
579+}
580+```
581+
582+### 2.5 Prefer Statically Analyzable Paths
583+
584+**Impact: HIGH (avoids accidental broad bundles and file traces)**
585+
586+Build tools work best when import and file-system paths are obvious at build time. If you hide the real path inside a variable or compose it too dynamically, the tool either has to include a broad set of possible files, warn that it cannot analyze the import, or widen file tracing to stay safe.
587+
588+Prefer explicit maps or literal paths so the set of reachable files stays narrow and predictable. This is the same rule whether you are choosing modules with `import()` or reading files in server/build code.
589+
590+When analysis becomes too broad, the cost is real:
591+
592+- Larger server bundles
593+
594+- Slower builds
595+
596+- Worse cold starts
597+
598+- More memory use
599+
600+**Incorrect: the bundler cannot tell what may be imported**
601+
602+```ts
603+const PAGE_MODULES = {
604+ home: './pages/home',
605+ settings: './pages/settings',
606+} as const
607+
608+const Page = await import(PAGE_MODULES[pageName])
609+```
610+
611+**Correct: use an explicit map of allowed modules**
612+
613+```ts
614+const PAGE_MODULES = {
615+ home: () => import('./pages/home'),
616+ settings: () => import('./pages/settings'),
617+} as const
618+
619+const Page = await PAGE_MODULES[pageName]()
620+```
621+
622+**Incorrect: a 2-value enum still hides the final path from static analysis**
623+
624+```ts
625+const baseDir = path.join(process.cwd(), 'content/' + contentKind)
626+```
627+
628+**Correct: make each final path literal at the callsite**
629+
630+```ts
631+const baseDir =
632+ kind === ContentKind.Blog
633+ ? path.join(process.cwd(), 'content/blog')
634+ : path.join(process.cwd(), 'content/docs')
635+```
636+
637+In Next.js server code, this matters for output file tracing too. `path.join(process.cwd(), someVar)` can widen the traced file set because Next.js statically analyze `import`, `require`, and `fs` usage.
638+
639+Reference: [https://nextjs.org/docs/app/api-reference/config/next-config-js/output](https://nextjs.org/docs/app/api-reference/config/next-config-js/output), [https://nextjs.org/learn/seo/dynamic-imports](https://nextjs.org/learn/seo/dynamic-imports), [https://vite.dev/guide/features.html](https://vite.dev/guide/features.html), [https://esbuild.github.io/api/](https://esbuild.github.io/api/), [https://www.npmjs.com/package/@rollup/plugin-dynamic-import-vars](https://www.npmjs.com/package/@rollup/plugin-dynamic-import-vars), [https://webpack.js.org/guides/dependency-management/](https://webpack.js.org/guides/dependency-management/)
640+
641+### 2.6 Preload Based on User Intent
642+
643+**Impact: MEDIUM (reduces perceived latency)**
644+
645+Preload heavy bundles before they're needed to reduce perceived latency.
646+
647+**Example: preload on hover/focus**
648+
649+```tsx
650+function EditorButton({ onClick }: { onClick: () => void }) {
651+ const preload = () => {
652+ if (typeof window !== 'undefined') {
653+ void import('./monaco-editor')
654+ }
655+ }
656+
657+ return (
658+ <button
659+ onMouseEnter={preload}
660+ onFocus={preload}
661+ onClick={onClick}
662+ >
663+ Open Editor
664+ </button>
665+ )
666+}
667+```
668+
669+**Example: preload when feature flag is enabled**
670+
671+```tsx
672+function FlagsProvider({ children, flags }: Props) {
673+ useEffect(() => {
674+ if (flags.editorEnabled && typeof window !== 'undefined') {
675+ void import('./monaco-editor').then(mod => mod.init())
676+ }
677+ }, [flags.editorEnabled])
678+
679+ return <FlagsContext.Provider value={flags}>
680+ {children}
681+ </FlagsContext.Provider>
682+}
683+```
684+
685+The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.
686+
687+---
688+
689+## 3. Server-Side Performance
690+
691+**Impact: HIGH**
692+
693+Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times.
694+
695+### 3.1 Authenticate Server Actions Like API Routes
696+
697+**Impact: CRITICAL (prevents unauthorized access to server mutations)**
698+
699+Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly.
700+
701+Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation."
702+
703+**Incorrect: no authentication check**
704+
705+```typescript
706+'use server'
707+
708+export async function deleteUser(userId: string) {
709+ // Anyone can call this! No auth check
710+ await db.user.delete({ where: { id: userId } })
711+ return { success: true }
712+}
713+```
714+
715+**Correct: authentication inside the action**
716+
717+```typescript
718+'use server'
719+
720+import { verifySession } from '@/lib/auth'
721+import { unauthorized } from '@/lib/errors'
722+
723+export async function deleteUser(userId: string) {
724+ // Always check auth inside the action
725+ const session = await verifySession()
726+
727+ if (!session) {
728+ throw unauthorized('Must be logged in')
729+ }
730+
731+ // Check authorization too
732+ if (session.user.role !== 'admin' && session.user.id !== userId) {
733+ throw unauthorized('Cannot delete other users')
734+ }
735+
736+ await db.user.delete({ where: { id: userId } })
737+ return { success: true }
738+}
739+```
740+
741+**With input validation:**
742+
743+```typescript
744+'use server'
745+
746+import { verifySession } from '@/lib/auth'
747+import { z } from 'zod'
748+
749+const updateProfileSchema = z.object({
750+ userId: z.string().uuid(),
751+ name: z.string().min(1).max(100),
752+ email: z.string().email()
753+})
754+
755+export async function updateProfile(data: unknown) {
756+ // Validate input first
757+ const validated = updateProfileSchema.parse(data)
758+
759+ // Then authenticate
760+ const session = await verifySession()
761+ if (!session) {
762+ throw new Error('Unauthorized')
763+ }
764+
765+ // Then authorize
766+ if (session.user.id !== validated.userId) {
767+ throw new Error('Can only update own profile')
768+ }
769+
770+ // Finally perform the mutation
771+ await db.user.update({
772+ where: { id: validated.userId },
773+ data: {
774+ name: validated.name,
775+ email: validated.email
776+ }
777+ })
778+
779+ return { success: true }
780+}
781+```
782+
783+Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)
784+
785+### 3.2 Avoid Duplicate Serialization in RSC Props
786+
787+**Impact: LOW (reduces network payload by avoiding duplicate serialization)**
788+
789+RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server.
790+
791+**Incorrect: duplicates array**
792+
793+```tsx
794+// RSC: sends 6 strings (2 arrays × 3 items)
795+<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />
796+```
797+
798+**Correct: sends 3 strings**
799+
800+```tsx
801+// RSC: send once
802+<ClientList usernames={usernames} />
803+
804+// Client: transform there
805+'use client'
806+const sorted = useMemo(() => [...usernames].sort(), [usernames])
807+```
808+
809+**Nested deduplication behavior:**
810+
811+```tsx
812+// string[] - duplicates everything
813+usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings
814+
815+// object[] - duplicates array structure only
816+users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)
817+```
818+
819+Deduplication works recursively. Impact varies by data type:
820+
821+- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated
822+
823+- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference
824+
825+**Operations breaking deduplication: create new references**
826+
827+- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`
828+
829+- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`
830+
831+**More examples:**
832+
833+```tsx
834+// ❌ Bad
835+<C users={users} active={users.filter(u => u.active)} />
836+<C product={product} productName={product.name} />
837+
838+// ✅ Good
839+<C users={users} />
840+<C product={product} />
841+// Do filtering/destructuring in client
842+```
843+
844+**Exception:** Pass derived data when transformation is expensive or client doesn't need original.
845+
846+### 3.3 Avoid Shared Module State for Request Data
847+
848+**Impact: HIGH (prevents concurrency bugs and request data leaks)**
849+
850+For React Server Components and client components rendered during SSR, avoid using mutable module-level variables to share request-scoped data. Server renders can run concurrently in the same process. If one render writes to shared module state and another render reads it, you can get race conditions, cross-request contamination, and security bugs where one user's data appears in another user's response.
851+
852+Treat module scope on the server as process-wide shared memory, not request-local state.
853+
854+**Incorrect: request data leaks across concurrent renders**
855+
856+```tsx
857+let currentUser: User | null = null
858+
859+export default async function Page() {
860+ currentUser = await auth()
861+ return <Dashboard />
862+}
863+
864+async function Dashboard() {
865+ return <div>{currentUser?.name}</div>
866+}
867+```
868+
869+If two requests overlap, request A can set `currentUser`, then request B overwrites it before request A finishes rendering `Dashboard`.
870+
871+**Correct: keep request data local to the render tree**
872+
873+```tsx
874+export default async function Page() {
875+ const user = await auth()
876+ return <Dashboard user={user} />
877+}
878+
879+function Dashboard({ user }: { user: User | null }) {
880+ return <div>{user?.name}</div>
881+}
882+```
883+
884+Safe exceptions:
885+
886+- Immutable static assets or config loaded once at module scope
887+
888+- Shared caches intentionally designed for cross-request reuse and keyed correctly
889+
890+- Process-wide singletons that do not store request- or user-specific mutable data
891+
892+For static assets and config, see [Hoist Static I/O to Module Level](./server-hoist-static-io.md).
893+
894+### 3.4 Cross-Request LRU Caching
895+
896+**Impact: HIGH (caches across requests)**
897+
898+`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
899+
900+**Implementation:**
901+
902+```typescript
903+import { LRUCache } from 'lru-cache'
904+
905+const cache = new LRUCache<string, any>({
906+ max: 1000,
907+ ttl: 5 * 60 * 1000 // 5 minutes
908+})
909+
910+export async function getUser(id: string) {
911+ const cached = cache.get(id)
912+ if (cached) return cached
913+
914+ const user = await db.user.findUnique({ where: { id } })
915+ cache.set(id, user)
916+ return user
917+}
918+
919+// Request 1: DB query, result cached
920+// Request 2: cache hit, no DB query
921+```
922+
923+Use when sequential user actions hit multiple endpoints needing the same data within seconds.
924+
925+**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis.
926+
927+**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.
928+
929+Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
930+
931+### 3.5 Hoist Static I/O to Module Level
932+
933+**Impact: HIGH (avoids repeated file/network I/O per request)**
934+
935+When loading static assets (fonts, logos, images, config files) in route handlers or server functions, hoist the I/O operation to module level. Module-level code runs once when the module is first imported, not on every request. This eliminates redundant file system reads or network fetches that would otherwise run on every invocation.
936+
937+**Incorrect: reads font file on every request**
938+
939+```typescript
940+// app/api/og/route.tsx
941+import { ImageResponse } from 'next/og'
942+
943+export async function GET(request: Request) {
944+ // Runs on EVERY request - expensive!
945+ const fontData = await fetch(
946+ new URL('./fonts/Inter.ttf', import.meta.url)
947+ ).then(res => res.arrayBuffer())
948+
949+ const logoData = await fetch(
950+ new URL('./images/logo.png', import.meta.url)
951+ ).then(res => res.arrayBuffer())
952+
953+ return new ImageResponse(
954+ <div style={{ fontFamily: 'Inter' }}>
955+ <img src={logoData} />
956+ Hello World
957+ </div>,
958+ { fonts: [{ name: 'Inter', data: fontData }] }
959+ )
960+}
961+```
962+
963+**Correct: loads once at module initialization**
964+
965+```typescript
966+// app/api/og/route.tsx
967+import { ImageResponse } from 'next/og'
968+
969+// Module-level: runs ONCE when module is first imported
970+const fontData = fetch(
971+ new URL('./fonts/Inter.ttf', import.meta.url)
972+).then(res => res.arrayBuffer())
973+
974+const logoData = fetch(
975+ new URL('./images/logo.png', import.meta.url)
976+).then(res => res.arrayBuffer())
977+
978+export async function GET(request: Request) {
979+ // Await the already-started promises
980+ const [font, logo] = await Promise.all([fontData, logoData])
981+
982+ return new ImageResponse(
983+ <div style={{ fontFamily: 'Inter' }}>
984+ <img src={logo} />
985+ Hello World
986+ </div>,
987+ { fonts: [{ name: 'Inter', data: font }] }
988+ )
989+}
990+```
991+
992+**Correct: synchronous fs at module level**
993+
994+```typescript
995+// app/api/og/route.tsx
996+import { ImageResponse } from 'next/og'
997+import { readFileSync } from 'fs'
998+import { join } from 'path'
999+
1000+// Synchronous read at module level - blocks only during module init
1001+const fontData = readFileSync(
1002+ join(process.cwd(), 'public/fonts/Inter.ttf')
1003+)
1004+
1005+const logoData = readFileSync(
1006+ join(process.cwd(), 'public/images/logo.png')
1007+)
1008+
1009+export async function GET(request: Request) {
1010+ return new ImageResponse(
1011+ <div style={{ fontFamily: 'Inter' }}>
1012+ <img src={logoData} />
1013+ Hello World
1014+ </div>,
1015+ { fonts: [{ name: 'Inter', data: fontData }] }
1016+ )
1017+}
1018+```
1019+
1020+**Incorrect: reads config on every call**
1021+
1022+```typescript
1023+import fs from 'node:fs/promises'
1024+
1025+export async function processRequest(data: Data) {
1026+ const config = JSON.parse(
1027+ await fs.readFile('./config.json', 'utf-8')
1028+ )
1029+ const template = await fs.readFile('./template.html', 'utf-8')
1030+
1031+ return render(template, data, config)
1032+}
1033+```
1034+
1035+**Correct: hoists config and template to module level**
1036+
1037+```typescript
1038+import fs from 'node:fs/promises'
1039+
1040+const configPromise = fs
1041+ .readFile('./config.json', 'utf-8')
1042+ .then(JSON.parse)
1043+const templatePromise = fs.readFile('./template.html', 'utf-8')
1044+
1045+export async function processRequest(data: Data) {
1046+ const [config, template] = await Promise.all([
1047+ configPromise,
1048+ templatePromise,
1049+ ])
1050+
1051+ return render(template, data, config)
1052+}
1053+```
1054+
1055+When to use this pattern:
1056+
1057+- Loading fonts for OG image generation
1058+
1059+- Loading static logos, icons, or watermarks
1060+
1061+- Reading configuration files that don't change at runtime
1062+
1063+- Loading email templates or other static templates
1064+
1065+- Any static asset that's the same across all requests
1066+
1067+When not to use this pattern:
1068+
1069+- Assets that vary per request or user
1070+
1071+- Files that may change during runtime (use caching with TTL instead)
1072+
1073+- Large files that would consume too much memory if kept loaded
1074+
1075+- Sensitive data that shouldn't persist in memory
1076+
1077+With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute), module-level caching is especially effective because multiple concurrent requests share the same function instance. The static assets stay loaded in memory across requests without cold start penalties.
1078+
1079+In traditional serverless, each cold start re-executes module-level code, but subsequent warm invocations reuse the loaded assets until the instance is recycled.
1080+
1081+### 3.6 Minimize Serialization at RSC Boundaries
1082+
1083+**Impact: HIGH (reduces data transfer size)**
1084+
1085+The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses.
1086+
1087+**Incorrect: serializes all 50 fields**
1088+
1089+```tsx
1090+async function Page() {
1091+ const user = await fetchUser() // 50 fields
1092+ return <Profile user={user} />
1093+}
1094+
1095+'use client'
1096+function Profile({ user }: { user: User }) {
1097+ return <div>{user.name}</div> // uses 1 field
1098+}
1099+```
1100+
1101+**Correct: serializes only 1 field**
1102+
1103+```tsx
1104+async function Page() {
1105+ const user = await fetchUser()
1106+ return <Profile name={user.name} />
1107+}
1108+
1109+'use client'
1110+function Profile({ name }: { name: string }) {
1111+ return <div>{name}</div>
1112+}
1113+```
1114+
1115+### 3.7 Parallel Data Fetching with Component Composition
1116+
1117+**Impact: CRITICAL (eliminates server-side waterfalls)**
1118+
1119+React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
1120+
1121+**Incorrect: Sidebar waits for Page's fetch to complete**
1122+
1123+```tsx
1124+export default async function Page() {
1125+ const header = await fetchHeader()
1126+ return (
1127+ <div>
1128+ <div>{header}</div>
1129+ <Sidebar />
1130+ </div>
1131+ )
1132+}
1133+
1134+async function Sidebar() {
1135+ const items = await fetchSidebarItems()
1136+ return <nav>{items.map(renderItem)}</nav>
1137+}
1138+```
1139+
1140+**Correct: both fetch simultaneously**
1141+
1142+```tsx
1143+async function Header() {
1144+ const data = await fetchHeader()
1145+ return <div>{data}</div>
1146+}
1147+
1148+async function Sidebar() {
1149+ const items = await fetchSidebarItems()
1150+ return <nav>{items.map(renderItem)}</nav>
1151+}
1152+
1153+export default function Page() {
1154+ return (
1155+ <div>
1156+ <Header />
1157+ <Sidebar />
1158+ </div>
1159+ )
1160+}
1161+```
1162+
1163+**Alternative with children prop:**
1164+
1165+```tsx
1166+async function Header() {
1167+ const data = await fetchHeader()
1168+ return <div>{data}</div>
1169+}
1170+
1171+async function Sidebar() {
1172+ const items = await fetchSidebarItems()
1173+ return <nav>{items.map(renderItem)}</nav>
1174+}
1175+
1176+function Layout({ children }: { children: ReactNode }) {
1177+ return (
1178+ <div>
1179+ <Header />
1180+ {children}
1181+ </div>
1182+ )
1183+}
1184+
1185+export default function Page() {
1186+ return (
1187+ <Layout>
1188+ <Sidebar />
1189+ </Layout>
1190+ )
1191+}
1192+```
1193+
1194+### 3.8 Parallel Nested Data Fetching
1195+
1196+**Impact: CRITICAL (eliminates server-side waterfalls)**
1197+
1198+When fetching nested data in parallel, chain dependent fetches within each item's promise so a slow item doesn't block the rest.
1199+
1200+**Incorrect: a single slow item blocks all nested fetches**
1201+
1202+```tsx
1203+const chats = await Promise.all(
1204+ chatIds.map(id => getChat(id))
1205+)
1206+
1207+const chatAuthors = await Promise.all(
1208+ chats.map(chat => getUser(chat.author))
1209+)
1210+```
1211+
1212+If one `getChat(id)` out of 100 is extremely slow, the authors of the other 99 chats can't start loading even though their data is ready.
1213+
1214+**Correct: each item chains its own nested fetch**
1215+
1216+```tsx
1217+const chatAuthors = await Promise.all(
1218+ chatIds.map(id => getChat(id).then(chat => getUser(chat.author)))
1219+)
1220+```
1221+
1222+Each item independently chains `getChat` → `getUser`, so a slow chat doesn't block author fetches for the others.
1223+
1224+### 3.9 Per-Request Deduplication with React.cache()
1225+
1226+**Impact: MEDIUM (deduplicates within request)**
1227+
1228+Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.
1229+
1230+**Usage:**
1231+
1232+```typescript
1233+import { cache } from 'react'
1234+
1235+export const getCurrentUser = cache(async () => {
1236+ const session = await auth()
1237+ if (!session?.user?.id) return null
1238+ return await db.user.findUnique({
1239+ where: { id: session.user.id }
1240+ })
1241+})
1242+```
1243+
1244+Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
1245+
1246+**Avoid inline objects as arguments:**
1247+
1248+`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits.
1249+
1250+**Incorrect: always cache miss**
1251+
1252+```typescript
1253+const getUser = cache(async (params: { uid: number }) => {
1254+ return await db.user.findUnique({ where: { id: params.uid } })
1255+})
1256+
1257+// Each call creates new object, never hits cache
1258+getUser({ uid: 1 })
1259+getUser({ uid: 1 }) // Cache miss, runs query again
1260+```
1261+
1262+**Correct: cache hit**
1263+
1264+```typescript
1265+const params = { uid: 1 }
1266+getUser(params) // Query runs
1267+getUser(params) // Cache hit (same reference)
1268+```
1269+
1270+If you must pass objects, pass the same reference:
1271+
1272+**Next.js-Specific Note:**
1273+
1274+In Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks:
1275+
1276+- Database queries (Prisma, Drizzle, etc.)
1277+
1278+- Heavy computations
1279+
1280+- Authentication checks
1281+
1282+- File system operations
1283+
1284+- Any non-fetch async work
1285+
1286+Use `React.cache()` to deduplicate these operations across your component tree.
1287+
1288+Reference: [https://react.dev/reference/react/cache](https://react.dev/reference/react/cache)
1289+
1290+### 3.10 Use after() for Non-Blocking Operations
1291+
1292+**Impact: MEDIUM (faster response times)**
1293+
1294+Use Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response.
1295+
1296+**Incorrect: blocks response**
1297+
1298+```tsx
1299+import { logUserAction } from '@/app/utils'
1300+
1301+export async function POST(request: Request) {
1302+ // Perform mutation
1303+ await updateDatabase(request)
1304+
1305+ // Logging blocks the response
1306+ const userAgent = request.headers.get('user-agent') || 'unknown'
1307+ await logUserAction({ userAgent })
1308+
1309+ return new Response(JSON.stringify({ status: 'success' }), {
1310+ status: 200,
1311+ headers: { 'Content-Type': 'application/json' }
1312+ })
1313+}
1314+```
1315+
1316+**Correct: non-blocking**
1317+
1318+```tsx
1319+import { after } from 'next/server'
1320+import { headers, cookies } from 'next/headers'
1321+import { logUserAction } from '@/app/utils'
1322+
1323+export async function POST(request: Request) {
1324+ // Perform mutation
1325+ await updateDatabase(request)
1326+
1327+ // Log after response is sent
1328+ after(async () => {
1329+ const userAgent = (await headers()).get('user-agent') || 'unknown'
1330+ const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
1331+
1332+ logUserAction({ sessionCookie, userAgent })
1333+ })
1334+
1335+ return new Response(JSON.stringify({ status: 'success' }), {
1336+ status: 200,
1337+ headers: { 'Content-Type': 'application/json' }
1338+ })
1339+}
1340+```
1341+
1342+The response is sent immediately while logging happens in the background.
1343+
1344+**Common use cases:**
1345+
1346+- Analytics tracking
1347+
1348+- Audit logging
1349+
1350+- Sending notifications
1351+
1352+- Cache invalidation
1353+
1354+- Cleanup tasks
1355+
1356+**Important notes:**
1357+
1358+- `after()` runs even if the response fails or redirects
1359+
1360+- Works in Server Actions, Route Handlers, and Server Components
1361+
1362+Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after)
1363+
1364+---
1365+
1366+## 4. Client-Side Data Fetching
1367+
1368+**Impact: MEDIUM-HIGH**
1369+
1370+Automatic deduplication and efficient data fetching patterns reduce redundant network requests.
1371+
1372+### 4.1 Deduplicate Global Event Listeners
1373+
1374+**Impact: LOW (single listener for N components)**
1375+
1376+Use `useSWRSubscription()` to share global event listeners across component instances.
1377+
1378+**Incorrect: N instances = N listeners**
1379+
1380+```tsx
1381+function useKeyboardShortcut(key: string, callback: () => void) {
1382+ useEffect(() => {
1383+ const handler = (e: KeyboardEvent) => {
1384+ if (e.metaKey && e.key === key) {
1385+ callback()
1386+ }
1387+ }
1388+ window.addEventListener('keydown', handler)
1389+ return () => window.removeEventListener('keydown', handler)
1390+ }, [key, callback])
1391+}
1392+```
1393+
1394+When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener.
1395+
1396+**Correct: N instances = 1 listener**
1397+
1398+```tsx
1399+import useSWRSubscription from 'swr/subscription'
1400+
1401+// Module-level Map to track callbacks per key
1402+const keyCallbacks = new Map<string, Set<() => void>>()
1403+
1404+function useKeyboardShortcut(key: string, callback: () => void) {
1405+ // Register this callback in the Map
1406+ useEffect(() => {
1407+ if (!keyCallbacks.has(key)) {
1408+ keyCallbacks.set(key, new Set())
1409+ }
1410+ keyCallbacks.get(key)!.add(callback)
1411+
1412+ return () => {
1413+ const set = keyCallbacks.get(key)
1414+ if (set) {
1415+ set.delete(callback)
1416+ if (set.size === 0) {
1417+ keyCallbacks.delete(key)
1418+ }
1419+ }
1420+ }
1421+ }, [key, callback])
1422+
1423+ useSWRSubscription('global-keydown', () => {
1424+ const handler = (e: KeyboardEvent) => {
1425+ if (e.metaKey && keyCallbacks.has(e.key)) {
1426+ keyCallbacks.get(e.key)!.forEach(cb => cb())
1427+ }
1428+ }
1429+ window.addEventListener('keydown', handler)
1430+ return () => window.removeEventListener('keydown', handler)
1431+ })
1432+}
1433+
1434+function Profile() {
1435+ // Multiple shortcuts will share the same listener
1436+ useKeyboardShortcut('p', () => { /* ... */ })
1437+ useKeyboardShortcut('k', () => { /* ... */ })
1438+ // ...
1439+}
1440+```
1441+
1442+### 4.2 Use Passive Event Listeners for Scrolling Performance
1443+
1444+**Impact: MEDIUM (eliminates scroll delay caused by event listeners)**
1445+
1446+Add `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay.
1447+
1448+**Incorrect:**
1449+
1450+```typescript
1451+useEffect(() => {
1452+ const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
1453+ const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
1454+
1455+ document.addEventListener('touchstart', handleTouch)
1456+ document.addEventListener('wheel', handleWheel)
1457+
1458+ return () => {
1459+ document.removeEventListener('touchstart', handleTouch)
1460+ document.removeEventListener('wheel', handleWheel)
1461+ }
1462+}, [])
1463+```
1464+
1465+**Correct:**
1466+
1467+```typescript
1468+useEffect(() => {
1469+ const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
1470+ const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
1471+
1472+ document.addEventListener('touchstart', handleTouch, { passive: true })
1473+ document.addEventListener('wheel', handleWheel, { passive: true })
1474+
1475+ return () => {
1476+ document.removeEventListener('touchstart', handleTouch)
1477+ document.removeEventListener('wheel', handleWheel)
1478+ }
1479+}, [])
1480+```
1481+
1482+**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.
1483+
1484+**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`.
1485+
1486+### 4.3 Use SWR for Automatic Deduplication
1487+
1488+**Impact: MEDIUM-HIGH (automatic deduplication)**
1489+
1490+SWR enables request deduplication, caching, and revalidation across component instances.
1491+
1492+**Incorrect: no deduplication, each instance fetches**
1493+
1494+```tsx
1495+function UserList() {
1496+ const [users, setUsers] = useState([])
1497+ useEffect(() => {
1498+ fetch('/api/users')
1499+ .then(r => r.json())
1500+ .then(setUsers)
1501+ }, [])
1502+}
1503+```
1504+
1505+**Correct: multiple instances share one request**
1506+
1507+```tsx
1508+import useSWR from 'swr'
1509+
1510+function UserList() {
1511+ const { data: users } = useSWR('/api/users', fetcher)
1512+}
1513+```
1514+
1515+**For immutable data:**
1516+
1517+```tsx
1518+import { useImmutableSWR } from '@/lib/swr'
1519+
1520+function StaticContent() {
1521+ const { data } = useImmutableSWR('/api/config', fetcher)
1522+}
1523+```
1524+
1525+**For mutations:**
1526+
1527+```tsx
1528+import { useSWRMutation } from 'swr/mutation'
1529+
1530+function UpdateButton() {
1531+ const { trigger } = useSWRMutation('/api/user', updateUser)
1532+ return <button onClick={() => trigger()}>Update</button>
1533+}
1534+```
1535+
1536+Reference: [https://swr.vercel.app](https://swr.vercel.app)
1537+
1538+### 4.4 Version and Minimize localStorage Data
1539+
1540+**Impact: MEDIUM (prevents schema conflicts, reduces storage size)**
1541+
1542+Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data.
1543+
1544+**Incorrect:**
1545+
1546+```typescript
1547+// No version, stores everything, no error handling
1548+localStorage.setItem('userConfig', JSON.stringify(fullUserObject))
1549+const data = localStorage.getItem('userConfig')
1550+```
1551+
1552+**Correct:**
1553+
1554+```typescript
1555+const VERSION = 'v2'
1556+
1557+function saveConfig(config: { theme: string; language: string }) {
1558+ try {
1559+ localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))
1560+ } catch {
1561+ // Throws in incognito/private browsing, quota exceeded, or disabled
1562+ }
1563+}
1564+
1565+function loadConfig() {
1566+ try {
1567+ const data = localStorage.getItem(`userConfig:${VERSION}`)
1568+ return data ? JSON.parse(data) : null
1569+ } catch {
1570+ return null
1571+ }
1572+}
1573+
1574+// Migration from v1 to v2
1575+function migrate() {
1576+ try {
1577+ const v1 = localStorage.getItem('userConfig:v1')
1578+ if (v1) {
1579+ const old = JSON.parse(v1)
1580+ saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang })
1581+ localStorage.removeItem('userConfig:v1')
1582+ }
1583+ } catch {}
1584+}
1585+```
1586+
1587+**Store minimal fields from server responses:**
1588+
1589+```typescript
1590+// User object has 20+ fields, only store what UI needs
1591+function cachePrefs(user: FullUser) {
1592+ try {
1593+ localStorage.setItem('prefs:v1', JSON.stringify({
1594+ theme: user.preferences.theme,
1595+ notifications: user.preferences.notifications
1596+ }))
1597+ } catch {}
1598+}
1599+```
1600+
1601+**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled.
1602+
1603+**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags.
1604+
1605+---
1606+
1607+## 5. Re-render Optimization
1608+
1609+**Impact: MEDIUM**
1610+
1611+Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.
1612+
1613+### 5.1 Calculate Derived State During Rendering
1614+
1615+**Impact: MEDIUM (avoids redundant renders and state drift)**
1616+
1617+If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead.
1618+
1619+**Incorrect: redundant state and effect**
1620+
1621+```tsx
1622+function Form() {
1623+ const [firstName, setFirstName] = useState('First')
1624+ const [lastName, setLastName] = useState('Last')
1625+ const [fullName, setFullName] = useState('')
1626+
1627+ useEffect(() => {
1628+ setFullName(firstName + ' ' + lastName)
1629+ }, [firstName, lastName])
1630+
1631+ return <p>{fullName}</p>
1632+}
1633+```
1634+
1635+**Correct: derive during render**
1636+
1637+```tsx
1638+function Form() {
1639+ const [firstName, setFirstName] = useState('First')
1640+ const [lastName, setLastName] = useState('Last')
1641+ const fullName = firstName + ' ' + lastName
1642+
1643+ return <p>{fullName}</p>
1644+}
1645+```
1646+
1647+Reference: [https://react.dev/learn/you-might-not-need-an-effect](https://react.dev/learn/you-might-not-need-an-effect)
1648+
1649+### 5.2 Defer State Reads to Usage Point
1650+
1651+**Impact: MEDIUM (avoids unnecessary subscriptions)**
1652+
1653+Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
1654+
1655+**Incorrect: subscribes to all searchParams changes**
1656+
1657+```tsx
1658+function ShareButton({ chatId }: { chatId: string }) {
1659+ const searchParams = useSearchParams()
1660+
1661+ const handleShare = () => {
1662+ const ref = searchParams.get('ref')
1663+ shareChat(chatId, { ref })
1664+ }
1665+
1666+ return <button onClick={handleShare}>Share</button>
1667+}
1668+```
1669+
1670+**Correct: reads on demand, no subscription**
1671+
1672+```tsx
1673+function ShareButton({ chatId }: { chatId: string }) {
1674+ const handleShare = () => {
1675+ const params = new URLSearchParams(window.location.search)
1676+ const ref = params.get('ref')
1677+ shareChat(chatId, { ref })
1678+ }
1679+
1680+ return <button onClick={handleShare}>Share</button>
1681+}
1682+```
1683+
1684+### 5.3 Do not wrap a simple expression with a primitive result type in useMemo
1685+
1686+**Impact: LOW-MEDIUM (wasted computation on every render)**
1687+
1688+When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`.
1689+
1690+Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.
1691+
1692+**Incorrect:**
1693+
1694+```tsx
1695+function Header({ user, notifications }: Props) {
1696+ const isLoading = useMemo(() => {
1697+ return user.isLoading || notifications.isLoading
1698+ }, [user.isLoading, notifications.isLoading])
1699+
1700+ if (isLoading) return <Skeleton />
1701+ // return some markup
1702+}
1703+```
1704+
1705+**Correct:**
1706+
1707+```tsx
1708+function Header({ user, notifications }: Props) {
1709+ const isLoading = user.isLoading || notifications.isLoading
1710+
1711+ if (isLoading) return <Skeleton />
1712+ // return some markup
1713+}
1714+```
1715+
1716+### 5.4 Don't Define Components Inside Components
1717+
1718+**Impact: HIGH (prevents remount on every render)**
1719+
1720+Defining a component inside another component creates a new component type on every render. React sees a different component each time and fully remounts it, destroying all state and DOM.
1721+
1722+A common reason developers do this is to access parent variables without passing props. Always pass props instead.
1723+
1724+**Incorrect: remounts on every render**
1725+
1726+```tsx
1727+function UserProfile({ user, theme }) {
1728+ // Defined inside to access `theme` - BAD
1729+ const Avatar = () => (
1730+ <img
1731+ src={user.avatarUrl}
1732+ className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
1733+ />
1734+ )
1735+
1736+ // Defined inside to access `user` - BAD
1737+ const Stats = () => (
1738+ <div>
1739+ <span>{user.followers} followers</span>
1740+ <span>{user.posts} posts</span>
1741+ </div>
1742+ )
1743+
1744+ return (
1745+ <div>
1746+ <Avatar />
1747+ <Stats />
1748+ </div>
1749+ )
1750+}
1751+```
1752+
1753+Every time `UserProfile` renders, `Avatar` and `Stats` are new component types. React unmounts the old instances and mounts new ones, losing any internal state, running effects again, and recreating DOM nodes.
1754+
1755+**Correct: pass props instead**
1756+
1757+```tsx
1758+function Avatar({ src, theme }: { src: string; theme: string }) {
1759+ return (
1760+ <img
1761+ src={src}
1762+ className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
1763+ />
1764+ )
1765+}
1766+
1767+function Stats({ followers, posts }: { followers: number; posts: number }) {
1768+ return (
1769+ <div>
1770+ <span>{followers} followers</span>
1771+ <span>{posts} posts</span>
1772+ </div>
1773+ )
1774+}
1775+
1776+function UserProfile({ user, theme }) {
1777+ return (
1778+ <div>
1779+ <Avatar src={user.avatarUrl} theme={theme} />
1780+ <Stats followers={user.followers} posts={user.posts} />
1781+ </div>
1782+ )
1783+}
1784+```
1785+
1786+**Symptoms of this bug:**
1787+
1788+- Input fields lose focus on every keystroke
1789+
1790+- Animations restart unexpectedly
1791+
1792+- `useEffect` cleanup/setup runs on every parent render
1793+
1794+- Scroll position resets inside the component
1795+
1796+### 5.5 Extract Default Non-primitive Parameter Value from Memoized Component to Constant
1797+
1798+**Impact: MEDIUM (restores memoization by using a constant for default value)**
1799+
1800+When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`.
1801+
1802+To address this issue, extract the default value into a constant.
1803+
1804+**Incorrect: `onClick` has different values on every rerender**
1805+
1806+```tsx
1807+const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
1808+ // ...
1809+})
1810+
1811+// Used without optional onClick
1812+<UserAvatar />
1813+```
1814+
1815+**Correct: stable default value**
1816+
1817+```tsx
1818+const NOOP = () => {};
1819+
1820+const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
1821+ // ...
1822+})
1823+
1824+// Used without optional onClick
1825+<UserAvatar />
1826+```
1827+
1828+### 5.6 Extract to Memoized Components
1829+
1830+**Impact: MEDIUM (enables early returns)**
1831+
1832+Extract expensive work into memoized components to enable early returns before computation.
1833+
1834+**Incorrect: computes avatar even when loading**
1835+
1836+```tsx
1837+function Profile({ user, loading }: Props) {
1838+ const avatar = useMemo(() => {
1839+ const id = computeAvatarId(user)
1840+ return <Avatar id={id} />
1841+ }, [user])
1842+
1843+ if (loading) return <Skeleton />
1844+ return <div>{avatar}</div>
1845+}
1846+```
1847+
1848+**Correct: skips computation when loading**
1849+
1850+```tsx
1851+const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
1852+ const id = useMemo(() => computeAvatarId(user), [user])
1853+ return <Avatar id={id} />
1854+})
1855+
1856+function Profile({ user, loading }: Props) {
1857+ if (loading) return <Skeleton />
1858+ return (
1859+ <div>
1860+ <UserAvatar user={user} />
1861+ </div>
1862+ )
1863+}
1864+```
1865+
1866+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders.
1867+
1868+### 5.7 Narrow Effect Dependencies
1869+
1870+**Impact: LOW (minimizes effect re-runs)**
1871+
1872+Specify primitive dependencies instead of objects to minimize effect re-runs.
1873+
1874+**Incorrect: re-runs on any user field change**
1875+
1876+```tsx
1877+useEffect(() => {
1878+ console.log(user.id)
1879+}, [user])
1880+```
1881+
1882+**Correct: re-runs only when id changes**
1883+
1884+```tsx
1885+useEffect(() => {
1886+ console.log(user.id)
1887+}, [user.id])
1888+```
1889+
1890+**For derived state, compute outside effect:**
1891+
1892+```tsx
1893+// Incorrect: runs on width=767, 766, 765...
1894+useEffect(() => {
1895+ if (width < 768) {
1896+ enableMobileMode()
1897+ }
1898+}, [width])
1899+
1900+// Correct: runs only on boolean transition
1901+const isMobile = width < 768
1902+useEffect(() => {
1903+ if (isMobile) {
1904+ enableMobileMode()
1905+ }
1906+}, [isMobile])
1907+```
1908+
1909+### 5.8 Put Interaction Logic in Event Handlers
1910+
1911+**Impact: MEDIUM (avoids effect re-runs and duplicate side effects)**
1912+
1913+If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action.
1914+
1915+**Incorrect: event modeled as state + effect**
1916+
1917+```tsx
1918+function Form() {
1919+ const [submitted, setSubmitted] = useState(false)
1920+ const theme = useContext(ThemeContext)
1921+
1922+ useEffect(() => {
1923+ if (submitted) {
1924+ post('/api/register')
1925+ showToast('Registered', theme)
1926+ }
1927+ }, [submitted, theme])
1928+
1929+ return <button onClick={() => setSubmitted(true)}>Submit</button>
1930+}
1931+```
1932+
1933+**Correct: do it in the handler**
1934+
1935+```tsx
1936+function Form() {
1937+ const theme = useContext(ThemeContext)
1938+
1939+ function handleSubmit() {
1940+ post('/api/register')
1941+ showToast('Registered', theme)
1942+ }
1943+
1944+ return <button onClick={handleSubmit}>Submit</button>
1945+}
1946+```
1947+
1948+Reference: [https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)
1949+
1950+### 5.9 Split Combined Hook Computations
1951+
1952+**Impact: MEDIUM (avoids recomputing independent steps)**
1953+
1954+When a hook contains multiple independent tasks with different dependencies, split them into separate hooks. A combined hook reruns all tasks when any dependency changes, even if some tasks don't use the changed value.
1955+
1956+**Incorrect: changing `sortOrder` recomputes filtering**
1957+
1958+```tsx
1959+const sortedProducts = useMemo(() => {
1960+ const filtered = products.filter((p) => p.category === category)
1961+ const sorted = filtered.toSorted((a, b) =>
1962+ sortOrder === "asc" ? a.price - b.price : b.price - a.price
1963+ )
1964+ return sorted
1965+}, [products, category, sortOrder])
1966+```
1967+
1968+**Correct: filtering only recomputes when products or category change**
1969+
1970+```tsx
1971+const filteredProducts = useMemo(
1972+ () => products.filter((p) => p.category === category),
1973+ [products, category]
1974+)
1975+
1976+const sortedProducts = useMemo(
1977+ () =>
1978+ filteredProducts.toSorted((a, b) =>
1979+ sortOrder === "asc" ? a.price - b.price : b.price - a.price
1980+ ),
1981+ [filteredProducts, sortOrder]
1982+)
1983+```
1984+
1985+This pattern also applies to `useEffect` when combining unrelated side effects:
1986+
1987+**Incorrect: both effects run when either dependency changes**
1988+
1989+```tsx
1990+useEffect(() => {
1991+ analytics.trackPageView(pathname)
1992+ document.title = `${pageTitle} | My App`
1993+}, [pathname, pageTitle])
1994+```
1995+
1996+**Correct: effects run independently**
1997+
1998+```tsx
1999+useEffect(() => {
2000+ analytics.trackPageView(pathname)
2001+}, [pathname])
2002+
2003+useEffect(() => {
2004+ document.title = `${pageTitle} | My App`
2005+}, [pageTitle])
2006+```
2007+
2008+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, it automatically optimizes dependency tracking and may handle some of these cases for you.
2009+
2010+### 5.10 Subscribe to Derived State
2011+
2012+**Impact: MEDIUM (reduces re-render frequency)**
2013+
2014+Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
2015+
2016+**Incorrect: re-renders on every pixel change**
2017+
2018+```tsx
2019+function Sidebar() {
2020+ const width = useWindowWidth() // updates continuously
2021+ const isMobile = width < 768
2022+ return <nav className={isMobile ? 'mobile' : 'desktop'} />
2023+}
2024+```
2025+
2026+**Correct: re-renders only when boolean changes**
2027+
2028+```tsx
2029+function Sidebar() {
2030+ const isMobile = useMediaQuery('(max-width: 767px)')
2031+ return <nav className={isMobile ? 'mobile' : 'desktop'} />
2032+}
2033+```
2034+
2035+### 5.11 Use Functional setState Updates
2036+
2037+**Impact: MEDIUM (prevents stale closures and unnecessary callback recreations)**
2038+
2039+When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.
2040+
2041+**Incorrect: requires state as dependency**
2042+
2043+```tsx
2044+function TodoList() {
2045+ const [items, setItems] = useState(initialItems)
2046+
2047+ // Callback must depend on items, recreated on every items change
2048+ const addItems = useCallback((newItems: Item[]) => {
2049+ setItems([...items, ...newItems])
2050+ }, [items]) // ❌ items dependency causes recreations
2051+
2052+ // Risk of stale closure if dependency is forgotten
2053+ const removeItem = useCallback((id: string) => {
2054+ setItems(items.filter(item => item.id !== id))
2055+ }, []) // ❌ Missing items dependency - will use stale items!
2056+
2057+ return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
2058+}
2059+```
2060+
2061+The first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value.
2062+
2063+**Correct: stable callbacks, no stale closures**
2064+
2065+```tsx
2066+function TodoList() {
2067+ const [items, setItems] = useState(initialItems)
2068+
2069+ // Stable callback, never recreated
2070+ const addItems = useCallback((newItems: Item[]) => {
2071+ setItems(curr => [...curr, ...newItems])
2072+ }, []) // ✅ No dependencies needed
2073+
2074+ // Always uses latest state, no stale closure risk
2075+ const removeItem = useCallback((id: string) => {
2076+ setItems(curr => curr.filter(item => item.id !== id))
2077+ }, []) // ✅ Safe and stable
2078+
2079+ return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
2080+}
2081+```
2082+
2083+**Benefits:**
2084+
2085+1. **Stable callback references** - Callbacks don't need to be recreated when state changes
2086+
2087+2. **No stale closures** - Always operates on the latest state value
2088+
2089+3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
2090+
2091+4. **Prevents bugs** - Eliminates the most common source of React closure bugs
2092+
2093+**When to use functional updates:**
2094+
2095+- Any setState that depends on the current state value
2096+
2097+- Inside useCallback/useMemo when state is needed
2098+
2099+- Event handlers that reference state
2100+
2101+- Async operations that update state
2102+
2103+**When direct updates are fine:**
2104+
2105+- Setting state to a static value: `setCount(0)`
2106+
2107+- Setting state from props/arguments only: `setName(newName)`
2108+
2109+- State doesn't depend on previous value
2110+
2111+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.
2112+
2113+### 5.12 Use Lazy State Initialization
2114+
2115+**Impact: MEDIUM (wasted computation on every render)**
2116+
2117+Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
2118+
2119+**Incorrect: runs on every render**
2120+
2121+```tsx
2122+function FilteredList({ items }: { items: Item[] }) {
2123+ // buildSearchIndex() runs on EVERY render, even after initialization
2124+ const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
2125+ const [query, setQuery] = useState('')
2126+
2127+ // When query changes, buildSearchIndex runs again unnecessarily
2128+ return <SearchResults index={searchIndex} query={query} />
2129+}
2130+
2131+function UserProfile() {
2132+ // JSON.parse runs on every render
2133+ const [settings, setSettings] = useState(
2134+ JSON.parse(localStorage.getItem('settings') || '{}')
2135+ )
2136+
2137+ return <SettingsForm settings={settings} onChange={setSettings} />
2138+}
2139+```
2140+
2141+**Correct: runs only once**
2142+
2143+```tsx
2144+function FilteredList({ items }: { items: Item[] }) {
2145+ // buildSearchIndex() runs ONLY on initial render
2146+ const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
2147+ const [query, setQuery] = useState('')
2148+
2149+ return <SearchResults index={searchIndex} query={query} />
2150+}
2151+
2152+function UserProfile() {
2153+ // JSON.parse runs only on initial render
2154+ const [settings, setSettings] = useState(() => {
2155+ const stored = localStorage.getItem('settings')
2156+ return stored ? JSON.parse(stored) : {}
2157+ })
2158+
2159+ return <SettingsForm settings={settings} onChange={setSettings} />
2160+}
2161+```
2162+
2163+Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
2164+
2165+For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
2166+
2167+### 5.13 Use Transitions for Non-Urgent Updates
2168+
2169+**Impact: MEDIUM (maintains UI responsiveness)**
2170+
2171+Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
2172+
2173+**Incorrect: blocks UI on every scroll**
2174+
2175+```tsx
2176+function ScrollTracker() {
2177+ const [scrollY, setScrollY] = useState(0)
2178+ useEffect(() => {
2179+ const handler = () => setScrollY(window.scrollY)
2180+ window.addEventListener('scroll', handler, { passive: true })
2181+ return () => window.removeEventListener('scroll', handler)
2182+ }, [])
2183+}
2184+```
2185+
2186+**Correct: non-blocking updates**
2187+
2188+```tsx
2189+import { startTransition } from 'react'
2190+
2191+function ScrollTracker() {
2192+ const [scrollY, setScrollY] = useState(0)
2193+ useEffect(() => {
2194+ const handler = () => {
2195+ startTransition(() => setScrollY(window.scrollY))
2196+ }
2197+ window.addEventListener('scroll', handler, { passive: true })
2198+ return () => window.removeEventListener('scroll', handler)
2199+ }, [])
2200+}
2201+```
2202+
2203+### 5.14 Use useDeferredValue for Expensive Derived Renders
2204+
2205+**Impact: MEDIUM (keeps input responsive during heavy computation)**
2206+
2207+When user input triggers expensive computations or renders, use `useDeferredValue` to keep the input responsive. The deferred value lags behind, allowing React to prioritize the input update and render the expensive result when idle.
2208+
2209+**Incorrect: input feels laggy while filtering**
2210+
2211+```tsx
2212+function Search({ items }: { items: Item[] }) {
2213+ const [query, setQuery] = useState('')
2214+ const filtered = items.filter(item => fuzzyMatch(item, query))
2215+
2216+ return (
2217+ <>
2218+ <input value={query} onChange={e => setQuery(e.target.value)} />
2219+ <ResultsList results={filtered} />
2220+ </>
2221+ )
2222+}
2223+```
2224+
2225+**Correct: input stays snappy, results render when ready**
2226+
2227+```tsx
2228+function Search({ items }: { items: Item[] }) {
2229+ const [query, setQuery] = useState('')
2230+ const deferredQuery = useDeferredValue(query)
2231+ const filtered = useMemo(
2232+ () => items.filter(item => fuzzyMatch(item, deferredQuery)),
2233+ [items, deferredQuery]
2234+ )
2235+ const isStale = query !== deferredQuery
2236+
2237+ return (
2238+ <>
2239+ <input value={query} onChange={e => setQuery(e.target.value)} />
2240+ <div style={{ opacity: isStale ? 0.7 : 1 }}>
2241+ <ResultsList results={filtered} />
2242+ </div>
2243+ </>
2244+ )
2245+}
2246+```
2247+
2248+**When to use:**
2249+
2250+- Filtering/searching large lists
2251+
2252+- Expensive visualizations (charts, graphs) reacting to input
2253+
2254+- Any derived state that causes noticeable render delays
2255+
2256+**Note:** Wrap the expensive computation in `useMemo` with the deferred value as a dependency, otherwise it still runs on every render.
2257+
2258+Reference: [https://react.dev/reference/react/useDeferredValue](https://react.dev/reference/react/useDeferredValue)
2259+
2260+### 5.15 Use useRef for Transient Values
2261+
2262+**Impact: MEDIUM (avoids unnecessary re-renders on frequent updates)**
2263+
2264+When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.
2265+
2266+**Incorrect: renders every update**
2267+
2268+```tsx
2269+function Tracker() {
2270+ const [lastX, setLastX] = useState(0)
2271+
2272+ useEffect(() => {
2273+ const onMove = (e: MouseEvent) => setLastX(e.clientX)
2274+ window.addEventListener('mousemove', onMove)
2275+ return () => window.removeEventListener('mousemove', onMove)
2276+ }, [])
2277+
2278+ return (
2279+ <div
2280+ style={{
2281+ position: 'fixed',
2282+ top: 0,
2283+ left: lastX,
2284+ width: 8,
2285+ height: 8,
2286+ background: 'black',
2287+ }}
2288+ />
2289+ )
2290+}
2291+```
2292+
2293+**Correct: no re-render for tracking**
2294+
2295+```tsx
2296+function Tracker() {
2297+ const lastXRef = useRef(0)
2298+ const dotRef = useRef<HTMLDivElement>(null)
2299+
2300+ useEffect(() => {
2301+ const onMove = (e: MouseEvent) => {
2302+ lastXRef.current = e.clientX
2303+ const node = dotRef.current
2304+ if (node) {
2305+ node.style.transform = `translateX(${e.clientX}px)`
2306+ }
2307+ }
2308+ window.addEventListener('mousemove', onMove)
2309+ return () => window.removeEventListener('mousemove', onMove)
2310+ }, [])
2311+
2312+ return (
2313+ <div
2314+ ref={dotRef}
2315+ style={{
2316+ position: 'fixed',
2317+ top: 0,
2318+ left: 0,
2319+ width: 8,
2320+ height: 8,
2321+ background: 'black',
2322+ transform: 'translateX(0px)',
2323+ }}
2324+ />
2325+ )
2326+}
2327+```
2328+
2329+---
2330+
2331+## 6. Rendering Performance
2332+
2333+**Impact: MEDIUM**
2334+
2335+Optimizing the rendering process reduces the work the browser needs to do.
2336+
2337+### 6.1 Animate SVG Wrapper Instead of SVG Element
2338+
2339+**Impact: LOW (enables hardware acceleration)**
2340+
2341+Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.
2342+
2343+**Incorrect: animating SVG directly - no hardware acceleration**
2344+
2345+```tsx
2346+function LoadingSpinner() {
2347+ return (
2348+ <svg
2349+ className="animate-spin"
2350+ width="24"
2351+ height="24"
2352+ viewBox="0 0 24 24"
2353+ >
2354+ <circle cx="12" cy="12" r="10" stroke="currentColor" />
2355+ </svg>
2356+ )
2357+}
2358+```
2359+
2360+**Correct: animating wrapper div - hardware accelerated**
2361+
2362+```tsx
2363+function LoadingSpinner() {
2364+ return (
2365+ <div className="animate-spin">
2366+ <svg
2367+ width="24"
2368+ height="24"
2369+ viewBox="0 0 24 24"
2370+ >
2371+ <circle cx="12" cy="12" r="10" stroke="currentColor" />
2372+ </svg>
2373+ </div>
2374+ )
2375+}
2376+```
2377+
2378+This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.
2379+
2380+### 6.2 CSS content-visibility for Long Lists
2381+
2382+**Impact: HIGH (faster initial render)**
2383+
2384+Apply `content-visibility: auto` to defer off-screen rendering.
2385+
2386+**CSS:**
2387+
2388+```css
2389+.message-item {
2390+ content-visibility: auto;
2391+ contain-intrinsic-size: 0 80px;
2392+}
2393+```
2394+
2395+**Example:**
2396+
2397+```tsx
2398+function MessageList({ messages }: { messages: Message[] }) {
2399+ return (
2400+ <div className="overflow-y-auto h-screen">
2401+ {messages.map(msg => (
2402+ <div key={msg.id} className="message-item">
2403+ <Avatar user={msg.author} />
2404+ <div>{msg.content}</div>
2405+ </div>
2406+ ))}
2407+ </div>
2408+ )
2409+}
2410+```
2411+
2412+For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
2413+
2414+### 6.3 Hoist Static JSX Elements
2415+
2416+**Impact: LOW (avoids re-creation)**
2417+
2418+Extract static JSX outside components to avoid re-creation.
2419+
2420+**Incorrect: recreates element every render**
2421+
2422+```tsx
2423+function LoadingSkeleton() {
2424+ return <div className="animate-pulse h-20 bg-gray-200" />
2425+}
2426+
2427+function Container() {
2428+ return (
2429+ <div>
2430+ {loading && <LoadingSkeleton />}
2431+ </div>
2432+ )
2433+}
2434+```
2435+
2436+**Correct: reuses same element**
2437+
2438+```tsx
2439+const loadingSkeleton = (
2440+ <div className="animate-pulse h-20 bg-gray-200" />
2441+)
2442+
2443+function Container() {
2444+ return (
2445+ <div>
2446+ {loading && loadingSkeleton}
2447+ </div>
2448+ )
2449+}
2450+```
2451+
2452+This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
2453+
2454+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary.
2455+
2456+### 6.4 Optimize SVG Precision
2457+
2458+**Impact: LOW (reduces file size)**
2459+
2460+Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
2461+
2462+**Incorrect: excessive precision**
2463+
2464+```svg
2465+<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
2466+```
2467+
2468+**Correct: 1 decimal place**
2469+
2470+```svg
2471+<path d="M 10.3 20.8 L 30.9 40.2" />
2472+```
2473+
2474+**Automate with SVGO:**
2475+
2476+```bash
2477+npx svgo --precision=1 --multipass icon.svg
2478+```
2479+
2480+### 6.5 Prevent Hydration Mismatch Without Flickering
2481+
2482+**Impact: MEDIUM (avoids visual flicker and hydration errors)**
2483+
2484+When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.
2485+
2486+**Incorrect: breaks SSR**
2487+
2488+```tsx
2489+function ThemeWrapper({ children }: { children: ReactNode }) {
2490+ // localStorage is not available on server - throws error
2491+ const theme = localStorage.getItem('theme') || 'light'
2492+
2493+ return (
2494+ <div className={theme}>
2495+ {children}
2496+ </div>
2497+ )
2498+}
2499+```
2500+
2501+Server-side rendering will fail because `localStorage` is undefined.
2502+
2503+**Incorrect: visual flickering**
2504+
2505+```tsx
2506+function ThemeWrapper({ children }: { children: ReactNode }) {
2507+ const [theme, setTheme] = useState('light')
2508+
2509+ useEffect(() => {
2510+ // Runs after hydration - causes visible flash
2511+ const stored = localStorage.getItem('theme')
2512+ if (stored) {
2513+ setTheme(stored)
2514+ }
2515+ }, [])
2516+
2517+ return (
2518+ <div className={theme}>
2519+ {children}
2520+ </div>
2521+ )
2522+}
2523+```
2524+
2525+Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
2526+
2527+**Correct: no flicker, no hydration mismatch**
2528+
2529+```tsx
2530+function ThemeWrapper({ children }: { children: ReactNode }) {
2531+ return (
2532+ <>
2533+ <div id="theme-wrapper">
2534+ {children}
2535+ </div>
2536+ <script
2537+ dangerouslySetInnerHTML={{
2538+ __html: `
2539+ (function() {
2540+ try {
2541+ var theme = localStorage.getItem('theme') || 'light';
2542+ var el = document.getElementById('theme-wrapper');
2543+ if (el) el.className = theme;
2544+ } catch (e) {}
2545+ })();
2546+ `,
2547+ }}
2548+ />
2549+ </>
2550+ )
2551+}
2552+```
2553+
2554+The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
2555+
2556+This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
2557+
2558+### 6.6 Suppress Expected Hydration Mismatches
2559+
2560+**Impact: LOW-MEDIUM (avoids noisy hydration warnings for known differences)**
2561+
2562+In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it.
2563+
2564+**Incorrect: known mismatch warnings**
2565+
2566+```tsx
2567+function Timestamp() {
2568+ return <span>{new Date().toLocaleString()}</span>
2569+}
2570+```
2571+
2572+**Correct: suppress expected mismatch only**
2573+
2574+```tsx
2575+function Timestamp() {
2576+ return (
2577+ <span suppressHydrationWarning>
2578+ {new Date().toLocaleString()}
2579+ </span>
2580+ )
2581+}
2582+```
2583+
2584+### 6.7 Use Activity Component for Show/Hide
2585+
2586+**Impact: MEDIUM (preserves state/DOM)**
2587+
2588+Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.
2589+
2590+**Usage:**
2591+
2592+```tsx
2593+import { Activity } from 'react'
2594+
2595+function Dropdown({ isOpen }: Props) {
2596+ return (
2597+ <Activity mode={isOpen ? 'visible' : 'hidden'}>
2598+ <ExpensiveMenu />
2599+ </Activity>
2600+ )
2601+}
2602+```
2603+
2604+Avoids expensive re-renders and state loss.
2605+
2606+### 6.8 Use defer or async on Script Tags
2607+
2608+**Impact: HIGH (eliminates render-blocking)**
2609+
2610+Script tags without `defer` or `async` block HTML parsing while the script downloads and executes. This delays First Contentful Paint and Time to Interactive.
2611+
2612+- **`defer`**: Downloads in parallel, executes after HTML parsing completes, maintains execution order
2613+
2614+- **`async`**: Downloads in parallel, executes immediately when ready, no guaranteed order
2615+
2616+Use `defer` for scripts that depend on DOM or other scripts. Use `async` for independent scripts like analytics.
2617+
2618+**Incorrect: blocks rendering**
2619+
2620+```tsx
2621+export default function Document() {
2622+ return (
2623+ <html>
2624+ <head>
2625+ <script src="https://example.com/analytics.js" />
2626+ <script src="/scripts/utils.js" />
2627+ </head>
2628+ <body>{/* content */}</body>
2629+ </html>
2630+ )
2631+}
2632+```
2633+
2634+**Correct: non-blocking**
2635+
2636+```tsx
2637+import Script from 'next/script'
2638+
2639+export default function Page() {
2640+ return (
2641+ <>
2642+ <Script src="https://example.com/analytics.js" strategy="afterInteractive" />
2643+ <Script src="/scripts/utils.js" strategy="beforeInteractive" />
2644+ </>
2645+ )
2646+}
2647+```
2648+
2649+**Note:** In Next.js, prefer the `next/script` component with `strategy` prop instead of raw script tags:
2650+
2651+Reference: [https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer)
2652+
2653+### 6.9 Use Explicit Conditional Rendering
2654+
2655+**Impact: LOW (prevents rendering 0 or NaN)**
2656+
2657+Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
2658+
2659+**Incorrect: renders "0" when count is 0**
2660+
2661+```tsx
2662+function Badge({ count }: { count: number }) {
2663+ return (
2664+ <div>
2665+ {count && <span className="badge">{count}</span>}
2666+ </div>
2667+ )
2668+}
2669+
2670+// When count = 0, renders: <div>0</div>
2671+// When count = 5, renders: <div><span class="badge">5</span></div>
2672+```
2673+
2674+**Correct: renders nothing when count is 0**
2675+
2676+```tsx
2677+function Badge({ count }: { count: number }) {
2678+ return (
2679+ <div>
2680+ {count > 0 ? <span className="badge">{count}</span> : null}
2681+ </div>
2682+ )
2683+}
2684+
2685+// When count = 0, renders: <div></div>
2686+// When count = 5, renders: <div><span class="badge">5</span></div>
2687+```
2688+
2689+### 6.10 Use React DOM Resource Hints
2690+
2691+**Impact: HIGH (reduces load time for critical resources)**
2692+
2693+React DOM provides APIs to hint the browser about resources it will need. These are especially useful in server components to start loading resources before the client even receives the HTML.
2694+
2695+- **`prefetchDNS(href)`**: Resolve DNS for a domain you expect to connect to
2696+
2697+- **`preconnect(href)`**: Establish connection (DNS + TCP + TLS) to a server
2698+
2699+- **`preload(href, options)`**: Fetch a resource (stylesheet, font, script, image) you'll use soon
2700+
2701+- **`preloadModule(href)`**: Fetch an ES module you'll use soon
2702+
2703+- **`preinit(href, options)`**: Fetch and evaluate a stylesheet or script
2704+
2705+- **`preinitModule(href)`**: Fetch and evaluate an ES module
2706+
2707+**Example: preconnect to third-party APIs**
2708+
2709+```tsx
2710+import { preconnect, prefetchDNS } from 'react-dom'
2711+
2712+export default function App() {
2713+ prefetchDNS('https://analytics.example.com')
2714+ preconnect('https://api.example.com')
2715+
2716+ return <main>{/* content */}</main>
2717+}
2718+```
2719+
2720+**Example: preload critical fonts and styles**
2721+
2722+```tsx
2723+import { preload, preinit } from 'react-dom'
2724+
2725+export default function RootLayout({ children }) {
2726+ // Preload font file
2727+ preload('/fonts/inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' })
2728+
2729+ // Fetch and apply critical stylesheet immediately
2730+ preinit('/styles/critical.css', { as: 'style' })
2731+
2732+ return (
2733+ <html>
2734+ <body>{children}</body>
2735+ </html>
2736+ )
2737+}
2738+```
2739+
2740+**Example: preload modules for code-split routes**
2741+
2742+```tsx
2743+import { preloadModule, preinitModule } from 'react-dom'
2744+
2745+function Navigation() {
2746+ const preloadDashboard = () => {
2747+ preloadModule('/dashboard.js', { as: 'script' })
2748+ }
2749+
2750+ return (
2751+ <nav>
2752+ <a href="/dashboard" onMouseEnter={preloadDashboard}>
2753+ Dashboard
2754+ </a>
2755+ </nav>
2756+ )
2757+}
2758+```
2759+
2760+**When to use each:**
2761+
2762+| API | Use case |
2763+
2764+|-----|----------|
2765+
2766+| `prefetchDNS` | Third-party domains you'll connect to later |
2767+
2768+| `preconnect` | APIs or CDNs you'll fetch from immediately |
2769+
2770+| `preload` | Critical resources needed for current page |
2771+
2772+| `preloadModule` | JS modules for likely next navigation |
2773+
2774+| `preinit` | Stylesheets/scripts that must execute early |
2775+
2776+| `preinitModule` | ES modules that must execute early |
2777+
2778+Reference: [https://react.dev/reference/react-dom#resource-preloading-apis](https://react.dev/reference/react-dom#resource-preloading-apis)
2779+
2780+### 6.11 Use useTransition Over Manual Loading States
2781+
2782+**Impact: LOW (reduces re-renders and improves code clarity)**
2783+
2784+Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.
2785+
2786+**Incorrect: manual loading state**
2787+
2788+```tsx
2789+function SearchResults() {
2790+ const [query, setQuery] = useState('')
2791+ const [results, setResults] = useState([])
2792+ const [isLoading, setIsLoading] = useState(false)
2793+
2794+ const handleSearch = async (value: string) => {
2795+ setIsLoading(true)
2796+ setQuery(value)
2797+ const data = await fetchResults(value)
2798+ setResults(data)
2799+ setIsLoading(false)
2800+ }
2801+
2802+ return (
2803+ <>
2804+ <input onChange={(e) => handleSearch(e.target.value)} />
2805+ {isLoading && <Spinner />}
2806+ <ResultsList results={results} />
2807+ </>
2808+ )
2809+}
2810+```
2811+
2812+**Correct: useTransition with built-in pending state**
2813+
2814+```tsx
2815+import { useTransition, useState } from 'react'
2816+
2817+function SearchResults() {
2818+ const [query, setQuery] = useState('')
2819+ const [results, setResults] = useState([])
2820+ const [isPending, startTransition] = useTransition()
2821+
2822+ const handleSearch = (value: string) => {
2823+ setQuery(value) // Update input immediately
2824+
2825+ startTransition(async () => {
2826+ // Fetch and update results
2827+ const data = await fetchResults(value)
2828+ setResults(data)
2829+ })
2830+ }
2831+
2832+ return (
2833+ <>
2834+ <input onChange={(e) => handleSearch(e.target.value)} />
2835+ {isPending && <Spinner />}
2836+ <ResultsList results={results} />
2837+ </>
2838+ )
2839+}
2840+```
2841+
2842+**Benefits:**
2843+
2844+- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`
2845+
2846+- **Error resilience**: Pending state correctly resets even if the transition throws
2847+
2848+- **Better responsiveness**: Keeps the UI responsive during updates
2849+
2850+- **Interrupt handling**: New transitions automatically cancel pending ones
2851+
2852+Reference: [https://react.dev/reference/react/useTransition](https://react.dev/reference/react/useTransition)
2853+
2854+---
2855+
2856+## 7. JavaScript Performance
2857+
2858+**Impact: LOW-MEDIUM**
2859+
2860+Micro-optimizations for hot paths can add up to meaningful improvements.
2861+
2862+### 7.1 Avoid Layout Thrashing
2863+
2864+**Impact: MEDIUM (prevents forced synchronous layouts and reduces performance bottlenecks)**
2865+
2866+Avoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow.
2867+
2868+**This is OK: browser batches style changes**
2869+
2870+```typescript
2871+function updateElementStyles(element: HTMLElement) {
2872+ // Each line invalidates style, but browser batches the recalculation
2873+ element.style.width = '100px'
2874+ element.style.height = '200px'
2875+ element.style.backgroundColor = 'blue'
2876+ element.style.border = '1px solid black'
2877+}
2878+```
2879+
2880+**Incorrect: interleaved reads and writes force reflows**
2881+
2882+```typescript
2883+function layoutThrashing(element: HTMLElement) {
2884+ element.style.width = '100px'
2885+ const width = element.offsetWidth // Forces reflow
2886+ element.style.height = '200px'
2887+ const height = element.offsetHeight // Forces another reflow
2888+}
2889+```
2890+
2891+**Correct: batch writes, then read once**
2892+
2893+```typescript
2894+function updateElementStyles(element: HTMLElement) {
2895+ // Batch all writes together
2896+ element.style.width = '100px'
2897+ element.style.height = '200px'
2898+ element.style.backgroundColor = 'blue'
2899+ element.style.border = '1px solid black'
2900+
2901+ // Read after all writes are done (single reflow)
2902+ const { width, height } = element.getBoundingClientRect()
2903+}
2904+```
2905+
2906+**Correct: batch reads, then writes**
2907+
2908+```typescript
2909+function updateElementStyles(element: HTMLElement) {
2910+ element.classList.add('highlighted-box')
2911+
2912+ const { width, height } = element.getBoundingClientRect()
2913+}
2914+```
2915+
2916+**Better: use CSS classes**
2917+
2918+**React example:**
2919+
2920+```tsx
2921+// Incorrect: interleaving style changes with layout queries
2922+function Box({ isHighlighted }: { isHighlighted: boolean }) {
2923+ const ref = useRef<HTMLDivElement>(null)
2924+
2925+ useEffect(() => {
2926+ if (ref.current && isHighlighted) {
2927+ ref.current.style.width = '100px'
2928+ const width = ref.current.offsetWidth // Forces layout
2929+ ref.current.style.height = '200px'
2930+ }
2931+ }, [isHighlighted])
2932+
2933+ return <div ref={ref}>Content</div>
2934+}
2935+
2936+// Correct: toggle class
2937+function Box({ isHighlighted }: { isHighlighted: boolean }) {
2938+ return (
2939+ <div className={isHighlighted ? 'highlighted-box' : ''}>
2940+ Content
2941+ </div>
2942+ )
2943+}
2944+```
2945+
2946+Prefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain.
2947+
2948+See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.
2949+
2950+### 7.2 Build Index Maps for Repeated Lookups
2951+
2952+**Impact: LOW-MEDIUM (1M ops to 2K ops)**
2953+
2954+Multiple `.find()` calls by the same key should use a Map.
2955+
2956+**Incorrect (O(n) per lookup):**
2957+
2958+```typescript
2959+function processOrders(orders: Order[], users: User[]) {
2960+ return orders.map(order => ({
2961+ ...order,
2962+ user: users.find(u => u.id === order.userId)
2963+ }))
2964+}
2965+```
2966+
2967+**Correct (O(1) per lookup):**
2968+
2969+```typescript
2970+function processOrders(orders: Order[], users: User[]) {
2971+ const userById = new Map(users.map(u => [u.id, u]))
2972+
2973+ return orders.map(order => ({
2974+ ...order,
2975+ user: userById.get(order.userId)
2976+ }))
2977+}
2978+```
2979+
2980+Build map once (O(n)), then all lookups are O(1).
2981+
2982+For 1000 orders × 1000 users: 1M ops → 2K ops.
2983+
2984+### 7.3 Cache Property Access in Loops
2985+
2986+**Impact: LOW-MEDIUM (reduces lookups)**
2987+
2988+Cache object property lookups in hot paths.
2989+
2990+**Incorrect: 3 lookups × N iterations**
2991+
2992+```typescript
2993+for (let i = 0; i < arr.length; i++) {
2994+ process(obj.config.settings.value)
2995+}
2996+```
2997+
2998+**Correct: 1 lookup total**
2999+
3000+```typescript
3001+const value = obj.config.settings.value
3002+const len = arr.length
3003+for (let i = 0; i < len; i++) {
3004+ process(value)
3005+}
3006+```
3007+
3008+### 7.4 Cache Repeated Function Calls
3009+
3010+**Impact: MEDIUM (avoid redundant computation)**
3011+
3012+Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.
3013+
3014+**Incorrect: redundant computation**
3015+
3016+```typescript
3017+function ProjectList({ projects }: { projects: Project[] }) {
3018+ return (
3019+ <div>
3020+ {projects.map(project => {
3021+ // slugify() called 100+ times for same project names
3022+ const slug = slugify(project.name)
3023+
3024+ return <ProjectCard key={project.id} slug={slug} />
3025+ })}
3026+ </div>
3027+ )
3028+}
3029+```
3030+
3031+**Correct: cached results**
3032+
3033+```typescript
3034+// Module-level cache
3035+const slugifyCache = new Map<string, string>()
3036+
3037+function cachedSlugify(text: string): string {
3038+ if (slugifyCache.has(text)) {
3039+ return slugifyCache.get(text)!
3040+ }
3041+ const result = slugify(text)
3042+ slugifyCache.set(text, result)
3043+ return result
3044+}
3045+
3046+function ProjectList({ projects }: { projects: Project[] }) {
3047+ return (
3048+ <div>
3049+ {projects.map(project => {
3050+ // Computed only once per unique project name
3051+ const slug = cachedSlugify(project.name)
3052+
3053+ return <ProjectCard key={project.id} slug={slug} />
3054+ })}
3055+ </div>
3056+ )
3057+}
3058+```
3059+
3060+**Simpler pattern for single-value functions:**
3061+
3062+```typescript
3063+let isLoggedInCache: boolean | null = null
3064+
3065+function isLoggedIn(): boolean {
3066+ if (isLoggedInCache !== null) {
3067+ return isLoggedInCache
3068+ }
3069+
3070+ isLoggedInCache = document.cookie.includes('auth=')
3071+ return isLoggedInCache
3072+}
3073+
3074+// Clear cache when auth changes
3075+function onAuthChange() {
3076+ isLoggedInCache = null
3077+}
3078+```
3079+
3080+Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
3081+
3082+Reference: [https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
3083+
3084+### 7.5 Cache Storage API Calls
3085+
3086+**Impact: LOW-MEDIUM (reduces expensive I/O)**
3087+
3088+`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.
3089+
3090+**Incorrect: reads storage on every call**
3091+
3092+```typescript
3093+function getTheme() {
3094+ return localStorage.getItem('theme') ?? 'light'
3095+}
3096+// Called 10 times = 10 storage reads
3097+```
3098+
3099+**Correct: Map cache**
3100+
3101+```typescript
3102+const storageCache = new Map<string, string | null>()
3103+
3104+function getLocalStorage(key: string) {
3105+ if (!storageCache.has(key)) {
3106+ storageCache.set(key, localStorage.getItem(key))
3107+ }
3108+ return storageCache.get(key)
3109+}
3110+
3111+function setLocalStorage(key: string, value: string) {
3112+ localStorage.setItem(key, value)
3113+ storageCache.set(key, value) // keep cache in sync
3114+}
3115+```
3116+
3117+Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
3118+
3119+**Cookie caching:**
3120+
3121+```typescript
3122+let cookieCache: Record<string, string> | null = null
3123+
3124+function getCookie(name: string) {
3125+ if (!cookieCache) {
3126+ cookieCache = Object.fromEntries(
3127+ document.cookie.split('; ').map(c => c.split('='))
3128+ )
3129+ }
3130+ return cookieCache[name]
3131+}
3132+```
3133+
3134+**Important: invalidate on external changes**
3135+
3136+```typescript
3137+window.addEventListener('storage', (e) => {
3138+ if (e.key) storageCache.delete(e.key)
3139+})
3140+
3141+document.addEventListener('visibilitychange', () => {
3142+ if (document.visibilityState === 'visible') {
3143+ storageCache.clear()
3144+ }
3145+})
3146+```
3147+
3148+If storage can change externally (another tab, server-set cookies), invalidate cache:
3149+
3150+### 7.6 Combine Multiple Array Iterations
3151+
3152+**Impact: LOW-MEDIUM (reduces iterations)**
3153+
3154+Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.
3155+
3156+**Incorrect: 3 iterations**
3157+
3158+```typescript
3159+const admins = users.filter(u => u.isAdmin)
3160+const testers = users.filter(u => u.isTester)
3161+const inactive = users.filter(u => !u.isActive)
3162+```
3163+
3164+**Correct: 1 iteration**
3165+
3166+```typescript
3167+const admins: User[] = []
3168+const testers: User[] = []
3169+const inactive: User[] = []
3170+
3171+for (const user of users) {
3172+ if (user.isAdmin) admins.push(user)
3173+ if (user.isTester) testers.push(user)
3174+ if (!user.isActive) inactive.push(user)
3175+}
3176+```
3177+
3178+### 7.7 Defer Non-Critical Work with requestIdleCallback
3179+
3180+**Impact: MEDIUM (keeps UI responsive during background tasks)**
3181+
3182+Use `requestIdleCallback()` to schedule non-critical work during browser idle periods. This keeps the main thread free for user interactions and animations, reducing jank and improving perceived performance.
3183+
3184+**Incorrect: blocks main thread during user interaction**
3185+
3186+```typescript
3187+function handleSearch(query: string) {
3188+ const results = searchItems(query)
3189+ setResults(results)
3190+
3191+ // These block the main thread immediately
3192+ analytics.track('search', { query })
3193+ saveToRecentSearches(query)
3194+ prefetchTopResults(results.slice(0, 3))
3195+}
3196+```
3197+
3198+**Correct: defers non-critical work to idle time**
3199+
3200+```typescript
3201+function handleSearch(query: string) {
3202+ const results = searchItems(query)
3203+ setResults(results)
3204+
3205+ // Defer non-critical work to idle periods
3206+ requestIdleCallback(() => {
3207+ analytics.track('search', { query })
3208+ })
3209+
3210+ requestIdleCallback(() => {
3211+ saveToRecentSearches(query)
3212+ })
3213+
3214+ requestIdleCallback(() => {
3215+ prefetchTopResults(results.slice(0, 3))
3216+ })
3217+}
3218+```
3219+
3220+**With timeout for required work:**
3221+
3222+```typescript
3223+// Ensure analytics fires within 2 seconds even if browser stays busy
3224+requestIdleCallback(
3225+ () => analytics.track('page_view', { path: location.pathname }),
3226+ { timeout: 2000 }
3227+)
3228+```
3229+
3230+**Chunking large tasks:**
3231+
3232+```typescript
3233+function processLargeDataset(items: Item[]) {
3234+ let index = 0
3235+
3236+ function processChunk(deadline: IdleDeadline) {
3237+ // Process items while we have idle time (aim for <50ms chunks)
3238+ while (index < items.length && deadline.timeRemaining() > 0) {
3239+ processItem(items[index])
3240+ index++
3241+ }
3242+
3243+ // Schedule next chunk if more items remain
3244+ if (index < items.length) {
3245+ requestIdleCallback(processChunk)
3246+ }
3247+ }
3248+
3249+ requestIdleCallback(processChunk)
3250+}
3251+```
3252+
3253+**With fallback for unsupported browsers:**
3254+
3255+```typescript
3256+const scheduleIdleWork = window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1))
3257+
3258+scheduleIdleWork(() => {
3259+ // Non-critical work
3260+})
3261+```
3262+
3263+**When to use:**
3264+
3265+- Analytics and telemetry
3266+
3267+- Saving state to localStorage/IndexedDB
3268+
3269+- Prefetching resources for likely next actions
3270+
3271+- Processing non-urgent data transformations
3272+
3273+- Lazy initialization of non-critical features
3274+
3275+**When NOT to use:**
3276+
3277+- User-initiated actions that need immediate feedback
3278+
3279+- Rendering updates the user is waiting for
3280+
3281+- Time-sensitive operations
3282+
3283+### 7.8 Early Length Check for Array Comparisons
3284+
3285+**Impact: MEDIUM-HIGH (avoids expensive operations when lengths differ)**
3286+
3287+When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
3288+
3289+In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
3290+
3291+**Incorrect: always runs expensive comparison**
3292+
3293+```typescript
3294+function hasChanges(current: string[], original: string[]) {
3295+ // Always sorts and joins, even when lengths differ
3296+ return current.sort().join() !== original.sort().join()
3297+}
3298+```
3299+
3300+Two O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings.
3301+
3302+**Correct (O(1) length check first):**
3303+
3304+```typescript
3305+function hasChanges(current: string[], original: string[]) {
3306+ // Early return if lengths differ
3307+ if (current.length !== original.length) {
3308+ return true
3309+ }
3310+ // Only sort when lengths match
3311+ const currentSorted = current.toSorted()
3312+ const originalSorted = original.toSorted()
3313+ for (let i = 0; i < currentSorted.length; i++) {
3314+ if (currentSorted[i] !== originalSorted[i]) {
3315+ return true
3316+ }
3317+ }
3318+ return false
3319+}
3320+```
3321+
3322+This new approach is more efficient because:
3323+
3324+- It avoids the overhead of sorting and joining the arrays when lengths differ
3325+
3326+- It avoids consuming memory for the joined strings (especially important for large arrays)
3327+
3328+- It avoids mutating the original arrays
3329+
3330+- It returns early when a difference is found
3331+
3332+### 7.9 Early Return from Functions
3333+
3334+**Impact: LOW-MEDIUM (avoids unnecessary computation)**
3335+
3336+Return early when result is determined to skip unnecessary processing.
3337+
3338+**Incorrect: processes all items even after finding answer**
3339+
3340+```typescript
3341+function validateUsers(users: User[]) {
3342+ let hasError = false
3343+ let errorMessage = ''
3344+
3345+ for (const user of users) {
3346+ if (!user.email) {
3347+ hasError = true
3348+ errorMessage = 'Email required'
3349+ }
3350+ if (!user.name) {
3351+ hasError = true
3352+ errorMessage = 'Name required'
3353+ }
3354+ // Continues checking all users even after error found
3355+ }
3356+
3357+ return hasError ? { valid: false, error: errorMessage } : { valid: true }
3358+}
3359+```
3360+
3361+**Correct: returns immediately on first error**
3362+
3363+```typescript
3364+function validateUsers(users: User[]) {
3365+ for (const user of users) {
3366+ if (!user.email) {
3367+ return { valid: false, error: 'Email required' }
3368+ }
3369+ if (!user.name) {
3370+ return { valid: false, error: 'Name required' }
3371+ }
3372+ }
3373+
3374+ return { valid: true }
3375+}
3376+```
3377+
3378+### 7.10 Hoist RegExp Creation
3379+
3380+**Impact: LOW-MEDIUM (avoids recreation)**
3381+
3382+Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.
3383+
3384+**Incorrect: new RegExp every render**
3385+
3386+```tsx
3387+function Highlighter({ text, query }: Props) {
3388+ const regex = new RegExp(`(${query})`, 'gi')
3389+ const parts = text.split(regex)
3390+ return <>{parts.map((part, i) => ...)}</>
3391+}
3392+```
3393+
3394+**Correct: memoize or hoist**
3395+
3396+```tsx
3397+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
3398+
3399+function Highlighter({ text, query }: Props) {
3400+ const regex = useMemo(
3401+ () => new RegExp(`(${escapeRegex(query)})`, 'gi'),
3402+ [query]
3403+ )
3404+ const parts = text.split(regex)
3405+ return <>{parts.map((part, i) => ...)}</>
3406+}
3407+```
3408+
3409+**Warning: global regex has mutable state**
3410+
3411+```typescript
3412+const regex = /foo/g
3413+regex.test('foo') // true, lastIndex = 3
3414+regex.test('foo') // false, lastIndex = 0
3415+```
3416+
3417+Global regex (`/g`) has mutable `lastIndex` state:
3418+
3419+### 7.11 Use flatMap to Map and Filter in One Pass
3420+
3421+**Impact: LOW-MEDIUM (eliminates intermediate array)**
3422+
3423+Chaining `.map().filter(Boolean)` creates an intermediate array and iterates twice. Use `.flatMap()` to transform and filter in a single pass.
3424+
3425+**Incorrect: 2 iterations, intermediate array**
3426+
3427+```typescript
3428+const userNames = users
3429+ .map(user => user.isActive ? user.name : null)
3430+ .filter(Boolean)
3431+```
3432+
3433+**Correct: 1 iteration, no intermediate array**
3434+
3435+```typescript
3436+const userNames = users.flatMap(user =>
3437+ user.isActive ? [user.name] : []
3438+)
3439+```
3440+
3441+**More examples:**
3442+
3443+```typescript
3444+// Extract valid emails from responses
3445+// Before
3446+const emails = responses
3447+ .map(r => r.success ? r.data.email : null)
3448+ .filter(Boolean)
3449+
3450+// After
3451+const emails = responses.flatMap(r =>
3452+ r.success ? [r.data.email] : []
3453+)
3454+
3455+// Parse and filter valid numbers
3456+// Before
3457+const numbers = strings
3458+ .map(s => parseInt(s, 10))
3459+ .filter(n => !isNaN(n))
3460+
3461+// After
3462+const numbers = strings.flatMap(s => {
3463+ const n = parseInt(s, 10)
3464+ return isNaN(n) ? [] : [n]
3465+})
3466+```
3467+
3468+**When to use:**
3469+
3470+- Transforming items while filtering some out
3471+
3472+- Conditional mapping where some inputs produce no output
3473+
3474+- Parsing/validating where invalid inputs should be skipped
3475+
3476+### 7.12 Use Loop for Min/Max Instead of Sort
3477+
3478+**Impact: LOW (O(n) instead of O(n log n))**
3479+
3480+Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.
3481+
3482+**Incorrect (O(n log n) - sort to find latest):**
3483+
3484+```typescript
3485+interface Project {
3486+ id: string
3487+ name: string
3488+ updatedAt: number
3489+}
3490+
3491+function getLatestProject(projects: Project[]) {
3492+ const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
3493+ return sorted[0]
3494+}
3495+```
3496+
3497+Sorts the entire array just to find the maximum value.
3498+
3499+**Incorrect (O(n log n) - sort for oldest and newest):**
3500+
3501+```typescript
3502+function getOldestAndNewest(projects: Project[]) {
3503+ const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
3504+ return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
3505+}
3506+```
3507+
3508+Still sorts unnecessarily when only min/max are needed.
3509+
3510+**Correct (O(n) - single loop):**
3511+
3512+```typescript
3513+function getLatestProject(projects: Project[]) {
3514+ if (projects.length === 0) return null
3515+
3516+ let latest = projects[0]
3517+
3518+ for (let i = 1; i < projects.length; i++) {
3519+ if (projects[i].updatedAt > latest.updatedAt) {
3520+ latest = projects[i]
3521+ }
3522+ }
3523+
3524+ return latest
3525+}
3526+
3527+function getOldestAndNewest(projects: Project[]) {
3528+ if (projects.length === 0) return { oldest: null, newest: null }
3529+
3530+ let oldest = projects[0]
3531+ let newest = projects[0]
3532+
3533+ for (let i = 1; i < projects.length; i++) {
3534+ if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
3535+ if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
3536+ }
3537+
3538+ return { oldest, newest }
3539+}
3540+```
3541+
3542+Single pass through the array, no copying, no sorting.
3543+
3544+**Alternative: Math.min/Math.max for small arrays**
3545+
3546+```typescript
3547+const numbers = [5, 2, 8, 1, 9]
3548+const min = Math.min(...numbers)
3549+const max = Math.max(...numbers)
3550+```
3551+
3552+This works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability.
3553+
3554+### 7.13 Use Set/Map for O(1) Lookups
3555+
3556+**Impact: LOW-MEDIUM (O(n) to O(1))**
3557+
3558+Convert arrays to Set/Map for repeated membership checks.
3559+
3560+**Incorrect (O(n) per check):**
3561+
3562+```typescript
3563+const allowedIds = ['a', 'b', 'c', ...]
3564+items.filter(item => allowedIds.includes(item.id))
3565+```
3566+
3567+**Correct (O(1) per check):**
3568+
3569+```typescript
3570+const allowedIds = new Set(['a', 'b', 'c', ...])
3571+items.filter(item => allowedIds.has(item.id))
3572+```
3573+
3574+### 7.14 Use toSorted() Instead of sort() for Immutability
3575+
3576+**Impact: MEDIUM-HIGH (prevents mutation bugs in React state)**
3577+
3578+`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation.
3579+
3580+**Incorrect: mutates original array**
3581+
3582+```typescript
3583+function UserList({ users }: { users: User[] }) {
3584+ // Mutates the users prop array!
3585+ const sorted = useMemo(
3586+ () => users.sort((a, b) => a.name.localeCompare(b.name)),
3587+ [users]
3588+ )
3589+ return <div>{sorted.map(renderUser)}</div>
3590+}
3591+```
3592+
3593+**Correct: creates new array**
3594+
3595+```typescript
3596+function UserList({ users }: { users: User[] }) {
3597+ // Creates new sorted array, original unchanged
3598+ const sorted = useMemo(
3599+ () => users.toSorted((a, b) => a.name.localeCompare(b.name)),
3600+ [users]
3601+ )
3602+ return <div>{sorted.map(renderUser)}</div>
3603+}
3604+```
3605+
3606+**Why this matters in React:**
3607+
3608+1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only
3609+
3610+2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
3611+
3612+**Browser support: fallback for older browsers**
3613+
3614+```typescript
3615+// Fallback for older browsers
3616+const sorted = [...items].sort((a, b) => a.value - b.value)
3617+```
3618+
3619+`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
3620+
3621+**Other immutable array methods:**
3622+
3623+- `.toSorted()` - immutable sort
3624+
3625+- `.toReversed()` - immutable reverse
3626+
3627+- `.toSpliced()` - immutable splice
3628+
3629+- `.with()` - immutable element replacement
3630+
3631+---
3632+
3633+## 8. Advanced Patterns
3634+
3635+**Impact: LOW**
3636+
3637+Advanced patterns for specific cases that require careful implementation.
3638+
3639+### 8.1 Do Not Put Effect Events in Dependency Arrays
3640+
3641+**Impact: LOW (avoids unnecessary effect re-runs and lint errors)**
3642+
3643+Effect Event functions do not have a stable identity. Their identity intentionally changes on every render. Do not include the function returned by `useEffectEvent` in a `useEffect` dependency array. Keep the actual reactive values as dependencies and call the Effect Event from inside the effect body or subscriptions created by that effect.
3644+
3645+**Incorrect: Effect Event added as a dependency**
3646+
3647+```tsx
3648+import { useEffect, useEffectEvent } from 'react'
3649+
3650+function ChatRoom({ roomId, onConnected }: {
3651+ roomId: string
3652+ onConnected: () => void
3653+}) {
3654+ const handleConnected = useEffectEvent(onConnected)
3655+
3656+ useEffect(() => {
3657+ const connection = createConnection(roomId)
3658+ connection.on('connected', handleConnected)
3659+ connection.connect()
3660+
3661+ return () => connection.disconnect()
3662+ }, [roomId, handleConnected])
3663+}
3664+```
3665+
3666+Including the Effect Event in dependencies makes the effect re-run every render and triggers the React Hooks lint rule.
3667+
3668+**Correct: depend on reactive values, not the Effect Event**
3669+
3670+```tsx
3671+import { useEffect, useEffectEvent } from 'react'
3672+
3673+function ChatRoom({ roomId, onConnected }: {
3674+ roomId: string
3675+ onConnected: () => void
3676+}) {
3677+ const handleConnected = useEffectEvent(onConnected)
3678+
3679+ useEffect(() => {
3680+ const connection = createConnection(roomId)
3681+ connection.on('connected', handleConnected)
3682+ connection.connect()
3683+
3684+ return () => connection.disconnect()
3685+ }, [roomId])
3686+}
3687+```
3688+
3689+Reference: [https://react.dev/reference/react/useEffectEvent#effect-event-in-deps](https://react.dev/reference/react/useEffectEvent#effect-event-in-deps)
3690+
3691+### 8.2 Initialize App Once, Not Per Mount
3692+
3693+**Impact: LOW-MEDIUM (avoids duplicate init in development)**
3694+
3695+Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.
3696+
3697+**Incorrect: runs twice in dev, re-runs on remount**
3698+
3699+```tsx
3700+function Comp() {
3701+ useEffect(() => {
3702+ loadFromStorage()
3703+ checkAuthToken()
3704+ }, [])
3705+
3706+ // ...
3707+}
3708+```
3709+
3710+**Correct: once per app load**
3711+
3712+```tsx
3713+let didInit = false
3714+
3715+function Comp() {
3716+ useEffect(() => {
3717+ if (didInit) return
3718+ didInit = true
3719+ loadFromStorage()
3720+ checkAuthToken()
3721+ }, [])
3722+
3723+ // ...
3724+}
3725+```
3726+
3727+Reference: [https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
3728+
3729+### 8.3 Store Event Handlers in Refs
3730+
3731+**Impact: LOW (stable subscriptions)**
3732+
3733+Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
3734+
3735+**Incorrect: re-subscribes on every render**
3736+
3737+```tsx
3738+function useWindowEvent(event: string, handler: (e) => void) {
3739+ useEffect(() => {
3740+ window.addEventListener(event, handler)
3741+ return () => window.removeEventListener(event, handler)
3742+ }, [event, handler])
3743+}
3744+```
3745+
3746+**Correct: stable subscription**
3747+
3748+```tsx
3749+import { useEffectEvent } from 'react'
3750+
3751+function useWindowEvent(event: string, handler: (e) => void) {
3752+ const onEvent = useEffectEvent(handler)
3753+
3754+ useEffect(() => {
3755+ window.addEventListener(event, onEvent)
3756+ return () => window.removeEventListener(event, onEvent)
3757+ }, [event])
3758+}
3759+```
3760+
3761+**Alternative: use `useEffectEvent` if you're on latest React:**
3762+
3763+`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
3764+
3765+### 8.4 useEffectEvent for Stable Callback Refs
3766+
3767+**Impact: LOW (prevents effect re-runs)**
3768+
3769+Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
3770+
3771+**Incorrect: effect re-runs on every callback change**
3772+
3773+```tsx
3774+function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
3775+ const [query, setQuery] = useState('')
3776+
3777+ useEffect(() => {
3778+ const timeout = setTimeout(() => onSearch(query), 300)
3779+ return () => clearTimeout(timeout)
3780+ }, [query, onSearch])
3781+}
3782+```
3783+
3784+**Correct: using React's useEffectEvent**
3785+
3786+```tsx
3787+import { useEffectEvent } from 'react';
3788+
3789+function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
3790+ const [query, setQuery] = useState('')
3791+ const onSearchEvent = useEffectEvent(onSearch)
3792+
3793+ useEffect(() => {
3794+ const timeout = setTimeout(() => onSearchEvent(query), 300)
3795+ return () => clearTimeout(timeout)
3796+ }, [query])
3797+}
3798+```
3799+
3800+---
3801+
3802+## References
3803+
3804+1. [https://react.dev](https://react.dev)
3805+2. [https://nextjs.org](https://nextjs.org)
3806+3. [https://swr.vercel.app](https://swr.vercel.app)
3807+4. [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
3808+5. [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
3809+6. [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
3810+7. [https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
9583811
