Two files, one repository
zebbern/claude-code-guide ships 1 format across 2 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 | 14 | 3% |
| Commands | 0 | 2 | 0 | 0% |
| Section tags | 4 | 8 | 0 | 33% |
What each file covers
Sections
2 shared · 58 only in A · 14 only in B- − React Best Practices
- − 1. Eliminating Waterfalls
- − 1.1 Defer Await Until Needed
- − 1.2 Dependency-Based Parallelization
- − 1.3 Prevent Waterfall Chains in API Routes
- − 1.4 Promise.all() for Independent Operations
- − 1.5 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 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 Cross-Request LRU Caching
- − 3.4 Minimize Serialization at RSC Boundaries
- − 3.5 Parallel Data Fetching with Component Composition
- − 3.6 Per-Request Deduplication with React.cache()
- − 3.7 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 Extract Default Non-primitive Parameter Value from Memoized Component to Constant
- − 5.5 Extract to Memoized Components
- − 5.6 Narrow Effect Dependencies
- − 5.7 Put Interaction Logic in Event Handlers
- − 5.8 Subscribe to Derived State
- − 5.9 Use Functional setState Updates
- − 5.10 Use Lazy State Initialization
- − 5.11 Use Transitions for Non-Urgent Updates
- − 5.12 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 Explicit Conditional Rendering
- − 6.9 Use useTransition Over Manual Loading States
- − 7. JavaScript Performance
- − 7.1 Avoid Layout Thrashing
- − 7.2 Build Index Maps for Repeated Lookups
- − 7.3 Cache Property Access in Loops
- − 7.4 Cache Repeated Function Calls
- − 7.5 Cache Storage API Calls
- − 7.6 Combine Multiple Array Iterations
- − 7.7 Early Length Check for Array Comparisons
- − 7.8 Early Return from Functions
- + React Composition Patterns
- + 1. Component Architecture
- + 1.1 Avoid Boolean Prop Proliferation
- + 1.2 Use Compound Components
- + 2. State Management
- + 2.1 Decouple State Management from UI
- + 2.2 Define Generic Context Interfaces for Dependency Injection
- + 2.3 Lift State into Provider Components
- + 3. Implementation Patterns
- + 3.1 Create Explicit Component Variants
- + 3.2 Prefer Composing Children Over Render Props
- + 4. React 19 APIs
- + 4.1 React 19 API Changes
- + References
- Abstract
- Table of Contents
Commands
0 shared · 2 only in A · 0 only in B- − node.style.transform = `translateX(${e.clientX}px)`
- − npx svgo --precision=1 --multipass icon.svg
Section tags
4 shared · 8 only in A · 0 only in B- − build
- − lint-format
- − architecture
- − types
- − security
- − dependencies
- − performance
- − deployment
- code-style
- api
- ui
- do-not
Line diff
zebbern/claude-code-guide · skills/react-best-practices/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 [Defer Await Until Needed](#11-defer-await-until-needed)
25 - 1.2 [Dependency-Based Parallelization](#12-dependency-based-parallelization)
26 - 1.3 [Prevent Waterfall Chains in API Routes](#13-prevent-waterfall-chains-in-api-routes)
27 - 1.4 [Promise.all() for Independent Operations](#14-promiseall-for-independent-operations)
28 - 1.5 [Strategic Suspense Boundaries](#15-strategic-suspense-boundaries)
292. [Bundle Size Optimization](#2-bundle-size-optimization) — **CRITICAL**
30 - 2.1 [Avoid Barrel File Imports](#21-avoid-barrel-file-imports)
31 - 2.2 [Conditional Module Loading](#22-conditional-module-loading)
32 - 2.3 [Defer Non-Critical Third-Party Libraries](#23-defer-non-critical-third-party-libraries)
33 - 2.4 [Dynamic Imports for Heavy Components](#24-dynamic-imports-for-heavy-components)
34 - 2.5 [Preload Based on User Intent](#25-preload-based-on-user-intent)
353. [Server-Side Performance](#3-server-side-performance) — **HIGH**
36 - 3.1 [Authenticate Server Actions Like API Routes](#31-authenticate-server-actions-like-api-routes)
37 - 3.2 [Avoid Duplicate Serialization in RSC Props](#32-avoid-duplicate-serialization-in-rsc-props)
38 - 3.3 [Cross-Request LRU Caching](#33-cross-request-lru-caching)
39 - 3.4 [Minimize Serialization at RSC Boundaries](#34-minimize-serialization-at-rsc-boundaries)
40 - 3.5 [Parallel Data Fetching with Component Composition](#35-parallel-data-fetching-with-component-composition)
41 - 3.6 [Per-Request Deduplication with React.cache()](#36-per-request-deduplication-with-reactcache)
42 - 3.7 [Use after() for Non-Blocking Operations](#37-use-after-for-non-blocking-operations)
434. [Client-Side Data Fetching](#4-client-side-data-fetching) — **MEDIUM-HIGH**
44 - 4.1 [Deduplicate Global Event Listeners](#41-deduplicate-global-event-listeners)
45 - 4.2 [Use Passive Event Listeners for Scrolling Performance](#42-use-passive-event-listeners-for-scrolling-performance)
46 - 4.3 [Use SWR for Automatic Deduplication](#43-use-swr-for-automatic-deduplication)
47 - 4.4 [Version and Minimize localStorage Data](#44-version-and-minimize-localstorage-data)
485. [Re-render Optimization](#5-re-render-optimization) — **MEDIUM**
49 - 5.1 [Calculate Derived State During Rendering](#51-calculate-derived-state-during-rendering)
50 - 5.2 [Defer State Reads to Usage Point](#52-defer-state-reads-to-usage-point)
51 - 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)
52 - 5.4 [Extract Default Non-primitive Parameter Value from Memoized Component to Constant](#54-extract-default-non-primitive-parameter-value-from-memoized-component-to-constant)
53 - 5.5 [Extract to Memoized Components](#55-extract-to-memoized-components)
54 - 5.6 [Narrow Effect Dependencies](#56-narrow-effect-dependencies)
55 - 5.7 [Put Interaction Logic in Event Handlers](#57-put-interaction-logic-in-event-handlers)
56 - 5.8 [Subscribe to Derived State](#58-subscribe-to-derived-state)
57 - 5.9 [Use Functional setState Updates](#59-use-functional-setstate-updates)
58 - 5.10 [Use Lazy State Initialization](#510-use-lazy-state-initialization)
59 - 5.11 [Use Transitions for Non-Urgent Updates](#511-use-transitions-for-non-urgent-updates)
60 - 5.12 [Use useRef for Transient Values](#512-use-useref-for-transient-values)
616. [Rendering Performance](#6-rendering-performance) — **MEDIUM**
62 - 6.1 [Animate SVG Wrapper Instead of SVG Element](#61-animate-svg-wrapper-instead-of-svg-element)
63 - 6.2 [CSS content-visibility for Long Lists](#62-css-content-visibility-for-long-lists)
64 - 6.3 [Hoist Static JSX Elements](#63-hoist-static-jsx-elements)
65 - 6.4 [Optimize SVG Precision](#64-optimize-svg-precision)
66 - 6.5 [Prevent Hydration Mismatch Without Flickering](#65-prevent-hydration-mismatch-without-flickering)
67 - 6.6 [Suppress Expected Hydration Mismatches](#66-suppress-expected-hydration-mismatches)
68 - 6.7 [Use Activity Component for Show/Hide](#67-use-activity-component-for-showhide)
69 - 6.8 [Use Explicit Conditional Rendering](#68-use-explicit-conditional-rendering)
70 - 6.9 [Use useTransition Over Manual Loading States](#69-use-usetransition-over-manual-loading-states)
717. [JavaScript Performance](#7-javascript-performance) — **LOW-MEDIUM**
72 - 7.1 [Avoid Layout Thrashing](#71-avoid-layout-thrashing)
73 - 7.2 [Build Index Maps for Repeated Lookups](#72-build-index-maps-for-repeated-lookups)
74 - 7.3 [Cache Property Access in Loops](#73-cache-property-access-in-loops)
75 - 7.4 [Cache Repeated Function Calls](#74-cache-repeated-function-calls)
76 - 7.5 [Cache Storage API Calls](#75-cache-storage-api-calls)
77 - 7.6 [Combine Multiple Array Iterations](#76-combine-multiple-array-iterations)
78 - 7.7 [Early Length Check for Array Comparisons](#77-early-length-check-for-array-comparisons)
79 - 7.8 [Early Return from Functions](#78-early-return-from-functions)
80 - 7.9 [Hoist RegExp Creation](#79-hoist-regexp-creation)
81 - 7.10 [Use Loop for Min/Max Instead of Sort](#710-use-loop-for-minmax-instead-of-sort)
82 - 7.11 [Use Set/Map for O(1) Lookups](#711-use-setmap-for-o1-lookups)
83 - 7.12 [Use toSorted() Instead of sort() for Immutability](#712-use-tosorted-instead-of-sort-for-immutability)
848. [Advanced Patterns](#8-advanced-patterns) — **LOW**
85 - 8.1 [Initialize App Once, Not Per Mount](#81-initialize-app-once-not-per-mount)
86 - 8.2 [Store Event Handlers in Refs](#82-store-event-handlers-in-refs)
87 - 8.3 [useEffectEvent for Stable Callback Refs](#83-useeffectevent-for-stable-callback-refs)
88
89---
90
91## 1. Eliminating Waterfalls
92
93**Impact: CRITICAL**
94
95Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
96
97### 1.1 Defer Await Until Needed
98
99**Impact: HIGH (avoids blocking unused code paths)**
100
101Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
102
103**Incorrect: blocks both branches**
104
105```typescript
106async function handleRequest(userId: string, skipProcessing: boolean) {
107 const userData = await fetchUserData(userId)
108
109 if (skipProcessing) {
110 // Returns immediately but still waited for userData
111 return { skipped: true }
112 }
113
114 // Only this branch uses userData
115 return processUserData(userData)
116}
117```
118
119**Correct: only blocks when needed**
120
121```typescript
122async function handleRequest(userId: string, skipProcessing: boolean) {
123 if (skipProcessing) {
124 // Returns immediately without waiting
125 return { skipped: true }
126 }
127
128 // Fetch only when needed
129 const userData = await fetchUserData(userId)
130 return processUserData(userData)
131}
132```
133
134**Another example: early return optimization**
135
136```typescript
137// Incorrect: always fetches permissions
138async function updateResource(resourceId: string, userId: string) {
139 const permissions = await fetchPermissions(userId)
140 const resource = await getResource(resourceId)
141
142 if (!resource) {
143 return { error: "Not found" }
144 }
145
146 if (!permissions.canEdit) {
147 return { error: "Forbidden" }
148 }
149
150 return await updateResourceData(resource, permissions)
151}
152
153// Correct: fetches only when needed
154async function updateResource(resourceId: string, userId: string) {
155 const resource = await getResource(resourceId)
156
157 if (!resource) {
158 return { error: "Not found" }
159 }
160
161 const permissions = await fetchPermissions(userId)
162
163 if (!permissions.canEdit) {
164 return { error: "Forbidden" }
165 }
166
167 return await updateResourceData(resource, permissions)
168}
169```
170
171This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
172
173### 1.2 Dependency-Based Parallelization
174
175**Impact: CRITICAL (2-10× improvement)**
176
177For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
178
179**Incorrect: profile waits for config unnecessarily**
180
181```typescript
182const [user, config] = await Promise.all([fetchUser(), fetchConfig()])
183const profile = await fetchProfile(user.id)
184```
185
186**Correct: config and profile run in parallel**
187
188```typescript
189import { all } from "better-all"
190
191const { user, config, profile } = await all({
192 async user() {
193 return fetchUser()
194 },
195 async config() {
196 return fetchConfig()
197 },
198 async profile() {
199 return fetchProfile((await this.$.user).id)
200 },
201})
202```
203
204**Alternative without extra dependencies:**
205
206```typescript
207const userPromise = fetchUser()
208const profilePromise = userPromise.then((user) => fetchProfile(user.id))
209
210const [user, config, profile] = await Promise.all([userPromise, fetchConfig(), profilePromise])
211```
212
213We can also create all the promises first, and do `Promise.all()` at the end.
214
215Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
216
217### 1.3 Prevent Waterfall Chains in API Routes
218
219**Impact: CRITICAL (2-10× improvement)**
220
221In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
222
223**Incorrect: config waits for auth, data waits for both**
224
225```typescript
226export async function GET(request: Request) {
227 const session = await auth()
228 const config = await fetchConfig()
229 const data = await fetchData(session.user.id)
230 return Response.json({ data, config })
231}
232```
233
234**Correct: auth and config start immediately**
235
236```typescript
237export async function GET(request: Request) {
238 const sessionPromise = auth()
239 const configPromise = fetchConfig()
240 const session = await sessionPromise
241 const [config, data] = await Promise.all([configPromise, fetchData(session.user.id)])
242 return Response.json({ data, config })
243}
244```
245
246For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
247
248### 1.4 Promise.all() for Independent Operations
249
250**Impact: CRITICAL (2-10× improvement)**
251
252When async operations have no interdependencies, execute them concurrently using `Promise.all()`.
253
254**Incorrect: sequential execution, 3 round trips**
255
256```typescript
257const user = await fetchUser()
258const posts = await fetchPosts()
259const comments = await fetchComments()
260```
261
262**Correct: parallel execution, 1 round trip**
263
264```typescript
265const [user, posts, comments] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()])
266```
267
268### 1.5 Strategic Suspense Boundaries
269
270**Impact: HIGH (faster initial paint)**
271
272Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
273
274**Incorrect: wrapper blocked by data fetching**
275
276```tsx
277async function Page() {
278 const data = await fetchData() // Blocks entire page
279
280 return (
281 <div>
282 <div>Sidebar</div>
283 <div>Header</div>
284 <div>
285 <DataDisplay data={data} />
286 </div>
287 <div>Footer</div>
288 </div>
289 )
290}
291```
292
293The entire layout waits for data even though only the middle section needs it.
294
295**Correct: wrapper shows immediately, data streams in**
296
297```tsx
298function Page() {
299 return (
300 <div>
301 <div>Sidebar</div>
302 <div>Header</div>
303 <div>
304 <Suspense fallback={<Skeleton />}>
305 <DataDisplay />
306 </Suspense>
307 </div>
308 <div>Footer</div>
309 </div>
310 )
311}
312
313async function DataDisplay() {
314 const data = await fetchData() // Only blocks this component
315 return <div>{data.content}</div>
316}
317```
318
319Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
320
321**Alternative: share promise across components**
322
323```tsx
324function Page() {
325 // Start fetch immediately, but don't await
326 const dataPromise = fetchData()
327
328 return (
329 <div>
330 <div>Sidebar</div>
331 <div>Header</div>
332 <Suspense fallback={<Skeleton />}>
333 <DataDisplay dataPromise={dataPromise} />
334 <DataSummary dataPromise={dataPromise} />
335 </Suspense>
336 <div>Footer</div>
337 </div>
338 )
339}
340
341function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
342 const data = use(dataPromise) // Unwraps the promise
343 return <div>{data.content}</div>
344}
345
346function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
347 const data = use(dataPromise) // Reuses the same promise
348 return <div>{data.summary}</div>
349}
350```
351
352Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.
353
354**When NOT to use this pattern:**
355
356- Critical data needed for layout decisions (affects positioning)
357
358- SEO-critical content above the fold
359
360- Small, fast queries where suspense overhead isn't worth it
361
362- When you want to avoid layout shift (loading → content jump)
363
364**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.
365
366---
367
368## 2. Bundle Size Optimization
369
370**Impact: CRITICAL**
371
372Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
373
374### 2.1 Avoid Barrel File Imports
375
376**Impact: CRITICAL (200-800ms import cost, slow builds)**
377
378Import 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'`).
379
380Popular 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.
381
382**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.
383
384**Incorrect: imports entire library**
385
386```tsx
387import { Check, X, Menu } from "lucide-react"
388// Loads 1,583 modules, takes ~2.8s extra in dev
389// Runtime cost: 200-800ms on every cold start
390
391import { Button, TextField } from "@mui/material"
392// Loads 2,225 modules, takes ~4.2s extra in dev
393```
394
395**Correct: imports only what you need**
396
397```tsx
398import Check from "lucide-react/dist/esm/icons/check"
399import X from "lucide-react/dist/esm/icons/x"
400import Menu from "lucide-react/dist/esm/icons/menu"
401// Loads only 3 modules (~2KB vs ~1MB)
402
403import Button from "@mui/material/Button"
404import TextField from "@mui/material/TextField"
405// Loads only what you use
406```
407
408**Alternative: Next.js 13.5+**
409
410```js
411// next.config.js - use optimizePackageImports
412module.exports = {
413 experimental: {
414 optimizePackageImports: ["lucide-react", "@mui/material"],
415 },
416}
417
418// Then you can keep the ergonomic barrel imports:
419import { Check, X, Menu } from "lucide-react"
420// Automatically transformed to direct imports at build time
421```
422
423Direct imports provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.
424
425Libraries 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`.
426
427Reference: [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
428
429### 2.2 Conditional Module Loading
430
431**Impact: HIGH (loads large data only when needed)**
432
433Load large data or modules only when a feature is activated.
434
435**Example: lazy-load animation frames**
436
437```tsx
438function AnimationPlayer({
439 enabled,
440 setEnabled,
441}: {
442 enabled: boolean
443 setEnabled: React.Dispatch<React.SetStateAction<boolean>>
444}) {
445 const [frames, setFrames] = useState<Frame[] | null>(null)
446
447 useEffect(() => {
448 if (enabled && !frames && typeof window !== "undefined") {
449 import("./animation-frames.js")
450 .then((mod) => setFrames(mod.frames))
451 .catch(() => setEnabled(false))
452 }
453 }, [enabled, frames, setEnabled])
454
455 if (!frames) return <Skeleton />
456 return <Canvas frames={frames} />
457}
458```
459
460The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.
461
462### 2.3 Defer Non-Critical Third-Party Libraries
463
464**Impact: MEDIUM (loads after hydration)**
465
466Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
467
468**Incorrect: blocks initial bundle**
469
470```tsx
471import { Analytics } from "@vercel/analytics/react"
472
473export default function RootLayout({ children }) {
474 return (
475 <html>
476 <body>
477 {children}
478 <Analytics />
479 </body>
480 </html>
481 )
482}
483```
484
485**Correct: loads after hydration**
486
487```tsx
488import dynamic from "next/dynamic"
489
490const Analytics = dynamic(() => import("@vercel/analytics/react").then((m) => m.Analytics), {
491 ssr: false,
492})
493
494export default function RootLayout({ children }) {
495 return (
496 <html>
497 <body>
498 {children}
499 <Analytics />
500 </body>
501 </html>
502 )
503}
504```
505
506### 2.4 Dynamic Imports for Heavy Components
507
508**Impact: CRITICAL (directly affects TTI and LCP)**
509
510Use `next/dynamic` to lazy-load large components not needed on initial render.
511
512**Incorrect: Monaco bundles with main chunk ~300KB**
513
514```tsx
515import { MonacoEditor } from "./monaco-editor"
516
517function CodePanel({ code }: { code: string }) {
518 return <MonacoEditor value={code} />
519}
520```
521
522**Correct: Monaco loads on demand**
523
524```tsx
525import dynamic from "next/dynamic"
526
527const MonacoEditor = dynamic(() => import("./monaco-editor").then((m) => m.MonacoEditor), {
528 ssr: false,
529})
530
531function CodePanel({ code }: { code: string }) {
532 return <MonacoEditor value={code} />
533}
534```
535
536### 2.5 Preload Based on User Intent
537
538**Impact: MEDIUM (reduces perceived latency)**
539
540Preload heavy bundles before they're needed to reduce perceived latency.
541
542**Example: preload on hover/focus**
543
544```tsx
545function EditorButton({ onClick }: { onClick: () => void }) {
546 const preload = () => {
547 if (typeof window !== "undefined") {
548 void import("./monaco-editor")
549 }
550 }
551
552 return (
553 <button onMouseEnter={preload} onFocus={preload} onClick={onClick}>
554 Open Editor
555 </button>
556 )
557}
558```
559
560**Example: preload when feature flag is enabled**
561
562```tsx
563function FlagsProvider({ children, flags }: Props) {
564 useEffect(() => {
565 if (flags.editorEnabled && typeof window !== "undefined") {
566 void import("./monaco-editor").then((mod) => mod.init())
567 }
568 }, [flags.editorEnabled])
569
570 return <FlagsContext.Provider value={flags}>{children}</FlagsContext.Provider>
571}
572```
573
574The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.
575
576---
577
578## 3. Server-Side Performance
579
580**Impact: HIGH**
581
582Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times.
583
584### 3.1 Authenticate Server Actions Like API Routes
585
586**Impact: CRITICAL (prevents unauthorized access to server mutations)**
587
588Server 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.
589
590Next.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."
591
592**Incorrect: no authentication check**
593
594```typescript
595"use server"
596
597export async function deleteUser(userId: string) {
598 // Anyone can call this! No auth check
599 await db.user.delete({ where: { id: userId } })
600 return { success: true }
601}
602```
603
604**Correct: authentication inside the action**
605
606```typescript
607"use server"
608
609import { verifySession } from "@/lib/auth"
610import { unauthorized } from "@/lib/errors"
611
612export async function deleteUser(userId: string) {
613 // Always check auth inside the action
614 const session = await verifySession()
615
616 if (!session) {
617 throw unauthorized("Must be logged in")
618 }
619
620 // Check authorization too
621 if (session.user.role !== "admin" && session.user.id !== userId) {
622 throw unauthorized("Cannot delete other users")
623 }
624
625 await db.user.delete({ where: { id: userId } })
626 return { success: true }
627}
628```
629
630**With input validation:**
631
632```typescript
633"use server"
634
635import { verifySession } from "@/lib/auth"
636import { z } from "zod"
637
638const updateProfileSchema = z.object({
639 userId: z.string().uuid(),
640 name: z.string().min(1).max(100),
641 email: z.string().email(),
642})
643
644export async function updateProfile(data: unknown) {
645 // Validate input first
646 const validated = updateProfileSchema.parse(data)
647
648 // Then authenticate
649 const session = await verifySession()
650 if (!session) {
651 throw new Error("Unauthorized")
652 }
653
654 // Then authorize
655 if (session.user.id !== validated.userId) {
656 throw new Error("Can only update own profile")
657 }
658
659 // Finally perform the mutation
660 await db.user.update({
661 where: { id: validated.userId },
662 data: {
663 name: validated.name,
664 email: validated.email,
665 },
666 })
667
668 return { success: true }
669}
670```
671
672Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)
673
674### 3.2 Avoid Duplicate Serialization in RSC Props
675
676**Impact: LOW (reduces network payload by avoiding duplicate serialization)**
677
678RSC→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.
679
680**Incorrect: duplicates array**
681
682```tsx
683// RSC: sends 6 strings (2 arrays × 3 items)
684<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />
685```
686
687**Correct: sends 3 strings**
688
689```tsx
690// RSC: send once
691;<ClientList usernames={usernames} />
692
693// Client: transform there
694;("use client")
695const sorted = useMemo(() => [...usernames].sort(), [usernames])
696```
697
698**Nested deduplication behavior:**
699
700```tsx
701// string[] - duplicates everything
702usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings
703
704// object[] - duplicates array structure only
705users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)
706```
707
708Deduplication works recursively. Impact varies by data type:
709
710- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated
711
712- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference
713
714**Operations breaking deduplication: create new references**
715
716- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`
717
718- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`
719
720**More examples:**
721
722```tsx
723// ❌ Bad
724<C users={users} active={users.filter(u => u.active)} />
725<C product={product} productName={product.name} />
726
727// ✅ Good
728<C users={users} />
729<C product={product} />
730// Do filtering/destructuring in client
731```
732
733**Exception:** Pass derived data when transformation is expensive or client doesn't need original.
734
735### 3.3 Cross-Request LRU Caching
736
737**Impact: HIGH (caches across requests)**
738
739`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
740
741**Implementation:**
742
743```typescript
744import { LRUCache } from "lru-cache"
745
746const cache = new LRUCache<string, any>({
747 max: 1000,
748 ttl: 5 * 60 * 1000, // 5 minutes
749})
750
751export async function getUser(id: string) {
752 const cached = cache.get(id)
753 if (cached) return cached
754
755 const user = await db.user.findUnique({ where: { id } })
756 cache.set(id, user)
757 return user
758}
759
760// Request 1: DB query, result cached
761// Request 2: cache hit, no DB query
762```
763
764Use when sequential user actions hit multiple endpoints needing the same data within seconds.
765
766**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.
767
768**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.
769
770Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
771
772### 3.4 Minimize Serialization at RSC Boundaries
773
774**Impact: HIGH (reduces data transfer size)**
775
776The 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.
777
778**Incorrect: serializes all 50 fields**
779
780```tsx
781async function Page() {
782 const user = await fetchUser() // 50 fields
783 return <Profile user={user} />
784}
785
786;("use client")
787function Profile({ user }: { user: User }) {
788 return <div>{user.name}</div> // uses 1 field
789}
790```
791
792**Correct: serializes only 1 field**
793
794```tsx
795async function Page() {
796 const user = await fetchUser()
797 return <Profile name={user.name} />
798}
799
800;("use client")
801function Profile({ name }: { name: string }) {
802 return <div>{name}</div>
803}
804```
805
806### 3.5 Parallel Data Fetching with Component Composition
807
808**Impact: CRITICAL (eliminates server-side waterfalls)**
809
810React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
811
812**Incorrect: Sidebar waits for Page's fetch to complete**
813
814```tsx
815export default async function Page() {
816 const header = await fetchHeader()
817 return (
818 <div>
819 <div>{header}</div>
820 <Sidebar />
821 </div>
822 )
823}
824
825async function Sidebar() {
826 const items = await fetchSidebarItems()
827 return <nav>{items.map(renderItem)}</nav>
828}
829```
830
831**Correct: both fetch simultaneously**
832
833```tsx
834async function Header() {
835 const data = await fetchHeader()
836 return <div>{data}</div>
837}
838
839async function Sidebar() {
840 const items = await fetchSidebarItems()
841 return <nav>{items.map(renderItem)}</nav>
842}
843
844export default function Page() {
845 return (
846 <div>
847 <Header />
848 <Sidebar />
849 </div>
850 )
851}
852```
853
854**Alternative with children prop:**
855
856```tsx
857async function Header() {
858 const data = await fetchHeader()
859 return <div>{data}</div>
860}
861
862async function Sidebar() {
863 const items = await fetchSidebarItems()
864 return <nav>{items.map(renderItem)}</nav>
865}
866
867function Layout({ children }: { children: ReactNode }) {
868 return (
869 <div>
870 <Header />
871 {children}
872 </div>
873 )
874}
875
876export default function Page() {
877 return (
878 <Layout>
879 <Sidebar />
880 </Layout>
881 )
882}
883```
884
885### 3.6 Per-Request Deduplication with React.cache()
886
887**Impact: MEDIUM (deduplicates within request)**
888
889Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.
890
891**Usage:**
892
893```typescript
894import { cache } from "react"
895
896export const getCurrentUser = cache(async () => {
897 const session = await auth()
898 if (!session?.user?.id) return null
899 return await db.user.findUnique({
900 where: { id: session.user.id },
901 })
902})
903```
904
905Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
906
907**Avoid inline objects as arguments:**
908
909`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits.
910
911**Incorrect: always cache miss**
912
913```typescript
914const getUser = cache(async (params: { uid: number }) => {
915 return await db.user.findUnique({ where: { id: params.uid } })
916})
917
918// Each call creates new object, never hits cache
919getUser({ uid: 1 })
920getUser({ uid: 1 }) // Cache miss, runs query again
921```
922
923**Correct: cache hit**
924
925```typescript
926const params = { uid: 1 }
927getUser(params) // Query runs
928getUser(params) // Cache hit (same reference)
929```
930
931If you must pass objects, pass the same reference:
932
933**Next.js-Specific Note:**
934
935In 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:
936
937- Database queries (Prisma, Drizzle, etc.)
938
939- Heavy computations
940
941- Authentication checks
942
943- File system operations
944
945- Any non-fetch async work
946
947Use `React.cache()` to deduplicate these operations across your component tree.
948
949Reference: [https://react.dev/reference/react/cache](https://react.dev/reference/react/cache)
950
951### 3.7 Use after() for Non-Blocking Operations
952
953**Impact: MEDIUM (faster response times)**
954
955Use 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.
956
957**Incorrect: blocks response**
958
959```tsx
960import { logUserAction } from "@/app/utils"
961
962export async function POST(request: Request) {
963 // Perform mutation
964 await updateDatabase(request)
965
966 // Logging blocks the response
967 const userAgent = request.headers.get("user-agent") || "unknown"
968 await logUserAction({ userAgent })
969
970 return new Response(JSON.stringify({ status: "success" }), {
971 status: 200,
972 headers: { "Content-Type": "application/json" },
973 })
974}
975```
976
977**Correct: non-blocking**
978
979```tsx
980import { after } from "next/server"
981import { headers, cookies } from "next/headers"
982import { logUserAction } from "@/app/utils"
983
984export async function POST(request: Request) {
985 // Perform mutation
986 await updateDatabase(request)
987
988 // Log after response is sent
989 after(async () => {
990 const userAgent = (await headers()).get("user-agent") || "unknown"
991 const sessionCookie = (await cookies()).get("session-id")?.value || "anonymous"
992
993 logUserAction({ sessionCookie, userAgent })
994 })
995
996 return new Response(JSON.stringify({ status: "success" }), {
997 status: 200,
998 headers: { "Content-Type": "application/json" },
999 })
1000}
1001```
1002
1003The response is sent immediately while logging happens in the background.
1004
1005**Common use cases:**
1006
1007- Analytics tracking
1008
1009- Audit logging
1010
1011- Sending notifications
1012
1013- Cache invalidation
1014
1015- Cleanup tasks
1016
1017**Important notes:**
1018
1019- `after()` runs even if the response fails or redirects
1020
1021- Works in Server Actions, Route Handlers, and Server Components
1022
1023Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after)
1024
1025---
1026
1027## 4. Client-Side Data Fetching
1028
1029**Impact: MEDIUM-HIGH**
1030
1031Automatic deduplication and efficient data fetching patterns reduce redundant network requests.
1032
1033### 4.1 Deduplicate Global Event Listeners
1034
1035**Impact: LOW (single listener for N components)**
1036
1037Use `useSWRSubscription()` to share global event listeners across component instances.
1038
1039**Incorrect: N instances = N listeners**
1040
1041```tsx
1042function useKeyboardShortcut(key: string, callback: () => void) {
1043 useEffect(() => {
1044 const handler = (e: KeyboardEvent) => {
1045 if (e.metaKey && e.key === key) {
1046 callback()
1047 }
1048 }
1049 window.addEventListener("keydown", handler)
1050 return () => window.removeEventListener("keydown", handler)
1051 }, [key, callback])
1052}
1053```
1054
1055When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener.
1056
1057**Correct: N instances = 1 listener**
1058
1059```tsx
1060import useSWRSubscription from "swr/subscription"
1061
1062// Module-level Map to track callbacks per key
1063const keyCallbacks = new Map<string, Set<() => void>>()
1064
1065function useKeyboardShortcut(key: string, callback: () => void) {
1066 // Register this callback in the Map
1067 useEffect(() => {
1068 if (!keyCallbacks.has(key)) {
1069 keyCallbacks.set(key, new Set())
1070 }
1071 keyCallbacks.get(key)!.add(callback)
1072
1073 return () => {
1074 const set = keyCallbacks.get(key)
1075 if (set) {
1076 set.delete(callback)
1077 if (set.size === 0) {
1078 keyCallbacks.delete(key)
1079 }
1080 }
1081 }
1082 }, [key, callback])
1083
1084 useSWRSubscription("global-keydown", () => {
1085 const handler = (e: KeyboardEvent) => {
1086 if (e.metaKey && keyCallbacks.has(e.key)) {
1087 keyCallbacks.get(e.key)!.forEach((cb) => cb())
1088 }
1089 }
1090 window.addEventListener("keydown", handler)
1091 return () => window.removeEventListener("keydown", handler)
1092 })
1093}
1094
1095function Profile() {
1096 // Multiple shortcuts will share the same listener
1097 useKeyboardShortcut("p", () => {
1098 /* ... */
1099 })
1100 useKeyboardShortcut("k", () => {
1101 /* ... */
1102 })
1103 // ...
1104}
1105```
1106
1107### 4.2 Use Passive Event Listeners for Scrolling Performance
1108
1109**Impact: MEDIUM (eliminates scroll delay caused by event listeners)**
1110
1111Add `{ 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.
1112
1113**Incorrect:**
1114
1115```typescript
1116useEffect(() => {
1117 const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
1118 const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
1119
1120 document.addEventListener("touchstart", handleTouch)
1121 document.addEventListener("wheel", handleWheel)
1122
1123 return () => {
1124 document.removeEventListener("touchstart", handleTouch)
1125 document.removeEventListener("wheel", handleWheel)
1126 }
1127}, [])
1128```
1129
1130**Correct:**
1131
1132```typescript
1133useEffect(() => {
1134 const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
1135 const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
1136
1137 document.addEventListener("touchstart", handleTouch, { passive: true })
1138 document.addEventListener("wheel", handleWheel, { passive: true })
1139
1140 return () => {
1141 document.removeEventListener("touchstart", handleTouch)
1142 document.removeEventListener("wheel", handleWheel)
1143 }
1144}, [])
1145```
1146
1147**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.
1148
1149**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`.
1150
1151### 4.3 Use SWR for Automatic Deduplication
1152
1153**Impact: MEDIUM-HIGH (automatic deduplication)**
1154
1155SWR enables request deduplication, caching, and revalidation across component instances.
1156
1157**Incorrect: no deduplication, each instance fetches**
1158
1159```tsx
1160function UserList() {
1161 const [users, setUsers] = useState([])
1162 useEffect(() => {
1163 fetch("/api/users")
1164 .then((r) => r.json())
1165 .then(setUsers)
1166 }, [])
1167}
1168```
1169
1170**Correct: multiple instances share one request**
1171
1172```tsx
1173import useSWR from "swr"
1174
1175function UserList() {
1176 const { data: users } = useSWR("/api/users", fetcher)
1177}
1178```
1179
1180**For immutable data:**
1181
1182```tsx
1183import { useImmutableSWR } from "@/lib/swr"
1184
1185function StaticContent() {
1186 const { data } = useImmutableSWR("/api/config", fetcher)
1187}
1188```
1189
1190**For mutations:**
1191
1192```tsx
1193import { useSWRMutation } from "swr/mutation"
1194
1195function UpdateButton() {
1196 const { trigger } = useSWRMutation("/api/user", updateUser)
1197 return <button onClick={() => trigger()}>Update</button>
1198}
1199```
1200
1201Reference: [https://swr.vercel.app](https://swr.vercel.app)
1202
1203### 4.4 Version and Minimize localStorage Data
1204
1205**Impact: MEDIUM (prevents schema conflicts, reduces storage size)**
1206
1207Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data.
1208
1209**Incorrect:**
1210
1211```typescript
1212// No version, stores everything, no error handling
1213localStorage.setItem("userConfig", JSON.stringify(fullUserObject))
1214const data = localStorage.getItem("userConfig")
1215```
1216
1217**Correct:**
1218
1219```typescript
1220const VERSION = "v2"
1221
1222function saveConfig(config: { theme: string; language: string }) {
1223 try {
1224 localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))
1225 } catch {
1226 // Throws in incognito/private browsing, quota exceeded, or disabled
1227 }
1228}
1229
1230function loadConfig() {
1231 try {
1232 const data = localStorage.getItem(`userConfig:${VERSION}`)
1233 return data ? JSON.parse(data) : null
1234 } catch {
1235 return null
1236 }
1237}
1238
1239// Migration from v1 to v2
1240function migrate() {
1241 try {
1242 const v1 = localStorage.getItem("userConfig:v1")
1243 if (v1) {
1244 const old = JSON.parse(v1)
1245 saveConfig({ theme: old.darkMode ? "dark" : "light", language: old.lang })
1246 localStorage.removeItem("userConfig:v1")
1247 }
1248 } catch {}
1249}
1250```
1251
1252**Store minimal fields from server responses:**
1253
1254```typescript
1255// User object has 20+ fields, only store what UI needs
1256function cachePrefs(user: FullUser) {
1257 try {
1258 localStorage.setItem(
1259 "prefs:v1",
1260 JSON.stringify({
1261 theme: user.preferences.theme,
1262 notifications: user.preferences.notifications,
1263 })
1264 )
1265 } catch {}
1266}
1267```
1268
1269**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled.
1270
1271**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags.
1272
1273---
1274
1275## 5. Re-render Optimization
1276
1277**Impact: MEDIUM**
1278
1279Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.
1280
1281### 5.1 Calculate Derived State During Rendering
1282
1283**Impact: MEDIUM (avoids redundant renders and state drift)**
1284
1285If 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.
1286
1287**Incorrect: redundant state and effect**
1288
1289```tsx
1290function Form() {
1291 const [firstName, setFirstName] = useState("First")
1292 const [lastName, setLastName] = useState("Last")
1293 const [fullName, setFullName] = useState("")
1294
1295 useEffect(() => {
1296 setFullName(firstName + " " + lastName)
1297 }, [firstName, lastName])
1298
1299 return <p>{fullName}</p>
1300}
1301```
1302
1303**Correct: derive during render**
1304
1305```tsx
1306function Form() {
1307 const [firstName, setFirstName] = useState("First")
1308 const [lastName, setLastName] = useState("Last")
1309 const fullName = firstName + " " + lastName
1310
1311 return <p>{fullName}</p>
1312}
1313```
1314
1315Reference: [https://react.dev/learn/you-might-not-need-an-effect](https://react.dev/learn/you-might-not-need-an-effect)
1316
1317### 5.2 Defer State Reads to Usage Point
1318
1319**Impact: MEDIUM (avoids unnecessary subscriptions)**
1320
1321Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
1322
1323**Incorrect: subscribes to all searchParams changes**
1324
1325```tsx
1326function ShareButton({ chatId }: { chatId: string }) {
1327 const searchParams = useSearchParams()
1328
1329 const handleShare = () => {
1330 const ref = searchParams.get("ref")
1331 shareChat(chatId, { ref })
1332 }
1333
1334 return <button onClick={handleShare}>Share</button>
1335}
1336```
1337
1338**Correct: reads on demand, no subscription**
1339
1340```tsx
1341function ShareButton({ chatId }: { chatId: string }) {
1342 const handleShare = () => {
1343 const params = new URLSearchParams(window.location.search)
1344 const ref = params.get("ref")
1345 shareChat(chatId, { ref })
1346 }
1347
1348 return <button onClick={handleShare}>Share</button>
1349}
1350```
1351
1352### 5.3 Do not wrap a simple expression with a primitive result type in useMemo
1353
1354**Impact: LOW-MEDIUM (wasted computation on every render)**
1355
1356When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`.
1357
1358Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.
1359
1360**Incorrect:**
1361
1362```tsx
1363function Header({ user, notifications }: Props) {
1364 const isLoading = useMemo(() => {
1365 return user.isLoading || notifications.isLoading
1366 }, [user.isLoading, notifications.isLoading])
1367
1368 if (isLoading) return <Skeleton />
1369 // return some markup
1370}
1371```
1372
1373**Correct:**
1374
1375```tsx
1376function Header({ user, notifications }: Props) {
1377 const isLoading = user.isLoading || notifications.isLoading
1378
1379 if (isLoading) return <Skeleton />
1380 // return some markup
1381}
1382```
1383
1384### 5.4 Extract Default Non-primitive Parameter Value from Memoized Component to Constant
1385
1386**Impact: MEDIUM (restores memoization by using a constant for default value)**
1387
1388When 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()`.
1389
1390To address this issue, extract the default value into a constant.
1391
1392**Incorrect: `onClick` has different values on every rerender**
1393
1394```tsx
1395const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
1396 // ...
1397})
1398
1399// Used without optional onClick
1400<UserAvatar />
1401```
1402
1403**Correct: stable default value**
1404
1405```tsx
1406const NOOP = () => {};
1407
1408const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
1409 // ...
1410})
1411
1412// Used without optional onClick
1413<UserAvatar />
1414```
1415
1416### 5.5 Extract to Memoized Components
1417
1418**Impact: MEDIUM (enables early returns)**
1419
1420Extract expensive work into memoized components to enable early returns before computation.
1421
1422**Incorrect: computes avatar even when loading**
1423
1424```tsx
1425function Profile({ user, loading }: Props) {
1426 const avatar = useMemo(() => {
1427 const id = computeAvatarId(user)
1428 return <Avatar id={id} />
1429 }, [user])
1430
1431 if (loading) return <Skeleton />
1432 return <div>{avatar}</div>
1433}
1434```
1435
1436**Correct: skips computation when loading**
1437
1438```tsx
1439const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
1440 const id = useMemo(() => computeAvatarId(user), [user])
1441 return <Avatar id={id} />
1442})
1443
1444function Profile({ user, loading }: Props) {
1445 if (loading) return <Skeleton />
1446 return (
1447 <div>
1448 <UserAvatar user={user} />
1449 </div>
1450 )
1451}
1452```
1453
1454**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.
1455
1456### 5.6 Narrow Effect Dependencies
1457
1458**Impact: LOW (minimizes effect re-runs)**
1459
1460Specify primitive dependencies instead of objects to minimize effect re-runs.
1461
1462**Incorrect: re-runs on any user field change**
1463
1464```tsx
1465useEffect(() => {
1466 console.log(user.id)
1467}, [user])
1468```
1469
1470**Correct: re-runs only when id changes**
1471
1472```tsx
1473useEffect(() => {
1474 console.log(user.id)
1475}, [user.id])
1476```
1477
1478**For derived state, compute outside effect:**
1479
1480```tsx
1481// Incorrect: runs on width=767, 766, 765...
1482useEffect(() => {
1483 if (width < 768) {
1484 enableMobileMode()
1485 }
1486}, [width])
1487
1488// Correct: runs only on boolean transition
1489const isMobile = width < 768
1490useEffect(() => {
1491 if (isMobile) {
1492 enableMobileMode()
1493 }
1494}, [isMobile])
1495```
1496
1497### 5.7 Put Interaction Logic in Event Handlers
1498
1499**Impact: MEDIUM (avoids effect re-runs and duplicate side effects)**
1500
1501If 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.
1502
1503**Incorrect: event modeled as state + effect**
1504
1505```tsx
1506function Form() {
1507 const [submitted, setSubmitted] = useState(false)
1508 const theme = useContext(ThemeContext)
1509
1510 useEffect(() => {
1511 if (submitted) {
1512 post("/api/register")
1513 showToast("Registered", theme)
1514 }
1515 }, [submitted, theme])
1516
1517 return <button onClick={() => setSubmitted(true)}>Submit</button>
1518}
1519```
1520
1521**Correct: do it in the handler**
1522
1523```tsx
1524function Form() {
1525 const theme = useContext(ThemeContext)
1526
1527 function handleSubmit() {
1528 post("/api/register")
1529 showToast("Registered", theme)
1530 }
1531
1532 return <button onClick={handleSubmit}>Submit</button>
1533}
1534```
1535
1536Reference: [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)
1537
1538### 5.8 Subscribe to Derived State
1539
1540**Impact: MEDIUM (reduces re-render frequency)**
1541
1542Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
1543
1544**Incorrect: re-renders on every pixel change**
1545
1546```tsx
1547function Sidebar() {
1548 const width = useWindowWidth() // updates continuously
1549 const isMobile = width < 768
1550 return <nav className={isMobile ? "mobile" : "desktop"} />
1551}
1552```
1553
1554**Correct: re-renders only when boolean changes**
1555
1556```tsx
1557function Sidebar() {
1558 const isMobile = useMediaQuery("(max-width: 767px)")
1559 return <nav className={isMobile ? "mobile" : "desktop"} />
1560}
1561```
1562
1563### 5.9 Use Functional setState Updates
1564
1565**Impact: MEDIUM (prevents stale closures and unnecessary callback recreations)**
1566
1567When 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.
1568
1569**Incorrect: requires state as dependency**
1570
1571```tsx
1572function TodoList() {
1573 const [items, setItems] = useState(initialItems)
1574
1575 // Callback must depend on items, recreated on every items change
1576 const addItems = useCallback(
1577 (newItems: Item[]) => {
1578 setItems([...items, ...newItems])
1579 },
1580 [items]
1581 ) // ❌ items dependency causes recreations
1582
1583 // Risk of stale closure if dependency is forgotten
1584 const removeItem = useCallback((id: string) => {
1585 setItems(items.filter((item) => item.id !== id))
1586 }, []) // ❌ Missing items dependency - will use stale items!
1587
1588 return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
1589}
1590```
1591
1592The 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.
1593
1594**Correct: stable callbacks, no stale closures**
1595
1596```tsx
1597function TodoList() {
1598 const [items, setItems] = useState(initialItems)
1599
1600 // Stable callback, never recreated
1601 const addItems = useCallback((newItems: Item[]) => {
1602 setItems((curr) => [...curr, ...newItems])
1603 }, []) // ✅ No dependencies needed
1604
1605 // Always uses latest state, no stale closure risk
1606 const removeItem = useCallback((id: string) => {
1607 setItems((curr) => curr.filter((item) => item.id !== id))
1608 }, []) // ✅ Safe and stable
1609
1610 return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
1611}
1612```
1613
1614**Benefits:**
1615
16161. **Stable callback references** - Callbacks don't need to be recreated when state changes
1617
16182. **No stale closures** - Always operates on the latest state value
1619
16203. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
1621
16224. **Prevents bugs** - Eliminates the most common source of React closure bugs
1623
1624**When to use functional updates:**
1625
1626- Any setState that depends on the current state value
1627
1628- Inside useCallback/useMemo when state is needed
1629
1630- Event handlers that reference state
1631
1632- Async operations that update state
1633
1634**When direct updates are fine:**
1635
1636- Setting state to a static value: `setCount(0)`
1637
1638- Setting state from props/arguments only: `setName(newName)`
1639
1640- State doesn't depend on previous value
1641
1642**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.
1643
1644### 5.10 Use Lazy State Initialization
1645
1646**Impact: MEDIUM (wasted computation on every render)**
1647
1648Pass 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.
1649
1650**Incorrect: runs on every render**
1651
1652```tsx
1653function FilteredList({ items }: { items: Item[] }) {
1654 // buildSearchIndex() runs on EVERY render, even after initialization
1655 const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
1656 const [query, setQuery] = useState("")
1657
1658 // When query changes, buildSearchIndex runs again unnecessarily
1659 return <SearchResults index={searchIndex} query={query} />
1660}
1661
1662function UserProfile() {
1663 // JSON.parse runs on every render
1664 const [settings, setSettings] = useState(JSON.parse(localStorage.getItem("settings") || "{}"))
1665
1666 return <SettingsForm settings={settings} onChange={setSettings} />
1667}
1668```
1669
1670**Correct: runs only once**
1671
1672```tsx
1673function FilteredList({ items }: { items: Item[] }) {
1674 // buildSearchIndex() runs ONLY on initial render
1675 const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
1676 const [query, setQuery] = useState("")
1677
1678 return <SearchResults index={searchIndex} query={query} />
1679}
1680
1681function UserProfile() {
1682 // JSON.parse runs only on initial render
1683 const [settings, setSettings] = useState(() => {
1684 const stored = localStorage.getItem("settings")
1685 return stored ? JSON.parse(stored) : {}
1686 })
1687
1688 return <SettingsForm settings={settings} onChange={setSettings} />
1689}
1690```
1691
1692Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
1693
1694For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
1695
1696### 5.11 Use Transitions for Non-Urgent Updates
1697
1698**Impact: MEDIUM (maintains UI responsiveness)**
1699
1700Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
1701
1702**Incorrect: blocks UI on every scroll**
1703
1704```tsx
1705function ScrollTracker() {
1706 const [scrollY, setScrollY] = useState(0)
1707 useEffect(() => {
1708 const handler = () => setScrollY(window.scrollY)
1709 window.addEventListener("scroll", handler, { passive: true })
1710 return () => window.removeEventListener("scroll", handler)
1711 }, [])
1712}
1713```
1714
1715**Correct: non-blocking updates**
1716
1717```tsx
1718import { startTransition } from "react"
1719
1720function ScrollTracker() {
1721 const [scrollY, setScrollY] = useState(0)
1722 useEffect(() => {
1723 const handler = () => {
1724 startTransition(() => setScrollY(window.scrollY))
1725 }
1726 window.addEventListener("scroll", handler, { passive: true })
1727 return () => window.removeEventListener("scroll", handler)
1728 }, [])
1729}
1730```
1731
1732### 5.12 Use useRef for Transient Values
1733
1734**Impact: MEDIUM (avoids unnecessary re-renders on frequent updates)**
1735
1736When 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.
1737
1738**Incorrect: renders every update**
1739
1740```tsx
1741function Tracker() {
1742 const [lastX, setLastX] = useState(0)
1743
1744 useEffect(() => {
1745 const onMove = (e: MouseEvent) => setLastX(e.clientX)
1746 window.addEventListener("mousemove", onMove)
1747 return () => window.removeEventListener("mousemove", onMove)
1748 }, [])
1749
1750 return (
1751 <div
1752 style={{
1753 position: "fixed",
1754 top: 0,
1755 left: lastX,
1756 width: 8,
1757 height: 8,
1758 background: "black",
1759 }}
1760 />
1761 )
1762}
1763```
1764
1765**Correct: no re-render for tracking**
1766
1767```tsx
1768function Tracker() {
1769 const lastXRef = useRef(0)
1770 const dotRef = useRef<HTMLDivElement>(null)
1771
1772 useEffect(() => {
1773 const onMove = (e: MouseEvent) => {
1774 lastXRef.current = e.clientX
1775 const node = dotRef.current
1776 if (node) {
1777 node.style.transform = `translateX(${e.clientX}px)`
1778 }
1779 }
1780 window.addEventListener("mousemove", onMove)
1781 return () => window.removeEventListener("mousemove", onMove)
1782 }, [])
1783
1784 return (
1785 <div
1786 ref={dotRef}
1787 style={{
1788 position: "fixed",
1789 top: 0,
1790 left: 0,
1791 width: 8,
1792 height: 8,
1793 background: "black",
1794 transform: "translateX(0px)",
1795 }}
1796 />
1797 )
1798}
1799```
1800
1801---
1802
1803## 6. Rendering Performance
1804
1805**Impact: MEDIUM**
1806
1807Optimizing the rendering process reduces the work the browser needs to do.
1808
1809### 6.1 Animate SVG Wrapper Instead of SVG Element
1810
1811**Impact: LOW (enables hardware acceleration)**
1812
1813Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.
1814
1815**Incorrect: animating SVG directly - no hardware acceleration**
1816
1817```tsx
1818function LoadingSpinner() {
1819 return (
1820 <svg className="animate-spin" width="24" height="24" viewBox="0 0 24 24">
1821 <circle cx="12" cy="12" r="10" stroke="currentColor" />
1822 </svg>
1823 )
1824}
1825```
1826
1827**Correct: animating wrapper div - hardware accelerated**
1828
1829```tsx
1830function LoadingSpinner() {
1831 return (
1832 <div className="animate-spin">
1833 <svg width="24" height="24" viewBox="0 0 24 24">
1834 <circle cx="12" cy="12" r="10" stroke="currentColor" />
1835 </svg>
1836 </div>
1837 )
1838}
1839```
1840
1841This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.
1842
1843### 6.2 CSS content-visibility for Long Lists
1844
1845**Impact: HIGH (faster initial render)**
1846
1847Apply `content-visibility: auto` to defer off-screen rendering.
1848
1849**CSS:**
1850
1851```css
1852.message-item {
1853 content-visibility: auto;
1854 contain-intrinsic-size: 0 80px;
1855}
1856```
1857
1858**Example:**
1859
1860```tsx
1861function MessageList({ messages }: { messages: Message[] }) {
1862 return (
1863 <div className="h-screen overflow-y-auto">
1864 {messages.map((msg) => (
1865 <div key={msg.id} className="message-item">
1866 <Avatar user={msg.author} />
1867 <div>{msg.content}</div>
1868 </div>
1869 ))}
1870 </div>
1871 )
1872}
1873```
1874
1875For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
1876
1877### 6.3 Hoist Static JSX Elements
1878
1879**Impact: LOW (avoids re-creation)**
1880
1881Extract static JSX outside components to avoid re-creation.
1882
1883**Incorrect: recreates element every render**
1884
1885```tsx
1886function LoadingSkeleton() {
1887 return <div className="h-20 animate-pulse bg-gray-200" />
1888}
1889
1890function Container() {
1891 return <div>{loading && <LoadingSkeleton />}</div>
1892}
1893```
1894
1895**Correct: reuses same element**
1896
1897```tsx
1898const loadingSkeleton = <div className="h-20 animate-pulse bg-gray-200" />
1899
1900function Container() {
1901 return <div>{loading && loadingSkeleton}</div>
1902}
1903```
1904
1905This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
1906
1907**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.
1908
1909### 6.4 Optimize SVG Precision
1910
1911**Impact: LOW (reduces file size)**
1912
1913Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
1914
1915**Incorrect: excessive precision**
1916
1917```svg
1918<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
1919```
1920
1921**Correct: 1 decimal place**
1922
1923```svg
1924<path d="M 10.3 20.8 L 30.9 40.2" />
1925```
1926
1927**Automate with SVGO:**
1928
1929```bash
1930npx svgo --precision=1 --multipass icon.svg
1931```
1932
1933### 6.5 Prevent Hydration Mismatch Without Flickering
1934
1935**Impact: MEDIUM (avoids visual flicker and hydration errors)**
1936
1937When 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.
1938
1939**Incorrect: breaks SSR**
1940
1941```tsx
1942function ThemeWrapper({ children }: { children: ReactNode }) {
1943 // localStorage is not available on server - throws error
1944 const theme = localStorage.getItem("theme") || "light"
1945
1946 return <div className={theme}>{children}</div>
1947}
1948```
1949
1950Server-side rendering will fail because `localStorage` is undefined.
1951
1952**Incorrect: visual flickering**
1953
1954```tsx
1955function ThemeWrapper({ children }: { children: ReactNode }) {
1956 const [theme, setTheme] = useState("light")
1957
1958 useEffect(() => {
1959 // Runs after hydration - causes visible flash
1960 const stored = localStorage.getItem("theme")
1961 if (stored) {
1962 setTheme(stored)
1963 }
1964 }, [])
1965
1966 return <div className={theme}>{children}</div>
1967}
1968```
1969
1970Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
1971
1972**Correct: no flicker, no hydration mismatch**
1973
1974```tsx
1975function ThemeWrapper({ children }: { children: ReactNode }) {
1976 return (
1977 <>
1978 <div id="theme-wrapper">{children}</div>
1979 <script
1980 dangerouslySetInnerHTML={{
1981 __html: `
1982 (function() {
1983 try {
1984 var theme = localStorage.getItem('theme') || 'light';
1985 var el = document.getElementById('theme-wrapper');
1986 if (el) el.className = theme;
1987 } catch (e) {}
1988 })();
1989 `,
1990 }}
1991 />
1992 </>
1993 )
1994}
1995```
1996
1997The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
1998
1999This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
2000
2001### 6.6 Suppress Expected Hydration Mismatches
2002
2003**Impact: LOW-MEDIUM (avoids noisy hydration warnings for known differences)**
2004
2005In 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.
2006
2007**Incorrect: known mismatch warnings**
2008
2009```tsx
2010function Timestamp() {
2011 return <span>{new Date().toLocaleString()}</span>
2012}
2013```
2014
2015**Correct: suppress expected mismatch only**
2016
2017```tsx
2018function Timestamp() {
2019 return <span suppressHydrationWarning>{new Date().toLocaleString()}</span>
2020}
2021```
2022
2023### 6.7 Use Activity Component for Show/Hide
2024
2025**Impact: MEDIUM (preserves state/DOM)**
2026
2027Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.
2028
2029**Usage:**
2030
2031```tsx
2032import { Activity } from "react"
2033
2034function Dropdown({ isOpen }: Props) {
2035 return (
2036 <Activity mode={isOpen ? "visible" : "hidden"}>
2037 <ExpensiveMenu />
2038 </Activity>
2039 )
2040}
2041```
2042
2043Avoids expensive re-renders and state loss.
2044
2045### 6.8 Use Explicit Conditional Rendering
2046
2047**Impact: LOW (prevents rendering 0 or NaN)**
2048
2049Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
2050
2051**Incorrect: renders "0" when count is 0**
2052
2053```tsx
2054function Badge({ count }: { count: number }) {
2055 return <div>{count && <span className="badge">{count}</span>}</div>
2056}
2057
2058// When count = 0, renders: <div>0</div>
2059// When count = 5, renders: <div><span class="badge">5</span></div>
2060```
2061
2062**Correct: renders nothing when count is 0**
2063
2064```tsx
2065function Badge({ count }: { count: number }) {
2066 return <div>{count > 0 ? <span className="badge">{count}</span> : null}</div>
2067}
2068
2069// When count = 0, renders: <div></div>
2070// When count = 5, renders: <div><span class="badge">5</span></div>
2071```
2072
2073### 6.9 Use useTransition Over Manual Loading States
2074
2075**Impact: LOW (reduces re-renders and improves code clarity)**
2076
2077Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.
2078
2079**Incorrect: manual loading state**
2080
2081```tsx
2082function SearchResults() {
2083 const [query, setQuery] = useState("")
2084 const [results, setResults] = useState([])
2085 const [isLoading, setIsLoading] = useState(false)
2086
2087 const handleSearch = async (value: string) => {
2088 setIsLoading(true)
2089 setQuery(value)
2090 const data = await fetchResults(value)
2091 setResults(data)
2092 setIsLoading(false)
2093 }
2094
2095 return (
2096 <>
2097 <input onChange={(e) => handleSearch(e.target.value)} />
2098 {isLoading && <Spinner />}
2099 <ResultsList results={results} />
2100 </>
2101 )
2102}
2103```
2104
2105**Correct: useTransition with built-in pending state**
2106
2107```tsx
2108import { useTransition, useState } from "react"
2109
2110function SearchResults() {
2111 const [query, setQuery] = useState("")
2112 const [results, setResults] = useState([])
2113 const [isPending, startTransition] = useTransition()
2114
2115 const handleSearch = (value: string) => {
2116 setQuery(value) // Update input immediately
2117
2118 startTransition(async () => {
2119 // Fetch and update results
2120 const data = await fetchResults(value)
2121 setResults(data)
2122 })
2123 }
2124
2125 return (
2126 <>
2127 <input onChange={(e) => handleSearch(e.target.value)} />
2128 {isPending && <Spinner />}
2129 <ResultsList results={results} />
2130 </>
2131 )
2132}
2133```
2134
2135**Benefits:**
2136
2137- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`
2138
2139- **Error resilience**: Pending state correctly resets even if the transition throws
2140
2141- **Better responsiveness**: Keeps the UI responsive during updates
2142
2143- **Interrupt handling**: New transitions automatically cancel pending ones
2144
2145Reference: [https://react.dev/reference/react/useTransition](https://react.dev/reference/react/useTransition)
2146
2147---
2148
2149## 7. JavaScript Performance
2150
2151**Impact: LOW-MEDIUM**
2152
2153Micro-optimizations for hot paths can add up to meaningful improvements.
2154
2155### 7.1 Avoid Layout Thrashing
2156
2157**Impact: MEDIUM (prevents forced synchronous layouts and reduces performance bottlenecks)**
2158
2159Avoid 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.
2160
2161**This is OK: browser batches style changes**
2162
2163```typescript
2164function updateElementStyles(element: HTMLElement) {
2165 // Each line invalidates style, but browser batches the recalculation
2166 element.style.width = "100px"
2167 element.style.height = "200px"
2168 element.style.backgroundColor = "blue"
2169 element.style.border = "1px solid black"
2170}
2171```
2172
2173**Incorrect: interleaved reads and writes force reflows**
2174
2175```typescript
2176function layoutThrashing(element: HTMLElement) {
2177 element.style.width = "100px"
2178 const width = element.offsetWidth // Forces reflow
2179 element.style.height = "200px"
2180 const height = element.offsetHeight // Forces another reflow
2181}
2182```
2183
2184**Correct: batch writes, then read once**
2185
2186```typescript
2187function updateElementStyles(element: HTMLElement) {
2188 // Batch all writes together
2189 element.style.width = "100px"
2190 element.style.height = "200px"
2191 element.style.backgroundColor = "blue"
2192 element.style.border = "1px solid black"
2193
2194 // Read after all writes are done (single reflow)
2195 const { width, height } = element.getBoundingClientRect()
2196}
2197```
2198
2199**Correct: batch reads, then writes**
2200
2201```typescript
2202function updateElementStyles(element: HTMLElement) {
2203 element.classList.add("highlighted-box")
2204
2205 const { width, height } = element.getBoundingClientRect()
2206}
2207```
2208
2209**Better: use CSS classes**
2210
2211**React example:**
2212
2213```tsx
2214// Incorrect: interleaving style changes with layout queries
2215function Box({ isHighlighted }: { isHighlighted: boolean }) {
2216 const ref = useRef<HTMLDivElement>(null)
2217
2218 useEffect(() => {
2219 if (ref.current && isHighlighted) {
2220 ref.current.style.width = "100px"
2221 const width = ref.current.offsetWidth // Forces layout
2222 ref.current.style.height = "200px"
2223 }
2224 }, [isHighlighted])
2225
2226 return <div ref={ref}>Content</div>
2227}
2228
2229// Correct: toggle class
2230function Box({ isHighlighted }: { isHighlighted: boolean }) {
2231 return <div className={isHighlighted ? "highlighted-box" : ""}>Content</div>
2232}
2233```
2234
2235Prefer 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.
2236
2237See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.
2238
2239### 7.2 Build Index Maps for Repeated Lookups
2240
2241**Impact: LOW-MEDIUM (1M ops to 2K ops)**
2242
2243Multiple `.find()` calls by the same key should use a Map.
2244
2245**Incorrect (O(n) per lookup):**
2246
2247```typescript
2248function processOrders(orders: Order[], users: User[]) {
2249 return orders.map((order) => ({
2250 ...order,
2251 user: users.find((u) => u.id === order.userId),
2252 }))
2253}
2254```
2255
2256**Correct (O(1) per lookup):**
2257
2258```typescript
2259function processOrders(orders: Order[], users: User[]) {
2260 const userById = new Map(users.map((u) => [u.id, u]))
2261
2262 return orders.map((order) => ({
2263 ...order,
2264 user: userById.get(order.userId),
2265 }))
2266}
2267```
2268
2269Build map once (O(n)), then all lookups are O(1).
2270
2271For 1000 orders × 1000 users: 1M ops → 2K ops.
2272
2273### 7.3 Cache Property Access in Loops
2274
2275**Impact: LOW-MEDIUM (reduces lookups)**
2276
2277Cache object property lookups in hot paths.
2278
2279**Incorrect: 3 lookups × N iterations**
2280
2281```typescript
2282for (let i = 0; i < arr.length; i++) {
2283 process(obj.config.settings.value)
2284}
2285```
2286
2287**Correct: 1 lookup total**
2288
2289```typescript
2290const value = obj.config.settings.value
2291const len = arr.length
2292for (let i = 0; i < len; i++) {
2293 process(value)
2294}
2295```
2296
2297### 7.4 Cache Repeated Function Calls
2298
2299**Impact: MEDIUM (avoid redundant computation)**
2300
2301Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.
2302
2303**Incorrect: redundant computation**
2304
2305```typescript
2306function ProjectList({ projects }: { projects: Project[] }) {
2307 return (
2308 <div>
2309 {projects.map(project => {
2310 // slugify() called 100+ times for same project names
2311 const slug = slugify(project.name)
2312
2313 return <ProjectCard key={project.id} slug={slug} />
2314 })}
2315 </div>
2316 )
2317}
2318```
2319
2320**Correct: cached results**
2321
2322```typescript
2323// Module-level cache
2324const slugifyCache = new Map<string, string>()
2325
2326function cachedSlugify(text: string): string {
2327 if (slugifyCache.has(text)) {
2328 return slugifyCache.get(text)!
2329 }
2330 const result = slugify(text)
2331 slugifyCache.set(text, result)
2332 return result
2333}
2334
2335function ProjectList({ projects }: { projects: Project[] }) {
2336 return (
2337 <div>
2338 {projects.map(project => {
2339 // Computed only once per unique project name
2340 const slug = cachedSlugify(project.name)
2341
2342 return <ProjectCard key={project.id} slug={slug} />
2343 })}
2344 </div>
2345 )
2346}
2347```
2348
2349**Simpler pattern for single-value functions:**
2350
2351```typescript
2352let isLoggedInCache: boolean | null = null
2353
2354function isLoggedIn(): boolean {
2355 if (isLoggedInCache !== null) {
2356 return isLoggedInCache
2357 }
2358
2359 isLoggedInCache = document.cookie.includes("auth=")
2360 return isLoggedInCache
2361}
2362
2363// Clear cache when auth changes
2364function onAuthChange() {
2365 isLoggedInCache = null
2366}
2367```
2368
2369Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
2370
2371Reference: [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)
2372
2373### 7.5 Cache Storage API Calls
2374
2375**Impact: LOW-MEDIUM (reduces expensive I/O)**
2376
2377`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.
2378
2379**Incorrect: reads storage on every call**
2380
2381```typescript
2382function getTheme() {
2383 return localStorage.getItem("theme") ?? "light"
2384}
2385// Called 10 times = 10 storage reads
2386```
2387
2388**Correct: Map cache**
2389
2390```typescript
2391const storageCache = new Map<string, string | null>()
2392
2393function getLocalStorage(key: string) {
2394 if (!storageCache.has(key)) {
2395 storageCache.set(key, localStorage.getItem(key))
2396 }
2397 return storageCache.get(key)
2398}
2399
2400function setLocalStorage(key: string, value: string) {
2401 localStorage.setItem(key, value)
2402 storageCache.set(key, value) // keep cache in sync
2403}
2404```
2405
2406Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
2407
2408**Cookie caching:**
2409
2410```typescript
2411let cookieCache: Record<string, string> | null = null
2412
2413function getCookie(name: string) {
2414 if (!cookieCache) {
2415 cookieCache = Object.fromEntries(document.cookie.split("; ").map((c) => c.split("=")))
2416 }
2417 return cookieCache[name]
2418}
2419```
2420
2421**Important: invalidate on external changes**
2422
2423```typescript
2424window.addEventListener("storage", (e) => {
2425 if (e.key) storageCache.delete(e.key)
2426})
2427
2428document.addEventListener("visibilitychange", () => {
2429 if (document.visibilityState === "visible") {
2430 storageCache.clear()
2431 }
2432})
2433```
2434
2435If storage can change externally (another tab, server-set cookies), invalidate cache:
2436
2437### 7.6 Combine Multiple Array Iterations
2438
2439**Impact: LOW-MEDIUM (reduces iterations)**
2440
2441Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.
2442
2443**Incorrect: 3 iterations**
2444
2445```typescript
2446const admins = users.filter((u) => u.isAdmin)
2447const testers = users.filter((u) => u.isTester)
2448const inactive = users.filter((u) => !u.isActive)
2449```
2450
2451**Correct: 1 iteration**
2452
2453```typescript
2454const admins: User[] = []
2455const testers: User[] = []
2456const inactive: User[] = []
2457
2458for (const user of users) {
2459 if (user.isAdmin) admins.push(user)
2460 if (user.isTester) testers.push(user)
2461 if (!user.isActive) inactive.push(user)
2462}
2463```
2464
2465### 7.7 Early Length Check for Array Comparisons
2466
2467**Impact: MEDIUM-HIGH (avoids expensive operations when lengths differ)**
2468
2469When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
2470
2471In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
2472
2473**Incorrect: always runs expensive comparison**
2474
2475```typescript
2476function hasChanges(current: string[], original: string[]) {
2477 // Always sorts and joins, even when lengths differ
2478 return current.sort().join() !== original.sort().join()
2479}
2480```
2481
2482Two 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.
2483
2484**Correct (O(1) length check first):**
2485
2486```typescript
2487function hasChanges(current: string[], original: string[]) {
2488 // Early return if lengths differ
2489 if (current.length !== original.length) {
2490 return true
2491 }
2492 // Only sort when lengths match
2493 const currentSorted = current.toSorted()
2494 const originalSorted = original.toSorted()
2495 for (let i = 0; i < currentSorted.length; i++) {
2496 if (currentSorted[i] !== originalSorted[i]) {
2497 return true
2498 }
2499 }
2500 return false
2501}
2502```
2503
2504This new approach is more efficient because:
2505
2506- It avoids the overhead of sorting and joining the arrays when lengths differ
2507
2508- It avoids consuming memory for the joined strings (especially important for large arrays)
2509
2510- It avoids mutating the original arrays
2511
2512- It returns early when a difference is found
2513
2514### 7.8 Early Return from Functions
2515
2516**Impact: LOW-MEDIUM (avoids unnecessary computation)**
2517
2518Return early when result is determined to skip unnecessary processing.
2519
2520**Incorrect: processes all items even after finding answer**
2521
2522```typescript
2523function validateUsers(users: User[]) {
2524 let hasError = false
2525 let errorMessage = ""
2526
2527 for (const user of users) {
2528 if (!user.email) {
2529 hasError = true
2530 errorMessage = "Email required"
2531 }
2532 if (!user.name) {
2533 hasError = true
2534 errorMessage = "Name required"
2535 }
2536 // Continues checking all users even after error found
2537 }
2538
2539 return hasError ? { valid: false, error: errorMessage } : { valid: true }
2540}
2541```
2542
2543**Correct: returns immediately on first error**
2544
2545```typescript
2546function validateUsers(users: User[]) {
2547 for (const user of users) {
2548 if (!user.email) {
2549 return { valid: false, error: "Email required" }
2550 }
2551 if (!user.name) {
2552 return { valid: false, error: "Name required" }
2553 }
2554 }
2555
2556 return { valid: true }
2557}
2558```
2559
2560### 7.9 Hoist RegExp Creation
2561
2562**Impact: LOW-MEDIUM (avoids recreation)**
2563
2564Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.
2565
2566**Incorrect: new RegExp every render**
2567
2568```tsx
2569function Highlighter({ text, query }: Props) {
2570 const regex = new RegExp(`(${query})`, 'gi')
2571 const parts = text.split(regex)
2572 return <>{parts.map((part, i) => ...)}</>
2573}
2574```
2575
2576**Correct: memoize or hoist**
2577
2578```tsx
2579const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
2580
2581function Highlighter({ text, query }: Props) {
2582 const regex = useMemo(
2583 () => new RegExp(`(${escapeRegex(query)})`, 'gi'),
2584 [query]
2585 )
2586 const parts = text.split(regex)
2587 return <>{parts.map((part, i) => ...)}</>
2588}
2589```
2590
2591**Warning: global regex has mutable state**
2592
2593```typescript
2594const regex = /foo/g
2595regex.test("foo") // true, lastIndex = 3
2596regex.test("foo") // false, lastIndex = 0
2597```
2598
2599Global regex (`/g`) has mutable `lastIndex` state:
2600
2601### 7.10 Use Loop for Min/Max Instead of Sort
2602
2603**Impact: LOW (O(n) instead of O(n log n))**
2604
2605Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.
2606
2607**Incorrect (O(n log n) - sort to find latest):**
2608
2609```typescript
2610interface Project {
2611 id: string
2612 name: string
2613 updatedAt: number
2614}
2615
2616function getLatestProject(projects: Project[]) {
2617 const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
2618 return sorted[0]
2619}
2620```
2621
2622Sorts the entire array just to find the maximum value.
2623
2624**Incorrect (O(n log n) - sort for oldest and newest):**
2625
2626```typescript
2627function getOldestAndNewest(projects: Project[]) {
2628 const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
2629 return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
2630}
2631```
2632
2633Still sorts unnecessarily when only min/max are needed.
2634
2635**Correct (O(n) - single loop):**
2636
2637```typescript
2638function getLatestProject(projects: Project[]) {
2639 if (projects.length === 0) return null
2640
2641 let latest = projects[0]
2642
2643 for (let i = 1; i < projects.length; i++) {
2644 if (projects[i].updatedAt > latest.updatedAt) {
2645 latest = projects[i]
2646 }
2647 }
2648
2649 return latest
2650}
2651
2652function getOldestAndNewest(projects: Project[]) {
2653 if (projects.length === 0) return { oldest: null, newest: null }
2654
2655 let oldest = projects[0]
2656 let newest = projects[0]
2657
2658 for (let i = 1; i < projects.length; i++) {
2659 if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
2660 if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
2661 }
2662
2663 return { oldest, newest }
2664}
2665```
2666
2667Single pass through the array, no copying, no sorting.
2668
2669**Alternative: Math.min/Math.max for small arrays**
2670
2671```typescript
2672const numbers = [5, 2, 8, 1, 9]
2673const min = Math.min(...numbers)
2674const max = Math.max(...numbers)
2675```
2676
2677This 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.
2678
2679### 7.11 Use Set/Map for O(1) Lookups
2680
2681**Impact: LOW-MEDIUM (O(n) to O(1))**
2682
2683Convert arrays to Set/Map for repeated membership checks.
2684
2685**Incorrect (O(n) per check):**
2686
2687```typescript
2688const allowedIds = ['a', 'b', 'c', ...]
2689items.filter(item => allowedIds.includes(item.id))
2690```
2691
2692**Correct (O(1) per check):**
2693
2694```typescript
2695const allowedIds = new Set(['a', 'b', 'c', ...])
2696items.filter(item => allowedIds.has(item.id))
2697```
2698
2699### 7.12 Use toSorted() Instead of sort() for Immutability
2700
2701**Impact: MEDIUM-HIGH (prevents mutation bugs in React state)**
2702
2703`.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.
2704
2705**Incorrect: mutates original array**
2706
2707```typescript
2708function UserList({ users }: { users: User[] }) {
2709 // Mutates the users prop array!
2710 const sorted = useMemo(
2711 () => users.sort((a, b) => a.name.localeCompare(b.name)),
2712 [users]
2713 )
2714 return <div>{sorted.map(renderUser)}</div>
2715}
2716```
2717
2718**Correct: creates new array**
2719
2720```typescript
2721function UserList({ users }: { users: User[] }) {
2722 // Creates new sorted array, original unchanged
2723 const sorted = useMemo(
2724 () => users.toSorted((a, b) => a.name.localeCompare(b.name)),
2725 [users]
2726 )
2727 return <div>{sorted.map(renderUser)}</div>
2728}
2729```
2730
2731**Why this matters in React:**
2732
27331. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only
2734
27352. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
2736
2737**Browser support: fallback for older browsers**
2738
2739```typescript
2740// Fallback for older browsers
2741const sorted = [...items].sort((a, b) => a.value - b.value)
2742```
2743
2744`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
2745
2746**Other immutable array methods:**
2747
2748- `.toSorted()` - immutable sort
2749
2750- `.toReversed()` - immutable reverse
2751
2752- `.toSpliced()` - immutable splice
2753
2754- `.with()` - immutable element replacement
2755
2756---
2757
2758## 8. Advanced Patterns
2759
2760**Impact: LOW**
2761
2762Advanced patterns for specific cases that require careful implementation.
2763
2764### 8.1 Initialize App Once, Not Per Mount
2765
2766**Impact: LOW-MEDIUM (avoids duplicate init in development)**
2767
2768Do 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.
2769
2770**Incorrect: runs twice in dev, re-runs on remount**
2771
2772```tsx
2773function Comp() {
2774 useEffect(() => {
2775 loadFromStorage()
2776 checkAuthToken()
2777 }, [])
2778
2779 // ...
2780}
2781```
2782
2783**Correct: once per app load**
2784
2785```tsx
2786let didInit = false
2787
2788function Comp() {
2789 useEffect(() => {
2790 if (didInit) return
2791 didInit = true
2792 loadFromStorage()
2793 checkAuthToken()
2794 }, [])
2795
2796 // ...
2797}
2798```
2799
2800Reference: [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)
2801
2802### 8.2 Store Event Handlers in Refs
2803
2804**Impact: LOW (stable subscriptions)**
2805
2806Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
2807
2808**Incorrect: re-subscribes on every render**
2809
2810```tsx
2811function useWindowEvent(event: string, handler: (e) => void) {
2812 useEffect(() => {
2813 window.addEventListener(event, handler)
2814 return () => window.removeEventListener(event, handler)
2815 }, [event, handler])
2816}
2817```
2818
2819**Correct: stable subscription**
2820
2821```tsx
2822import { useEffectEvent } from "react"
2823
2824function useWindowEvent(event: string, handler: (e) => void) {
2825 const onEvent = useEffectEvent(handler)
2826
2827 useEffect(() => {
2828 window.addEventListener(event, onEvent)
2829 return () => window.removeEventListener(event, onEvent)
2830 }, [event])
2831}
2832```
2833
2834**Alternative: use `useEffectEvent` if you're on latest React:**
2835
2836`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
2837
2838### 8.3 useEffectEvent for Stable Callback Refs
2839
2840**Impact: LOW (prevents effect re-runs)**
2841
2842Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
2843
2844**Incorrect: effect re-runs on every callback change**
2845
2846```tsx
2847function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
2848 const [query, setQuery] = useState("")
2849
2850 useEffect(() => {
2851 const timeout = setTimeout(() => onSearch(query), 300)
2852 return () => clearTimeout(timeout)
2853 }, [query, onSearch])
2854}
2855```
2856
2857**Correct: using React's useEffectEvent**
2858
2859```tsx
2860import { useEffectEvent } from "react"
2861
2862function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
2863 const [query, setQuery] = useState("")
2864 const onSearchEvent = useEffectEvent(onSearch)
2865
2866 useEffect(() => {
2867 const timeout = setTimeout(() => onSearchEvent(query), 300)
2868 return () => clearTimeout(timeout)
2869 }, [query])
2870}
2871```
2872
2873---
2874
2875## References
2876
28771. [https://react.dev](https://react.dev)
28782. [https://nextjs.org](https://nextjs.org)
28793. [https://swr.vercel.app](https://swr.vercel.app)
28804. [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
28815. [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
28826. [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
28837. [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)
2884
zebbern/claude-code-guide · skills/composition-patterns/AGENTS.md
@@ +1 @@
1# React Composition Patterns
2
3**Version 1.0.0**
4Engineering
5January 2026
6
7> **Note:**
8> This document is mainly for agents and LLMs to follow when maintaining,
9> generating, or refactoring React codebases using composition. 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
17Composition patterns for building flexible, maintainable React components. Avoid boolean prop proliferation by using compound components, lifting state, and composing internals. These patterns make codebases easier for both humans and AI agents to work with as they scale.
18
19---
20
21## Table of Contents
22
231. [Component Architecture](#1-component-architecture) — **HIGH**
24 - 1.1 [Avoid Boolean Prop Proliferation](#11-avoid-boolean-prop-proliferation)
25 - 1.2 [Use Compound Components](#12-use-compound-components)
262. [State Management](#2-state-management) — **MEDIUM**
27 - 2.1 [Decouple State Management from UI](#21-decouple-state-management-from-ui)
28 - 2.2 [Define Generic Context Interfaces for Dependency Injection](#22-define-generic-context-interfaces-for-dependency-injection)
29 - 2.3 [Lift State into Provider Components](#23-lift-state-into-provider-components)
303. [Implementation Patterns](#3-implementation-patterns) — **MEDIUM**
31 - 3.1 [Create Explicit Component Variants](#31-create-explicit-component-variants)
32 - 3.2 [Prefer Composing Children Over Render Props](#32-prefer-composing-children-over-render-props)
334. [React 19 APIs](#4-react-19-apis) — **MEDIUM**
34 - 4.1 [React 19 API Changes](#41-react-19-api-changes)
35
36---
37
38## 1. Component Architecture
39
40**Impact: HIGH**
41
42Fundamental patterns for structuring components to avoid prop
43proliferation and enable flexible composition.
44
45### 1.1 Avoid Boolean Prop Proliferation
46
47**Impact: CRITICAL (prevents unmaintainable component variants)**
48
49Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
50
51component behavior. Each boolean doubles possible states and creates
52
53unmaintainable conditional logic. Use composition instead.
54
55**Incorrect: boolean props create exponential complexity**
56
57```tsx
58function Composer({
59 onSubmit,
60 isThread,
61 channelId,
62 isDMThread,
63 dmId,
64 isEditing,
65 isForwarding,
66}: Props) {
67 return (
68 <form>
69 <Header />
70 <Input />
71 {isDMThread ? (
72 <AlsoSendToDMField id={dmId} />
73 ) : isThread ? (
74 <AlsoSendToChannelField id={channelId} />
75 ) : null}
76 {isEditing ? <EditActions /> : isForwarding ? <ForwardActions /> : <DefaultActions />}
77 <Footer onSubmit={onSubmit} />
78 </form>
79 )
80}
81```
82
83**Correct: composition eliminates conditionals**
84
85```tsx
86// Channel composer
87function ChannelComposer() {
88 return (
89 <Composer.Frame>
90 <Composer.Header />
91 <Composer.Input />
92 <Composer.Footer>
93 <Composer.Attachments />
94 <Composer.Formatting />
95 <Composer.Emojis />
96 <Composer.Submit />
97 </Composer.Footer>
98 </Composer.Frame>
99 )
100}
101
102// Thread composer - adds "also send to channel" field
103function ThreadComposer({ channelId }: { channelId: string }) {
104 return (
105 <Composer.Frame>
106 <Composer.Header />
107 <Composer.Input />
108 <AlsoSendToChannelField id={channelId} />
109 <Composer.Footer>
110 <Composer.Formatting />
111 <Composer.Emojis />
112 <Composer.Submit />
113 </Composer.Footer>
114 </Composer.Frame>
115 )
116}
117
118// Edit composer - different footer actions
119function EditComposer() {
120 return (
121 <Composer.Frame>
122 <Composer.Input />
123 <Composer.Footer>
124 <Composer.Formatting />
125 <Composer.Emojis />
126 <Composer.CancelEdit />
127 <Composer.SaveEdit />
128 </Composer.Footer>
129 </Composer.Frame>
130 )
131}
132```
133
134Each variant is explicit about what it renders. We can share internals without
135
136sharing a single monolithic parent.
137
138### 1.2 Use Compound Components
139
140**Impact: HIGH (enables flexible composition without prop drilling)**
141
142Structure complex components as compound components with a shared context. Each
143
144subcomponent accesses shared state via context, not props. Consumers compose the
145
146pieces they need.
147
148**Incorrect: monolithic component with render props**
149
150```tsx
151function Composer({
152 renderHeader,
153 renderFooter,
154 renderActions,
155 showAttachments,
156 showFormatting,
157 showEmojis,
158}: Props) {
159 return (
160 <form>
161 {renderHeader?.()}
162 <Input />
163 {showAttachments && <Attachments />}
164 {renderFooter ? (
165 renderFooter()
166 ) : (
167 <Footer>
168 {showFormatting && <Formatting />}
169 {showEmojis && <Emojis />}
170 {renderActions?.()}
171 </Footer>
172 )}
173 </form>
174 )
175}
176```
177
178**Correct: compound components with shared context**
179
180```tsx
181const ComposerContext = createContext<ComposerContextValue | null>(null)
182
183function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
184 return <ComposerContext value={{ state, actions, meta }}>{children}</ComposerContext>
185}
186
187function ComposerFrame({ children }: { children: React.ReactNode }) {
188 return <form>{children}</form>
189}
190
191function ComposerInput() {
192 const {
193 state,
194 actions: { update },
195 meta: { inputRef },
196 } = use(ComposerContext)
197 return (
198 <TextInput
199 ref={inputRef}
200 value={state.input}
201 onChangeText={(text) => update((s) => ({ ...s, input: text }))}
202 />
203 )
204}
205
206function ComposerSubmit() {
207 const {
208 actions: { submit },
209 } = use(ComposerContext)
210 return <Button onPress={submit}>Send</Button>
211}
212
213// Export as compound component
214const Composer = {
215 Provider: ComposerProvider,
216 Frame: ComposerFrame,
217 Input: ComposerInput,
218 Submit: ComposerSubmit,
219 Header: ComposerHeader,
220 Footer: ComposerFooter,
221 Attachments: ComposerAttachments,
222 Formatting: ComposerFormatting,
223 Emojis: ComposerEmojis,
224}
225```
226
227**Usage:**
228
229```tsx
230<Composer.Provider state={state} actions={actions} meta={meta}>
231 <Composer.Frame>
232 <Composer.Header />
233 <Composer.Input />
234 <Composer.Footer>
235 <Composer.Formatting />
236 <Composer.Submit />
237 </Composer.Footer>
238 </Composer.Frame>
239</Composer.Provider>
240```
241
242Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
243
244---
245
246## 2. State Management
247
248**Impact: MEDIUM**
249
250Patterns for lifting state and managing shared context across
251composed components.
252
253### 2.1 Decouple State Management from UI
254
255**Impact: MEDIUM (enables swapping state implementations without changing UI)**
256
257The provider component should be the only place that knows how state is managed.
258
259UI components consume the context interface—they don't know if state comes from
260
261useState, Zustand, or a server sync.
262
263**Incorrect: UI coupled to state implementation**
264
265```tsx
266function ChannelComposer({ channelId }: { channelId: string }) {
267 // UI component knows about global state implementation
268 const state = useGlobalChannelState(channelId)
269 const { submit, updateInput } = useChannelSync(channelId)
270
271 return (
272 <Composer.Frame>
273 <Composer.Input value={state.input} onChange={(text) => sync.updateInput(text)} />
274 <Composer.Submit onPress={() => sync.submit()} />
275 </Composer.Frame>
276 )
277}
278```
279
280**Correct: state management isolated in provider**
281
282```tsx
283// Provider handles all state management details
284function ChannelProvider({
285 channelId,
286 children,
287}: {
288 channelId: string
289 children: React.ReactNode
290}) {
291 const { state, update, submit } = useGlobalChannel(channelId)
292 const inputRef = useRef(null)
293
294 return (
295 <Composer.Provider state={state} actions={{ update, submit }} meta={{ inputRef }}>
296 {children}
297 </Composer.Provider>
298 )
299}
300
301// UI component only knows about the context interface
302function ChannelComposer() {
303 return (
304 <Composer.Frame>
305 <Composer.Header />
306 <Composer.Input />
307 <Composer.Footer>
308 <Composer.Submit />
309 </Composer.Footer>
310 </Composer.Frame>
311 )
312}
313
314// Usage
315function Channel({ channelId }: { channelId: string }) {
316 return (
317 <ChannelProvider channelId={channelId}>
318 <ChannelComposer />
319 </ChannelProvider>
320 )
321}
322```
323
324**Different providers, same UI:**
325
326```tsx
327// Local state for ephemeral forms
328function ForwardMessageProvider({ children }) {
329 const [state, setState] = useState(initialState)
330 const forwardMessage = useForwardMessage()
331
332 return (
333 <Composer.Provider state={state} actions={{ update: setState, submit: forwardMessage }}>
334 {children}
335 </Composer.Provider>
336 )
337}
338
339// Global synced state for channels
340function ChannelProvider({ channelId, children }) {
341 const { state, update, submit } = useGlobalChannel(channelId)
342
343 return (
344 <Composer.Provider state={state} actions={{ update, submit }}>
345 {children}
346 </Composer.Provider>
347 )
348}
349```
350
351The same `Composer.Input` component works with both providers because it only
352
353depends on the context interface, not the implementation.
354
355### 2.2 Define Generic Context Interfaces for Dependency Injection
356
357**Impact: HIGH (enables dependency-injectable state across use-cases)**
358
359Define a **generic interface** for your component context with three parts:
360
361`state`, `actions`, and `meta`. This interface is a contract that any provider
362
363can implement—enabling the same UI components to work with completely different
364
365state implementations.
366
367**Core principle:** Lift state, compose internals, make state
368
369dependency-injectable.
370
371**Incorrect: UI coupled to specific state implementation**
372
373```tsx
374function ComposerInput() {
375 // Tightly coupled to a specific hook
376 const { input, setInput } = useChannelComposerState()
377 return <TextInput value={input} onChangeText={setInput} />
378}
379```
380
381**Correct: generic interface enables dependency injection**
382
383```tsx
384// Define a GENERIC interface that any provider can implement
385interface ComposerState {
386 input: string
387 attachments: Attachment[]
388 isSubmitting: boolean
389}
390
391interface ComposerActions {
392 update: (updater: (state: ComposerState) => ComposerState) => void
393 submit: () => void
394}
395
396interface ComposerMeta {
397 inputRef: React.RefObject<TextInput>
398}
399
400interface ComposerContextValue {
401 state: ComposerState
402 actions: ComposerActions
403 meta: ComposerMeta
404}
405
406const ComposerContext = createContext<ComposerContextValue | null>(null)
407```
408
409**UI components consume the interface, not the implementation:**
410
411```tsx
412function ComposerInput() {
413 const {
414 state,
415 actions: { update },
416 meta,
417 } = use(ComposerContext)
418
419 // This component works with ANY provider that implements the interface
420 return (
421 <TextInput
422 ref={meta.inputRef}
423 value={state.input}
424 onChangeText={(text) => update((s) => ({ ...s, input: text }))}
425 />
426 )
427}
428```
429
430**Different providers implement the same interface:**
431
432```tsx
433// Provider A: Local state for ephemeral forms
434function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
435 const [state, setState] = useState(initialState)
436 const inputRef = useRef(null)
437 const submit = useForwardMessage()
438
439 return (
440 <ComposerContext
441 value={{
442 state,
443 actions: { update: setState, submit },
444 meta: { inputRef },
445 }}
446 >
447 {children}
448 </ComposerContext>
449 )
450}
451
452// Provider B: Global synced state for channels
453function ChannelProvider({ channelId, children }: Props) {
454 const { state, update, submit } = useGlobalChannel(channelId)
455 const inputRef = useRef(null)
456
457 return (
458 <ComposerContext
459 value={{
460 state,
461 actions: { update, submit },
462 meta: { inputRef },
463 }}
464 >
465 {children}
466 </ComposerContext>
467 )
468}
469```
470
471**The same composed UI works with both:**
472
473```tsx
474// Works with ForwardMessageProvider (local state)
475<ForwardMessageProvider>
476 <Composer.Frame>
477 <Composer.Input />
478 <Composer.Submit />
479 </Composer.Frame>
480</ForwardMessageProvider>
481
482// Works with ChannelProvider (global synced state)
483<ChannelProvider channelId="abc">
484 <Composer.Frame>
485 <Composer.Input />
486 <Composer.Submit />
487 </Composer.Frame>
488</ChannelProvider>
489```
490
491**Custom UI outside the component can access state and actions:**
492
493```tsx
494function ForwardMessageDialog() {
495 return (
496 <ForwardMessageProvider>
497 <Dialog>
498 {/* The composer UI */}
499 <Composer.Frame>
500 <Composer.Input placeholder="Add a message, if you'd like." />
501 <Composer.Footer>
502 <Composer.Formatting />
503 <Composer.Emojis />
504 </Composer.Footer>
505 </Composer.Frame>
506
507 {/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
508 <MessagePreview />
509
510 {/* Actions at the bottom of the dialog */}
511 <DialogActions>
512 <CancelButton />
513 <ForwardButton />
514 </DialogActions>
515 </Dialog>
516 </ForwardMessageProvider>
517 )
518}
519
520// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
521function ForwardButton() {
522 const {
523 actions: { submit },
524 } = use(ComposerContext)
525 return <Button onPress={submit}>Forward</Button>
526}
527
528// This preview lives OUTSIDE Composer.Frame but can read composer's state!
529function MessagePreview() {
530 const { state } = use(ComposerContext)
531 return <Preview message={state.input} attachments={state.attachments} />
532}
533```
534
535The provider boundary is what matters—not the visual nesting. Components that
536
537need shared state don't have to be inside the `Composer.Frame`. They just need
538
539to be within the provider.
540
541The `ForwardButton` and `MessagePreview` are not visually inside the composer
542
543box, but they can still access its state and actions. This is the power of
544
545lifting state into providers.
546
547The UI is reusable bits you compose together. The state is dependency-injected
548
549by the provider. Swap the provider, keep the UI.
550
551### 2.3 Lift State into Provider Components
552
553**Impact: HIGH (enables state sharing outside component boundaries)**
554
555Move state management into dedicated provider components. This allows sibling
556
557components outside the main UI to access and modify state without prop drilling
558
559or awkward refs.
560
561**Incorrect: state trapped inside component**
562
563```tsx
564function ForwardMessageComposer() {
565 const [state, setState] = useState(initialState)
566 const forwardMessage = useForwardMessage()
567
568 return (
569 <Composer.Frame>
570 <Composer.Input />
571 <Composer.Footer />
572 </Composer.Frame>
573 )
574}
575
576// Problem: How does this button access composer state?
577function ForwardMessageDialog() {
578 return (
579 <Dialog>
580 <ForwardMessageComposer />
581 <MessagePreview /> {/* Needs composer state */}
582 <DialogActions>
583 <CancelButton />
584 <ForwardButton /> {/* Needs to call submit */}
585 </DialogActions>
586 </Dialog>
587 )
588}
589```
590
591**Incorrect: useEffect to sync state up**
592
593```tsx
594function ForwardMessageDialog() {
595 const [input, setInput] = useState("")
596 return (
597 <Dialog>
598 <ForwardMessageComposer onInputChange={setInput} />
599 <MessagePreview input={input} />
600 </Dialog>
601 )
602}
603
604function ForwardMessageComposer({ onInputChange }) {
605 const [state, setState] = useState(initialState)
606 useEffect(() => {
607 onInputChange(state.input) // Sync on every change 😬
608 }, [state.input])
609}
610```
611
612**Incorrect: reading state from ref on submit**
613
614```tsx
615function ForwardMessageDialog() {
616 const stateRef = useRef(null)
617 return (
618 <Dialog>
619 <ForwardMessageComposer stateRef={stateRef} />
620 <ForwardButton onPress={() => submit(stateRef.current)} />
621 </Dialog>
622 )
623}
624```
625
626**Correct: state lifted to provider**
627
628```tsx
629function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
630 const [state, setState] = useState(initialState)
631 const forwardMessage = useForwardMessage()
632 const inputRef = useRef(null)
633
634 return (
635 <Composer.Provider
636 state={state}
637 actions={{ update: setState, submit: forwardMessage }}
638 meta={{ inputRef }}
639 >
640 {children}
641 </Composer.Provider>
642 )
643}
644
645function ForwardMessageDialog() {
646 return (
647 <ForwardMessageProvider>
648 <Dialog>
649 <ForwardMessageComposer />
650 <MessagePreview /> {/* Custom components can access state and actions */}
651 <DialogActions>
652 <CancelButton />
653 <ForwardButton /> {/* Custom components can access state and actions */}
654 </DialogActions>
655 </Dialog>
656 </ForwardMessageProvider>
657 )
658}
659
660function ForwardButton() {
661 const { actions } = use(Composer.Context)
662 return <Button onPress={actions.submit}>Forward</Button>
663}
664```
665
666The ForwardButton lives outside the Composer.Frame but still has access to the
667
668submit action because it's within the provider. Even though it's a one-off
669
670component, it can still access the composer's state and actions from outside the
671
672UI itself.
673
674**Key insight:** Components that need shared state don't have to be visually
675
676nested inside each other—they just need to be within the same provider.
677
678---
679
680## 3. Implementation Patterns
681
682**Impact: MEDIUM**
683
684Specific techniques for implementing compound components and
685context providers.
686
687### 3.1 Create Explicit Component Variants
688
689**Impact: MEDIUM (self-documenting code, no hidden conditionals)**
690
691Instead of one component with many boolean props, create explicit variant
692
693components. Each variant composes the pieces it needs. The code documents
694
695itself.
696
697**Incorrect: one component, many modes**
698
699```tsx
700// What does this component actually render?
701<Composer isThread isEditing={false} channelId="abc" showAttachments showFormatting={false} />
702```
703
704**Correct: explicit variants**
705
706```tsx
707// Immediately clear what this renders
708<ThreadComposer channelId="abc" />
709
710// Or
711<EditMessageComposer messageId="xyz" />
712
713// Or
714<ForwardMessageComposer messageId="123" />
715```
716
717Each implementation is unique, explicit and self-contained. Yet they can each
718
719use shared parts.
720
721**Implementation:**
722
723```tsx
724function ThreadComposer({ channelId }: { channelId: string }) {
725 return (
726 <ThreadProvider channelId={channelId}>
727 <Composer.Frame>
728 <Composer.Input />
729 <AlsoSendToChannelField channelId={channelId} />
730 <Composer.Footer>
731 <Composer.Formatting />
732 <Composer.Emojis />
733 <Composer.Submit />
734 </Composer.Footer>
735 </Composer.Frame>
736 </ThreadProvider>
737 )
738}
739
740function EditMessageComposer({ messageId }: { messageId: string }) {
741 return (
742 <EditMessageProvider messageId={messageId}>
743 <Composer.Frame>
744 <Composer.Input />
745 <Composer.Footer>
746 <Composer.Formatting />
747 <Composer.Emojis />
748 <Composer.CancelEdit />
749 <Composer.SaveEdit />
750 </Composer.Footer>
751 </Composer.Frame>
752 </EditMessageProvider>
753 )
754}
755
756function ForwardMessageComposer({ messageId }: { messageId: string }) {
757 return (
758 <ForwardMessageProvider messageId={messageId}>
759 <Composer.Frame>
760 <Composer.Input placeholder="Add a message, if you'd like." />
761 <Composer.Footer>
762 <Composer.Formatting />
763 <Composer.Emojis />
764 <Composer.Mentions />
765 </Composer.Footer>
766 </Composer.Frame>
767 </ForwardMessageProvider>
768 )
769}
770```
771
772Each variant is explicit about:
773
774- What provider/state it uses
775
776- What UI elements it includes
777
778- What actions are available
779
780No boolean prop combinations to reason about. No impossible states.
781
782### 3.2 Prefer Composing Children Over Render Props
783
784**Impact: MEDIUM (cleaner composition, better readability)**
785
786Use `children` for composition instead of `renderX` props. Children are more
787
788readable, compose naturally, and don't require understanding callback
789
790signatures.
791
792**Incorrect: render props**
793
794```tsx
795function Composer({
796 renderHeader,
797 renderFooter,
798 renderActions,
799}: {
800 renderHeader?: () => React.ReactNode
801 renderFooter?: () => React.ReactNode
802 renderActions?: () => React.ReactNode
803}) {
804 return (
805 <form>
806 {renderHeader?.()}
807 <Input />
808 {renderFooter ? renderFooter() : <DefaultFooter />}
809 {renderActions?.()}
810 </form>
811 )
812}
813
814// Usage is awkward and inflexible
815return (
816 <Composer
817 renderHeader={() => <CustomHeader />}
818 renderFooter={() => (
819 <>
820 <Formatting />
821 <Emojis />
822 </>
823 )}
824 renderActions={() => <SubmitButton />}
825 />
826)
827```
828
829**Correct: compound components with children**
830
831```tsx
832function ComposerFrame({ children }: { children: React.ReactNode }) {
833 return <form>{children}</form>
834}
835
836function ComposerFooter({ children }: { children: React.ReactNode }) {
837 return <footer className="flex">{children}</footer>
838}
839
840// Usage is flexible
841return (
842 <Composer.Frame>
843 <CustomHeader />
844 <Composer.Input />
845 <Composer.Footer>
846 <Composer.Formatting />
847 <Composer.Emojis />
848 <SubmitButton />
849 </Composer.Footer>
850 </Composer.Frame>
851)
852```
853
854**When render props are appropriate:**
855
856```tsx
857// Render props work well when you need to pass data back
858<List data={items} renderItem={({ item, index }) => <Item item={item} index={index} />} />
859```
860
861Use render props when the parent needs to provide data or state to the child.
862
863Use children when composing static structure.
864
865---
866
867## 4. React 19 APIs
868
869**Impact: MEDIUM**
870
871React 19+ only. Don't use `forwardRef`; use `use()` instead of `useContext()`.
872
873### 4.1 React 19 API Changes
874
875**Impact: MEDIUM (cleaner component definitions and context usage)**
876
877> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
878
879In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
880
881**Incorrect: forwardRef in React 19**
882
883```tsx
884const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
885 return <TextInput ref={ref} {...props} />
886})
887```
888
889**Correct: ref as a regular prop**
890
891```tsx
892function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
893 return <TextInput ref={ref} {...props} />
894}
895```
896
897**Incorrect: useContext in React 19**
898
899```tsx
900const value = useContext(MyContext)
901```
902
903**Correct: use instead of useContext**
904
905```tsx
906const value = use(MyContext)
907```
908
909`use()` can also be called conditionally, unlike `useContext()`.
910
911---
912
913## References
914
9151. [https://react.dev](https://react.dev)
9162. [https://react.dev/learn/passing-data-deeply-with-context](https://react.dev/learn/passing-data-deeply-with-context)
9173. [https://react.dev/reference/react/use](https://react.dev/reference/react/use)
918
@@ −1 +1 @@
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 [Defer Await Until Needed](#11-defer-await-until-needed)
25− - 1.2 [Dependency-Based Parallelization](#12-dependency-based-parallelization)
26− - 1.3 [Prevent Waterfall Chains in API Routes](#13-prevent-waterfall-chains-in-api-routes)
27− - 1.4 [Promise.all() for Independent Operations](#14-promiseall-for-independent-operations)
28− - 1.5 [Strategic Suspense Boundaries](#15-strategic-suspense-boundaries)
29−2. [Bundle Size Optimization](#2-bundle-size-optimization) — **CRITICAL**
30− - 2.1 [Avoid Barrel File Imports](#21-avoid-barrel-file-imports)
31− - 2.2 [Conditional Module Loading](#22-conditional-module-loading)
32− - 2.3 [Defer Non-Critical Third-Party Libraries](#23-defer-non-critical-third-party-libraries)
33− - 2.4 [Dynamic Imports for Heavy Components](#24-dynamic-imports-for-heavy-components)
34− - 2.5 [Preload Based on User Intent](#25-preload-based-on-user-intent)
35−3. [Server-Side Performance](#3-server-side-performance) — **HIGH**
36− - 3.1 [Authenticate Server Actions Like API Routes](#31-authenticate-server-actions-like-api-routes)
37− - 3.2 [Avoid Duplicate Serialization in RSC Props](#32-avoid-duplicate-serialization-in-rsc-props)
38− - 3.3 [Cross-Request LRU Caching](#33-cross-request-lru-caching)
39− - 3.4 [Minimize Serialization at RSC Boundaries](#34-minimize-serialization-at-rsc-boundaries)
40− - 3.5 [Parallel Data Fetching with Component Composition](#35-parallel-data-fetching-with-component-composition)
41− - 3.6 [Per-Request Deduplication with React.cache()](#36-per-request-deduplication-with-reactcache)
42− - 3.7 [Use after() for Non-Blocking Operations](#37-use-after-for-non-blocking-operations)
43−4. [Client-Side Data Fetching](#4-client-side-data-fetching) — **MEDIUM-HIGH**
44− - 4.1 [Deduplicate Global Event Listeners](#41-deduplicate-global-event-listeners)
45− - 4.2 [Use Passive Event Listeners for Scrolling Performance](#42-use-passive-event-listeners-for-scrolling-performance)
46− - 4.3 [Use SWR for Automatic Deduplication](#43-use-swr-for-automatic-deduplication)
47− - 4.4 [Version and Minimize localStorage Data](#44-version-and-minimize-localstorage-data)
48−5. [Re-render Optimization](#5-re-render-optimization) — **MEDIUM**
49− - 5.1 [Calculate Derived State During Rendering](#51-calculate-derived-state-during-rendering)
50− - 5.2 [Defer State Reads to Usage Point](#52-defer-state-reads-to-usage-point)
51− - 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)
52− - 5.4 [Extract Default Non-primitive Parameter Value from Memoized Component to Constant](#54-extract-default-non-primitive-parameter-value-from-memoized-component-to-constant)
53− - 5.5 [Extract to Memoized Components](#55-extract-to-memoized-components)
54− - 5.6 [Narrow Effect Dependencies](#56-narrow-effect-dependencies)
55− - 5.7 [Put Interaction Logic in Event Handlers](#57-put-interaction-logic-in-event-handlers)
56− - 5.8 [Subscribe to Derived State](#58-subscribe-to-derived-state)
57− - 5.9 [Use Functional setState Updates](#59-use-functional-setstate-updates)
58− - 5.10 [Use Lazy State Initialization](#510-use-lazy-state-initialization)
59− - 5.11 [Use Transitions for Non-Urgent Updates](#511-use-transitions-for-non-urgent-updates)
60− - 5.12 [Use useRef for Transient Values](#512-use-useref-for-transient-values)
61−6. [Rendering Performance](#6-rendering-performance) — **MEDIUM**
62− - 6.1 [Animate SVG Wrapper Instead of SVG Element](#61-animate-svg-wrapper-instead-of-svg-element)
63− - 6.2 [CSS content-visibility for Long Lists](#62-css-content-visibility-for-long-lists)
64− - 6.3 [Hoist Static JSX Elements](#63-hoist-static-jsx-elements)
65− - 6.4 [Optimize SVG Precision](#64-optimize-svg-precision)
66− - 6.5 [Prevent Hydration Mismatch Without Flickering](#65-prevent-hydration-mismatch-without-flickering)
67− - 6.6 [Suppress Expected Hydration Mismatches](#66-suppress-expected-hydration-mismatches)
68− - 6.7 [Use Activity Component for Show/Hide](#67-use-activity-component-for-showhide)
69− - 6.8 [Use Explicit Conditional Rendering](#68-use-explicit-conditional-rendering)
70− - 6.9 [Use useTransition Over Manual Loading States](#69-use-usetransition-over-manual-loading-states)
71−7. [JavaScript Performance](#7-javascript-performance) — **LOW-MEDIUM**
72− - 7.1 [Avoid Layout Thrashing](#71-avoid-layout-thrashing)
73− - 7.2 [Build Index Maps for Repeated Lookups](#72-build-index-maps-for-repeated-lookups)
74− - 7.3 [Cache Property Access in Loops](#73-cache-property-access-in-loops)
75− - 7.4 [Cache Repeated Function Calls](#74-cache-repeated-function-calls)
76− - 7.5 [Cache Storage API Calls](#75-cache-storage-api-calls)
77− - 7.6 [Combine Multiple Array Iterations](#76-combine-multiple-array-iterations)
78− - 7.7 [Early Length Check for Array Comparisons](#77-early-length-check-for-array-comparisons)
79− - 7.8 [Early Return from Functions](#78-early-return-from-functions)
80− - 7.9 [Hoist RegExp Creation](#79-hoist-regexp-creation)
81− - 7.10 [Use Loop for Min/Max Instead of Sort](#710-use-loop-for-minmax-instead-of-sort)
82− - 7.11 [Use Set/Map for O(1) Lookups](#711-use-setmap-for-o1-lookups)
83− - 7.12 [Use toSorted() Instead of sort() for Immutability](#712-use-tosorted-instead-of-sort-for-immutability)
84−8. [Advanced Patterns](#8-advanced-patterns) — **LOW**
85− - 8.1 [Initialize App Once, Not Per Mount](#81-initialize-app-once-not-per-mount)
86− - 8.2 [Store Event Handlers in Refs](#82-store-event-handlers-in-refs)
87− - 8.3 [useEffectEvent for Stable Callback Refs](#83-useeffectevent-for-stable-callback-refs)
88−
89−---
90−
91−## 1. Eliminating Waterfalls
92−
93−**Impact: CRITICAL**
94−
95−Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
96−
97−### 1.1 Defer Await Until Needed
98−
99−**Impact: HIGH (avoids blocking unused code paths)**
100−
101−Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
102−
103−**Incorrect: blocks both branches**
104−
105−```typescript
106−async function handleRequest(userId: string, skipProcessing: boolean) {
107− const userData = await fetchUserData(userId)
108−
109− if (skipProcessing) {
110− // Returns immediately but still waited for userData
111− return { skipped: true }
112− }
113−
114− // Only this branch uses userData
115− return processUserData(userData)
116−}
117−```
118−
119−**Correct: only blocks when needed**
120−
121−```typescript
122−async function handleRequest(userId: string, skipProcessing: boolean) {
123− if (skipProcessing) {
124− // Returns immediately without waiting
125− return { skipped: true }
126− }
127−
128− // Fetch only when needed
129− const userData = await fetchUserData(userId)
130− return processUserData(userData)
131−}
132−```
133−
134−**Another example: early return optimization**
135−
136−```typescript
137−// Incorrect: always fetches permissions
138−async function updateResource(resourceId: string, userId: string) {
139− const permissions = await fetchPermissions(userId)
140− const resource = await getResource(resourceId)
141−
142− if (!resource) {
143− return { error: "Not found" }
144− }
145−
146− if (!permissions.canEdit) {
147− return { error: "Forbidden" }
148− }
149−
150− return await updateResourceData(resource, permissions)
151−}
152−
153−// Correct: fetches only when needed
154−async function updateResource(resourceId: string, userId: string) {
155− const resource = await getResource(resourceId)
156−
157− if (!resource) {
158− return { error: "Not found" }
159− }
160−
161− const permissions = await fetchPermissions(userId)
162−
163− if (!permissions.canEdit) {
164− return { error: "Forbidden" }
165− }
166−
167− return await updateResourceData(resource, permissions)
168−}
169−```
170−
171−This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
172−
173−### 1.2 Dependency-Based Parallelization
174−
175−**Impact: CRITICAL (2-10× improvement)**
176−
177−For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
178−
179−**Incorrect: profile waits for config unnecessarily**
180−
181−```typescript
182−const [user, config] = await Promise.all([fetchUser(), fetchConfig()])
183−const profile = await fetchProfile(user.id)
184−```
185−
186−**Correct: config and profile run in parallel**
187−
188−```typescript
189−import { all } from "better-all"
190−
191−const { user, config, profile } = await all({
192− async user() {
193− return fetchUser()
194− },
195− async config() {
196− return fetchConfig()
197− },
198− async profile() {
199− return fetchProfile((await this.$.user).id)
200− },
201−})
202−```
203−
204−**Alternative without extra dependencies:**
205−
206−```typescript
207−const userPromise = fetchUser()
208−const profilePromise = userPromise.then((user) => fetchProfile(user.id))
209−
210−const [user, config, profile] = await Promise.all([userPromise, fetchConfig(), profilePromise])
211−```
212−
213−We can also create all the promises first, and do `Promise.all()` at the end.
214−
215−Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
216−
217−### 1.3 Prevent Waterfall Chains in API Routes
218−
219−**Impact: CRITICAL (2-10× improvement)**
220−
221−In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
222−
223−**Incorrect: config waits for auth, data waits for both**
224−
225−```typescript
226−export async function GET(request: Request) {
227− const session = await auth()
228− const config = await fetchConfig()
229− const data = await fetchData(session.user.id)
230− return Response.json({ data, config })
231−}
232−```
233−
234−**Correct: auth and config start immediately**
235−
236−```typescript
237−export async function GET(request: Request) {
238− const sessionPromise = auth()
239− const configPromise = fetchConfig()
240− const session = await sessionPromise
241− const [config, data] = await Promise.all([configPromise, fetchData(session.user.id)])
242− return Response.json({ data, config })
243−}
244−```
245−
246−For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
247−
248−### 1.4 Promise.all() for Independent Operations
249−
250−**Impact: CRITICAL (2-10× improvement)**
251−
252−When async operations have no interdependencies, execute them concurrently using `Promise.all()`.
253−
254−**Incorrect: sequential execution, 3 round trips**
255−
256−```typescript
257−const user = await fetchUser()
258−const posts = await fetchPosts()
259−const comments = await fetchComments()
260−```
261−
262−**Correct: parallel execution, 1 round trip**
263−
264−```typescript
265−const [user, posts, comments] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()])
266−```
267−
268−### 1.5 Strategic Suspense Boundaries
269−
270−**Impact: HIGH (faster initial paint)**
271−
272−Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
273−
274−**Incorrect: wrapper blocked by data fetching**
275−
276−```tsx
277−async function Page() {
278− const data = await fetchData() // Blocks entire page
279−
280− return (
281− <div>
282− <div>Sidebar</div>
283− <div>Header</div>
284− <div>
285− <DataDisplay data={data} />
286− </div>
287− <div>Footer</div>
288− </div>
289− )
290−}
291−```
292−
293−The entire layout waits for data even though only the middle section needs it.
294−
295−**Correct: wrapper shows immediately, data streams in**
296−
297−```tsx
298−function Page() {
299− return (
300− <div>
301− <div>Sidebar</div>
302− <div>Header</div>
303− <div>
304− <Suspense fallback={<Skeleton />}>
305− <DataDisplay />
306− </Suspense>
307− </div>
308− <div>Footer</div>
309− </div>
310− )
311−}
312−
313−async function DataDisplay() {
314− const data = await fetchData() // Only blocks this component
315− return <div>{data.content}</div>
316−}
317−```
318−
319−Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
320−
321−**Alternative: share promise across components**
322−
323−```tsx
324−function Page() {
325− // Start fetch immediately, but don't await
326− const dataPromise = fetchData()
327−
328− return (
329− <div>
330− <div>Sidebar</div>
331− <div>Header</div>
332− <Suspense fallback={<Skeleton />}>
333− <DataDisplay dataPromise={dataPromise} />
334− <DataSummary dataPromise={dataPromise} />
335− </Suspense>
336− <div>Footer</div>
337− </div>
338− )
339−}
340−
341−function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
342− const data = use(dataPromise) // Unwraps the promise
343− return <div>{data.content}</div>
344−}
345−
346−function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
347− const data = use(dataPromise) // Reuses the same promise
348− return <div>{data.summary}</div>
349−}
350−```
351−
352−Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.
353−
354−**When NOT to use this pattern:**
355−
356−- Critical data needed for layout decisions (affects positioning)
357−
358−- SEO-critical content above the fold
359−
360−- Small, fast queries where suspense overhead isn't worth it
361−
362−- When you want to avoid layout shift (loading → content jump)
363−
364−**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.
365−
366−---
367−
368−## 2. Bundle Size Optimization
369−
370−**Impact: CRITICAL**
371−
372−Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
373−
374−### 2.1 Avoid Barrel File Imports
375−
376−**Impact: CRITICAL (200-800ms import cost, slow builds)**
377−
378−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'`).
379−
380−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.
381−
382−**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.
383−
384−**Incorrect: imports entire library**
385−
386−```tsx
387−import { Check, X, Menu } from "lucide-react"
388−// Loads 1,583 modules, takes ~2.8s extra in dev
389−// Runtime cost: 200-800ms on every cold start
390−
391−import { Button, TextField } from "@mui/material"
392−// Loads 2,225 modules, takes ~4.2s extra in dev
393−```
394−
395−**Correct: imports only what you need**
396−
397−```tsx
398−import Check from "lucide-react/dist/esm/icons/check"
399−import X from "lucide-react/dist/esm/icons/x"
400−import Menu from "lucide-react/dist/esm/icons/menu"
401−// Loads only 3 modules (~2KB vs ~1MB)
402−
403−import Button from "@mui/material/Button"
404−import TextField from "@mui/material/TextField"
405−// Loads only what you use
406−```
407−
408−**Alternative: Next.js 13.5+**
409−
410−```js
411−// next.config.js - use optimizePackageImports
412−module.exports = {
413− experimental: {
414− optimizePackageImports: ["lucide-react", "@mui/material"],
415− },
416−}
417−
418−// Then you can keep the ergonomic barrel imports:
419−import { Check, X, Menu } from "lucide-react"
420−// Automatically transformed to direct imports at build time
421−```
422−
423−Direct imports provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.
424−
425−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`.
426−
427−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)
428−
429−### 2.2 Conditional Module Loading
430−
431−**Impact: HIGH (loads large data only when needed)**
432−
433−Load large data or modules only when a feature is activated.
434−
435−**Example: lazy-load animation frames**
436−
437−```tsx
438−function AnimationPlayer({
439− enabled,
440− setEnabled,
441−}: {
442− enabled: boolean
443− setEnabled: React.Dispatch<React.SetStateAction<boolean>>
444−}) {
445− const [frames, setFrames] = useState<Frame[] | null>(null)
446−
447− useEffect(() => {
448− if (enabled && !frames && typeof window !== "undefined") {
449− import("./animation-frames.js")
450− .then((mod) => setFrames(mod.frames))
451− .catch(() => setEnabled(false))
452− }
453− }, [enabled, frames, setEnabled])
454−
455− if (!frames) return <Skeleton />
456− return <Canvas frames={frames} />
457−}
458−```
459−
460−The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.
461−
462−### 2.3 Defer Non-Critical Third-Party Libraries
463−
464−**Impact: MEDIUM (loads after hydration)**
465−
466−Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
467−
468−**Incorrect: blocks initial bundle**
469−
470−```tsx
471−import { Analytics } from "@vercel/analytics/react"
472−
473−export default function RootLayout({ children }) {
474− return (
475− <html>
476− <body>
477− {children}
478− <Analytics />
479− </body>
480− </html>
481− )
482−}
483−```
484−
485−**Correct: loads after hydration**
486−
487−```tsx
488−import dynamic from "next/dynamic"
489−
490−const Analytics = dynamic(() => import("@vercel/analytics/react").then((m) => m.Analytics), {
491− ssr: false,
492−})
493−
494−export default function RootLayout({ children }) {
495− return (
496− <html>
497− <body>
498− {children}
499− <Analytics />
500− </body>
501− </html>
502− )
503−}
504−```
505−
506−### 2.4 Dynamic Imports for Heavy Components
507−
508−**Impact: CRITICAL (directly affects TTI and LCP)**
509−
510−Use `next/dynamic` to lazy-load large components not needed on initial render.
511−
512−**Incorrect: Monaco bundles with main chunk ~300KB**
513−
514−```tsx
515−import { MonacoEditor } from "./monaco-editor"
516−
517−function CodePanel({ code }: { code: string }) {
518− return <MonacoEditor value={code} />
519−}
520−```
521−
522−**Correct: Monaco loads on demand**
523−
524−```tsx
525−import dynamic from "next/dynamic"
526−
527−const MonacoEditor = dynamic(() => import("./monaco-editor").then((m) => m.MonacoEditor), {
528− ssr: false,
529−})
530−
531−function CodePanel({ code }: { code: string }) {
532− return <MonacoEditor value={code} />
533−}
534−```
535−
536−### 2.5 Preload Based on User Intent
537−
538−**Impact: MEDIUM (reduces perceived latency)**
539−
540−Preload heavy bundles before they're needed to reduce perceived latency.
541−
542−**Example: preload on hover/focus**
543−
544−```tsx
545−function EditorButton({ onClick }: { onClick: () => void }) {
546− const preload = () => {
547− if (typeof window !== "undefined") {
548− void import("./monaco-editor")
549− }
550− }
551−
552− return (
553− <button onMouseEnter={preload} onFocus={preload} onClick={onClick}>
554− Open Editor
555− </button>
556− )
557−}
558−```
559−
560−**Example: preload when feature flag is enabled**
561−
562−```tsx
563−function FlagsProvider({ children, flags }: Props) {
564− useEffect(() => {
565− if (flags.editorEnabled && typeof window !== "undefined") {
566− void import("./monaco-editor").then((mod) => mod.init())
567− }
568− }, [flags.editorEnabled])
569−
570− return <FlagsContext.Provider value={flags}>{children}</FlagsContext.Provider>
571−}
572−```
573−
574−The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.
575−
576−---
577−
578−## 3. Server-Side Performance
579−
580−**Impact: HIGH**
581−
582−Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times.
583−
584−### 3.1 Authenticate Server Actions Like API Routes
585−
586−**Impact: CRITICAL (prevents unauthorized access to server mutations)**
587−
588−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.
589−
590−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."
591−
592−**Incorrect: no authentication check**
593−
594−```typescript
595−"use server"
596−
597−export async function deleteUser(userId: string) {
598− // Anyone can call this! No auth check
599− await db.user.delete({ where: { id: userId } })
600− return { success: true }
601−}
602−```
603−
604−**Correct: authentication inside the action**
605−
606−```typescript
607−"use server"
608−
609−import { verifySession } from "@/lib/auth"
610−import { unauthorized } from "@/lib/errors"
611−
612−export async function deleteUser(userId: string) {
613− // Always check auth inside the action
614− const session = await verifySession()
615−
616− if (!session) {
617− throw unauthorized("Must be logged in")
618− }
619−
620− // Check authorization too
621− if (session.user.role !== "admin" && session.user.id !== userId) {
622− throw unauthorized("Cannot delete other users")
623− }
624−
625− await db.user.delete({ where: { id: userId } })
626− return { success: true }
627−}
628−```
629−
630−**With input validation:**
631−
632−```typescript
633−"use server"
634−
635−import { verifySession } from "@/lib/auth"
636−import { z } from "zod"
637−
638−const updateProfileSchema = z.object({
639− userId: z.string().uuid(),
640− name: z.string().min(1).max(100),
641− email: z.string().email(),
642−})
643−
644−export async function updateProfile(data: unknown) {
645− // Validate input first
646− const validated = updateProfileSchema.parse(data)
647−
648− // Then authenticate
649− const session = await verifySession()
650− if (!session) {
651− throw new Error("Unauthorized")
652− }
653−
654− // Then authorize
655− if (session.user.id !== validated.userId) {
656− throw new Error("Can only update own profile")
657− }
658−
659− // Finally perform the mutation
660− await db.user.update({
661− where: { id: validated.userId },
662− data: {
663− name: validated.name,
664− email: validated.email,
665− },
666− })
667−
668− return { success: true }
669−}
670−```
671−
672−Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)
673−
674−### 3.2 Avoid Duplicate Serialization in RSC Props
675−
676−**Impact: LOW (reduces network payload by avoiding duplicate serialization)**
677−
678−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.
679−
680−**Incorrect: duplicates array**
681−
682−```tsx
683−// RSC: sends 6 strings (2 arrays × 3 items)
684−<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />
685−```
686−
687−**Correct: sends 3 strings**
688−
689−```tsx
690−// RSC: send once
691−;<ClientList usernames={usernames} />
692−
693−// Client: transform there
694−;("use client")
695−const sorted = useMemo(() => [...usernames].sort(), [usernames])
696−```
697−
698−**Nested deduplication behavior:**
699−
700−```tsx
701−// string[] - duplicates everything
702−usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings
703−
704−// object[] - duplicates array structure only
705−users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)
706−```
707−
708−Deduplication works recursively. Impact varies by data type:
709−
710−- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated
711−
712−- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference
713−
714−**Operations breaking deduplication: create new references**
715−
716−- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`
717−
718−- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`
719−
720−**More examples:**
721−
722−```tsx
723−// ❌ Bad
724−<C users={users} active={users.filter(u => u.active)} />
725−<C product={product} productName={product.name} />
726−
727−// ✅ Good
728−<C users={users} />
729−<C product={product} />
730−// Do filtering/destructuring in client
731−```
732−
733−**Exception:** Pass derived data when transformation is expensive or client doesn't need original.
734−
735−### 3.3 Cross-Request LRU Caching
736−
737−**Impact: HIGH (caches across requests)**
738−
739−`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
740−
741−**Implementation:**
742−
743−```typescript
744−import { LRUCache } from "lru-cache"
745−
746−const cache = new LRUCache<string, any>({
747− max: 1000,
748− ttl: 5 * 60 * 1000, // 5 minutes
749−})
750−
751−export async function getUser(id: string) {
752− const cached = cache.get(id)
753− if (cached) return cached
754−
755− const user = await db.user.findUnique({ where: { id } })
756− cache.set(id, user)
757− return user
758−}
759−
760−// Request 1: DB query, result cached
761−// Request 2: cache hit, no DB query
762−```
763−
764−Use when sequential user actions hit multiple endpoints needing the same data within seconds.
765−
766−**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.
767−
768−**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.
769−
770−Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
771−
772−### 3.4 Minimize Serialization at RSC Boundaries
773−
774−**Impact: HIGH (reduces data transfer size)**
775−
776−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.
777−
778−**Incorrect: serializes all 50 fields**
779−
780−```tsx
781−async function Page() {
782− const user = await fetchUser() // 50 fields
783− return <Profile user={user} />
784−}
785−
786−;("use client")
787−function Profile({ user }: { user: User }) {
788− return <div>{user.name}</div> // uses 1 field
789−}
790−```
791−
792−**Correct: serializes only 1 field**
793−
794−```tsx
795−async function Page() {
796− const user = await fetchUser()
797− return <Profile name={user.name} />
798−}
799−
800−;("use client")
801−function Profile({ name }: { name: string }) {
802− return <div>{name}</div>
803−}
804−```
805−
806−### 3.5 Parallel Data Fetching with Component Composition
807−
808−**Impact: CRITICAL (eliminates server-side waterfalls)**
809−
810−React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
811−
812−**Incorrect: Sidebar waits for Page's fetch to complete**
813−
814−```tsx
815−export default async function Page() {
816− const header = await fetchHeader()
817− return (
818− <div>
819− <div>{header}</div>
820− <Sidebar />
821− </div>
822− )
823−}
824−
825−async function Sidebar() {
826− const items = await fetchSidebarItems()
827− return <nav>{items.map(renderItem)}</nav>
828−}
829−```
830−
831−**Correct: both fetch simultaneously**
832−
833−```tsx
834−async function Header() {
835− const data = await fetchHeader()
836− return <div>{data}</div>
837−}
838−
839−async function Sidebar() {
840− const items = await fetchSidebarItems()
841− return <nav>{items.map(renderItem)}</nav>
842−}
843−
844−export default function Page() {
845− return (
846− <div>
847− <Header />
848− <Sidebar />
849− </div>
850− )
851−}
852−```
853−
854−**Alternative with children prop:**
855−
856−```tsx
857−async function Header() {
858− const data = await fetchHeader()
859− return <div>{data}</div>
860−}
861−
862−async function Sidebar() {
863− const items = await fetchSidebarItems()
864− return <nav>{items.map(renderItem)}</nav>
865−}
866−
867−function Layout({ children }: { children: ReactNode }) {
868− return (
869− <div>
870− <Header />
871− {children}
872− </div>
873− )
874−}
875−
876−export default function Page() {
877− return (
878− <Layout>
879− <Sidebar />
880− </Layout>
881− )
882−}
883−```
884−
885−### 3.6 Per-Request Deduplication with React.cache()
886−
887−**Impact: MEDIUM (deduplicates within request)**
888−
889−Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.
890−
891−**Usage:**
892−
893−```typescript
894−import { cache } from "react"
895−
896−export const getCurrentUser = cache(async () => {
897− const session = await auth()
898− if (!session?.user?.id) return null
899− return await db.user.findUnique({
900− where: { id: session.user.id },
901− })
902−})
903−```
904−
905−Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
906−
907−**Avoid inline objects as arguments:**
908−
909−`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits.
910−
911−**Incorrect: always cache miss**
912−
913−```typescript
914−const getUser = cache(async (params: { uid: number }) => {
915− return await db.user.findUnique({ where: { id: params.uid } })
916−})
917−
918−// Each call creates new object, never hits cache
919−getUser({ uid: 1 })
920−getUser({ uid: 1 }) // Cache miss, runs query again
921−```
922−
923−**Correct: cache hit**
924−
925−```typescript
926−const params = { uid: 1 }
927−getUser(params) // Query runs
928−getUser(params) // Cache hit (same reference)
929−```
930−
931−If you must pass objects, pass the same reference:
932−
933−**Next.js-Specific Note:**
934−
935−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:
936−
937−- Database queries (Prisma, Drizzle, etc.)
938−
939−- Heavy computations
940−
941−- Authentication checks
942−
943−- File system operations
944−
945−- Any non-fetch async work
946−
947−Use `React.cache()` to deduplicate these operations across your component tree.
948−
949−Reference: [https://react.dev/reference/react/cache](https://react.dev/reference/react/cache)
950−
951−### 3.7 Use after() for Non-Blocking Operations
952−
953−**Impact: MEDIUM (faster response times)**
954−
955−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.
956−
957−**Incorrect: blocks response**
958−
959−```tsx
960−import { logUserAction } from "@/app/utils"
961−
962−export async function POST(request: Request) {
963− // Perform mutation
964− await updateDatabase(request)
965−
966− // Logging blocks the response
967− const userAgent = request.headers.get("user-agent") || "unknown"
968− await logUserAction({ userAgent })
969−
970− return new Response(JSON.stringify({ status: "success" }), {
971− status: 200,
972− headers: { "Content-Type": "application/json" },
973− })
974−}
975−```
976−
977−**Correct: non-blocking**
978−
979−```tsx
980−import { after } from "next/server"
981−import { headers, cookies } from "next/headers"
982−import { logUserAction } from "@/app/utils"
983−
984−export async function POST(request: Request) {
985− // Perform mutation
986− await updateDatabase(request)
987−
988− // Log after response is sent
989− after(async () => {
990− const userAgent = (await headers()).get("user-agent") || "unknown"
991− const sessionCookie = (await cookies()).get("session-id")?.value || "anonymous"
992−
993− logUserAction({ sessionCookie, userAgent })
994− })
995−
996− return new Response(JSON.stringify({ status: "success" }), {
997− status: 200,
998− headers: { "Content-Type": "application/json" },
999− })
1000−}
1001−```
1002−
1003−The response is sent immediately while logging happens in the background.
1004−
1005−**Common use cases:**
1006−
1007−- Analytics tracking
1008−
1009−- Audit logging
1010−
1011−- Sending notifications
1012−
1013−- Cache invalidation
1014−
1015−- Cleanup tasks
1016−
1017−**Important notes:**
1018−
1019−- `after()` runs even if the response fails or redirects
1020−
1021−- Works in Server Actions, Route Handlers, and Server Components
1022−
1023−Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after)
1024−
1025−---
1026−
1027−## 4. Client-Side Data Fetching
1028−
1029−**Impact: MEDIUM-HIGH**
1030−
1031−Automatic deduplication and efficient data fetching patterns reduce redundant network requests.
1032−
1033−### 4.1 Deduplicate Global Event Listeners
1034−
1035−**Impact: LOW (single listener for N components)**
1036−
1037−Use `useSWRSubscription()` to share global event listeners across component instances.
1038−
1039−**Incorrect: N instances = N listeners**
1040−
1041−```tsx
1042−function useKeyboardShortcut(key: string, callback: () => void) {
1043− useEffect(() => {
1044− const handler = (e: KeyboardEvent) => {
1045− if (e.metaKey && e.key === key) {
1046− callback()
1047− }
1048− }
1049− window.addEventListener("keydown", handler)
1050− return () => window.removeEventListener("keydown", handler)
1051− }, [key, callback])
1052−}
1053−```
1054−
1055−When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener.
1056−
1057−**Correct: N instances = 1 listener**
1058−
1059−```tsx
1060−import useSWRSubscription from "swr/subscription"
1061−
1062−// Module-level Map to track callbacks per key
1063−const keyCallbacks = new Map<string, Set<() => void>>()
1064−
1065−function useKeyboardShortcut(key: string, callback: () => void) {
1066− // Register this callback in the Map
1067− useEffect(() => {
1068− if (!keyCallbacks.has(key)) {
1069− keyCallbacks.set(key, new Set())
1070− }
1071− keyCallbacks.get(key)!.add(callback)
1072−
1073− return () => {
1074− const set = keyCallbacks.get(key)
1075− if (set) {
1076− set.delete(callback)
1077− if (set.size === 0) {
1078− keyCallbacks.delete(key)
1079− }
1080− }
1081− }
1082− }, [key, callback])
1083−
1084− useSWRSubscription("global-keydown", () => {
1085− const handler = (e: KeyboardEvent) => {
1086− if (e.metaKey && keyCallbacks.has(e.key)) {
1087− keyCallbacks.get(e.key)!.forEach((cb) => cb())
1088− }
1089− }
1090− window.addEventListener("keydown", handler)
1091− return () => window.removeEventListener("keydown", handler)
1092− })
1093−}
1094−
1095−function Profile() {
1096− // Multiple shortcuts will share the same listener
1097− useKeyboardShortcut("p", () => {
1098− /* ... */
1099− })
1100− useKeyboardShortcut("k", () => {
1101− /* ... */
1102− })
1103− // ...
1104−}
1105−```
1106−
1107−### 4.2 Use Passive Event Listeners for Scrolling Performance
1108−
1109−**Impact: MEDIUM (eliminates scroll delay caused by event listeners)**
1110−
1111−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.
1112−
1113−**Incorrect:**
1114−
1115−```typescript
1116−useEffect(() => {
1117− const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
1118− const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
1119−
1120− document.addEventListener("touchstart", handleTouch)
1121− document.addEventListener("wheel", handleWheel)
1122−
1123− return () => {
1124− document.removeEventListener("touchstart", handleTouch)
1125− document.removeEventListener("wheel", handleWheel)
1126− }
1127−}, [])
1128−```
1129−
1130−**Correct:**
1131−
1132−```typescript
1133−useEffect(() => {
1134− const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
1135− const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
1136−
1137− document.addEventListener("touchstart", handleTouch, { passive: true })
1138− document.addEventListener("wheel", handleWheel, { passive: true })
1139−
1140− return () => {
1141− document.removeEventListener("touchstart", handleTouch)
1142− document.removeEventListener("wheel", handleWheel)
1143− }
1144−}, [])
1145−```
1146−
1147−**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.
1148−
1149−**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`.
1150−
1151−### 4.3 Use SWR for Automatic Deduplication
1152−
1153−**Impact: MEDIUM-HIGH (automatic deduplication)**
1154−
1155−SWR enables request deduplication, caching, and revalidation across component instances.
1156−
1157−**Incorrect: no deduplication, each instance fetches**
1158−
1159−```tsx
1160−function UserList() {
1161− const [users, setUsers] = useState([])
1162− useEffect(() => {
1163− fetch("/api/users")
1164− .then((r) => r.json())
1165− .then(setUsers)
1166− }, [])
1167−}
1168−```
1169−
1170−**Correct: multiple instances share one request**
1171−
1172−```tsx
1173−import useSWR from "swr"
1174−
1175−function UserList() {
1176− const { data: users } = useSWR("/api/users", fetcher)
1177−}
1178−```
1179−
1180−**For immutable data:**
1181−
1182−```tsx
1183−import { useImmutableSWR } from "@/lib/swr"
1184−
1185−function StaticContent() {
1186− const { data } = useImmutableSWR("/api/config", fetcher)
1187−}
1188−```
1189−
1190−**For mutations:**
1191−
1192−```tsx
1193−import { useSWRMutation } from "swr/mutation"
1194−
1195−function UpdateButton() {
1196− const { trigger } = useSWRMutation("/api/user", updateUser)
1197− return <button onClick={() => trigger()}>Update</button>
1198−}
1199−```
1200−
1201−Reference: [https://swr.vercel.app](https://swr.vercel.app)
1202−
1203−### 4.4 Version and Minimize localStorage Data
1204−
1205−**Impact: MEDIUM (prevents schema conflicts, reduces storage size)**
1206−
1207−Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data.
1208−
1209−**Incorrect:**
1210−
1211−```typescript
1212−// No version, stores everything, no error handling
1213−localStorage.setItem("userConfig", JSON.stringify(fullUserObject))
1214−const data = localStorage.getItem("userConfig")
1215−```
1216−
1217−**Correct:**
1218−
1219−```typescript
1220−const VERSION = "v2"
1221−
1222−function saveConfig(config: { theme: string; language: string }) {
1223− try {
1224− localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))
1225− } catch {
1226− // Throws in incognito/private browsing, quota exceeded, or disabled
1227− }
1228−}
1229−
1230−function loadConfig() {
1231− try {
1232− const data = localStorage.getItem(`userConfig:${VERSION}`)
1233− return data ? JSON.parse(data) : null
1234− } catch {
1235− return null
1236− }
1237−}
1238−
1239−// Migration from v1 to v2
1240−function migrate() {
1241− try {
1242− const v1 = localStorage.getItem("userConfig:v1")
1243− if (v1) {
1244− const old = JSON.parse(v1)
1245− saveConfig({ theme: old.darkMode ? "dark" : "light", language: old.lang })
1246− localStorage.removeItem("userConfig:v1")
1247− }
1248− } catch {}
1249−}
1250−```
1251−
1252−**Store minimal fields from server responses:**
1253−
1254−```typescript
1255−// User object has 20+ fields, only store what UI needs
1256−function cachePrefs(user: FullUser) {
1257− try {
1258− localStorage.setItem(
1259− "prefs:v1",
1260− JSON.stringify({
1261− theme: user.preferences.theme,
1262− notifications: user.preferences.notifications,
1263− })
1264− )
1265− } catch {}
1266−}
1267−```
1268−
1269−**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled.
1270−
1271−**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags.
1272−
1273−---
1274−
1275−## 5. Re-render Optimization
1276−
1277−**Impact: MEDIUM**
1278−
1279−Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.
1280−
1281−### 5.1 Calculate Derived State During Rendering
1282−
1283−**Impact: MEDIUM (avoids redundant renders and state drift)**
1284−
1285−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.
1286−
1287−**Incorrect: redundant state and effect**
1288−
1289−```tsx
1290−function Form() {
1291− const [firstName, setFirstName] = useState("First")
1292− const [lastName, setLastName] = useState("Last")
1293− const [fullName, setFullName] = useState("")
1294−
1295− useEffect(() => {
1296− setFullName(firstName + " " + lastName)
1297− }, [firstName, lastName])
1298−
1299− return <p>{fullName}</p>
1300−}
1301−```
1302−
1303−**Correct: derive during render**
1304−
1305−```tsx
1306−function Form() {
1307− const [firstName, setFirstName] = useState("First")
1308− const [lastName, setLastName] = useState("Last")
1309− const fullName = firstName + " " + lastName
1310−
1311− return <p>{fullName}</p>
1312−}
1313−```
1314−
1315−Reference: [https://react.dev/learn/you-might-not-need-an-effect](https://react.dev/learn/you-might-not-need-an-effect)
1316−
1317−### 5.2 Defer State Reads to Usage Point
1318−
1319−**Impact: MEDIUM (avoids unnecessary subscriptions)**
1320−
1321−Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
1322−
1323−**Incorrect: subscribes to all searchParams changes**
1324−
1325−```tsx
1326−function ShareButton({ chatId }: { chatId: string }) {
1327− const searchParams = useSearchParams()
1328−
1329− const handleShare = () => {
1330− const ref = searchParams.get("ref")
1331− shareChat(chatId, { ref })
1332− }
1333−
1334− return <button onClick={handleShare}>Share</button>
1335−}
1336−```
1337−
1338−**Correct: reads on demand, no subscription**
1339−
1340−```tsx
1341−function ShareButton({ chatId }: { chatId: string }) {
1342− const handleShare = () => {
1343− const params = new URLSearchParams(window.location.search)
1344− const ref = params.get("ref")
1345− shareChat(chatId, { ref })
1346− }
1347−
1348− return <button onClick={handleShare}>Share</button>
1349−}
1350−```
1351−
1352−### 5.3 Do not wrap a simple expression with a primitive result type in useMemo
1353−
1354−**Impact: LOW-MEDIUM (wasted computation on every render)**
1355−
1356−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`.
1357−
1358−Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.
1359−
1360−**Incorrect:**
1361−
1362−```tsx
1363−function Header({ user, notifications }: Props) {
1364− const isLoading = useMemo(() => {
1365− return user.isLoading || notifications.isLoading
1366− }, [user.isLoading, notifications.isLoading])
1367−
1368− if (isLoading) return <Skeleton />
1369− // return some markup
1370−}
1371−```
1372−
1373−**Correct:**
1374−
1375−```tsx
1376−function Header({ user, notifications }: Props) {
1377− const isLoading = user.isLoading || notifications.isLoading
1378−
1379− if (isLoading) return <Skeleton />
1380− // return some markup
1381−}
1382−```
1383−
1384−### 5.4 Extract Default Non-primitive Parameter Value from Memoized Component to Constant
1385−
1386−**Impact: MEDIUM (restores memoization by using a constant for default value)**
1387−
1388−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()`.
1389−
1390−To address this issue, extract the default value into a constant.
1391−
1392−**Incorrect: `onClick` has different values on every rerender**
1393−
1394−```tsx
1395−const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
1396− // ...
1397−})
1398−
1399−// Used without optional onClick
1400−<UserAvatar />
1401−```
1402−
1403−**Correct: stable default value**
1404−
1405−```tsx
1406−const NOOP = () => {};
1407−
1408−const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
1409− // ...
1410−})
1411−
1412−// Used without optional onClick
1413−<UserAvatar />
1414−```
1415−
1416−### 5.5 Extract to Memoized Components
1417−
1418−**Impact: MEDIUM (enables early returns)**
1419−
1420−Extract expensive work into memoized components to enable early returns before computation.
1421−
1422−**Incorrect: computes avatar even when loading**
1423−
1424−```tsx
1425−function Profile({ user, loading }: Props) {
1426− const avatar = useMemo(() => {
1427− const id = computeAvatarId(user)
1428− return <Avatar id={id} />
1429− }, [user])
1430−
1431− if (loading) return <Skeleton />
1432− return <div>{avatar}</div>
1433−}
1434−```
1435−
1436−**Correct: skips computation when loading**
1437−
1438−```tsx
1439−const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
1440− const id = useMemo(() => computeAvatarId(user), [user])
1441− return <Avatar id={id} />
1442−})
1443−
1444−function Profile({ user, loading }: Props) {
1445− if (loading) return <Skeleton />
1446− return (
1447− <div>
1448− <UserAvatar user={user} />
1449− </div>
1450− )
1451−}
1452−```
1453−
1454−**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.
1455−
1456−### 5.6 Narrow Effect Dependencies
1457−
1458−**Impact: LOW (minimizes effect re-runs)**
1459−
1460−Specify primitive dependencies instead of objects to minimize effect re-runs.
1461−
1462−**Incorrect: re-runs on any user field change**
1463−
1464−```tsx
1465−useEffect(() => {
1466− console.log(user.id)
1467−}, [user])
1468−```
1469−
1470−**Correct: re-runs only when id changes**
1471−
1472−```tsx
1473−useEffect(() => {
1474− console.log(user.id)
1475−}, [user.id])
1476−```
1477−
1478−**For derived state, compute outside effect:**
1479−
1480−```tsx
1481−// Incorrect: runs on width=767, 766, 765...
1482−useEffect(() => {
1483− if (width < 768) {
1484− enableMobileMode()
1485− }
1486−}, [width])
1487−
1488−// Correct: runs only on boolean transition
1489−const isMobile = width < 768
1490−useEffect(() => {
1491− if (isMobile) {
1492− enableMobileMode()
1493− }
1494−}, [isMobile])
1495−```
1496−
1497−### 5.7 Put Interaction Logic in Event Handlers
1498−
1499−**Impact: MEDIUM (avoids effect re-runs and duplicate side effects)**
1500−
1501−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.
1502−
1503−**Incorrect: event modeled as state + effect**
1504−
1505−```tsx
1506−function Form() {
1507− const [submitted, setSubmitted] = useState(false)
1508− const theme = useContext(ThemeContext)
1509−
1510− useEffect(() => {
1511− if (submitted) {
1512− post("/api/register")
1513− showToast("Registered", theme)
1514− }
1515− }, [submitted, theme])
1516−
1517− return <button onClick={() => setSubmitted(true)}>Submit</button>
1518−}
1519−```
1520−
1521−**Correct: do it in the handler**
1522−
1523−```tsx
1524−function Form() {
1525− const theme = useContext(ThemeContext)
1526−
1527− function handleSubmit() {
1528− post("/api/register")
1529− showToast("Registered", theme)
1530− }
1531−
1532− return <button onClick={handleSubmit}>Submit</button>
1533−}
1534−```
1535−
1536−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)
1537−
1538−### 5.8 Subscribe to Derived State
1539−
1540−**Impact: MEDIUM (reduces re-render frequency)**
1541−
1542−Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
1543−
1544−**Incorrect: re-renders on every pixel change**
1545−
1546−```tsx
1547−function Sidebar() {
1548− const width = useWindowWidth() // updates continuously
1549− const isMobile = width < 768
1550− return <nav className={isMobile ? "mobile" : "desktop"} />
1551−}
1552−```
1553−
1554−**Correct: re-renders only when boolean changes**
1555−
1556−```tsx
1557−function Sidebar() {
1558− const isMobile = useMediaQuery("(max-width: 767px)")
1559− return <nav className={isMobile ? "mobile" : "desktop"} />
1560−}
1561−```
1562−
1563−### 5.9 Use Functional setState Updates
1564−
1565−**Impact: MEDIUM (prevents stale closures and unnecessary callback recreations)**
1566−
1567−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.
1568−
1569−**Incorrect: requires state as dependency**
1570−
1571−```tsx
1572−function TodoList() {
1573− const [items, setItems] = useState(initialItems)
1574−
1575− // Callback must depend on items, recreated on every items change
1576− const addItems = useCallback(
1577− (newItems: Item[]) => {
1578− setItems([...items, ...newItems])
1579− },
1580− [items]
1581− ) // ❌ items dependency causes recreations
1582−
1583− // Risk of stale closure if dependency is forgotten
1584− const removeItem = useCallback((id: string) => {
1585− setItems(items.filter((item) => item.id !== id))
1586− }, []) // ❌ Missing items dependency - will use stale items!
1587−
1588− return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
1589−}
1590−```
1591−
1592−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.
1593−
1594−**Correct: stable callbacks, no stale closures**
1595−
1596−```tsx
1597−function TodoList() {
1598− const [items, setItems] = useState(initialItems)
1599−
1600− // Stable callback, never recreated
1601− const addItems = useCallback((newItems: Item[]) => {
1602− setItems((curr) => [...curr, ...newItems])
1603− }, []) // ✅ No dependencies needed
1604−
1605− // Always uses latest state, no stale closure risk
1606− const removeItem = useCallback((id: string) => {
1607− setItems((curr) => curr.filter((item) => item.id !== id))
1608− }, []) // ✅ Safe and stable
1609−
1610− return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
1611−}
1612−```
1613−
1614−**Benefits:**
1615−
1616−1. **Stable callback references** - Callbacks don't need to be recreated when state changes
1617−
1618−2. **No stale closures** - Always operates on the latest state value
1619−
1620−3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
1621−
1622−4. **Prevents bugs** - Eliminates the most common source of React closure bugs
1623−
1624−**When to use functional updates:**
1625−
1626−- Any setState that depends on the current state value
1627−
1628−- Inside useCallback/useMemo when state is needed
1629−
1630−- Event handlers that reference state
1631−
1632−- Async operations that update state
1633−
1634−**When direct updates are fine:**
1635−
1636−- Setting state to a static value: `setCount(0)`
1637−
1638−- Setting state from props/arguments only: `setName(newName)`
1639−
1640−- State doesn't depend on previous value
1641−
1642−**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.
1643−
1644−### 5.10 Use Lazy State Initialization
1645−
1646−**Impact: MEDIUM (wasted computation on every render)**
1647−
1648−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.
1649−
1650−**Incorrect: runs on every render**
1651−
1652−```tsx
1653−function FilteredList({ items }: { items: Item[] }) {
1654− // buildSearchIndex() runs on EVERY render, even after initialization
1655− const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
1656− const [query, setQuery] = useState("")
1657−
1658− // When query changes, buildSearchIndex runs again unnecessarily
1659− return <SearchResults index={searchIndex} query={query} />
1660−}
1661−
1662−function UserProfile() {
1663− // JSON.parse runs on every render
1664− const [settings, setSettings] = useState(JSON.parse(localStorage.getItem("settings") || "{}"))
1665−
1666− return <SettingsForm settings={settings} onChange={setSettings} />
1667−}
1668−```
1669−
1670−**Correct: runs only once**
1671−
1672−```tsx
1673−function FilteredList({ items }: { items: Item[] }) {
1674− // buildSearchIndex() runs ONLY on initial render
1675− const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
1676− const [query, setQuery] = useState("")
1677−
1678− return <SearchResults index={searchIndex} query={query} />
1679−}
1680−
1681−function UserProfile() {
1682− // JSON.parse runs only on initial render
1683− const [settings, setSettings] = useState(() => {
1684− const stored = localStorage.getItem("settings")
1685− return stored ? JSON.parse(stored) : {}
1686− })
1687−
1688− return <SettingsForm settings={settings} onChange={setSettings} />
1689−}
1690−```
1691−
1692−Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
1693−
1694−For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
1695−
1696−### 5.11 Use Transitions for Non-Urgent Updates
1697−
1698−**Impact: MEDIUM (maintains UI responsiveness)**
1699−
1700−Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
1701−
1702−**Incorrect: blocks UI on every scroll**
1703−
1704−```tsx
1705−function ScrollTracker() {
1706− const [scrollY, setScrollY] = useState(0)
1707− useEffect(() => {
1708− const handler = () => setScrollY(window.scrollY)
1709− window.addEventListener("scroll", handler, { passive: true })
1710− return () => window.removeEventListener("scroll", handler)
1711− }, [])
1712−}
1713−```
1714−
1715−**Correct: non-blocking updates**
1716−
1717−```tsx
1718−import { startTransition } from "react"
1719−
1720−function ScrollTracker() {
1721− const [scrollY, setScrollY] = useState(0)
1722− useEffect(() => {
1723− const handler = () => {
1724− startTransition(() => setScrollY(window.scrollY))
1725− }
1726− window.addEventListener("scroll", handler, { passive: true })
1727− return () => window.removeEventListener("scroll", handler)
1728− }, [])
1729−}
1730−```
1731−
1732−### 5.12 Use useRef for Transient Values
1733−
1734−**Impact: MEDIUM (avoids unnecessary re-renders on frequent updates)**
1735−
1736−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.
1737−
1738−**Incorrect: renders every update**
1739−
1740−```tsx
1741−function Tracker() {
1742− const [lastX, setLastX] = useState(0)
1743−
1744− useEffect(() => {
1745− const onMove = (e: MouseEvent) => setLastX(e.clientX)
1746− window.addEventListener("mousemove", onMove)
1747− return () => window.removeEventListener("mousemove", onMove)
1748− }, [])
1749−
1750− return (
1751− <div
1752− style={{
1753− position: "fixed",
1754− top: 0,
1755− left: lastX,
1756− width: 8,
1757− height: 8,
1758− background: "black",
1759− }}
1760− />
1761− )
1762−}
1763−```
1764−
1765−**Correct: no re-render for tracking**
1766−
1767−```tsx
1768−function Tracker() {
1769− const lastXRef = useRef(0)
1770− const dotRef = useRef<HTMLDivElement>(null)
1771−
1772− useEffect(() => {
1773− const onMove = (e: MouseEvent) => {
1774− lastXRef.current = e.clientX
1775− const node = dotRef.current
1776− if (node) {
1777− node.style.transform = `translateX(${e.clientX}px)`
1778− }
1779− }
1780− window.addEventListener("mousemove", onMove)
1781− return () => window.removeEventListener("mousemove", onMove)
1782− }, [])
1783−
1784− return (
1785− <div
1786− ref={dotRef}
1787− style={{
1788− position: "fixed",
1789− top: 0,
1790− left: 0,
1791− width: 8,
1792− height: 8,
1793− background: "black",
1794− transform: "translateX(0px)",
1795− }}
1796− />
1797− )
1798−}
1799−```
1800−
1801−---
1802−
1803−## 6. Rendering Performance
1804−
1805−**Impact: MEDIUM**
1806−
1807−Optimizing the rendering process reduces the work the browser needs to do.
1808−
1809−### 6.1 Animate SVG Wrapper Instead of SVG Element
1810−
1811−**Impact: LOW (enables hardware acceleration)**
1812−
1813−Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.
1814−
1815−**Incorrect: animating SVG directly - no hardware acceleration**
1816−
1817−```tsx
1818−function LoadingSpinner() {
1819− return (
1820− <svg className="animate-spin" width="24" height="24" viewBox="0 0 24 24">
1821− <circle cx="12" cy="12" r="10" stroke="currentColor" />
1822− </svg>
1823− )
1824−}
1825−```
1826−
1827−**Correct: animating wrapper div - hardware accelerated**
1828−
1829−```tsx
1830−function LoadingSpinner() {
1831− return (
1832− <div className="animate-spin">
1833− <svg width="24" height="24" viewBox="0 0 24 24">
1834− <circle cx="12" cy="12" r="10" stroke="currentColor" />
1835− </svg>
1836− </div>
1837− )
1838−}
1839−```
1840−
1841−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.
1842−
1843−### 6.2 CSS content-visibility for Long Lists
1844−
1845−**Impact: HIGH (faster initial render)**
1846−
1847−Apply `content-visibility: auto` to defer off-screen rendering.
1848−
1849−**CSS:**
1850−
1851−```css
1852−.message-item {
1853− content-visibility: auto;
1854− contain-intrinsic-size: 0 80px;
1855−}
1856−```
1857−
1858−**Example:**
1859−
1860−```tsx
1861−function MessageList({ messages }: { messages: Message[] }) {
1862− return (
1863− <div className="h-screen overflow-y-auto">
1864− {messages.map((msg) => (
1865− <div key={msg.id} className="message-item">
1866− <Avatar user={msg.author} />
1867− <div>{msg.content}</div>
1868− </div>
1869− ))}
1870− </div>
1871− )
1872−}
1873−```
1874−
1875−For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
1876−
1877−### 6.3 Hoist Static JSX Elements
1878−
1879−**Impact: LOW (avoids re-creation)**
1880−
1881−Extract static JSX outside components to avoid re-creation.
1882−
1883−**Incorrect: recreates element every render**
1884−
1885−```tsx
1886−function LoadingSkeleton() {
1887− return <div className="h-20 animate-pulse bg-gray-200" />
1888−}
1889−
1890−function Container() {
1891− return <div>{loading && <LoadingSkeleton />}</div>
1892−}
1893−```
1894−
1895−**Correct: reuses same element**
1896−
1897−```tsx
1898−const loadingSkeleton = <div className="h-20 animate-pulse bg-gray-200" />
1899−
1900−function Container() {
1901− return <div>{loading && loadingSkeleton}</div>
1902−}
1903−```
1904−
1905−This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
1906−
1907−**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.
1908−
1909−### 6.4 Optimize SVG Precision
1910−
1911−**Impact: LOW (reduces file size)**
1912−
1913−Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
1914−
1915−**Incorrect: excessive precision**
1916−
1917−```svg
1918−<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
1919−```
1920−
1921−**Correct: 1 decimal place**
1922−
1923−```svg
1924−<path d="M 10.3 20.8 L 30.9 40.2" />
1925−```
1926−
1927−**Automate with SVGO:**
1928−
1929−```bash
1930−npx svgo --precision=1 --multipass icon.svg
1931−```
1932−
1933−### 6.5 Prevent Hydration Mismatch Without Flickering
1934−
1935−**Impact: MEDIUM (avoids visual flicker and hydration errors)**
1936−
1937−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.
1938−
1939−**Incorrect: breaks SSR**
1940−
1941−```tsx
1942−function ThemeWrapper({ children }: { children: ReactNode }) {
1943− // localStorage is not available on server - throws error
1944− const theme = localStorage.getItem("theme") || "light"
1945−
1946− return <div className={theme}>{children}</div>
1947−}
1948−```
1949−
1950−Server-side rendering will fail because `localStorage` is undefined.
1951−
1952−**Incorrect: visual flickering**
1953−
1954−```tsx
1955−function ThemeWrapper({ children }: { children: ReactNode }) {
1956− const [theme, setTheme] = useState("light")
1957−
1958− useEffect(() => {
1959− // Runs after hydration - causes visible flash
1960− const stored = localStorage.getItem("theme")
1961− if (stored) {
1962− setTheme(stored)
1963− }
1964− }, [])
1965−
1966− return <div className={theme}>{children}</div>
1967−}
1968−```
1969−
1970−Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
1971−
1972−**Correct: no flicker, no hydration mismatch**
1973−
1974−```tsx
1975−function ThemeWrapper({ children }: { children: ReactNode }) {
1976− return (
1977− <>
1978− <div id="theme-wrapper">{children}</div>
1979− <script
1980− dangerouslySetInnerHTML={{
1981− __html: `
1982− (function() {
1983− try {
1984− var theme = localStorage.getItem('theme') || 'light';
1985− var el = document.getElementById('theme-wrapper');
1986− if (el) el.className = theme;
1987− } catch (e) {}
1988− })();
1989− `,
1990− }}
1991− />
1992− </>
1993− )
1994−}
1995−```
1996−
1997−The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
1998−
1999−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.
2000−
2001−### 6.6 Suppress Expected Hydration Mismatches
2002−
2003−**Impact: LOW-MEDIUM (avoids noisy hydration warnings for known differences)**
2004−
2005−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.
2006−
2007−**Incorrect: known mismatch warnings**
2008−
2009−```tsx
2010−function Timestamp() {
2011− return <span>{new Date().toLocaleString()}</span>
2012−}
2013−```
2014−
2015−**Correct: suppress expected mismatch only**
2016−
2017−```tsx
2018−function Timestamp() {
2019− return <span suppressHydrationWarning>{new Date().toLocaleString()}</span>
2020−}
2021−```
2022−
2023−### 6.7 Use Activity Component for Show/Hide
2024−
2025−**Impact: MEDIUM (preserves state/DOM)**
2026−
2027−Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.
2028−
2029−**Usage:**
2030−
2031−```tsx
2032−import { Activity } from "react"
2033−
2034−function Dropdown({ isOpen }: Props) {
2035− return (
2036− <Activity mode={isOpen ? "visible" : "hidden"}>
2037− <ExpensiveMenu />
2038− </Activity>
2039− )
2040−}
2041−```
2042−
2043−Avoids expensive re-renders and state loss.
2044−
2045−### 6.8 Use Explicit Conditional Rendering
2046−
2047−**Impact: LOW (prevents rendering 0 or NaN)**
2048−
2049−Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
2050−
2051−**Incorrect: renders "0" when count is 0**
2052−
2053−```tsx
2054−function Badge({ count }: { count: number }) {
2055− return <div>{count && <span className="badge">{count}</span>}</div>
2056−}
2057−
2058−// When count = 0, renders: <div>0</div>
2059−// When count = 5, renders: <div><span class="badge">5</span></div>
2060−```
2061−
2062−**Correct: renders nothing when count is 0**
2063−
2064−```tsx
2065−function Badge({ count }: { count: number }) {
2066− return <div>{count > 0 ? <span className="badge">{count}</span> : null}</div>
2067−}
2068−
2069−// When count = 0, renders: <div></div>
2070−// When count = 5, renders: <div><span class="badge">5</span></div>
2071−```
2072−
2073−### 6.9 Use useTransition Over Manual Loading States
2074−
2075−**Impact: LOW (reduces re-renders and improves code clarity)**
2076−
2077−Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.
2078−
2079−**Incorrect: manual loading state**
2080−
2081−```tsx
2082−function SearchResults() {
2083− const [query, setQuery] = useState("")
2084− const [results, setResults] = useState([])
2085− const [isLoading, setIsLoading] = useState(false)
2086−
2087− const handleSearch = async (value: string) => {
2088− setIsLoading(true)
2089− setQuery(value)
2090− const data = await fetchResults(value)
2091− setResults(data)
2092− setIsLoading(false)
2093− }
2094−
2095− return (
2096− <>
2097− <input onChange={(e) => handleSearch(e.target.value)} />
2098− {isLoading && <Spinner />}
2099− <ResultsList results={results} />
2100− </>
2101− )
2102−}
2103−```
2104−
2105−**Correct: useTransition with built-in pending state**
2106−
2107−```tsx
2108−import { useTransition, useState } from "react"
2109−
2110−function SearchResults() {
2111− const [query, setQuery] = useState("")
2112− const [results, setResults] = useState([])
2113− const [isPending, startTransition] = useTransition()
2114−
2115− const handleSearch = (value: string) => {
2116− setQuery(value) // Update input immediately
2117−
2118− startTransition(async () => {
2119− // Fetch and update results
2120− const data = await fetchResults(value)
2121− setResults(data)
2122− })
2123− }
2124−
2125− return (
2126− <>
2127− <input onChange={(e) => handleSearch(e.target.value)} />
2128− {isPending && <Spinner />}
2129− <ResultsList results={results} />
2130− </>
2131− )
2132−}
2133−```
2134−
2135−**Benefits:**
2136−
2137−- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`
2138−
2139−- **Error resilience**: Pending state correctly resets even if the transition throws
2140−
2141−- **Better responsiveness**: Keeps the UI responsive during updates
2142−
2143−- **Interrupt handling**: New transitions automatically cancel pending ones
2144−
2145−Reference: [https://react.dev/reference/react/useTransition](https://react.dev/reference/react/useTransition)
2146−
2147−---
2148−
2149−## 7. JavaScript Performance
2150−
2151−**Impact: LOW-MEDIUM**
2152−
2153−Micro-optimizations for hot paths can add up to meaningful improvements.
2154−
2155−### 7.1 Avoid Layout Thrashing
2156−
2157−**Impact: MEDIUM (prevents forced synchronous layouts and reduces performance bottlenecks)**
2158−
2159−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.
2160−
2161−**This is OK: browser batches style changes**
2162−
2163−```typescript
2164−function updateElementStyles(element: HTMLElement) {
2165− // Each line invalidates style, but browser batches the recalculation
2166− element.style.width = "100px"
2167− element.style.height = "200px"
2168− element.style.backgroundColor = "blue"
2169− element.style.border = "1px solid black"
2170−}
2171−```
2172−
2173−**Incorrect: interleaved reads and writes force reflows**
2174−
2175−```typescript
2176−function layoutThrashing(element: HTMLElement) {
2177− element.style.width = "100px"
2178− const width = element.offsetWidth // Forces reflow
2179− element.style.height = "200px"
2180− const height = element.offsetHeight // Forces another reflow
2181−}
2182−```
2183−
2184−**Correct: batch writes, then read once**
2185−
2186−```typescript
2187−function updateElementStyles(element: HTMLElement) {
2188− // Batch all writes together
2189− element.style.width = "100px"
2190− element.style.height = "200px"
2191− element.style.backgroundColor = "blue"
2192− element.style.border = "1px solid black"
2193−
2194− // Read after all writes are done (single reflow)
2195− const { width, height } = element.getBoundingClientRect()
2196−}
2197−```
2198−
2199−**Correct: batch reads, then writes**
2200−
2201−```typescript
2202−function updateElementStyles(element: HTMLElement) {
2203− element.classList.add("highlighted-box")
2204−
2205− const { width, height } = element.getBoundingClientRect()
2206−}
2207−```
2208−
2209−**Better: use CSS classes**
2210−
2211−**React example:**
2212−
2213−```tsx
2214−// Incorrect: interleaving style changes with layout queries
2215−function Box({ isHighlighted }: { isHighlighted: boolean }) {
2216− const ref = useRef<HTMLDivElement>(null)
2217−
2218− useEffect(() => {
2219− if (ref.current && isHighlighted) {
2220− ref.current.style.width = "100px"
2221− const width = ref.current.offsetWidth // Forces layout
2222− ref.current.style.height = "200px"
2223− }
2224− }, [isHighlighted])
2225−
2226− return <div ref={ref}>Content</div>
2227−}
2228−
2229−// Correct: toggle class
2230−function Box({ isHighlighted }: { isHighlighted: boolean }) {
2231− return <div className={isHighlighted ? "highlighted-box" : ""}>Content</div>
2232−}
2233−```
2234−
2235−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.
2236−
2237−See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.
2238−
2239−### 7.2 Build Index Maps for Repeated Lookups
2240−
2241−**Impact: LOW-MEDIUM (1M ops to 2K ops)**
2242−
2243−Multiple `.find()` calls by the same key should use a Map.
2244−
2245−**Incorrect (O(n) per lookup):**
2246−
2247−```typescript
2248−function processOrders(orders: Order[], users: User[]) {
2249− return orders.map((order) => ({
2250− ...order,
2251− user: users.find((u) => u.id === order.userId),
2252− }))
2253−}
2254−```
2255−
2256−**Correct (O(1) per lookup):**
2257−
2258−```typescript
2259−function processOrders(orders: Order[], users: User[]) {
2260− const userById = new Map(users.map((u) => [u.id, u]))
2261−
2262− return orders.map((order) => ({
2263− ...order,
2264− user: userById.get(order.userId),
2265− }))
2266−}
2267−```
2268−
2269−Build map once (O(n)), then all lookups are O(1).
2270−
2271−For 1000 orders × 1000 users: 1M ops → 2K ops.
2272−
2273−### 7.3 Cache Property Access in Loops
2274−
2275−**Impact: LOW-MEDIUM (reduces lookups)**
2276−
2277−Cache object property lookups in hot paths.
2278−
2279−**Incorrect: 3 lookups × N iterations**
2280−
2281−```typescript
2282−for (let i = 0; i < arr.length; i++) {
2283− process(obj.config.settings.value)
2284−}
2285−```
2286−
2287−**Correct: 1 lookup total**
2288−
2289−```typescript
2290−const value = obj.config.settings.value
2291−const len = arr.length
2292−for (let i = 0; i < len; i++) {
2293− process(value)
2294−}
2295−```
2296−
2297−### 7.4 Cache Repeated Function Calls
2298−
2299−**Impact: MEDIUM (avoid redundant computation)**
2300−
2301−Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.
2302−
2303−**Incorrect: redundant computation**
2304−
2305−```typescript
2306−function ProjectList({ projects }: { projects: Project[] }) {
2307− return (
2308− <div>
2309− {projects.map(project => {
2310− // slugify() called 100+ times for same project names
2311− const slug = slugify(project.name)
2312−
2313− return <ProjectCard key={project.id} slug={slug} />
2314− })}
2315− </div>
2316− )
2317−}
2318−```
2319−
2320−**Correct: cached results**
2321−
2322−```typescript
2323−// Module-level cache
2324−const slugifyCache = new Map<string, string>()
2325−
2326−function cachedSlugify(text: string): string {
2327− if (slugifyCache.has(text)) {
2328− return slugifyCache.get(text)!
2329− }
2330− const result = slugify(text)
2331− slugifyCache.set(text, result)
2332− return result
2333−}
2334−
2335−function ProjectList({ projects }: { projects: Project[] }) {
2336− return (
2337− <div>
2338− {projects.map(project => {
2339− // Computed only once per unique project name
2340− const slug = cachedSlugify(project.name)
2341−
2342− return <ProjectCard key={project.id} slug={slug} />
2343− })}
2344− </div>
2345− )
2346−}
2347−```
2348−
2349−**Simpler pattern for single-value functions:**
2350−
2351−```typescript
2352−let isLoggedInCache: boolean | null = null
2353−
2354−function isLoggedIn(): boolean {
2355− if (isLoggedInCache !== null) {
2356− return isLoggedInCache
2357− }
2358−
2359− isLoggedInCache = document.cookie.includes("auth=")
2360− return isLoggedInCache
2361−}
2362−
2363−// Clear cache when auth changes
2364−function onAuthChange() {
2365− isLoggedInCache = null
2366−}
2367−```
2368−
2369−Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
2370−
2371−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)
2372−
2373−### 7.5 Cache Storage API Calls
2374−
2375−**Impact: LOW-MEDIUM (reduces expensive I/O)**
2376−
2377−`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.
2378−
2379−**Incorrect: reads storage on every call**
2380−
2381−```typescript
2382−function getTheme() {
2383− return localStorage.getItem("theme") ?? "light"
2384−}
2385−// Called 10 times = 10 storage reads
2386−```
2387−
2388−**Correct: Map cache**
2389−
2390−```typescript
2391−const storageCache = new Map<string, string | null>()
2392−
2393−function getLocalStorage(key: string) {
2394− if (!storageCache.has(key)) {
2395− storageCache.set(key, localStorage.getItem(key))
2396− }
2397− return storageCache.get(key)
2398−}
2399−
2400−function setLocalStorage(key: string, value: string) {
2401− localStorage.setItem(key, value)
2402− storageCache.set(key, value) // keep cache in sync
2403−}
2404−```
2405−
2406−Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
2407−
2408−**Cookie caching:**
2409−
2410−```typescript
2411−let cookieCache: Record<string, string> | null = null
2412−
2413−function getCookie(name: string) {
2414− if (!cookieCache) {
2415− cookieCache = Object.fromEntries(document.cookie.split("; ").map((c) => c.split("=")))
2416− }
2417− return cookieCache[name]
2418−}
2419−```
2420−
2421−**Important: invalidate on external changes**
2422−
2423−```typescript
2424−window.addEventListener("storage", (e) => {
2425− if (e.key) storageCache.delete(e.key)
2426−})
2427−
2428−document.addEventListener("visibilitychange", () => {
2429− if (document.visibilityState === "visible") {
2430− storageCache.clear()
2431− }
2432−})
2433−```
2434−
2435−If storage can change externally (another tab, server-set cookies), invalidate cache:
2436−
2437−### 7.6 Combine Multiple Array Iterations
2438−
2439−**Impact: LOW-MEDIUM (reduces iterations)**
2440−
2441−Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.
2442−
2443−**Incorrect: 3 iterations**
2444−
2445−```typescript
2446−const admins = users.filter((u) => u.isAdmin)
2447−const testers = users.filter((u) => u.isTester)
2448−const inactive = users.filter((u) => !u.isActive)
2449−```
2450−
2451−**Correct: 1 iteration**
2452−
2453−```typescript
2454−const admins: User[] = []
2455−const testers: User[] = []
2456−const inactive: User[] = []
2457−
2458−for (const user of users) {
2459− if (user.isAdmin) admins.push(user)
2460− if (user.isTester) testers.push(user)
2461− if (!user.isActive) inactive.push(user)
2462−}
2463−```
2464−
2465−### 7.7 Early Length Check for Array Comparisons
2466−
2467−**Impact: MEDIUM-HIGH (avoids expensive operations when lengths differ)**
2468−
2469−When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
2470−
2471−In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
2472−
2473−**Incorrect: always runs expensive comparison**
2474−
2475−```typescript
2476−function hasChanges(current: string[], original: string[]) {
2477− // Always sorts and joins, even when lengths differ
2478− return current.sort().join() !== original.sort().join()
2479−}
2480−```
2481−
2482−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.
2483−
2484−**Correct (O(1) length check first):**
2485−
2486−```typescript
2487−function hasChanges(current: string[], original: string[]) {
2488− // Early return if lengths differ
2489− if (current.length !== original.length) {
2490− return true
2491− }
2492− // Only sort when lengths match
2493− const currentSorted = current.toSorted()
2494− const originalSorted = original.toSorted()
2495− for (let i = 0; i < currentSorted.length; i++) {
2496− if (currentSorted[i] !== originalSorted[i]) {
2497− return true
2498− }
2499− }
2500− return false
2501−}
2502−```
2503−
2504−This new approach is more efficient because:
2505−
2506−- It avoids the overhead of sorting and joining the arrays when lengths differ
2507−
2508−- It avoids consuming memory for the joined strings (especially important for large arrays)
2509−
2510−- It avoids mutating the original arrays
2511−
2512−- It returns early when a difference is found
2513−
2514−### 7.8 Early Return from Functions
2515−
2516−**Impact: LOW-MEDIUM (avoids unnecessary computation)**
2517−
2518−Return early when result is determined to skip unnecessary processing.
2519−
2520−**Incorrect: processes all items even after finding answer**
2521−
2522−```typescript
2523−function validateUsers(users: User[]) {
2524− let hasError = false
2525− let errorMessage = ""
2526−
2527− for (const user of users) {
2528− if (!user.email) {
2529− hasError = true
2530− errorMessage = "Email required"
2531− }
2532− if (!user.name) {
2533− hasError = true
2534− errorMessage = "Name required"
2535− }
2536− // Continues checking all users even after error found
2537− }
2538−
2539− return hasError ? { valid: false, error: errorMessage } : { valid: true }
2540−}
2541−```
2542−
2543−**Correct: returns immediately on first error**
2544−
2545−```typescript
2546−function validateUsers(users: User[]) {
2547− for (const user of users) {
2548− if (!user.email) {
2549− return { valid: false, error: "Email required" }
2550− }
2551− if (!user.name) {
2552− return { valid: false, error: "Name required" }
2553− }
2554− }
2555−
2556− return { valid: true }
2557−}
2558−```
2559−
2560−### 7.9 Hoist RegExp Creation
2561−
2562−**Impact: LOW-MEDIUM (avoids recreation)**
2563−
2564−Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.
2565−
2566−**Incorrect: new RegExp every render**
2567−
2568−```tsx
2569−function Highlighter({ text, query }: Props) {
2570− const regex = new RegExp(`(${query})`, 'gi')
2571− const parts = text.split(regex)
2572− return <>{parts.map((part, i) => ...)}</>
2573−}
2574−```
2575−
2576−**Correct: memoize or hoist**
2577−
2578−```tsx
2579−const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
2580−
2581−function Highlighter({ text, query }: Props) {
2582− const regex = useMemo(
2583− () => new RegExp(`(${escapeRegex(query)})`, 'gi'),
2584− [query]
2585− )
2586− const parts = text.split(regex)
2587− return <>{parts.map((part, i) => ...)}</>
2588−}
2589−```
2590−
2591−**Warning: global regex has mutable state**
2592−
2593−```typescript
2594−const regex = /foo/g
2595−regex.test("foo") // true, lastIndex = 3
2596−regex.test("foo") // false, lastIndex = 0
2597−```
2598−
2599−Global regex (`/g`) has mutable `lastIndex` state:
2600−
2601−### 7.10 Use Loop for Min/Max Instead of Sort
2602−
2603−**Impact: LOW (O(n) instead of O(n log n))**
2604−
2605−Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.
2606−
2607−**Incorrect (O(n log n) - sort to find latest):**
2608−
2609−```typescript
2610−interface Project {
2611− id: string
2612− name: string
2613− updatedAt: number
2614−}
2615−
2616−function getLatestProject(projects: Project[]) {
2617− const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
2618− return sorted[0]
2619−}
2620−```
2621−
2622−Sorts the entire array just to find the maximum value.
2623−
2624−**Incorrect (O(n log n) - sort for oldest and newest):**
2625−
2626−```typescript
2627−function getOldestAndNewest(projects: Project[]) {
2628− const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
2629− return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
2630−}
2631−```
2632−
2633−Still sorts unnecessarily when only min/max are needed.
2634−
2635−**Correct (O(n) - single loop):**
2636−
2637−```typescript
2638−function getLatestProject(projects: Project[]) {
2639− if (projects.length === 0) return null
2640−
2641− let latest = projects[0]
2642−
2643− for (let i = 1; i < projects.length; i++) {
2644− if (projects[i].updatedAt > latest.updatedAt) {
2645− latest = projects[i]
2646− }
2647− }
2648−
2649− return latest
2650−}
2651−
2652−function getOldestAndNewest(projects: Project[]) {
2653− if (projects.length === 0) return { oldest: null, newest: null }
2654−
2655− let oldest = projects[0]
2656− let newest = projects[0]
2657−
2658− for (let i = 1; i < projects.length; i++) {
2659− if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
2660− if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
2661− }
2662−
2663− return { oldest, newest }
2664−}
2665−```
2666−
2667−Single pass through the array, no copying, no sorting.
2668−
2669−**Alternative: Math.min/Math.max for small arrays**
2670−
2671−```typescript
2672−const numbers = [5, 2, 8, 1, 9]
2673−const min = Math.min(...numbers)
2674−const max = Math.max(...numbers)
2675−```
2676−
2677−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.
2678−
2679−### 7.11 Use Set/Map for O(1) Lookups
2680−
2681−**Impact: LOW-MEDIUM (O(n) to O(1))**
2682−
2683−Convert arrays to Set/Map for repeated membership checks.
2684−
2685−**Incorrect (O(n) per check):**
2686−
2687−```typescript
2688−const allowedIds = ['a', 'b', 'c', ...]
2689−items.filter(item => allowedIds.includes(item.id))
2690−```
2691−
2692−**Correct (O(1) per check):**
2693−
2694−```typescript
2695−const allowedIds = new Set(['a', 'b', 'c', ...])
2696−items.filter(item => allowedIds.has(item.id))
2697−```
2698−
2699−### 7.12 Use toSorted() Instead of sort() for Immutability
2700−
2701−**Impact: MEDIUM-HIGH (prevents mutation bugs in React state)**
2702−
2703−`.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.
2704−
2705−**Incorrect: mutates original array**
2706−
2707−```typescript
2708−function UserList({ users }: { users: User[] }) {
2709− // Mutates the users prop array!
2710− const sorted = useMemo(
2711− () => users.sort((a, b) => a.name.localeCompare(b.name)),
2712− [users]
2713− )
2714− return <div>{sorted.map(renderUser)}</div>
2715−}
2716−```
2717−
2718−**Correct: creates new array**
2719−
2720−```typescript
2721−function UserList({ users }: { users: User[] }) {
2722− // Creates new sorted array, original unchanged
2723− const sorted = useMemo(
2724− () => users.toSorted((a, b) => a.name.localeCompare(b.name)),
2725− [users]
2726− )
2727− return <div>{sorted.map(renderUser)}</div>
2728−}
2729−```
2730−
2731−**Why this matters in React:**
2732−
2733−1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only
2734−
2735−2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
2736−
2737−**Browser support: fallback for older browsers**
2738−
2739−```typescript
2740−// Fallback for older browsers
2741−const sorted = [...items].sort((a, b) => a.value - b.value)
2742−```
2743−
2744−`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
2745−
2746−**Other immutable array methods:**
2747−
2748−- `.toSorted()` - immutable sort
2749−
2750−- `.toReversed()` - immutable reverse
2751−
2752−- `.toSpliced()` - immutable splice
2753−
2754−- `.with()` - immutable element replacement
2755−
2756−---
2757−
2758−## 8. Advanced Patterns
2759−
2760−**Impact: LOW**
2761−
2762−Advanced patterns for specific cases that require careful implementation.
2763−
2764−### 8.1 Initialize App Once, Not Per Mount
2765−
2766−**Impact: LOW-MEDIUM (avoids duplicate init in development)**
2767−
2768−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.
2769−
2770−**Incorrect: runs twice in dev, re-runs on remount**
2771−
2772−```tsx
2773−function Comp() {
2774− useEffect(() => {
2775− loadFromStorage()
2776− checkAuthToken()
2777− }, [])
2778−
2779− // ...
2780−}
2781−```
2782−
2783−**Correct: once per app load**
2784−
2785−```tsx
2786−let didInit = false
2787−
2788−function Comp() {
2789− useEffect(() => {
2790− if (didInit) return
2791− didInit = true
2792− loadFromStorage()
2793− checkAuthToken()
2794− }, [])
2795−
2796− // ...
2797−}
2798−```
2799−
2800−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)
2801−
2802−### 8.2 Store Event Handlers in Refs
2803−
2804−**Impact: LOW (stable subscriptions)**
2805−
2806−Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
2807−
2808−**Incorrect: re-subscribes on every render**
2809−
2810−```tsx
2811−function useWindowEvent(event: string, handler: (e) => void) {
2812− useEffect(() => {
2813− window.addEventListener(event, handler)
2814− return () => window.removeEventListener(event, handler)
2815− }, [event, handler])
2816−}
2817−```
2818−
2819−**Correct: stable subscription**
2820−
2821−```tsx
2822−import { useEffectEvent } from "react"
2823−
2824−function useWindowEvent(event: string, handler: (e) => void) {
2825− const onEvent = useEffectEvent(handler)
2826−
2827− useEffect(() => {
2828− window.addEventListener(event, onEvent)
2829− return () => window.removeEventListener(event, onEvent)
2830− }, [event])
2831−}
2832−```
2833−
2834−**Alternative: use `useEffectEvent` if you're on latest React:**
2835−
2836−`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
2837−
2838−### 8.3 useEffectEvent for Stable Callback Refs
2839−
2840−**Impact: LOW (prevents effect re-runs)**
2841−
2842−Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
2843−
2844−**Incorrect: effect re-runs on every callback change**
2845−
2846−```tsx
2847−function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
2848− const [query, setQuery] = useState("")
2849−
2850− useEffect(() => {
2851− const timeout = setTimeout(() => onSearch(query), 300)
2852− return () => clearTimeout(timeout)
2853− }, [query, onSearch])
2854−}
2855−```
2856−
2857−**Correct: using React's useEffectEvent**
2858−
2859−```tsx
2860−import { useEffectEvent } from "react"
2861−
2862−function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
2863− const [query, setQuery] = useState("")
2864− const onSearchEvent = useEffectEvent(onSearch)
2865−
2866− useEffect(() => {
2867− const timeout = setTimeout(() => onSearchEvent(query), 300)
2868− return () => clearTimeout(timeout)
2869− }, [query])
2870−}
2871−```
2872−
2873−---
2874−
2875−## References
2876−
2877−1. [https://react.dev](https://react.dev)
2878−2. [https://nextjs.org](https://nextjs.org)
2879−3. [https://swr.vercel.app](https://swr.vercel.app)
2880−4. [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
2881−5. [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
2882−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)
2883−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)
1+# React Composition Patterns
2+
3+**Version 1.0.0**
4+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 codebases using composition. 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+Composition patterns for building flexible, maintainable React components. Avoid boolean prop proliferation by using compound components, lifting state, and composing internals. These patterns make codebases easier for both humans and AI agents to work with as they scale.
18+
19+---
20+
21+## Table of Contents
22+
23+1. [Component Architecture](#1-component-architecture) — **HIGH**
24+ - 1.1 [Avoid Boolean Prop Proliferation](#11-avoid-boolean-prop-proliferation)
25+ - 1.2 [Use Compound Components](#12-use-compound-components)
26+2. [State Management](#2-state-management) — **MEDIUM**
27+ - 2.1 [Decouple State Management from UI](#21-decouple-state-management-from-ui)
28+ - 2.2 [Define Generic Context Interfaces for Dependency Injection](#22-define-generic-context-interfaces-for-dependency-injection)
29+ - 2.3 [Lift State into Provider Components](#23-lift-state-into-provider-components)
30+3. [Implementation Patterns](#3-implementation-patterns) — **MEDIUM**
31+ - 3.1 [Create Explicit Component Variants](#31-create-explicit-component-variants)
32+ - 3.2 [Prefer Composing Children Over Render Props](#32-prefer-composing-children-over-render-props)
33+4. [React 19 APIs](#4-react-19-apis) — **MEDIUM**
34+ - 4.1 [React 19 API Changes](#41-react-19-api-changes)
35+
36+---
37+
38+## 1. Component Architecture
39+
40+**Impact: HIGH**
41+
42+Fundamental patterns for structuring components to avoid prop
43+proliferation and enable flexible composition.
44+
45+### 1.1 Avoid Boolean Prop Proliferation
46+
47+**Impact: CRITICAL (prevents unmaintainable component variants)**
48+
49+Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
50+
51+component behavior. Each boolean doubles possible states and creates
52+
53+unmaintainable conditional logic. Use composition instead.
54+
55+**Incorrect: boolean props create exponential complexity**
56+
57+```tsx
58+function Composer({
59+ onSubmit,
60+ isThread,
61+ channelId,
62+ isDMThread,
63+ dmId,
64+ isEditing,
65+ isForwarding,
66+}: Props) {
67+ return (
68+ <form>
69+ <Header />
70+ <Input />
71+ {isDMThread ? (
72+ <AlsoSendToDMField id={dmId} />
73+ ) : isThread ? (
74+ <AlsoSendToChannelField id={channelId} />
75+ ) : null}
76+ {isEditing ? <EditActions /> : isForwarding ? <ForwardActions /> : <DefaultActions />}
77+ <Footer onSubmit={onSubmit} />
78+ </form>
79+ )
80+}
81+```
82+
83+**Correct: composition eliminates conditionals**
84+
85+```tsx
86+// Channel composer
87+function ChannelComposer() {
88+ return (
89+ <Composer.Frame>
90+ <Composer.Header />
91+ <Composer.Input />
92+ <Composer.Footer>
93+ <Composer.Attachments />
94+ <Composer.Formatting />
95+ <Composer.Emojis />
96+ <Composer.Submit />
97+ </Composer.Footer>
98+ </Composer.Frame>
99+ )
100+}
101+
102+// Thread composer - adds "also send to channel" field
103+function ThreadComposer({ channelId }: { channelId: string }) {
104+ return (
105+ <Composer.Frame>
106+ <Composer.Header />
107+ <Composer.Input />
108+ <AlsoSendToChannelField id={channelId} />
109+ <Composer.Footer>
110+ <Composer.Formatting />
111+ <Composer.Emojis />
112+ <Composer.Submit />
113+ </Composer.Footer>
114+ </Composer.Frame>
115+ )
116+}
117+
118+// Edit composer - different footer actions
119+function EditComposer() {
120+ return (
121+ <Composer.Frame>
122+ <Composer.Input />
123+ <Composer.Footer>
124+ <Composer.Formatting />
125+ <Composer.Emojis />
126+ <Composer.CancelEdit />
127+ <Composer.SaveEdit />
128+ </Composer.Footer>
129+ </Composer.Frame>
130+ )
131+}
132+```
133+
134+Each variant is explicit about what it renders. We can share internals without
135+
136+sharing a single monolithic parent.
137+
138+### 1.2 Use Compound Components
139+
140+**Impact: HIGH (enables flexible composition without prop drilling)**
141+
142+Structure complex components as compound components with a shared context. Each
143+
144+subcomponent accesses shared state via context, not props. Consumers compose the
145+
146+pieces they need.
147+
148+**Incorrect: monolithic component with render props**
149+
150+```tsx
151+function Composer({
152+ renderHeader,
153+ renderFooter,
154+ renderActions,
155+ showAttachments,
156+ showFormatting,
157+ showEmojis,
158+}: Props) {
159+ return (
160+ <form>
161+ {renderHeader?.()}
162+ <Input />
163+ {showAttachments && <Attachments />}
164+ {renderFooter ? (
165+ renderFooter()
166+ ) : (
167+ <Footer>
168+ {showFormatting && <Formatting />}
169+ {showEmojis && <Emojis />}
170+ {renderActions?.()}
171+ </Footer>
172+ )}
173+ </form>
174+ )
175+}
176+```
177+
178+**Correct: compound components with shared context**
179+
180+```tsx
181+const ComposerContext = createContext<ComposerContextValue | null>(null)
182+
183+function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
184+ return <ComposerContext value={{ state, actions, meta }}>{children}</ComposerContext>
185+}
186+
187+function ComposerFrame({ children }: { children: React.ReactNode }) {
188+ return <form>{children}</form>
189+}
190+
191+function ComposerInput() {
192+ const {
193+ state,
194+ actions: { update },
195+ meta: { inputRef },
196+ } = use(ComposerContext)
197+ return (
198+ <TextInput
199+ ref={inputRef}
200+ value={state.input}
201+ onChangeText={(text) => update((s) => ({ ...s, input: text }))}
202+ />
203+ )
204+}
205+
206+function ComposerSubmit() {
207+ const {
208+ actions: { submit },
209+ } = use(ComposerContext)
210+ return <Button onPress={submit}>Send</Button>
211+}
212+
213+// Export as compound component
214+const Composer = {
215+ Provider: ComposerProvider,
216+ Frame: ComposerFrame,
217+ Input: ComposerInput,
218+ Submit: ComposerSubmit,
219+ Header: ComposerHeader,
220+ Footer: ComposerFooter,
221+ Attachments: ComposerAttachments,
222+ Formatting: ComposerFormatting,
223+ Emojis: ComposerEmojis,
224+}
225+```
226+
227+**Usage:**
228+
229+```tsx
230+<Composer.Provider state={state} actions={actions} meta={meta}>
231+ <Composer.Frame>
232+ <Composer.Header />
233+ <Composer.Input />
234+ <Composer.Footer>
235+ <Composer.Formatting />
236+ <Composer.Submit />
237+ </Composer.Footer>
238+ </Composer.Frame>
239+</Composer.Provider>
240+```
241+
242+Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
243+
244+---
245+
246+## 2. State Management
247+
248+**Impact: MEDIUM**
249+
250+Patterns for lifting state and managing shared context across
251+composed components.
252+
253+### 2.1 Decouple State Management from UI
254+
255+**Impact: MEDIUM (enables swapping state implementations without changing UI)**
256+
257+The provider component should be the only place that knows how state is managed.
258+
259+UI components consume the context interface—they don't know if state comes from
260+
261+useState, Zustand, or a server sync.
262+
263+**Incorrect: UI coupled to state implementation**
264+
265+```tsx
266+function ChannelComposer({ channelId }: { channelId: string }) {
267+ // UI component knows about global state implementation
268+ const state = useGlobalChannelState(channelId)
269+ const { submit, updateInput } = useChannelSync(channelId)
270+
271+ return (
272+ <Composer.Frame>
273+ <Composer.Input value={state.input} onChange={(text) => sync.updateInput(text)} />
274+ <Composer.Submit onPress={() => sync.submit()} />
275+ </Composer.Frame>
276+ )
277+}
278+```
279+
280+**Correct: state management isolated in provider**
281+
282+```tsx
283+// Provider handles all state management details
284+function ChannelProvider({
285+ channelId,
286+ children,
287+}: {
288+ channelId: string
289+ children: React.ReactNode
290+}) {
291+ const { state, update, submit } = useGlobalChannel(channelId)
292+ const inputRef = useRef(null)
293+
294+ return (
295+ <Composer.Provider state={state} actions={{ update, submit }} meta={{ inputRef }}>
296+ {children}
297+ </Composer.Provider>
298+ )
299+}
300+
301+// UI component only knows about the context interface
302+function ChannelComposer() {
303+ return (
304+ <Composer.Frame>
305+ <Composer.Header />
306+ <Composer.Input />
307+ <Composer.Footer>
308+ <Composer.Submit />
309+ </Composer.Footer>
310+ </Composer.Frame>
311+ )
312+}
313+
314+// Usage
315+function Channel({ channelId }: { channelId: string }) {
316+ return (
317+ <ChannelProvider channelId={channelId}>
318+ <ChannelComposer />
319+ </ChannelProvider>
320+ )
321+}
322+```
323+
324+**Different providers, same UI:**
325+
326+```tsx
327+// Local state for ephemeral forms
328+function ForwardMessageProvider({ children }) {
329+ const [state, setState] = useState(initialState)
330+ const forwardMessage = useForwardMessage()
331+
332+ return (
333+ <Composer.Provider state={state} actions={{ update: setState, submit: forwardMessage }}>
334+ {children}
335+ </Composer.Provider>
336+ )
337+}
338+
339+// Global synced state for channels
340+function ChannelProvider({ channelId, children }) {
341+ const { state, update, submit } = useGlobalChannel(channelId)
342+
343+ return (
344+ <Composer.Provider state={state} actions={{ update, submit }}>
345+ {children}
346+ </Composer.Provider>
347+ )
348+}
349+```
350+
351+The same `Composer.Input` component works with both providers because it only
352+
353+depends on the context interface, not the implementation.
354+
355+### 2.2 Define Generic Context Interfaces for Dependency Injection
356+
357+**Impact: HIGH (enables dependency-injectable state across use-cases)**
358+
359+Define a **generic interface** for your component context with three parts:
360+
361+`state`, `actions`, and `meta`. This interface is a contract that any provider
362+
363+can implement—enabling the same UI components to work with completely different
364+
365+state implementations.
366+
367+**Core principle:** Lift state, compose internals, make state
368+
369+dependency-injectable.
370+
371+**Incorrect: UI coupled to specific state implementation**
372+
373+```tsx
374+function ComposerInput() {
375+ // Tightly coupled to a specific hook
376+ const { input, setInput } = useChannelComposerState()
377+ return <TextInput value={input} onChangeText={setInput} />
378+}
379+```
380+
381+**Correct: generic interface enables dependency injection**
382+
383+```tsx
384+// Define a GENERIC interface that any provider can implement
385+interface ComposerState {
386+ input: string
387+ attachments: Attachment[]
388+ isSubmitting: boolean
389+}
390+
391+interface ComposerActions {
392+ update: (updater: (state: ComposerState) => ComposerState) => void
393+ submit: () => void
394+}
395+
396+interface ComposerMeta {
397+ inputRef: React.RefObject<TextInput>
398+}
399+
400+interface ComposerContextValue {
401+ state: ComposerState
402+ actions: ComposerActions
403+ meta: ComposerMeta
404+}
405+
406+const ComposerContext = createContext<ComposerContextValue | null>(null)
407+```
408+
409+**UI components consume the interface, not the implementation:**
410+
411+```tsx
412+function ComposerInput() {
413+ const {
414+ state,
415+ actions: { update },
416+ meta,
417+ } = use(ComposerContext)
418+
419+ // This component works with ANY provider that implements the interface
420+ return (
421+ <TextInput
422+ ref={meta.inputRef}
423+ value={state.input}
424+ onChangeText={(text) => update((s) => ({ ...s, input: text }))}
425+ />
426+ )
427+}
428+```
429+
430+**Different providers implement the same interface:**
431+
432+```tsx
433+// Provider A: Local state for ephemeral forms
434+function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
435+ const [state, setState] = useState(initialState)
436+ const inputRef = useRef(null)
437+ const submit = useForwardMessage()
438+
439+ return (
440+ <ComposerContext
441+ value={{
442+ state,
443+ actions: { update: setState, submit },
444+ meta: { inputRef },
445+ }}
446+ >
447+ {children}
448+ </ComposerContext>
449+ )
450+}
451+
452+// Provider B: Global synced state for channels
453+function ChannelProvider({ channelId, children }: Props) {
454+ const { state, update, submit } = useGlobalChannel(channelId)
455+ const inputRef = useRef(null)
456+
457+ return (
458+ <ComposerContext
459+ value={{
460+ state,
461+ actions: { update, submit },
462+ meta: { inputRef },
463+ }}
464+ >
465+ {children}
466+ </ComposerContext>
467+ )
468+}
469+```
470+
471+**The same composed UI works with both:**
472+
473+```tsx
474+// Works with ForwardMessageProvider (local state)
475+<ForwardMessageProvider>
476+ <Composer.Frame>
477+ <Composer.Input />
478+ <Composer.Submit />
479+ </Composer.Frame>
480+</ForwardMessageProvider>
481+
482+// Works with ChannelProvider (global synced state)
483+<ChannelProvider channelId="abc">
484+ <Composer.Frame>
485+ <Composer.Input />
486+ <Composer.Submit />
487+ </Composer.Frame>
488+</ChannelProvider>
489+```
490+
491+**Custom UI outside the component can access state and actions:**
492+
493+```tsx
494+function ForwardMessageDialog() {
495+ return (
496+ <ForwardMessageProvider>
497+ <Dialog>
498+ {/* The composer UI */}
499+ <Composer.Frame>
500+ <Composer.Input placeholder="Add a message, if you'd like." />
501+ <Composer.Footer>
502+ <Composer.Formatting />
503+ <Composer.Emojis />
504+ </Composer.Footer>
505+ </Composer.Frame>
506+
507+ {/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
508+ <MessagePreview />
509+
510+ {/* Actions at the bottom of the dialog */}
511+ <DialogActions>
512+ <CancelButton />
513+ <ForwardButton />
514+ </DialogActions>
515+ </Dialog>
516+ </ForwardMessageProvider>
517+ )
518+}
519+
520+// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
521+function ForwardButton() {
522+ const {
523+ actions: { submit },
524+ } = use(ComposerContext)
525+ return <Button onPress={submit}>Forward</Button>
526+}
527+
528+// This preview lives OUTSIDE Composer.Frame but can read composer's state!
529+function MessagePreview() {
530+ const { state } = use(ComposerContext)
531+ return <Preview message={state.input} attachments={state.attachments} />
532+}
533+```
534+
535+The provider boundary is what matters—not the visual nesting. Components that
536+
537+need shared state don't have to be inside the `Composer.Frame`. They just need
538+
539+to be within the provider.
540+
541+The `ForwardButton` and `MessagePreview` are not visually inside the composer
542+
543+box, but they can still access its state and actions. This is the power of
544+
545+lifting state into providers.
546+
547+The UI is reusable bits you compose together. The state is dependency-injected
548+
549+by the provider. Swap the provider, keep the UI.
550+
551+### 2.3 Lift State into Provider Components
552+
553+**Impact: HIGH (enables state sharing outside component boundaries)**
554+
555+Move state management into dedicated provider components. This allows sibling
556+
557+components outside the main UI to access and modify state without prop drilling
558+
559+or awkward refs.
560+
561+**Incorrect: state trapped inside component**
562+
563+```tsx
564+function ForwardMessageComposer() {
565+ const [state, setState] = useState(initialState)
566+ const forwardMessage = useForwardMessage()
567+
568+ return (
569+ <Composer.Frame>
570+ <Composer.Input />
571+ <Composer.Footer />
572+ </Composer.Frame>
573+ )
574+}
575+
576+// Problem: How does this button access composer state?
577+function ForwardMessageDialog() {
578+ return (
579+ <Dialog>
580+ <ForwardMessageComposer />
581+ <MessagePreview /> {/* Needs composer state */}
582+ <DialogActions>
583+ <CancelButton />
584+ <ForwardButton /> {/* Needs to call submit */}
585+ </DialogActions>
586+ </Dialog>
587+ )
588+}
589+```
590+
591+**Incorrect: useEffect to sync state up**
592+
593+```tsx
594+function ForwardMessageDialog() {
595+ const [input, setInput] = useState("")
596+ return (
597+ <Dialog>
598+ <ForwardMessageComposer onInputChange={setInput} />
599+ <MessagePreview input={input} />
600+ </Dialog>
601+ )
602+}
603+
604+function ForwardMessageComposer({ onInputChange }) {
605+ const [state, setState] = useState(initialState)
606+ useEffect(() => {
607+ onInputChange(state.input) // Sync on every change 😬
608+ }, [state.input])
609+}
610+```
611+
612+**Incorrect: reading state from ref on submit**
613+
614+```tsx
615+function ForwardMessageDialog() {
616+ const stateRef = useRef(null)
617+ return (
618+ <Dialog>
619+ <ForwardMessageComposer stateRef={stateRef} />
620+ <ForwardButton onPress={() => submit(stateRef.current)} />
621+ </Dialog>
622+ )
623+}
624+```
625+
626+**Correct: state lifted to provider**
627+
628+```tsx
629+function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
630+ const [state, setState] = useState(initialState)
631+ const forwardMessage = useForwardMessage()
632+ const inputRef = useRef(null)
633+
634+ return (
635+ <Composer.Provider
636+ state={state}
637+ actions={{ update: setState, submit: forwardMessage }}
638+ meta={{ inputRef }}
639+ >
640+ {children}
641+ </Composer.Provider>
642+ )
643+}
644+
645+function ForwardMessageDialog() {
646+ return (
647+ <ForwardMessageProvider>
648+ <Dialog>
649+ <ForwardMessageComposer />
650+ <MessagePreview /> {/* Custom components can access state and actions */}
651+ <DialogActions>
652+ <CancelButton />
653+ <ForwardButton /> {/* Custom components can access state and actions */}
654+ </DialogActions>
655+ </Dialog>
656+ </ForwardMessageProvider>
657+ )
658+}
659+
660+function ForwardButton() {
661+ const { actions } = use(Composer.Context)
662+ return <Button onPress={actions.submit}>Forward</Button>
663+}
664+```
665+
666+The ForwardButton lives outside the Composer.Frame but still has access to the
667+
668+submit action because it's within the provider. Even though it's a one-off
669+
670+component, it can still access the composer's state and actions from outside the
671+
672+UI itself.
673+
674+**Key insight:** Components that need shared state don't have to be visually
675+
676+nested inside each other—they just need to be within the same provider.
677+
678+---
679+
680+## 3. Implementation Patterns
681+
682+**Impact: MEDIUM**
683+
684+Specific techniques for implementing compound components and
685+context providers.
686+
687+### 3.1 Create Explicit Component Variants
688+
689+**Impact: MEDIUM (self-documenting code, no hidden conditionals)**
690+
691+Instead of one component with many boolean props, create explicit variant
692+
693+components. Each variant composes the pieces it needs. The code documents
694+
695+itself.
696+
697+**Incorrect: one component, many modes**
698+
699+```tsx
700+// What does this component actually render?
701+<Composer isThread isEditing={false} channelId="abc" showAttachments showFormatting={false} />
702+```
703+
704+**Correct: explicit variants**
705+
706+```tsx
707+// Immediately clear what this renders
708+<ThreadComposer channelId="abc" />
709+
710+// Or
711+<EditMessageComposer messageId="xyz" />
712+
713+// Or
714+<ForwardMessageComposer messageId="123" />
715+```
716+
717+Each implementation is unique, explicit and self-contained. Yet they can each
718+
719+use shared parts.
720+
721+**Implementation:**
722+
723+```tsx
724+function ThreadComposer({ channelId }: { channelId: string }) {
725+ return (
726+ <ThreadProvider channelId={channelId}>
727+ <Composer.Frame>
728+ <Composer.Input />
729+ <AlsoSendToChannelField channelId={channelId} />
730+ <Composer.Footer>
731+ <Composer.Formatting />
732+ <Composer.Emojis />
733+ <Composer.Submit />
734+ </Composer.Footer>
735+ </Composer.Frame>
736+ </ThreadProvider>
737+ )
738+}
739+
740+function EditMessageComposer({ messageId }: { messageId: string }) {
741+ return (
742+ <EditMessageProvider messageId={messageId}>
743+ <Composer.Frame>
744+ <Composer.Input />
745+ <Composer.Footer>
746+ <Composer.Formatting />
747+ <Composer.Emojis />
748+ <Composer.CancelEdit />
749+ <Composer.SaveEdit />
750+ </Composer.Footer>
751+ </Composer.Frame>
752+ </EditMessageProvider>
753+ )
754+}
755+
756+function ForwardMessageComposer({ messageId }: { messageId: string }) {
757+ return (
758+ <ForwardMessageProvider messageId={messageId}>
759+ <Composer.Frame>
760+ <Composer.Input placeholder="Add a message, if you'd like." />
761+ <Composer.Footer>
762+ <Composer.Formatting />
763+ <Composer.Emojis />
764+ <Composer.Mentions />
765+ </Composer.Footer>
766+ </Composer.Frame>
767+ </ForwardMessageProvider>
768+ )
769+}
770+```
771+
772+Each variant is explicit about:
773+
774+- What provider/state it uses
775+
776+- What UI elements it includes
777+
778+- What actions are available
779+
780+No boolean prop combinations to reason about. No impossible states.
781+
782+### 3.2 Prefer Composing Children Over Render Props
783+
784+**Impact: MEDIUM (cleaner composition, better readability)**
785+
786+Use `children` for composition instead of `renderX` props. Children are more
787+
788+readable, compose naturally, and don't require understanding callback
789+
790+signatures.
791+
792+**Incorrect: render props**
793+
794+```tsx
795+function Composer({
796+ renderHeader,
797+ renderFooter,
798+ renderActions,
799+}: {
800+ renderHeader?: () => React.ReactNode
801+ renderFooter?: () => React.ReactNode
802+ renderActions?: () => React.ReactNode
803+}) {
804+ return (
805+ <form>
806+ {renderHeader?.()}
807+ <Input />
808+ {renderFooter ? renderFooter() : <DefaultFooter />}
809+ {renderActions?.()}
810+ </form>
811+ )
812+}
813+
814+// Usage is awkward and inflexible
815+return (
816+ <Composer
817+ renderHeader={() => <CustomHeader />}
818+ renderFooter={() => (
819+ <>
820+ <Formatting />
821+ <Emojis />
822+ </>
823+ )}
824+ renderActions={() => <SubmitButton />}
825+ />
826+)
827+```
828+
829+**Correct: compound components with children**
830+
831+```tsx
832+function ComposerFrame({ children }: { children: React.ReactNode }) {
833+ return <form>{children}</form>
834+}
835+
836+function ComposerFooter({ children }: { children: React.ReactNode }) {
837+ return <footer className="flex">{children}</footer>
838+}
839+
840+// Usage is flexible
841+return (
842+ <Composer.Frame>
843+ <CustomHeader />
844+ <Composer.Input />
845+ <Composer.Footer>
846+ <Composer.Formatting />
847+ <Composer.Emojis />
848+ <SubmitButton />
849+ </Composer.Footer>
850+ </Composer.Frame>
851+)
852+```
853+
854+**When render props are appropriate:**
855+
856+```tsx
857+// Render props work well when you need to pass data back
858+<List data={items} renderItem={({ item, index }) => <Item item={item} index={index} />} />
859+```
860+
861+Use render props when the parent needs to provide data or state to the child.
862+
863+Use children when composing static structure.
864+
865+---
866+
867+## 4. React 19 APIs
868+
869+**Impact: MEDIUM**
870+
871+React 19+ only. Don't use `forwardRef`; use `use()` instead of `useContext()`.
872+
873+### 4.1 React 19 API Changes
874+
875+**Impact: MEDIUM (cleaner component definitions and context usage)**
876+
877+> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
878+
879+In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
880+
881+**Incorrect: forwardRef in React 19**
882+
883+```tsx
884+const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
885+ return <TextInput ref={ref} {...props} />
886+})
887+```
888+
889+**Correct: ref as a regular prop**
890+
891+```tsx
892+function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
893+ return <TextInput ref={ref} {...props} />
894+}
895+```
896+
897+**Incorrect: useContext in React 19**
898+
899+```tsx
900+const value = useContext(MyContext)
901+```
902+
903+**Correct: use instead of useContext**
904+
905+```tsx
906+const value = use(MyContext)
907+```
908+
909+`use()` can also be called conditionally, unlike `useContext()`.
910+
911+---
912+
913+## References
914+
915+1. [https://react.dev](https://react.dev)
916+2. [https://react.dev/learn/passing-data-deeply-with-context](https://react.dev/learn/passing-data-deeply-with-context)
917+3. [https://react.dev/reference/react/use](https://react.dev/reference/react/use)
2884918
