picasso-skill 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/picasso.md +26 -542
- package/commands/godmode.md +3 -13
- package/commands/roast.md +3 -14
- package/commands/score.md +3 -18
- package/commands/steal.md +3 -31
- package/package.json +1 -1
- package/references/accessibility-wcag.md +3 -0
- package/references/code-typography.md +36 -166
- package/references/color-and-contrast.md +78 -345
- package/references/generative-art.md +49 -561
- package/references/modern-css-performance.md +46 -258
- package/references/motion-and-animation.md +225 -88
- package/references/navigation-patterns.md +29 -186
- package/references/performance-optimization.md +42 -678
- package/references/react-patterns.md +56 -216
- package/references/responsive-design.md +77 -379
- package/references/sensory-design.md +62 -263
- package/references/ux-writing.md +64 -354
- package/references/animation-performance.md +0 -244
- package/references/interaction-design.md +0 -162
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Performance Optimization Reference
|
|
2
2
|
|
|
3
|
-
React/Next.js performance rules based on Vercel's guidance. Organized by priority.
|
|
3
|
+
React/Next.js performance rules based on Vercel's guidance. Organized by priority.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -8,728 +8,92 @@ React/Next.js performance rules based on Vercel's guidance. Organized by priorit
|
|
|
8
8
|
|
|
9
9
|
Waterfalls are the single biggest performance killer. Every sequential `await` that could be parallel is wasted time.
|
|
10
10
|
|
|
11
|
-
###
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const user = await fetchUser(userId);
|
|
17
|
-
const posts = await fetchPosts(userId);
|
|
18
|
-
const notifications = await fetchNotifications(userId);
|
|
19
|
-
return { user, posts, notifications };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// GOOD: Parallel - total time = max(A, B, C)
|
|
23
|
-
async function getPageData(userId: string) {
|
|
24
|
-
const [user, posts, notifications] = await Promise.all([
|
|
25
|
-
fetchUser(userId),
|
|
26
|
-
fetchPosts(userId),
|
|
27
|
-
fetchNotifications(userId),
|
|
28
|
-
]);
|
|
29
|
-
return { user, posts, notifications };
|
|
30
|
-
}
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
### Defer Await Until Needed
|
|
34
|
-
|
|
35
|
-
Don't block code branches that don't use the data.
|
|
36
|
-
|
|
37
|
-
```typescript
|
|
38
|
-
// BAD: Blocks even if we early-return
|
|
39
|
-
async function handleRequest(req: Request) {
|
|
40
|
-
const config = await fetchConfig();
|
|
41
|
-
const user = await getUser(req);
|
|
42
|
-
|
|
43
|
-
if (!user) {
|
|
44
|
-
return redirect('/login'); // config was fetched for nothing
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return renderPage(user, config);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// GOOD: Start fetch immediately, await only when needed
|
|
51
|
-
async function handleRequest(req: Request) {
|
|
52
|
-
const configPromise = fetchConfig(); // fire immediately, don't await
|
|
53
|
-
const user = await getUser(req);
|
|
54
|
-
|
|
55
|
-
if (!user) {
|
|
56
|
-
return redirect('/login'); // config fetch may still be in-flight, that's fine
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const config = await configPromise; // await only when we need it
|
|
60
|
-
return renderPage(user, config);
|
|
61
|
-
}
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
### Dependency-Based Parallelization with better-all
|
|
65
|
-
|
|
66
|
-
When some fetches depend on others but you still want maximum parallelism:
|
|
67
|
-
|
|
68
|
-
```typescript
|
|
69
|
-
import { all } from 'better-all';
|
|
70
|
-
|
|
71
|
-
// Runs A and B in parallel. C starts as soon as A finishes (doesn't wait for B).
|
|
72
|
-
const { user, posts, comments } = await all({
|
|
73
|
-
user: () => fetchUser(id),
|
|
74
|
-
posts: () => fetchPosts(id),
|
|
75
|
-
comments: async ({ user }) => fetchComments(user.teamId), // depends on user
|
|
76
|
-
});
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
### Strategic Suspense Boundaries
|
|
80
|
-
|
|
81
|
-
Wrap independent data-loading sections in their own Suspense boundary so they stream independently.
|
|
82
|
-
|
|
83
|
-
```tsx
|
|
84
|
-
// BAD: One boundary = entire page waits for slowest component
|
|
85
|
-
<Suspense fallback={<PageSkeleton />}>
|
|
86
|
-
<Header /> {/* 50ms */}
|
|
87
|
-
<Feed /> {/* 2000ms - blocks everything */}
|
|
88
|
-
<Sidebar /> {/* 100ms */}
|
|
89
|
-
</Suspense>
|
|
90
|
-
|
|
91
|
-
// GOOD: Independent boundaries = each streams when ready
|
|
92
|
-
<Header />
|
|
93
|
-
<Suspense fallback={<FeedSkeleton />}>
|
|
94
|
-
<Feed />
|
|
95
|
-
</Suspense>
|
|
96
|
-
<Suspense fallback={<SidebarSkeleton />}>
|
|
97
|
-
<Sidebar />
|
|
98
|
-
</Suspense>
|
|
99
|
-
```
|
|
11
|
+
### Rules
|
|
12
|
+
- Use `Promise.all()` for independent fetches (total time = max instead of sum)
|
|
13
|
+
- Start fetches immediately, defer `await` until the value is needed (fire-and-forget pattern)
|
|
14
|
+
- Use `better-all` when some fetches depend on others but you still want maximum parallelism
|
|
15
|
+
- Wrap independent data-loading sections in their own `<Suspense>` boundary so they stream independently (don't let one slow component block the whole page)
|
|
100
16
|
|
|
101
17
|
---
|
|
102
18
|
|
|
103
19
|
## Priority 2 - CRITICAL: Bundle Size
|
|
104
20
|
|
|
105
21
|
### Avoid Barrel File Imports
|
|
106
|
-
|
|
107
22
|
Barrel files (`index.ts` re-exports) pull in entire modules. Cost: 200-800ms of parse time.
|
|
108
23
|
|
|
109
24
|
```typescript
|
|
110
25
|
// BAD: Imports entire icon library through barrel file
|
|
111
26
|
import { ChevronDown } from '@/components/icons';
|
|
112
|
-
// This imports index.ts which re-exports 500 icons
|
|
113
|
-
|
|
114
27
|
// GOOD: Direct import from source file
|
|
115
28
|
import { ChevronDown } from '@/components/icons/ChevronDown';
|
|
116
|
-
|
|
117
|
-
// BAD: Barrel file for utils
|
|
118
|
-
import { formatDate } from '@/lib/utils';
|
|
119
|
-
// Pulls in every util function
|
|
120
|
-
|
|
121
|
-
// GOOD: Direct import
|
|
122
|
-
import { formatDate } from '@/lib/utils/formatDate';
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
Configure your bundler to detect this:
|
|
126
|
-
|
|
127
|
-
```javascript
|
|
128
|
-
// next.config.js
|
|
129
|
-
module.exports = {
|
|
130
|
-
experimental: {
|
|
131
|
-
optimizePackageImports: ['@/components/icons', 'lucide-react', 'date-fns'],
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
29
|
```
|
|
135
30
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
```typescript
|
|
139
|
-
import dynamic from 'next/dynamic';
|
|
140
|
-
|
|
141
|
-
// Heavy editor component - only loads when rendered
|
|
142
|
-
const CodeEditor = dynamic(() => import('@/components/CodeEditor'), {
|
|
143
|
-
loading: () => <EditorSkeleton />,
|
|
144
|
-
});
|
|
31
|
+
Configure detection: `optimizePackageImports` in `next.config.js` for known barrel-heavy packages.
|
|
145
32
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
});
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
### Defer Non-Critical Third-Party Scripts
|
|
154
|
-
|
|
155
|
-
```typescript
|
|
156
|
-
// Analytics, error tracking, chat widgets - none are needed for first render
|
|
157
|
-
const Analytics = dynamic(() => import('@/components/Analytics'), {
|
|
158
|
-
ssr: false,
|
|
159
|
-
});
|
|
160
|
-
const ErrorTracker = dynamic(() => import('@/components/ErrorTracker'), {
|
|
161
|
-
ssr: false,
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// Load after page is interactive
|
|
165
|
-
export default function Layout({ children }) {
|
|
166
|
-
return (
|
|
167
|
-
<>
|
|
168
|
-
{children}
|
|
169
|
-
<Suspense fallback={null}>
|
|
170
|
-
<Analytics />
|
|
171
|
-
<ErrorTracker />
|
|
172
|
-
</Suspense>
|
|
173
|
-
</>
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
```
|
|
33
|
+
### Dynamic Imports
|
|
34
|
+
- Use `next/dynamic` for heavy components (editors, charts, maps) with `loading` fallback
|
|
35
|
+
- Use `ssr: false` for client-only libraries
|
|
36
|
+
- Defer non-critical third-party scripts (analytics, error tracking, chat widgets) via dynamic import + `<Suspense fallback={null}>`
|
|
177
37
|
|
|
178
38
|
### Preload on User Intent
|
|
179
|
-
|
|
180
|
-
Start loading a route or component when the user shows intent (hover, focus) instead of on click.
|
|
181
|
-
|
|
182
|
-
```tsx
|
|
183
|
-
import { useRouter } from 'next/navigation';
|
|
184
|
-
|
|
185
|
-
function NavLink({ href, children }) {
|
|
186
|
-
const router = useRouter();
|
|
187
|
-
|
|
188
|
-
return (
|
|
189
|
-
<Link
|
|
190
|
-
href={href}
|
|
191
|
-
onMouseEnter={() => router.prefetch(href)}
|
|
192
|
-
onFocus={() => router.prefetch(href)}
|
|
193
|
-
>
|
|
194
|
-
{children}
|
|
195
|
-
</Link>
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
For heavy components:
|
|
201
|
-
|
|
202
|
-
```typescript
|
|
203
|
-
// Preload the module on hover, render on click
|
|
204
|
-
const importEditor = () => import('@/components/CodeEditor');
|
|
205
|
-
const CodeEditor = dynamic(importEditor);
|
|
206
|
-
|
|
207
|
-
function EditorButton() {
|
|
208
|
-
const [show, setShow] = useState(false);
|
|
209
|
-
|
|
210
|
-
return (
|
|
211
|
-
<>
|
|
212
|
-
<button
|
|
213
|
-
onMouseEnter={() => importEditor()} // preload on hover
|
|
214
|
-
onClick={() => setShow(true)}
|
|
215
|
-
>
|
|
216
|
-
Open Editor
|
|
217
|
-
</button>
|
|
218
|
-
{show && <CodeEditor />}
|
|
219
|
-
</>
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
```
|
|
39
|
+
Start loading a route or component on hover/focus instead of on click. Use `router.prefetch(href)` on `onMouseEnter`/`onFocus`. For heavy components, call the dynamic import function on hover, render on click.
|
|
223
40
|
|
|
224
41
|
### Conditional Module Loading
|
|
225
|
-
|
|
226
|
-
Only load modules when the feature is actually used.
|
|
227
|
-
|
|
228
|
-
```typescript
|
|
229
|
-
async function exportData(format: 'csv' | 'xlsx') {
|
|
230
|
-
if (format === 'xlsx') {
|
|
231
|
-
// xlsx library is 200KB+ - only load when needed
|
|
232
|
-
const XLSX = await import('xlsx');
|
|
233
|
-
return XLSX.utils.json_to_sheet(data);
|
|
234
|
-
}
|
|
235
|
-
// CSV is trivial, no import needed
|
|
236
|
-
return data.map(row => row.join(',')).join('\n');
|
|
237
|
-
}
|
|
238
|
-
```
|
|
42
|
+
Only `import()` heavy modules when the feature path is actually taken (e.g., load `xlsx` only when user picks XLSX format).
|
|
239
43
|
|
|
240
44
|
---
|
|
241
45
|
|
|
242
46
|
## Priority 3 - HIGH: Server-Side Performance
|
|
243
47
|
|
|
244
|
-
###
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
import { cache } from 'react';
|
|
250
|
-
|
|
251
|
-
export const getUser = cache(async (userId: string) => {
|
|
252
|
-
const res = await fetch(`/api/users/${userId}`);
|
|
253
|
-
return res.json();
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
// Component A calls getUser('123') - makes network request
|
|
257
|
-
// Component B calls getUser('123') - returns cached result from same request
|
|
258
|
-
// Next request: cache is cleared, fresh fetch
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
### LRU Cache for Cross-Request Caching
|
|
262
|
-
|
|
263
|
-
For data that doesn't change per-request (config, feature flags, static content):
|
|
264
|
-
|
|
265
|
-
```typescript
|
|
266
|
-
import { LRUCache } from 'lru-cache';
|
|
267
|
-
|
|
268
|
-
const cache = new LRUCache<string, any>({
|
|
269
|
-
max: 500, // max entries
|
|
270
|
-
ttl: 1000 * 60 * 5, // 5 minute TTL
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
export async function getConfig(key: string) {
|
|
274
|
-
const cached = cache.get(key);
|
|
275
|
-
if (cached) return cached;
|
|
276
|
-
|
|
277
|
-
const config = await db.config.findUnique({ where: { key } });
|
|
278
|
-
cache.set(key, config);
|
|
279
|
-
return config;
|
|
280
|
-
}
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
### Parallel Data Fetching with Component Composition
|
|
284
|
-
|
|
285
|
-
Let each Server Component fetch its own data. React deduplicates and parallelizes automatically.
|
|
286
|
-
|
|
287
|
-
```tsx
|
|
288
|
-
// page.tsx - Don't fetch everything here and pass down
|
|
289
|
-
export default function DashboardPage() {
|
|
290
|
-
return (
|
|
291
|
-
<div>
|
|
292
|
-
<UserHeader /> {/* fetches user data */}
|
|
293
|
-
<StatsPanel /> {/* fetches stats data */}
|
|
294
|
-
<ActivityFeed /> {/* fetches activity data */}
|
|
295
|
-
</div>
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Each component is independent - React runs them in parallel
|
|
300
|
-
async function UserHeader() {
|
|
301
|
-
const user = await getUser(); // deduplicated with React.cache
|
|
302
|
-
return <header>{user.name}</header>;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
async function StatsPanel() {
|
|
306
|
-
const stats = await getStats();
|
|
307
|
-
return <div>{stats.total}</div>;
|
|
308
|
-
}
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
### Minimize Serialization at RSC Boundaries
|
|
312
|
-
|
|
313
|
-
Only pass serializable, minimal data from Server to Client Components.
|
|
314
|
-
|
|
315
|
-
```tsx
|
|
316
|
-
// BAD: Passing entire user object (large, may have non-serializable fields)
|
|
317
|
-
<ClientComponent user={fullUserObject} />
|
|
318
|
-
|
|
319
|
-
// GOOD: Pass only what the client needs
|
|
320
|
-
<ClientComponent userName={user.name} userAvatar={user.avatarUrl} />
|
|
321
|
-
```
|
|
48
|
+
### Rules
|
|
49
|
+
- Use `React.cache()` for per-request deduplication (multiple components calling the same function = one execution per request)
|
|
50
|
+
- Use `LRUCache` for cross-request caching (config, feature flags, static content) with TTL
|
|
51
|
+
- Let each Server Component fetch its own data; React deduplicates and parallelizes automatically
|
|
52
|
+
- Minimize serialization at RSC boundaries: pass only primitive/minimal data from Server to Client Components
|
|
322
53
|
|
|
323
54
|
---
|
|
324
55
|
|
|
325
56
|
## Priority 4 - MEDIUM-HIGH: Client-Side Data
|
|
326
57
|
|
|
327
|
-
###
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const fetcher = (url: string) => fetch(url).then(r => r.json());
|
|
333
|
-
|
|
334
|
-
function useUser(id: string) {
|
|
335
|
-
const { data, error, isLoading, mutate } = useSWR(
|
|
336
|
-
`/api/users/${id}`,
|
|
337
|
-
fetcher,
|
|
338
|
-
{
|
|
339
|
-
revalidateOnFocus: false, // Don't refetch on tab focus
|
|
340
|
-
dedupingInterval: 5000, // Deduplicate requests within 5s
|
|
341
|
-
staleWhileRevalidate: true, // Show stale data while fetching fresh
|
|
342
|
-
}
|
|
343
|
-
);
|
|
344
|
-
|
|
345
|
-
return { user: data, error, isLoading, mutate };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Multiple components calling useUser('123') = ONE network request
|
|
349
|
-
```
|
|
350
|
-
|
|
351
|
-
### Deduplicate Global Event Listeners
|
|
352
|
-
|
|
353
|
-
```typescript
|
|
354
|
-
// BAD: Every component instance adds its own listener
|
|
355
|
-
function Component() {
|
|
356
|
-
useEffect(() => {
|
|
357
|
-
const handler = () => { /* ... */ };
|
|
358
|
-
window.addEventListener('resize', handler);
|
|
359
|
-
return () => window.removeEventListener('resize', handler);
|
|
360
|
-
}, []);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// GOOD: Single shared listener, components subscribe to derived state
|
|
364
|
-
import { useSyncExternalStore } from 'react';
|
|
365
|
-
|
|
366
|
-
let width = window.innerWidth;
|
|
367
|
-
const listeners = new Set<() => void>();
|
|
368
|
-
|
|
369
|
-
window.addEventListener('resize', () => {
|
|
370
|
-
width = window.innerWidth;
|
|
371
|
-
listeners.forEach(l => l());
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
function subscribe(listener: () => void) {
|
|
375
|
-
listeners.add(listener);
|
|
376
|
-
return () => listeners.delete(listener);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
export function useWindowWidth() {
|
|
380
|
-
return useSyncExternalStore(subscribe, () => width);
|
|
381
|
-
}
|
|
382
|
-
```
|
|
58
|
+
### Rules
|
|
59
|
+
- Use SWR or React Query for deduplication, caching, and revalidation (multiple components calling the same hook = one network request)
|
|
60
|
+
- Set `revalidateOnFocus: false` and `dedupingInterval` to control refetch behavior
|
|
61
|
+
- Deduplicate global event listeners with `useSyncExternalStore` instead of per-component `addEventListener`
|
|
383
62
|
|
|
384
63
|
---
|
|
385
64
|
|
|
386
65
|
## Priority 5 - MEDIUM: Re-render Optimization
|
|
387
66
|
|
|
388
|
-
###
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
<div>
|
|
396
|
-
<SearchInput value={query} onChange={setQuery} />
|
|
397
|
-
<ExpensiveHeader /> {/* Re-renders on every keystroke! */}
|
|
398
|
-
<SearchResults query={query} />
|
|
399
|
-
</div>
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// GOOD: Isolate state to the component that needs it
|
|
404
|
-
function SearchPage() {
|
|
405
|
-
return (
|
|
406
|
-
<div>
|
|
407
|
-
<SearchSection /> {/* Contains its own state */}
|
|
408
|
-
<ExpensiveHeader /> {/* Never re-renders from search */}
|
|
409
|
-
</div>
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function SearchSection() {
|
|
414
|
-
const [query, setQuery] = useState('');
|
|
415
|
-
return (
|
|
416
|
-
<>
|
|
417
|
-
<SearchInput value={query} onChange={setQuery} />
|
|
418
|
-
<SearchResults query={query} />
|
|
419
|
-
</>
|
|
420
|
-
);
|
|
421
|
-
}
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
### Narrow Effect Dependencies
|
|
425
|
-
|
|
426
|
-
```typescript
|
|
427
|
-
// BAD: Effect runs whenever any user field changes
|
|
428
|
-
useEffect(() => {
|
|
429
|
-
trackPageView(user.id);
|
|
430
|
-
}, [user]); // user is an object, new reference every render
|
|
431
|
-
|
|
432
|
-
// GOOD: Depend on the primitive you actually use
|
|
433
|
-
const userId = user.id;
|
|
434
|
-
useEffect(() => {
|
|
435
|
-
trackPageView(userId);
|
|
436
|
-
}, [userId]); // Only runs when the ID actually changes
|
|
437
|
-
```
|
|
438
|
-
|
|
439
|
-
### Subscribe to Derived State
|
|
440
|
-
|
|
441
|
-
```typescript
|
|
442
|
-
// BAD: Re-renders on every pixel of resize
|
|
443
|
-
function Component() {
|
|
444
|
-
const width = useWindowWidth(); // 1024, 1023, 1022...
|
|
445
|
-
const isMobile = width < 768;
|
|
446
|
-
// Renders on every width change even if isMobile doesn't change
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// GOOD: Only re-renders when the boolean flips
|
|
450
|
-
function Component() {
|
|
451
|
-
const isMobile = useSyncExternalStore(
|
|
452
|
-
subscribe,
|
|
453
|
-
() => window.innerWidth < 768 // returns boolean, not number
|
|
454
|
-
);
|
|
455
|
-
// Only re-renders when crossing the 768px threshold
|
|
456
|
-
}
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
### Lazy State Initialization
|
|
460
|
-
|
|
461
|
-
```typescript
|
|
462
|
-
// BAD: Expensive computation runs on every render (result is ignored after first)
|
|
463
|
-
const [data, setData] = useState(parseExpensiveJSON(localStorage.getItem('data')));
|
|
464
|
-
|
|
465
|
-
// GOOD: Function is only called once on mount
|
|
466
|
-
const [data, setData] = useState(() => parseExpensiveJSON(localStorage.getItem('data')));
|
|
467
|
-
```
|
|
468
|
-
|
|
469
|
-
### Extract Memoized Components
|
|
470
|
-
|
|
471
|
-
```tsx
|
|
472
|
-
// When a parent re-renders but a child's props don't change:
|
|
473
|
-
const ExpensiveList = memo(function ExpensiveList({ items }: { items: Item[] }) {
|
|
474
|
-
return items.map(item => <ExpensiveItem key={item.id} item={item} />);
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
// Use with useCallback for event handlers
|
|
478
|
-
const handleClick = useCallback((id: string) => {
|
|
479
|
-
setSelected(id);
|
|
480
|
-
}, []); // Stable reference
|
|
481
|
-
|
|
482
|
-
<ExpensiveList items={items} onClick={handleClick} />
|
|
483
|
-
```
|
|
484
|
-
|
|
485
|
-
### useTransition for Non-Urgent Updates
|
|
486
|
-
|
|
487
|
-
```typescript
|
|
488
|
-
function SearchWithSuggestions() {
|
|
489
|
-
const [query, setQuery] = useState('');
|
|
490
|
-
const [results, setResults] = useState([]);
|
|
491
|
-
const [isPending, startTransition] = useTransition();
|
|
492
|
-
|
|
493
|
-
function handleChange(value: string) {
|
|
494
|
-
setQuery(value); // Urgent: update input immediately
|
|
495
|
-
startTransition(() => {
|
|
496
|
-
setResults(filterResults(value)); // Non-urgent: can be interrupted
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
return (
|
|
501
|
-
<>
|
|
502
|
-
<input value={query} onChange={e => handleChange(e.target.value)} />
|
|
503
|
-
<div style={{ opacity: isPending ? 0.7 : 1 }}>
|
|
504
|
-
<ResultsList results={results} />
|
|
505
|
-
</div>
|
|
506
|
-
</>
|
|
507
|
-
);
|
|
508
|
-
}
|
|
509
|
-
```
|
|
67
|
+
### Rules
|
|
68
|
+
- Isolate state to the component that owns it; don't let a parent re-render siblings that don't use the state
|
|
69
|
+
- Depend on primitives in `useEffect` deps, not objects (`user.id` not `user`)
|
|
70
|
+
- Subscribe to derived booleans via `useSyncExternalStore` (e.g., `isMobile` not `width`)
|
|
71
|
+
- Use lazy state initialization: `useState(() => expensiveComputation())` not `useState(expensiveComputation())`
|
|
72
|
+
- Use `React.memo` + `useCallback` for expensive child components (skip if React Compiler is enabled)
|
|
73
|
+
- Use `useTransition` for non-urgent updates (search results filtering, large list re-renders)
|
|
510
74
|
|
|
511
75
|
---
|
|
512
76
|
|
|
513
77
|
## Priority 6 - MEDIUM: Rendering Performance
|
|
514
78
|
|
|
515
|
-
###
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
contain-intrinsic-size: auto 300px; /* Estimated height to prevent scroll jump */
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/* For a list of items */
|
|
526
|
-
.list-item {
|
|
527
|
-
content-visibility: auto;
|
|
528
|
-
contain-intrinsic-size: auto 80px;
|
|
529
|
-
}
|
|
530
|
-
```
|
|
531
|
-
|
|
532
|
-
### Hoist Static JSX Outside Components
|
|
533
|
-
|
|
534
|
-
Static elements don't need to be recreated on every render.
|
|
535
|
-
|
|
536
|
-
```tsx
|
|
537
|
-
// BAD: emptyState is recreated on every render of List
|
|
538
|
-
function List({ items }) {
|
|
539
|
-
const emptyState = (
|
|
540
|
-
<div className="empty">
|
|
541
|
-
<p>No items found</p>
|
|
542
|
-
</div>
|
|
543
|
-
);
|
|
544
|
-
|
|
545
|
-
if (items.length === 0) return emptyState;
|
|
546
|
-
return <ul>{items.map(renderItem)}</ul>;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// GOOD: Created once, reused forever
|
|
550
|
-
const emptyState = (
|
|
551
|
-
<div className="empty">
|
|
552
|
-
<p>No items found</p>
|
|
553
|
-
</div>
|
|
554
|
-
);
|
|
555
|
-
|
|
556
|
-
function List({ items }) {
|
|
557
|
-
if (items.length === 0) return emptyState;
|
|
558
|
-
return <ul>{items.map(renderItem)}</ul>;
|
|
559
|
-
}
|
|
560
|
-
```
|
|
561
|
-
|
|
562
|
-
### Prevent Hydration Mismatch with Inline Script
|
|
563
|
-
|
|
564
|
-
For theme, auth state, or locale that must be available before React hydrates:
|
|
565
|
-
|
|
566
|
-
```tsx
|
|
567
|
-
// layout.tsx
|
|
568
|
-
<head>
|
|
569
|
-
<script
|
|
570
|
-
dangerouslySetInnerHTML={{
|
|
571
|
-
__html: `
|
|
572
|
-
(function() {
|
|
573
|
-
var theme = localStorage.getItem('theme') || 'system';
|
|
574
|
-
if (theme === 'system') {
|
|
575
|
-
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
576
|
-
}
|
|
577
|
-
document.documentElement.setAttribute('data-theme', theme);
|
|
578
|
-
document.documentElement.style.colorScheme = theme;
|
|
579
|
-
})();
|
|
580
|
-
`,
|
|
581
|
-
}}
|
|
582
|
-
/>
|
|
583
|
-
</head>
|
|
584
|
-
```
|
|
585
|
-
|
|
586
|
-
This runs synchronously before React hydrates, preventing the flash of wrong theme.
|
|
587
|
-
|
|
588
|
-
### Optimize SVG Precision
|
|
589
|
-
|
|
590
|
-
```bash
|
|
591
|
-
# Default SVG from design tools: way too many decimal places
|
|
592
|
-
# <path d="M12.456789 34.567891 L56.789012 78.901234" />
|
|
593
|
-
|
|
594
|
-
# After svgo --precision=1:
|
|
595
|
-
# <path d="M12.5 34.6 L56.8 78.9" />
|
|
596
|
-
|
|
597
|
-
npx svgo --precision=1 --multipass icons/*.svg
|
|
598
|
-
```
|
|
599
|
-
|
|
600
|
-
Reduces SVG file size by 20-40% with no visible difference.
|
|
601
|
-
|
|
602
|
-
### Use Activity Component for Show/Hide
|
|
603
|
-
|
|
604
|
-
React 19+ `<Activity>` preserves state and DOM when hiding, instead of unmounting.
|
|
605
|
-
|
|
606
|
-
```tsx
|
|
607
|
-
import { Activity } from 'react';
|
|
608
|
-
|
|
609
|
-
function TabPanel({ activeTab }) {
|
|
610
|
-
return (
|
|
611
|
-
<>
|
|
612
|
-
<Activity mode={activeTab === 'editor' ? 'visible' : 'hidden'}>
|
|
613
|
-
<CodeEditor /> {/* State preserved when hidden */}
|
|
614
|
-
</Activity>
|
|
615
|
-
<Activity mode={activeTab === 'preview' ? 'visible' : 'hidden'}>
|
|
616
|
-
<Preview /> {/* State preserved when hidden */}
|
|
617
|
-
</Activity>
|
|
618
|
-
</>
|
|
619
|
-
);
|
|
620
|
-
}
|
|
621
|
-
```
|
|
622
|
-
|
|
623
|
-
### Explicit Conditional Rendering
|
|
624
|
-
|
|
625
|
-
```tsx
|
|
626
|
-
// BAD: && can render "0" or "NaN" as text
|
|
627
|
-
{count && <Badge count={count} />}
|
|
628
|
-
// If count is 0, renders "0" as text in the DOM
|
|
629
|
-
|
|
630
|
-
// GOOD: Explicit boolean check
|
|
631
|
-
{count > 0 ? <Badge count={count} /> : null}
|
|
632
|
-
|
|
633
|
-
// GOOD: Double negation for truthy check
|
|
634
|
-
{!!items.length ? <List items={items} /> : <EmptyState />}
|
|
635
|
-
```
|
|
79
|
+
### Rules
|
|
80
|
+
- Use `content-visibility: auto` with `contain-intrinsic-size` for off-screen DOM (10x faster initial render for long pages)
|
|
81
|
+
- Hoist static JSX outside component functions (created once, reused forever)
|
|
82
|
+
- Use an inline `<script>` in `<head>` for theme/auth/locale to prevent hydration mismatch
|
|
83
|
+
- Optimize SVG precision: `npx svgo --precision=1 --multipass` reduces file size 20-40%
|
|
84
|
+
- Use React 19+ `<Activity>` to preserve state/DOM when hiding tab panels instead of unmounting
|
|
85
|
+
- Use explicit boolean checks for conditional rendering (`count > 0 ?` not `count &&` which can render "0")
|
|
636
86
|
|
|
637
87
|
---
|
|
638
88
|
|
|
639
89
|
## Priority 7 - LOW-MEDIUM: JavaScript Performance
|
|
640
90
|
|
|
641
|
-
###
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// GOOD: O(1) lookup after one-time O(n) index build
|
|
650
|
-
const userIndex = useMemo(() => {
|
|
651
|
-
const map = new Map<string, User>();
|
|
652
|
-
for (const user of users) {
|
|
653
|
-
map.set(user.id, user);
|
|
654
|
-
}
|
|
655
|
-
return map;
|
|
656
|
-
}, [users]);
|
|
657
|
-
|
|
658
|
-
function findUser(id: string) {
|
|
659
|
-
return userIndex.get(id); // Instant lookup
|
|
660
|
-
}
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
### Set/Map for Membership Checks
|
|
664
|
-
|
|
665
|
-
```typescript
|
|
666
|
-
// BAD: O(n) per check
|
|
667
|
-
const selectedIds = ['a', 'b', 'c', ...hundredsMore];
|
|
668
|
-
items.filter(item => selectedIds.includes(item.id)); // O(n*m)
|
|
669
|
-
|
|
670
|
-
// GOOD: O(1) per check
|
|
671
|
-
const selectedSet = new Set(selectedIds);
|
|
672
|
-
items.filter(item => selectedSet.has(item.id)); // O(n)
|
|
673
|
-
```
|
|
674
|
-
|
|
675
|
-
### Combine Array Iterations
|
|
676
|
-
|
|
677
|
-
```typescript
|
|
678
|
-
// BAD: Three passes over the array
|
|
679
|
-
const active = users.filter(u => u.active);
|
|
680
|
-
const names = active.map(u => u.name);
|
|
681
|
-
const sorted = names.sort();
|
|
682
|
-
|
|
683
|
-
// GOOD: Single pass + sort
|
|
684
|
-
const names: string[] = [];
|
|
685
|
-
for (const u of users) {
|
|
686
|
-
if (u.active) names.push(u.name);
|
|
687
|
-
}
|
|
688
|
-
names.sort();
|
|
689
|
-
```
|
|
690
|
-
|
|
691
|
-
This matters when arrays have 1000+ items or the operation runs frequently (on scroll, on keystroke).
|
|
692
|
-
|
|
693
|
-
### Cache Storage API Calls
|
|
694
|
-
|
|
695
|
-
```typescript
|
|
696
|
-
// BAD: Synchronous localStorage call on every render
|
|
697
|
-
function useTheme() {
|
|
698
|
-
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');
|
|
699
|
-
// localStorage.getItem is synchronous and blocks the main thread
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// GOOD: Read once, cache in memory
|
|
703
|
-
let cachedTheme: string | null = null;
|
|
704
|
-
function getTheme() {
|
|
705
|
-
if (cachedTheme === null) {
|
|
706
|
-
cachedTheme = localStorage.getItem('theme') || 'light';
|
|
707
|
-
}
|
|
708
|
-
return cachedTheme;
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
function setTheme(theme: string) {
|
|
712
|
-
cachedTheme = theme;
|
|
713
|
-
localStorage.setItem('theme', theme); // Write-through
|
|
714
|
-
}
|
|
715
|
-
```
|
|
716
|
-
|
|
717
|
-
### toSorted() Instead of sort()
|
|
718
|
-
|
|
719
|
-
```typescript
|
|
720
|
-
// BAD: Mutates the original array (breaks React state rules)
|
|
721
|
-
const sorted = items.sort((a, b) => a.name.localeCompare(b.name));
|
|
722
|
-
// items is now mutated! React won't detect the change
|
|
723
|
-
|
|
724
|
-
// GOOD: Returns new sorted array (immutable, React-safe)
|
|
725
|
-
const sorted = items.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
726
|
-
// items is unchanged, sorted is a new array
|
|
727
|
-
|
|
728
|
-
// Also: toReversed(), toSpliced(), with()
|
|
729
|
-
const reversed = items.toReversed();
|
|
730
|
-
const withRemoval = items.toSpliced(2, 1); // Remove item at index 2
|
|
731
|
-
const withReplacement = items.with(3, newItem); // Replace item at index 3
|
|
732
|
-
```
|
|
91
|
+
### Rules
|
|
92
|
+
- Build `Map` indexes for O(1) lookups instead of `.find()` on arrays
|
|
93
|
+
- Use `Set` for membership checks instead of `.includes()` on arrays
|
|
94
|
+
- Combine `.filter().map().sort()` into a single loop when arrays have 1000+ items
|
|
95
|
+
- Cache `localStorage` reads in memory; read once, write-through on set
|
|
96
|
+
- Use `toSorted()`, `toReversed()`, `toSpliced()`, `with()` for immutable array operations (React-safe, no mutation)
|
|
733
97
|
|
|
734
98
|
---
|
|
735
99
|
|