Compare commits
99 Commits
bc77178ece
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30b55f887f | ||
|
|
15e2c4c961 | ||
|
|
8870845fa1 | ||
|
|
437e2bc96a | ||
|
|
696dc69763 | ||
|
|
f752d27101 | ||
|
|
ce6871d623 | ||
|
|
3a8c049092 | ||
|
|
24f53ddbce | ||
|
|
e0ff9dec0a | ||
|
|
14f9b622b0 | ||
|
|
0c6c9da56c | ||
|
|
33a260017a | ||
|
|
1e810e1782 | ||
|
|
34c8f90e26 | ||
|
|
ea85a053c5 | ||
|
|
78d7c51a7a | ||
|
|
f3d8bf9b94 | ||
|
|
668568b5b5 | ||
|
|
90ac590737 | ||
|
|
b7e10efd1d | ||
|
|
026debdc06 | ||
|
|
53edb2b307 | ||
|
|
d2732828cc | ||
|
|
22e2fde058 | ||
|
|
fbd184e2be | ||
|
|
7d2b2ddd71 | ||
|
|
792e8c7346 | ||
|
|
baebc1fc80 | ||
|
|
8b5aa8c8a6 | ||
|
|
e3cf141f55 | ||
|
|
8d26e375ff | ||
|
|
820eea7eca | ||
|
|
7518baccfa | ||
|
|
620e41edae | ||
|
|
50dc415987 | ||
|
|
be4b2c6000 | ||
|
|
37baeb9172 | ||
|
|
45775c8c3f | ||
|
|
18d5b42136 | ||
|
|
4c618781f6 | ||
|
|
e62c6f07df | ||
|
|
488845ae1f | ||
|
|
9ea006ec22 | ||
|
|
a5c2bcbc4d | ||
|
|
c2aba25814 | ||
|
|
2278d2a218 | ||
|
|
0b92f999bc | ||
|
|
cc9686147e | ||
|
|
bd613adb8a | ||
|
|
205743f3b1 | ||
|
|
a8bd13ef43 | ||
|
|
647a44e46a | ||
|
|
6495369c56 | ||
|
|
b4effc8545 | ||
|
|
6bd210075f | ||
|
|
716f7bb3fb | ||
|
|
f27f3ffaac | ||
|
|
ff66480227 | ||
|
|
b3dd36fa72 | ||
|
|
55eb32b22a | ||
|
|
8cf0c3267d | ||
|
|
569b9e5da2 | ||
|
|
acb3b9d557 | ||
|
|
1583d424fd | ||
|
|
60e81e266d | ||
|
|
736a6fabf0 | ||
|
|
65d630d754 | ||
|
|
e74710a20a | ||
|
|
9139977cd6 | ||
|
|
a1b2c4b069 | ||
|
|
36008bb72a | ||
|
|
2c542e7756 | ||
|
|
dc6d894a37 | ||
|
|
40a1c8ef8e | ||
|
|
7288c926d0 | ||
|
|
d93cf742ff | ||
|
|
209d203fe8 | ||
|
|
1d0328d8eb | ||
|
|
d5235310d5 | ||
|
|
2c02e3d7e9 | ||
|
|
fc146b0e18 | ||
|
|
92adfad464 | ||
|
|
669ff9b43a | ||
|
|
486f99c897 | ||
|
|
6d8699005c | ||
|
|
43457df3a9 | ||
|
|
312c06abbc | ||
|
|
0aaf705be2 | ||
|
|
fb44349bf8 | ||
| d5e23c0ce9 | |||
|
|
2ce0d417dc | ||
| fef13ce7b8 | |||
| 17caaab5fb | |||
| f815d1e861 | |||
|
|
1105415284 | ||
|
|
03107841ac | ||
| 5ce68c7932 | |||
|
|
307e778d62 |
141
.agents/skills/find-skills/SKILL.md
Normal file
141
.agents/skills/find-skills/SKILL.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
name: find-skills
|
||||||
|
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Find Skills
|
||||||
|
|
||||||
|
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when the user:
|
||||||
|
|
||||||
|
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||||
|
- Says "find a skill for X" or "is there a skill for X"
|
||||||
|
- Asks "can you do X" where X is a specialized capability
|
||||||
|
- Expresses interest in extending agent capabilities
|
||||||
|
- Wants to search for tools, templates, or workflows
|
||||||
|
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||||
|
|
||||||
|
## What is the Skills CLI?
|
||||||
|
|
||||||
|
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||||
|
|
||||||
|
**Key commands:**
|
||||||
|
|
||||||
|
- `npx skills find [query] [--owner <owner>]` - Search for skills interactively or by keyword, optionally scoped to a GitHub owner
|
||||||
|
- `npx skills add <package>` - Install a skill from GitHub or other sources
|
||||||
|
- `npx skills update` - Update all installed skills
|
||||||
|
|
||||||
|
**Browse skills at:** https://skills.sh/
|
||||||
|
|
||||||
|
## How to Help Users Find Skills
|
||||||
|
|
||||||
|
### Step 1: Understand What They Need
|
||||||
|
|
||||||
|
When a user asks for help with something, identify:
|
||||||
|
|
||||||
|
1. The domain (e.g., React, testing, design, deployment)
|
||||||
|
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||||
|
3. Whether this is a common enough task that a skill likely exists
|
||||||
|
|
||||||
|
### Step 2: Check the Leaderboard First
|
||||||
|
|
||||||
|
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
|
||||||
|
|
||||||
|
For example, top skills for web development include:
|
||||||
|
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
|
||||||
|
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
|
||||||
|
|
||||||
|
### Step 3: Search for Skills
|
||||||
|
|
||||||
|
If the leaderboard doesn't cover the user's need, run the find command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills find [query] [--owner <owner>]
|
||||||
|
```
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||||
|
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||||
|
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||||
|
|
||||||
|
### Step 4: Verify Quality Before Recommending
|
||||||
|
|
||||||
|
**Do not recommend a skill based solely on search results.** Always verify:
|
||||||
|
|
||||||
|
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
|
||||||
|
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
|
||||||
|
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
|
||||||
|
|
||||||
|
### Step 5: Present Options to the User
|
||||||
|
|
||||||
|
When you find relevant skills, present them to the user with:
|
||||||
|
|
||||||
|
1. The skill name and what it does
|
||||||
|
2. The install count and source
|
||||||
|
3. The install command they can run
|
||||||
|
4. A link to learn more at skills.sh
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```
|
||||||
|
I found a skill that might help! The "react-best-practices" skill provides
|
||||||
|
React and Next.js performance optimization guidelines from Vercel Engineering.
|
||||||
|
(185K installs)
|
||||||
|
|
||||||
|
To install it:
|
||||||
|
npx skills add vercel-labs/agent-skills@react-best-practices
|
||||||
|
|
||||||
|
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Offer to Install
|
||||||
|
|
||||||
|
If the user wants to proceed, you can install the skill for them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add <owner/repo@skill> -g -y
|
||||||
|
```
|
||||||
|
|
||||||
|
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
|
||||||
|
|
||||||
|
## Common Skill Categories
|
||||||
|
|
||||||
|
When searching, consider these common categories:
|
||||||
|
|
||||||
|
| Category | Example Queries |
|
||||||
|
| --------------- | ---------------------------------------- |
|
||||||
|
| Web Development | react, nextjs, typescript, css, tailwind |
|
||||||
|
| Testing | testing, jest, playwright, e2e |
|
||||||
|
| DevOps | deploy, docker, kubernetes, ci-cd |
|
||||||
|
| Documentation | docs, readme, changelog, api-docs |
|
||||||
|
| Code Quality | review, lint, refactor, best-practices |
|
||||||
|
| Design | ui, ux, design-system, accessibility |
|
||||||
|
| Productivity | workflow, automation, git |
|
||||||
|
|
||||||
|
## Tips for Effective Searches
|
||||||
|
|
||||||
|
1. **Use specific keywords**: "react testing" is better than just "testing"
|
||||||
|
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
|
||||||
|
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
|
||||||
|
|
||||||
|
## When No Skills Are Found
|
||||||
|
|
||||||
|
If no relevant skills exist:
|
||||||
|
|
||||||
|
1. Acknowledge that no existing skill was found
|
||||||
|
2. Offer to help with the task directly using your general capabilities
|
||||||
|
3. Suggest the user could create their own skill with `npx skills init`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
I searched for skills related to "xyz" but didn't find any matches.
|
||||||
|
I can still help you with this task directly! Would you like me to proceed?
|
||||||
|
|
||||||
|
If this is something you do often, you could create your own skill:
|
||||||
|
npx skills init my-xyz-skill
|
||||||
|
```
|
||||||
328
.agents/skills/frontend-ui-engineering/SKILL.md
Normal file
328
.agents/skills/frontend-ui-engineering/SKILL.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
---
|
||||||
|
name: frontend-ui-engineering
|
||||||
|
description: Builds production-quality, accessible, responsive user-facing UIs. Use when building or modifying interfaces and pages, creating components, implementing layouts, meeting WCAG accessibility requirements, managing state, or when the output needs to look and feel production-quality rather than AI-generated.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend UI Engineering
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Build production-quality user interfaces that are accessible, performant, and visually polished. The goal is UI that looks like it was built by a design-aware engineer at a top company — not like it was generated by an AI. This means real design system adherence, proper accessibility, thoughtful interaction patterns, and no generic "AI aesthetic."
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Building new UI components or pages
|
||||||
|
- Modifying existing user-facing interfaces
|
||||||
|
- Implementing responsive layouts
|
||||||
|
- Adding interactivity or state management
|
||||||
|
- Fixing visual or UX issues
|
||||||
|
|
||||||
|
## Component Architecture
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
Colocate everything related to a component:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/
|
||||||
|
TaskList/
|
||||||
|
TaskList.tsx # Component implementation
|
||||||
|
TaskList.test.tsx # Tests
|
||||||
|
TaskList.stories.tsx # Storybook stories (if using)
|
||||||
|
use-task-list.ts # Custom hook (if complex state)
|
||||||
|
types.ts # Component-specific types (if needed)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
**Prefer composition over configuration:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good: Composable
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tasks</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<TaskList tasks={tasks} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
// Avoid: Over-configured
|
||||||
|
<Card
|
||||||
|
title="Tasks"
|
||||||
|
headerVariant="large"
|
||||||
|
bodyPadding="md"
|
||||||
|
content={<TaskList tasks={tasks} />}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keep components focused:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good: Does one thing
|
||||||
|
export function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-center gap-3 p-3">
|
||||||
|
<Checkbox checked={task.done} onChange={() => onToggle(task.id)} />
|
||||||
|
<span className={task.done ? 'line-through text-muted' : ''}>{task.title}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onDelete(task.id)}>
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Separate data fetching from presentation:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Container: handles data
|
||||||
|
export function TaskListContainer() {
|
||||||
|
const { tasks, isLoading, error } = useTasks();
|
||||||
|
|
||||||
|
if (isLoading) return <TaskListSkeleton />;
|
||||||
|
if (error) return <ErrorState message="Failed to load tasks" retry={refetch} />;
|
||||||
|
if (tasks.length === 0) return <EmptyState message="No tasks yet" />;
|
||||||
|
|
||||||
|
return <TaskList tasks={tasks} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presentation: handles rendering
|
||||||
|
export function TaskList({ tasks }: { tasks: Task[] }) {
|
||||||
|
return (
|
||||||
|
<ul role="list" className="divide-y">
|
||||||
|
{tasks.map(task => <TaskItem key={task.id} task={task} />)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
**Choose the simplest approach that works:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Local state (useState) → Component-specific UI state
|
||||||
|
Lifted state → Shared between 2-3 sibling components
|
||||||
|
Context → Theme, auth, locale (read-heavy, write-rare)
|
||||||
|
URL state (searchParams) → Filters, pagination, shareable UI state
|
||||||
|
Server state (React Query, SWR) → Remote data with caching
|
||||||
|
Global store (Zustand, Redux) → Complex client state shared app-wide
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avoid prop drilling deeper than 3 levels.** If you're passing props through components that don't use them, introduce context or restructure the component tree.
|
||||||
|
|
||||||
|
## Design System Adherence
|
||||||
|
|
||||||
|
### Avoid the AI Aesthetic
|
||||||
|
|
||||||
|
AI-generated UI has recognizable patterns. Avoid all of them:
|
||||||
|
|
||||||
|
| AI Default | Why It Is a Problem | Production Quality |
|
||||||
|
|---|---|---|
|
||||||
|
| Purple/indigo everything | Models default to visually "safe" palettes, making every app look identical | Use the project's actual color palette |
|
||||||
|
| Excessive gradients | Gradients add visual noise and clash with most design systems | Flat or subtle gradients matching the design system |
|
||||||
|
| Rounded everything (rounded-2xl) | Maximum rounding signals "friendly" but ignores the hierarchy of corner radii in real designs | Consistent border-radius from the design system |
|
||||||
|
| Generic hero sections | Template-driven layout with no connection to the actual content or user need | Content-first layouts |
|
||||||
|
| Lorem ipsum-style copy | Placeholder text hides layout problems that real content reveals (length, wrapping, overflow) | Realistic placeholder content |
|
||||||
|
| Oversized padding everywhere | Equal generous padding destroys visual hierarchy and wastes screen space | Consistent spacing scale |
|
||||||
|
| Stock card grids | Uniform grids are a layout shortcut that ignores information priority and scanning patterns | Purpose-driven layouts |
|
||||||
|
| Shadow-heavy design | Layered shadows add depth that competes with content and slows rendering on low-end devices | Subtle or no shadows unless the design system specifies |
|
||||||
|
|
||||||
|
### Spacing and Layout
|
||||||
|
|
||||||
|
Use a consistent spacing scale. Don't invent values:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Use the scale: 0.25rem increments (or whatever the project uses) */
|
||||||
|
/* Good */ padding: 1rem; /* 16px */
|
||||||
|
/* Good */ gap: 0.75rem; /* 12px */
|
||||||
|
/* Bad */ padding: 13px; /* Not on any scale */
|
||||||
|
/* Bad */ margin-top: 2.3rem; /* Not on any scale */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
Respect the type hierarchy:
|
||||||
|
|
||||||
|
```
|
||||||
|
h1 → Page title (one per page)
|
||||||
|
h2 → Section title
|
||||||
|
h3 → Subsection title
|
||||||
|
body → Default text
|
||||||
|
small → Secondary/helper text
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't skip heading levels. Don't use heading styles for non-heading content.
|
||||||
|
|
||||||
|
### Color
|
||||||
|
|
||||||
|
- Use semantic color tokens: `text-primary`, `bg-surface`, `border-default` — not raw hex values
|
||||||
|
- Ensure sufficient contrast (4.5:1 for normal text, 3:1 for large text)
|
||||||
|
- Don't rely solely on color to convey information (use icons, text, or patterns too)
|
||||||
|
|
||||||
|
## Accessibility (WCAG 2.1 AA)
|
||||||
|
|
||||||
|
Every component must meet these standards:
|
||||||
|
|
||||||
|
### Keyboard Navigation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Every interactive element must be keyboard accessible
|
||||||
|
<button onClick={handleClick}>Click me</button> // ✓ Focusable by default
|
||||||
|
<div onClick={handleClick}>Click me</div> // ✗ Not focusable
|
||||||
|
<div role="button" tabIndex={0} onClick={handleClick} // ✓ But prefer <button>
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleClick();
|
||||||
|
if (e.key === ' ') e.preventDefault();
|
||||||
|
}}
|
||||||
|
onKeyUp={e => {
|
||||||
|
if (e.key === ' ') handleClick();
|
||||||
|
}}>
|
||||||
|
Click me
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ARIA Labels
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Label interactive elements that lack visible text
|
||||||
|
<button aria-label="Close dialog"><XIcon /></button>
|
||||||
|
|
||||||
|
// Label form inputs
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input id="email" type="email" />
|
||||||
|
|
||||||
|
// Or use aria-label when no visible label exists
|
||||||
|
<input aria-label="Search tasks" type="search" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Focus Management
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Move focus when content changes
|
||||||
|
function Dialog({ isOpen, onClose }: DialogProps) {
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) closeRef.current?.focus();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Trap focus inside dialog when open
|
||||||
|
return (
|
||||||
|
<dialog open={isOpen}>
|
||||||
|
<button ref={closeRef} onClick={onClose}>Close</button>
|
||||||
|
{/* dialog content */}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Meaningful Empty and Error States
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Don't show blank screens
|
||||||
|
function TaskList({ tasks }: { tasks: Task[] }) {
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div role="status" className="text-center py-12">
|
||||||
|
<TasksEmptyIcon className="mx-auto h-12 w-12 text-muted" />
|
||||||
|
<h3 className="mt-2 text-sm font-medium">No tasks</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted">Get started by creating a new task.</p>
|
||||||
|
<Button className="mt-4" onClick={onCreateTask}>Create Task</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ul role="list">...</ul>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Design
|
||||||
|
|
||||||
|
Design for mobile first, then expand:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Tailwind: mobile-first responsive
|
||||||
|
<div className="
|
||||||
|
grid grid-cols-1 /* Mobile: single column */
|
||||||
|
sm:grid-cols-2 /* Small: 2 columns */
|
||||||
|
lg:grid-cols-3 /* Large: 3 columns */
|
||||||
|
gap-4
|
||||||
|
">
|
||||||
|
```
|
||||||
|
|
||||||
|
Test at these breakpoints: 320px, 768px, 1024px, 1440px.
|
||||||
|
|
||||||
|
## Loading and Transitions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Skeleton loading (not spinners for content)
|
||||||
|
function TaskListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" aria-busy="true" aria-label="Loading tasks">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic updates for perceived speed
|
||||||
|
function useToggleTask() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: toggleTask,
|
||||||
|
onMutate: async (taskId) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['tasks'] });
|
||||||
|
const previous = queryClient.getQueryData(['tasks']);
|
||||||
|
|
||||||
|
queryClient.setQueryData(['tasks'], (old: Task[]) =>
|
||||||
|
old.map(t => t.id === taskId ? { ...t, done: !t.done } : t)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_err, _taskId, context) => {
|
||||||
|
queryClient.setQueryData(['tasks'], context?.previous);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
For detailed accessibility requirements and testing tools, see `references/accessibility-checklist.md`.
|
||||||
|
|
||||||
|
## Common Rationalizations
|
||||||
|
|
||||||
|
| Rationalization | Reality |
|
||||||
|
|---|---|
|
||||||
|
| "Accessibility is a nice-to-have" | It's a legal requirement in many jurisdictions and an engineering quality standard. |
|
||||||
|
| "We'll make it responsive later" | Retrofitting responsive design is 3x harder than building it from the start. |
|
||||||
|
| "The design isn't final, so I'll skip styling" | Use the design system defaults. Unstyled UI creates a broken first impression for reviewers. |
|
||||||
|
| "This is just a prototype" | Prototypes become production code. Build the foundation right. |
|
||||||
|
| "The AI aesthetic is fine for now" | It signals low quality. Use the project's actual design system from the start. |
|
||||||
|
|
||||||
|
## Red Flags
|
||||||
|
|
||||||
|
- Components with more than 200 lines (split them)
|
||||||
|
- Inline styles or arbitrary pixel values
|
||||||
|
- Missing error states, loading states, or empty states
|
||||||
|
- No keyboard navigation testing
|
||||||
|
- Color as the sole indicator of state (red/green without text or icons)
|
||||||
|
- Generic "AI look" (purple gradients, oversized cards, stock layouts)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After building UI:
|
||||||
|
|
||||||
|
- [ ] Component renders without console errors
|
||||||
|
- [ ] All interactive elements are keyboard accessible (Tab through the page)
|
||||||
|
- [ ] Screen reader can convey the page's content and structure
|
||||||
|
- [ ] Responsive: works at 320px, 768px, 1024px, 1440px
|
||||||
|
- [ ] Loading, error, and empty states all handled
|
||||||
|
- [ ] Follows the project's design system (spacing, colors, typography)
|
||||||
|
- [ ] No accessibility warnings in dev tools or axe-core
|
||||||
197
.agents/skills/prototype/PICKER.md
Normal file
197
.agents/skills/prototype/PICKER.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# The Picker
|
||||||
|
|
||||||
|
The picker's appearance is **not a design decision** — it is this spec. Copy the markup, CSS, and wiring below verbatim; the only values that change per run are the variant names and count. It stays identical across every project so it always reads as harness chrome, never as part of the design being judged. Do not restyle it with the project's tokens, fonts, or colors.
|
||||||
|
|
||||||
|
It is a floating dark pill, bottom-center. Dark glass works on top of any page — light or dark — which is why it is not theme-aware.
|
||||||
|
|
||||||
|
## Markup
|
||||||
|
|
||||||
|
The sliding highlight span first, one button per variant, a hairline divider, then the replay button (only when at least one variant has motion to re-trigger):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<nav class="proto-picker" aria-label="Prototype variants">
|
||||||
|
<span class="proto-picker-highlight" aria-hidden="true"></span>
|
||||||
|
<button class="proto-picker-item" data-active aria-current="true">Quiet</button>
|
||||||
|
<button class="proto-picker-item">Editorial</button>
|
||||||
|
<button class="proto-picker-item">Playful</button>
|
||||||
|
<span class="proto-picker-divider" aria-hidden="true"></span>
|
||||||
|
<button class="proto-picker-item proto-picker-replay" aria-label="Replay animation (R)">↻</button>
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
In a framework, keep the class names and structure; only the rendering syntax changes.
|
||||||
|
|
||||||
|
## Styles
|
||||||
|
|
||||||
|
```css
|
||||||
|
.proto-picker {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2147483647;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(10, 10, 10, 0.82);
|
||||||
|
-webkit-backdrop-filter: blur(12px) saturate(1.4);
|
||||||
|
backdrop-filter: blur(12px) saturate(1.4);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.24),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.12);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-highlight {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 0;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The slide is enabled only after first paint (data-ready), so load doesn't animate. */
|
||||||
|
.proto-picker[data-ready] .proto-picker-highlight {
|
||||||
|
transition:
|
||||||
|
transform 250ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||||
|
width 250ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.proto-picker[data-ready] .proto-picker-highlight { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-item {
|
||||||
|
position: relative; /* sits above the highlight */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-item:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-item:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-item:focus-visible {
|
||||||
|
outline: 2px solid rgba(255, 255, 255, 0.4);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-item[data-active] {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-replay {
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker[data-position="top"] {
|
||||||
|
bottom: auto;
|
||||||
|
top: 24px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Verbatim.** These values are the spec. No project fonts, no brand colors, no theme switching, no extra shadows or borders.
|
||||||
|
- **The highlight slides; the variant swap stays instant.** The active pill animates between buttons (250ms, strong ease-out) as spatial feedback on the picker itself — but the variant being previewed still switches with no transition. The `width` transition is a deliberate exception to the transform/opacity rule: the element is 28px tall, absolutely positioned, and has no layout dependents, so the paint cost is negligible.
|
||||||
|
- **One allowed modification:** if a variant occupies the bottom-center of the screen (a toast stack, a bottom sheet, a dock), set `data-position="top"` so the picker never covers the work. Nothing else about it may move or change.
|
||||||
|
- **Replay is conditional.** Render the replay button and its divider only when at least one variant has an entrance or state animation worth re-triggering; a static comparison gets a shorter pill.
|
||||||
|
|
||||||
|
## Behavior contract
|
||||||
|
|
||||||
|
The contract is fixed regardless of how the harness renders:
|
||||||
|
|
||||||
|
- Number keys `1–N` and `←`/`→` switch variants; `R` replays. Ignore key events when focus is in an input, textarea, select, or contenteditable, or when a modifier is held.
|
||||||
|
- Clicking an item switches to it; exactly one item carries `data-active` and `aria-current="true"` at all times, and the highlight slides to it.
|
||||||
|
- Selection persists across reload via a URL param (`?v=2`), falling back to variant 1. The highlight takes its initial position without animating (`data-ready` is added after first paint).
|
||||||
|
- Switching re-mounts the variant (so entrance animations re-run); the replay key re-mounts without switching.
|
||||||
|
|
||||||
|
## Reference wiring
|
||||||
|
|
||||||
|
Verbatim for the standalone-HTML branch; in a framework, keep the same behavior but express it idiomatically (state instead of `innerHTML`, a keyed re-mount instead of `requestAnimationFrame`, refs + a layout effect for the highlight measurement).
|
||||||
|
|
||||||
|
```js
|
||||||
|
// `variants` is an array of render functions, one per variant, in picker order.
|
||||||
|
const stage = document.getElementById('stage');
|
||||||
|
const picker = document.querySelector('.proto-picker');
|
||||||
|
const highlight = picker.querySelector('.proto-picker-highlight');
|
||||||
|
const items = [...picker.querySelectorAll('.proto-picker-item:not(.proto-picker-replay)')];
|
||||||
|
const replay = picker.querySelector('.proto-picker-replay');
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
function moveHighlight() {
|
||||||
|
const el = items[current];
|
||||||
|
highlight.style.width = el.offsetWidth + 'px';
|
||||||
|
highlight.style.transform = `translateX(${el.offsetLeft}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(i) {
|
||||||
|
stage.innerHTML = '';
|
||||||
|
// Clear first, render next frame, so entrance animations re-run.
|
||||||
|
requestAnimationFrame(() => { stage.innerHTML = variants[i](); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(i) {
|
||||||
|
if (i < 0 || i >= variants.length) return;
|
||||||
|
current = i;
|
||||||
|
items.forEach((el, j) => {
|
||||||
|
el.toggleAttribute('data-active', j === i);
|
||||||
|
if (j === i) el.setAttribute('aria-current', 'true');
|
||||||
|
else el.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
moveHighlight();
|
||||||
|
const url = new URL(location);
|
||||||
|
url.searchParams.set('v', i + 1);
|
||||||
|
history.replaceState(null, '', url);
|
||||||
|
mount(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((el, i) => el.addEventListener('click', () => setActive(i)));
|
||||||
|
replay?.addEventListener('click', () => mount(current));
|
||||||
|
window.addEventListener('resize', moveHighlight);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable) return;
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
const num = parseInt(e.key, 10);
|
||||||
|
if (num >= 1 && num <= variants.length) setActive(num - 1);
|
||||||
|
else if (e.key === 'ArrowRight') setActive((current + 1) % variants.length);
|
||||||
|
else if (e.key === 'ArrowLeft') setActive((current - 1 + variants.length) % variants.length);
|
||||||
|
else if (e.key === 'r' || e.key === 'R') mount(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
setActive((parseInt(new URLSearchParams(location.search).get('v'), 10) || 1) - 1);
|
||||||
|
// Enable the slide only after first paint, so load doesn't animate.
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => picker.setAttribute('data-ready', '')));
|
||||||
|
```
|
||||||
90
.agents/skills/prototype/SKILL.md
Normal file
90
.agents/skills/prototype/SKILL.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: prototype
|
||||||
|
description: Build multiple genuinely different versions of a UI piece you describe, rendered behind a visual picker so you can flip through them live and promote the one that feels right. Only runs when explicitly invoked; it does not trigger on its own.
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prototyping Variants
|
||||||
|
|
||||||
|
A divergence skill. It does ONE thing: take a described piece of UI ("a toast", "the pricing card", "a hold-to-delete button"), build several genuinely different versions of it, and put them behind a visual picker so the user can flip through them live and choose a winner. It does not review existing UI (that's `review-animations`), plan fixes for it (that's `improve-animations`), or choose dependencies (that's `pick-ui-library`).
|
||||||
|
|
||||||
|
## Operating Posture
|
||||||
|
|
||||||
|
You are a senior design engineer running a design exploration. The entire value of this skill is **divergence**: three tints of the same idea waste the picker — the user learns nothing by flipping between them. Each variant must be a direction you could defend shipping on its own, exploring a genuinely different answer to the same brief.
|
||||||
|
|
||||||
|
Divergence is not an excuse to drop the craft bar. Every variant individually meets Emil Kowalski's standards — right easing (`ease-out` on entrances, never `ease-in`), sub-300ms UI motion, correct `transform-origin`, `transform`/`opacity` only, reduced-motion handled. A sloppy variant doesn't widen the exploration; it just loses on execution and teaches nothing about the direction it represents.
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
1. **Never touch production code during exploration.** Everything lives in an isolated prototype surface (see Phase 4). Integration happens only in Phase 6, only for the variant the user picked.
|
||||||
|
2. **Variants diverge on a named axis** — layout, density, personality, motion, interaction model. Before building, you must be able to state each variant's axis in a phrase. Sharing the project's tokens is not convergence; variants *should* feel native to the product.
|
||||||
|
3. **Every variant fully works.** Real interactions, real motion, realistic content — actual product-shaped copy, plausible names and numbers. No lorem ipsum, no dead buttons, no "imagine this part".
|
||||||
|
4. **The picker is chrome, not a contestant.** Its exact markup, styles, and behavior are specified in [PICKER.md](PICKER.md) — copy them verbatim. Its look is not a design decision and never adapts to the project.
|
||||||
|
5. **Clean up after the choice.** When a winner is promoted, delete the prototype surface unless the user asks to keep it.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Phase 1 — Scope
|
||||||
|
|
||||||
|
One thing per run. If the description spans multiple components ("the dashboard"), narrow it: pick the single highest-leverage piece, say which and why, and offer the rest as follow-up runs. Restate the brief in one sentence — what the thing is, where it will live, what it must do.
|
||||||
|
|
||||||
|
### Phase 2 — Recon
|
||||||
|
|
||||||
|
Before designing anything, map the ground the variants must stand on:
|
||||||
|
|
||||||
|
- **Stack**: framework, styling system (Tailwind, CSS modules, vanilla), motion library if any.
|
||||||
|
- **Tokens**: colors, radii, spacing, fonts, easing/duration variables. Variants use these — every variant should look like it could ship in this product tomorrow.
|
||||||
|
- **Personality**: playful consumer app or crisp dashboard? This bounds how far the boldest variant may go.
|
||||||
|
- **Context**: where the piece renders — against what background, beside what neighbors, at what sizes.
|
||||||
|
|
||||||
|
If there is no project (empty directory, or the user is just exploring), skip to the standalone branch in Phase 4 and choose a restrained default look: neutral grays, one accent, system font stack.
|
||||||
|
|
||||||
|
### Phase 3 — Choose directions
|
||||||
|
|
||||||
|
Default **3 variants**; up to 5 when the user asks or the design space is genuinely wide. More than 5 dilutes the comparison.
|
||||||
|
|
||||||
|
Before writing any code, list the set: a name and an axis for each. Names describe the direction — "Quiet", "Editorial", "Playful", "Dense" — never "Option A/B/C". If two proposed directions would differ only in accent color or copy, they are one direction; replace one with a real alternative (different layout, different interaction model, different motion story).
|
||||||
|
|
||||||
|
**Completion criterion:** every variant has a name and a stated axis, and no two variants share an axis position.
|
||||||
|
|
||||||
|
### Phase 4 — Build the picker harness
|
||||||
|
|
||||||
|
Two branches, by what exists:
|
||||||
|
|
||||||
|
- **In a project with a dev server** — an isolated route or page (`/prototypes/<slug>`, or the framework's equivalent), one file per variant plus a small harness file. Nothing imports from the prototype surface into production code.
|
||||||
|
- **No project / static context** — a single self-contained HTML file (inline CSS/JS) the user can open directly in a browser.
|
||||||
|
|
||||||
|
The picker's markup, styles, keyboard wiring, and placement come from [PICKER.md](PICKER.md), verbatim — load it now and build exactly that. Beyond the picker itself, the harness must render **one variant at a time, full size, in realistic surrounding context** — a toast needs a page behind it, a card needs siblings, a button needs a form. Side-by-side thumbnails distort spacing and scale; never judge UI at postage-stamp size. Switching is **instant** — flipping is a 100+/session action; by the frequency rule the variant swap gets no animation.
|
||||||
|
|
||||||
|
### Phase 5 — Verify and hand off
|
||||||
|
|
||||||
|
Run the harness. Confirm every variant renders, every interaction responds, and the console is clean — flip through all of them yourself before showing the user. If browser tooling is available, screenshot each variant.
|
||||||
|
|
||||||
|
Then present the set and **stop — the choice belongs to the user**:
|
||||||
|
|
||||||
|
| # | Variant | Axis | When it's the right choice | Its cost |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Quiet | Minimal motion, borders over shadows | The product is a daily-use tool | Least memorable |
|
||||||
|
| 2 | Editorial | Large type, generous whitespace | The moment deserves weight | Eats vertical space |
|
||||||
|
|
||||||
|
Close with where the picker is running (URL or file path) and the keys to flip.
|
||||||
|
|
||||||
|
**Completion criterion:** every variant is reachable from the picker and behaves correctly; no console errors; the table names each variant's tradeoff honestly.
|
||||||
|
|
||||||
|
### Phase 6 — Promote on selection
|
||||||
|
|
||||||
|
When the user picks: integrate that variant where it belongs, following the project's existing conventions (file layout, naming, token usage), then delete the prototype surface per Hard Rule 5. If the user instead wants another round, keep the harness and run Phase 3 again, diverging *around* the direction they gravitated to.
|
||||||
|
|
||||||
|
## Invocation Variants
|
||||||
|
|
||||||
|
| Invocation | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| `<description>` | Full workflow: scope → recon → 3 variants → picker → wait for choice |
|
||||||
|
| `<description> x5` | Same, with that many variants (capped at 5) |
|
||||||
|
| `riff <variant>` | New round: keep the harness, generate a fresh set diverging around the named variant's direction |
|
||||||
|
| `keep <variant>` | Promote that variant into the codebase and delete the prototype surface |
|
||||||
|
| `keep <variant>, leave the picker` | Promote, but keep the prototype surface around |
|
||||||
|
|
||||||
|
## Tone
|
||||||
|
|
||||||
|
Sell each variant honestly — one line on when it wins, one on what it costs. Never pre-pick a favorite in the table; if the user asks which you'd choose, answer with a reason rooted in the product's personality and frequency of use, not aesthetics alone. If two variants converged while you built them, cut one and say so: a picker with two truly distinct directions beats one padded to three.
|
||||||
3810
.agents/skills/vercel-react-best-practices/AGENTS.md
Normal file
3810
.agents/skills/vercel-react-best-practices/AGENTS.md
Normal file
File diff suppressed because it is too large
Load Diff
123
.agents/skills/vercel-react-best-practices/README.md
Normal file
123
.agents/skills/vercel-react-best-practices/README.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# React Best Practices
|
||||||
|
|
||||||
|
A structured repository for creating and maintaining React Best Practices optimized for agents and LLMs.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `rules/` - Individual rule files (one per rule)
|
||||||
|
- `_sections.md` - Section metadata (titles, impacts, descriptions)
|
||||||
|
- `_template.md` - Template for creating new rules
|
||||||
|
- `area-description.md` - Individual rule files
|
||||||
|
- `src/` - Build scripts and utilities
|
||||||
|
- `metadata.json` - Document metadata (version, organization, abstract)
|
||||||
|
- __`AGENTS.md`__ - Compiled output (generated)
|
||||||
|
- __`test-cases.json`__ - Test cases for LLM evaluation (generated)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build AGENTS.md from rules:
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Validate rule files:
|
||||||
|
```bash
|
||||||
|
pnpm validate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Extract test cases:
|
||||||
|
```bash
|
||||||
|
pnpm extract-tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a New Rule
|
||||||
|
|
||||||
|
1. Copy `rules/_template.md` to `rules/area-description.md`
|
||||||
|
2. Choose the appropriate area prefix:
|
||||||
|
- `async-` for Eliminating Waterfalls (Section 1)
|
||||||
|
- `bundle-` for Bundle Size Optimization (Section 2)
|
||||||
|
- `server-` for Server-Side Performance (Section 3)
|
||||||
|
- `client-` for Client-Side Data Fetching (Section 4)
|
||||||
|
- `rerender-` for Re-render Optimization (Section 5)
|
||||||
|
- `rendering-` for Rendering Performance (Section 6)
|
||||||
|
- `js-` for JavaScript Performance (Section 7)
|
||||||
|
- `advanced-` for Advanced Patterns (Section 8)
|
||||||
|
3. Fill in the frontmatter and content
|
||||||
|
4. Ensure you have clear examples with explanations
|
||||||
|
5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
|
||||||
|
|
||||||
|
## Rule File Structure
|
||||||
|
|
||||||
|
Each rule file should follow this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: Rule Title Here
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: Optional description
|
||||||
|
tags: tag1, tag2, tag3
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule Title Here
|
||||||
|
|
||||||
|
Brief explanation of the rule and why it matters.
|
||||||
|
|
||||||
|
**Incorrect (description of what's wrong):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad code example
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (description of what's right):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good code example
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional explanatory text after examples.
|
||||||
|
|
||||||
|
Reference: [Link](https://example.com)
|
||||||
|
|
||||||
|
## File Naming Convention
|
||||||
|
|
||||||
|
- Files starting with `_` are special (excluded from build)
|
||||||
|
- Rule files: `area-description.md` (e.g., `async-parallel.md`)
|
||||||
|
- Section is automatically inferred from filename prefix
|
||||||
|
- Rules are sorted alphabetically by title within each section
|
||||||
|
- IDs (e.g., 1.1, 1.2) are auto-generated during build
|
||||||
|
|
||||||
|
## Impact Levels
|
||||||
|
|
||||||
|
- `CRITICAL` - Highest priority, major performance gains
|
||||||
|
- `HIGH` - Significant performance improvements
|
||||||
|
- `MEDIUM-HIGH` - Moderate-high gains
|
||||||
|
- `MEDIUM` - Moderate performance improvements
|
||||||
|
- `LOW-MEDIUM` - Low-medium gains
|
||||||
|
- `LOW` - Incremental improvements
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `pnpm build` - Compile rules into AGENTS.md
|
||||||
|
- `pnpm validate` - Validate all rule files
|
||||||
|
- `pnpm extract-tests` - Extract test cases for LLM evaluation
|
||||||
|
- `pnpm dev` - Build and validate
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding or modifying rules:
|
||||||
|
|
||||||
|
1. Use the correct filename prefix for your section
|
||||||
|
2. Follow the `_template.md` structure
|
||||||
|
3. Include clear bad/good examples with explanations
|
||||||
|
4. Add appropriate tags
|
||||||
|
5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
|
||||||
|
6. Rules are automatically sorted by title - no need to manage numbers!
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Originally created by [@shuding](https://x.com/shuding) at [Vercel](https://vercel.com).
|
||||||
149
.agents/skills/vercel-react-best-practices/SKILL.md
Normal file
149
.agents/skills/vercel-react-best-practices/SKILL.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
name: vercel-react-best-practices
|
||||||
|
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: vercel
|
||||||
|
version: "1.0.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vercel React Best Practices
|
||||||
|
|
||||||
|
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 70 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Reference these guidelines when:
|
||||||
|
- Writing new React components or Next.js pages
|
||||||
|
- Implementing data fetching (client or server-side)
|
||||||
|
- Reviewing code for performance issues
|
||||||
|
- Refactoring existing React/Next.js code
|
||||||
|
- Optimizing bundle size or load times
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
| Priority | Category | Impact | Prefix |
|
||||||
|
|----------|----------|--------|--------|
|
||||||
|
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
|
||||||
|
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
|
||||||
|
| 3 | Server-Side Performance | HIGH | `server-` |
|
||||||
|
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
|
||||||
|
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
|
||||||
|
| 6 | Rendering Performance | MEDIUM | `rendering-` |
|
||||||
|
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
|
||||||
|
| 8 | Advanced Patterns | LOW | `advanced-` |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### 1. Eliminating Waterfalls (CRITICAL)
|
||||||
|
|
||||||
|
- `async-cheap-condition-before-await` - Check cheap sync conditions before awaiting flags or remote values
|
||||||
|
- `async-defer-await` - Move await into branches where actually used
|
||||||
|
- `async-parallel` - Use Promise.all() for independent operations
|
||||||
|
- `async-dependencies` - Use better-all for partial dependencies
|
||||||
|
- `async-api-routes` - Start promises early, await late in API routes
|
||||||
|
- `async-suspense-boundaries` - Use Suspense to stream content
|
||||||
|
|
||||||
|
### 2. Bundle Size Optimization (CRITICAL)
|
||||||
|
|
||||||
|
- `bundle-barrel-imports` - Import directly, avoid barrel files
|
||||||
|
- `bundle-analyzable-paths` - Prefer statically analyzable import and file-system paths to avoid broad bundles and traces
|
||||||
|
- `bundle-dynamic-imports` - Use next/dynamic for heavy components
|
||||||
|
- `bundle-defer-third-party` - Load analytics/logging after hydration
|
||||||
|
- `bundle-conditional` - Load modules only when feature is activated
|
||||||
|
- `bundle-preload` - Preload on hover/focus for perceived speed
|
||||||
|
|
||||||
|
### 3. Server-Side Performance (HIGH)
|
||||||
|
|
||||||
|
- `server-auth-actions` - Authenticate server actions like API routes
|
||||||
|
- `server-cache-react` - Use React.cache() for per-request deduplication
|
||||||
|
- `server-cache-lru` - Use LRU cache for cross-request caching
|
||||||
|
- `server-dedup-props` - Avoid duplicate serialization in RSC props
|
||||||
|
- `server-hoist-static-io` - Hoist static I/O (fonts, logos) to module level
|
||||||
|
- `server-no-shared-module-state` - Avoid module-level mutable request state in RSC/SSR
|
||||||
|
- `server-serialization` - Minimize data passed to client components
|
||||||
|
- `server-parallel-fetching` - Restructure components to parallelize fetches
|
||||||
|
- `server-parallel-nested-fetching` - Chain nested fetches per item in Promise.all
|
||||||
|
- `server-after-nonblocking` - Use after() for non-blocking operations
|
||||||
|
|
||||||
|
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
|
||||||
|
|
||||||
|
- `client-swr-dedup` - Use SWR for automatic request deduplication
|
||||||
|
- `client-event-listeners` - Deduplicate global event listeners
|
||||||
|
- `client-passive-event-listeners` - Use passive listeners for scroll
|
||||||
|
- `client-localstorage-schema` - Version and minimize localStorage data
|
||||||
|
|
||||||
|
### 5. Re-render Optimization (MEDIUM)
|
||||||
|
|
||||||
|
- `rerender-defer-reads` - Don't subscribe to state only used in callbacks
|
||||||
|
- `rerender-memo` - Extract expensive work into memoized components
|
||||||
|
- `rerender-memo-with-default-value` - Hoist default non-primitive props
|
||||||
|
- `rerender-dependencies` - Use primitive dependencies in effects
|
||||||
|
- `rerender-derived-state` - Subscribe to derived booleans, not raw values
|
||||||
|
- `rerender-derived-state-no-effect` - Derive state during render, not effects
|
||||||
|
- `rerender-functional-setstate` - Use functional setState for stable callbacks
|
||||||
|
- `rerender-lazy-state-init` - Pass function to useState for expensive values
|
||||||
|
- `rerender-simple-expression-in-memo` - Avoid memo for simple primitives
|
||||||
|
- `rerender-split-combined-hooks` - Split hooks with independent dependencies
|
||||||
|
- `rerender-move-effect-to-event` - Put interaction logic in event handlers
|
||||||
|
- `rerender-transitions` - Use startTransition for non-urgent updates
|
||||||
|
- `rerender-use-deferred-value` - Defer expensive renders to keep input responsive
|
||||||
|
- `rerender-use-ref-transient-values` - Use refs for transient frequent values
|
||||||
|
- `rerender-no-inline-components` - Don't define components inside components
|
||||||
|
|
||||||
|
### 6. Rendering Performance (MEDIUM)
|
||||||
|
|
||||||
|
- `rendering-animate-svg-wrapper` - Animate div wrapper, not SVG element
|
||||||
|
- `rendering-content-visibility` - Use content-visibility for long lists
|
||||||
|
- `rendering-hoist-jsx` - Extract static JSX outside components
|
||||||
|
- `rendering-svg-precision` - Reduce SVG coordinate precision
|
||||||
|
- `rendering-hydration-no-flicker` - Use inline script for client-only data
|
||||||
|
- `rendering-hydration-suppress-warning` - Suppress expected mismatches
|
||||||
|
- `rendering-activity` - Use Activity component for show/hide
|
||||||
|
- `rendering-conditional-render` - Use ternary, not && for conditionals
|
||||||
|
- `rendering-usetransition-loading` - Prefer useTransition for loading state
|
||||||
|
- `rendering-resource-hints` - Use React DOM resource hints for preloading
|
||||||
|
- `rendering-script-defer-async` - Use defer or async on script tags
|
||||||
|
|
||||||
|
### 7. JavaScript Performance (LOW-MEDIUM)
|
||||||
|
|
||||||
|
- `js-batch-dom-css` - Group CSS changes via classes or cssText
|
||||||
|
- `js-index-maps` - Build Map for repeated lookups
|
||||||
|
- `js-cache-property-access` - Cache object properties in loops
|
||||||
|
- `js-cache-function-results` - Cache function results in module-level Map
|
||||||
|
- `js-cache-storage` - Cache localStorage/sessionStorage reads
|
||||||
|
- `js-combine-iterations` - Combine multiple filter/map into one loop
|
||||||
|
- `js-length-check-first` - Check array length before expensive comparison
|
||||||
|
- `js-early-exit` - Return early from functions
|
||||||
|
- `js-hoist-regexp` - Hoist RegExp creation outside loops
|
||||||
|
- `js-min-max-loop` - Use loop for min/max instead of sort
|
||||||
|
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
|
||||||
|
- `js-tosorted-immutable` - Use toSorted() for immutability
|
||||||
|
- `js-flatmap-filter` - Use flatMap to map and filter in one pass
|
||||||
|
- `js-request-idle-callback` - Defer non-critical work to browser idle time
|
||||||
|
|
||||||
|
### 8. Advanced Patterns (LOW)
|
||||||
|
|
||||||
|
- `advanced-effect-event-deps` - Don't put `useEffectEvent` results in effect deps
|
||||||
|
- `advanced-event-handler-refs` - Store event handlers in refs
|
||||||
|
- `advanced-init-once` - Initialize app once per app load
|
||||||
|
- `advanced-use-latest` - useLatest for stable callback refs
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Read individual rule files for detailed explanations and code examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
rules/async-parallel.md
|
||||||
|
rules/bundle-barrel-imports.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Each rule file contains:
|
||||||
|
- Brief explanation of why it matters
|
||||||
|
- Incorrect code example with explanation
|
||||||
|
- Correct code example with explanation
|
||||||
|
- Additional context and references
|
||||||
|
|
||||||
|
## Full Compiled Document
|
||||||
|
|
||||||
|
For the complete guide with all rules expanded: `AGENTS.md`
|
||||||
15
.agents/skills/vercel-react-best-practices/metadata.json
Normal file
15
.agents/skills/vercel-react-best-practices/metadata.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"organization": "Vercel Engineering",
|
||||||
|
"date": "January 2026",
|
||||||
|
"abstract": "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.",
|
||||||
|
"references": [
|
||||||
|
"https://react.dev",
|
||||||
|
"https://nextjs.org",
|
||||||
|
"https://swr.vercel.app",
|
||||||
|
"https://github.com/shuding/better-all",
|
||||||
|
"https://github.com/isaacs/node-lru-cache",
|
||||||
|
"https://vercel.com/blog/how-we-optimized-package-imports-in-next-js",
|
||||||
|
"https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Sections
|
||||||
|
|
||||||
|
This file defines all sections, their ordering, impact levels, and descriptions.
|
||||||
|
The section ID (in parentheses) is the filename prefix used to group rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Eliminating Waterfalls (async)
|
||||||
|
|
||||||
|
**Impact:** CRITICAL
|
||||||
|
**Description:** Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
|
||||||
|
|
||||||
|
## 2. Bundle Size Optimization (bundle)
|
||||||
|
|
||||||
|
**Impact:** CRITICAL
|
||||||
|
**Description:** Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
|
||||||
|
|
||||||
|
## 3. Server-Side Performance (server)
|
||||||
|
|
||||||
|
**Impact:** HIGH
|
||||||
|
**Description:** Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times.
|
||||||
|
|
||||||
|
## 4. Client-Side Data Fetching (client)
|
||||||
|
|
||||||
|
**Impact:** MEDIUM-HIGH
|
||||||
|
**Description:** Automatic deduplication and efficient data fetching patterns reduce redundant network requests.
|
||||||
|
|
||||||
|
## 5. Re-render Optimization (rerender)
|
||||||
|
|
||||||
|
**Impact:** MEDIUM
|
||||||
|
**Description:** Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.
|
||||||
|
|
||||||
|
## 6. Rendering Performance (rendering)
|
||||||
|
|
||||||
|
**Impact:** MEDIUM
|
||||||
|
**Description:** Optimizing the rendering process reduces the work the browser needs to do.
|
||||||
|
|
||||||
|
## 7. JavaScript Performance (js)
|
||||||
|
|
||||||
|
**Impact:** LOW-MEDIUM
|
||||||
|
**Description:** Micro-optimizations for hot paths can add up to meaningful improvements.
|
||||||
|
|
||||||
|
## 8. Advanced Patterns (advanced)
|
||||||
|
|
||||||
|
**Impact:** LOW
|
||||||
|
**Description:** Advanced patterns for specific cases that require careful implementation.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Rule Title Here
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: Optional description of impact (e.g., "20-50% improvement")
|
||||||
|
tags: tag1, tag2
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule Title Here
|
||||||
|
|
||||||
|
**Impact: MEDIUM (optional impact description)**
|
||||||
|
|
||||||
|
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
|
||||||
|
|
||||||
|
**Incorrect (description of what's wrong):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad code example here
|
||||||
|
const bad = example()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (description of what's right):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good code example here
|
||||||
|
const good = example()
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [Link to documentation or resource](https://example.com)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
title: Do Not Put Effect Events in Dependency Arrays
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: avoids unnecessary effect re-runs and lint errors
|
||||||
|
tags: advanced, hooks, useEffectEvent, dependencies, effects
|
||||||
|
---
|
||||||
|
|
||||||
|
## Do Not Put Effect Events in Dependency Arrays
|
||||||
|
|
||||||
|
Effect Event functions do not have a stable identity. Their identity intentionally changes on every render. Do not include the function returned by `useEffectEvent` in a `useEffect` dependency array. Keep the actual reactive values as dependencies and call the Effect Event from inside the effect body or subscriptions created by that effect.
|
||||||
|
|
||||||
|
**Incorrect (Effect Event added as a dependency):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useEffectEvent } from 'react'
|
||||||
|
|
||||||
|
function ChatRoom({ roomId, onConnected }: {
|
||||||
|
roomId: string
|
||||||
|
onConnected: () => void
|
||||||
|
}) {
|
||||||
|
const handleConnected = useEffectEvent(onConnected)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const connection = createConnection(roomId)
|
||||||
|
connection.on('connected', handleConnected)
|
||||||
|
connection.connect()
|
||||||
|
|
||||||
|
return () => connection.disconnect()
|
||||||
|
}, [roomId, handleConnected])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Including the Effect Event in dependencies makes the effect re-run every render and triggers the React Hooks lint rule.
|
||||||
|
|
||||||
|
**Correct (depend on reactive values, not the Effect Event):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useEffectEvent } from 'react'
|
||||||
|
|
||||||
|
function ChatRoom({ roomId, onConnected }: {
|
||||||
|
roomId: string
|
||||||
|
onConnected: () => void
|
||||||
|
}) {
|
||||||
|
const handleConnected = useEffectEvent(onConnected)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const connection = createConnection(roomId)
|
||||||
|
connection.on('connected', handleConnected)
|
||||||
|
connection.connect()
|
||||||
|
|
||||||
|
return () => connection.disconnect()
|
||||||
|
}, [roomId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [React useEffectEvent: Effect Event in deps](https://react.dev/reference/react/useEffectEvent#effect-event-in-deps)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
title: Store Event Handlers in Refs
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: stable subscriptions
|
||||||
|
tags: advanced, hooks, refs, event-handlers, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Store Event Handlers in Refs
|
||||||
|
|
||||||
|
Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
|
||||||
|
|
||||||
|
**Incorrect (re-subscribes on every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(event, handler)
|
||||||
|
return () => window.removeEventListener(event, handler)
|
||||||
|
}, [event, handler])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (stable subscription):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
const handlerRef = useRef(handler)
|
||||||
|
useEffect(() => {
|
||||||
|
handlerRef.current = handler
|
||||||
|
}, [handler])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (e) => handlerRef.current(e)
|
||||||
|
window.addEventListener(event, listener)
|
||||||
|
return () => window.removeEventListener(event, listener)
|
||||||
|
}, [event])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative: use `useEffectEvent` if you're on latest React:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffectEvent } from 'react'
|
||||||
|
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
const onEvent = useEffectEvent(handler)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(event, onEvent)
|
||||||
|
return () => window.removeEventListener(event, onEvent)
|
||||||
|
}, [event])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
title: Initialize App Once, Not Per Mount
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: avoids duplicate init in development
|
||||||
|
tags: initialization, useEffect, app-startup, side-effects
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initialize App Once, Not Per Mount
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect (runs twice in dev, re-runs on remount):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Comp() {
|
||||||
|
useEffect(() => {
|
||||||
|
loadFromStorage()
|
||||||
|
checkAuthToken()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (once per app load):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
let didInit = false
|
||||||
|
|
||||||
|
function Comp() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (didInit) return
|
||||||
|
didInit = true
|
||||||
|
loadFromStorage()
|
||||||
|
checkAuthToken()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: useEffectEvent for Stable Callback Refs
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: prevents effect re-runs
|
||||||
|
tags: advanced, hooks, useEffectEvent, refs, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## useEffectEvent for Stable Callback Refs
|
||||||
|
|
||||||
|
Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
|
||||||
|
|
||||||
|
**Incorrect (effect re-runs on every callback change):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => onSearch(query), 300)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [query, onSearch])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (using React's useEffectEvent):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffectEvent } from 'react';
|
||||||
|
|
||||||
|
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const onSearchEvent = useEffectEvent(onSearch)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => onSearchEvent(query), 300)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [query])
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: Prevent Waterfall Chains in API Routes
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: 2-10× improvement
|
||||||
|
tags: api-routes, server-actions, waterfalls, parallelization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prevent Waterfall Chains in API Routes
|
||||||
|
|
||||||
|
In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
|
||||||
|
|
||||||
|
**Incorrect (config waits for auth, data waits for both):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await auth()
|
||||||
|
const config = await fetchConfig()
|
||||||
|
const data = await fetchData(session.user.id)
|
||||||
|
return Response.json({ data, config })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (auth and config start immediately):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const sessionPromise = auth()
|
||||||
|
const configPromise = fetchConfig()
|
||||||
|
const session = await sessionPromise
|
||||||
|
const [config, data] = await Promise.all([
|
||||||
|
configPromise,
|
||||||
|
fetchData(session.user.id)
|
||||||
|
])
|
||||||
|
return Response.json({ data, config })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Check Cheap Conditions Before Async Flags
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: avoids unnecessary async work when a synchronous guard already fails
|
||||||
|
tags: async, await, feature-flags, short-circuit, conditional
|
||||||
|
---
|
||||||
|
|
||||||
|
## Check Cheap Conditions Before Async Flags
|
||||||
|
|
||||||
|
When a branch uses `await` for a flag or remote value and also requires a **cheap synchronous** condition (local props, request metadata, already-loaded state), evaluate the cheap condition **first**. Otherwise you pay for the async call even when the compound condition can never be true.
|
||||||
|
|
||||||
|
This is a specialization of [Defer Await Until Needed](./async-defer-await.md) for `flag && cheapCondition` style checks.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const someFlag = await getFlag()
|
||||||
|
|
||||||
|
if (someFlag && someCondition) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (someCondition) {
|
||||||
|
const someFlag = await getFlag()
|
||||||
|
if (someFlag) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This matters when `getFlag` hits the network, a feature-flag service, or `React.cache` / DB work: skipping it when `someCondition` is false removes that cost on the cold path.
|
||||||
|
|
||||||
|
Keep the original order if `someCondition` is expensive, depends on the flag, or you must run side effects in a fixed order.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: Defer Await Until Needed
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: avoids blocking unused code paths
|
||||||
|
tags: async, await, conditional, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defer Await Until Needed
|
||||||
|
|
||||||
|
Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
|
||||||
|
|
||||||
|
**Incorrect (blocks both branches):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function handleRequest(userId: string, skipProcessing: boolean) {
|
||||||
|
const userData = await fetchUserData(userId)
|
||||||
|
|
||||||
|
if (skipProcessing) {
|
||||||
|
// Returns immediately but still waited for userData
|
||||||
|
return { skipped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only this branch uses userData
|
||||||
|
return processUserData(userData)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (only blocks when needed):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function handleRequest(userId: string, skipProcessing: boolean) {
|
||||||
|
if (skipProcessing) {
|
||||||
|
// Returns immediately without waiting
|
||||||
|
return { skipped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch only when needed
|
||||||
|
const userData = await fetchUserData(userId)
|
||||||
|
return processUserData(userData)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Another example (early return optimization):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Incorrect: always fetches permissions
|
||||||
|
async function updateResource(resourceId: string, userId: string) {
|
||||||
|
const permissions = await fetchPermissions(userId)
|
||||||
|
const resource = await getResource(resourceId)
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return { error: 'Not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.canEdit) {
|
||||||
|
return { error: 'Forbidden' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return await updateResourceData(resource, permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct: fetches only when needed
|
||||||
|
async function updateResource(resourceId: string, userId: string) {
|
||||||
|
const resource = await getResource(resourceId)
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return { error: 'Not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = await fetchPermissions(userId)
|
||||||
|
|
||||||
|
if (!permissions.canEdit) {
|
||||||
|
return { error: 'Forbidden' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return await updateResourceData(resource, permissions)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
|
||||||
|
|
||||||
|
For `await getFlag()` combined with a cheap synchronous guard (`flag && someCondition`), see [Check Cheap Conditions Before Async Flags](./async-cheap-condition-before-await.md).
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
title: Dependency-Based Parallelization
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: 2-10× improvement
|
||||||
|
tags: async, parallelization, dependencies, better-all
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency-Based Parallelization
|
||||||
|
|
||||||
|
For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
|
||||||
|
|
||||||
|
**Incorrect (profile waits for config unnecessarily):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [user, config] = await Promise.all([
|
||||||
|
fetchUser(),
|
||||||
|
fetchConfig()
|
||||||
|
])
|
||||||
|
const profile = await fetchProfile(user.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (config and profile run in parallel):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { all } from 'better-all'
|
||||||
|
|
||||||
|
const { user, config, profile } = await all({
|
||||||
|
async user() { return fetchUser() },
|
||||||
|
async config() { return fetchConfig() },
|
||||||
|
async profile() {
|
||||||
|
return fetchProfile((await this.$.user).id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative without extra dependencies:**
|
||||||
|
|
||||||
|
We can also create all the promises first, and do `Promise.all()` at the end.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const userPromise = fetchUser()
|
||||||
|
const profilePromise = userPromise.then(user => fetchProfile(user.id))
|
||||||
|
|
||||||
|
const [user, config, profile] = await Promise.all([
|
||||||
|
userPromise,
|
||||||
|
fetchConfig(),
|
||||||
|
profilePromise
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Promise.all() for Independent Operations
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: 2-10× improvement
|
||||||
|
tags: async, parallelization, promises, waterfalls
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promise.all() for Independent Operations
|
||||||
|
|
||||||
|
When async operations have no interdependencies, execute them concurrently using `Promise.all()`.
|
||||||
|
|
||||||
|
**Incorrect (sequential execution, 3 round trips):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const user = await fetchUser()
|
||||||
|
const posts = await fetchPosts()
|
||||||
|
const comments = await fetchComments()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (parallel execution, 1 round trip):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [user, posts, comments] = await Promise.all([
|
||||||
|
fetchUser(),
|
||||||
|
fetchPosts(),
|
||||||
|
fetchComments()
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
title: Strategic Suspense Boundaries
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: faster initial paint
|
||||||
|
tags: async, suspense, streaming, layout-shift
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategic Suspense Boundaries
|
||||||
|
|
||||||
|
Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
|
||||||
|
|
||||||
|
**Incorrect (wrapper blocked by data fetching):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function Page() {
|
||||||
|
const data = await fetchData() // Blocks entire page
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>Sidebar</div>
|
||||||
|
<div>Header</div>
|
||||||
|
<div>
|
||||||
|
<DataDisplay data={data} />
|
||||||
|
</div>
|
||||||
|
<div>Footer</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The entire layout waits for data even though only the middle section needs it.
|
||||||
|
|
||||||
|
**Correct (wrapper shows immediately, data streams in):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Page() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>Sidebar</div>
|
||||||
|
<div>Header</div>
|
||||||
|
<div>
|
||||||
|
<Suspense fallback={<Skeleton />}>
|
||||||
|
<DataDisplay />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<div>Footer</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function DataDisplay() {
|
||||||
|
const data = await fetchData() // Only blocks this component
|
||||||
|
return <div>{data.content}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
|
||||||
|
|
||||||
|
**Alternative (share promise across components):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Page() {
|
||||||
|
// Start fetch immediately, but don't await
|
||||||
|
const dataPromise = fetchData()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>Sidebar</div>
|
||||||
|
<div>Header</div>
|
||||||
|
<Suspense fallback={<Skeleton />}>
|
||||||
|
<DataDisplay dataPromise={dataPromise} />
|
||||||
|
<DataSummary dataPromise={dataPromise} />
|
||||||
|
</Suspense>
|
||||||
|
<div>Footer</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
|
||||||
|
const data = use(dataPromise) // Unwraps the promise
|
||||||
|
return <div>{data.content}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
|
||||||
|
const data = use(dataPromise) // Reuses the same promise
|
||||||
|
return <div>{data.summary}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.
|
||||||
|
|
||||||
|
**When NOT to use this pattern:**
|
||||||
|
|
||||||
|
- Critical data needed for layout decisions (affects positioning)
|
||||||
|
- SEO-critical content above the fold
|
||||||
|
- Small, fast queries where suspense overhead isn't worth it
|
||||||
|
- When you want to avoid layout shift (loading → content jump)
|
||||||
|
|
||||||
|
**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
title: Prefer Statically Analyzable Paths
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: avoids accidental broad bundles and file traces
|
||||||
|
tags: bundle, nextjs, vite, webpack, rollup, esbuild, path
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prefer Statically Analyzable Paths
|
||||||
|
|
||||||
|
Build tools work best when import and file-system paths are obvious at build time. If you hide the real path inside a variable or compose it too dynamically, the tool either has to include a broad set of possible files, warn that it cannot analyze the import, or widen file tracing to stay safe.
|
||||||
|
|
||||||
|
Prefer explicit maps or literal paths so the set of reachable files stays narrow and predictable. This is the same rule whether you are choosing modules with `import()` or reading files in server/build code.
|
||||||
|
|
||||||
|
When analysis becomes too broad, the cost is real:
|
||||||
|
- Larger server bundles
|
||||||
|
- Slower builds
|
||||||
|
- Worse cold starts
|
||||||
|
- More memory use
|
||||||
|
|
||||||
|
### Import Paths
|
||||||
|
|
||||||
|
**Incorrect (the bundler cannot tell what may be imported):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const PAGE_MODULES = {
|
||||||
|
home: './pages/home',
|
||||||
|
settings: './pages/settings',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const Page = await import(PAGE_MODULES[pageName])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (use an explicit map of allowed modules):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const PAGE_MODULES = {
|
||||||
|
home: () => import('./pages/home'),
|
||||||
|
settings: () => import('./pages/settings'),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const Page = await PAGE_MODULES[pageName]()
|
||||||
|
```
|
||||||
|
|
||||||
|
### File-System Paths
|
||||||
|
|
||||||
|
**Incorrect (a 2-value enum still hides the final path from static analysis):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const baseDir = path.join(process.cwd(), 'content/' + contentKind)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (make each final path literal at the callsite):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const baseDir =
|
||||||
|
kind === ContentKind.Blog
|
||||||
|
? path.join(process.cwd(), 'content/blog')
|
||||||
|
: path.join(process.cwd(), 'content/docs')
|
||||||
|
```
|
||||||
|
|
||||||
|
In Next.js server code, this matters for output file tracing too. `path.join(process.cwd(), someVar)` can widen the traced file set because Next.js statically analyze `import`, `require`, and `fs` usage.
|
||||||
|
|
||||||
|
Reference: [Next.js output](https://nextjs.org/docs/app/api-reference/config/next-config-js/output), [Next.js dynamic imports](https://nextjs.org/learn/seo/dynamic-imports), [Vite features](https://vite.dev/guide/features.html), [esbuild API](https://esbuild.github.io/api/), [Rollup dynamic import vars](https://www.npmjs.com/package/@rollup/plugin-dynamic-import-vars), [Webpack dependency management](https://webpack.js.org/guides/dependency-management/)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
title: Avoid Barrel File Imports
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: 200-800ms import cost, slow builds
|
||||||
|
tags: bundle, imports, tree-shaking, barrel-files, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avoid Barrel File Imports
|
||||||
|
|
||||||
|
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'`).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Incorrect (imports entire library):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Check, X, Menu } from 'lucide-react'
|
||||||
|
// Loads 1,583 modules, takes ~2.8s extra in dev
|
||||||
|
// Runtime cost: 200-800ms on every cold start
|
||||||
|
|
||||||
|
import { Button, TextField } from '@mui/material'
|
||||||
|
// Loads 2,225 modules, takes ~4.2s extra in dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct - Next.js 13.5+ (recommended):**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js - automatically optimizes barrel imports at build time
|
||||||
|
module.exports = {
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ['lucide-react', '@mui/material']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Keep the standard imports - Next.js transforms them to direct imports
|
||||||
|
import { Check, X, Menu } from 'lucide-react'
|
||||||
|
// Full TypeScript support, no manual path wrangling
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the recommended approach because it preserves TypeScript type safety and editor autocompletion while still eliminating the barrel import cost.
|
||||||
|
|
||||||
|
**Correct - Direct imports (non-Next.js projects):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
// Loads only what you use
|
||||||
|
```
|
||||||
|
|
||||||
|
> **TypeScript warning:** Some libraries (notably `lucide-react`) don't ship `.d.ts` files for their deep import paths. Importing from `lucide-react/dist/esm/icons/check` resolves to an implicit `any` type, causing errors under `strict` or `noImplicitAny`. Prefer `optimizePackageImports` when available, or verify the library exports types for its subpaths before using direct imports.
|
||||||
|
|
||||||
|
These optimizations provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
Reference: [How we optimized package imports in Next.js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
title: Conditional Module Loading
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: loads large data only when needed
|
||||||
|
tags: bundle, conditional-loading, lazy-loading
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conditional Module Loading
|
||||||
|
|
||||||
|
Load large data or modules only when a feature is activated.
|
||||||
|
|
||||||
|
**Example (lazy-load animation frames):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) {
|
||||||
|
const [frames, setFrames] = useState<Frame[] | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled && !frames && typeof window !== 'undefined') {
|
||||||
|
import('./animation-frames.js')
|
||||||
|
.then(mod => setFrames(mod.frames))
|
||||||
|
.catch(() => setEnabled(false))
|
||||||
|
}
|
||||||
|
}, [enabled, frames, setEnabled])
|
||||||
|
|
||||||
|
if (!frames) return <Skeleton />
|
||||||
|
return <Canvas frames={frames} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
title: Defer Non-Critical Third-Party Libraries
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: loads after hydration
|
||||||
|
tags: bundle, third-party, analytics, defer
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defer Non-Critical Third-Party Libraries
|
||||||
|
|
||||||
|
Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
|
||||||
|
|
||||||
|
**Incorrect (blocks initial bundle):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Analytics } from '@vercel/analytics/react'
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
<Analytics />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (loads after hydration):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
|
const Analytics = dynamic(
|
||||||
|
() => import('@vercel/analytics/react').then(m => m.Analytics),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
<Analytics />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: Dynamic Imports for Heavy Components
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: directly affects TTI and LCP
|
||||||
|
tags: bundle, dynamic-import, code-splitting, next-dynamic
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dynamic Imports for Heavy Components
|
||||||
|
|
||||||
|
Use `next/dynamic` to lazy-load large components not needed on initial render.
|
||||||
|
|
||||||
|
**Incorrect (Monaco bundles with main chunk ~300KB):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { MonacoEditor } from './monaco-editor'
|
||||||
|
|
||||||
|
function CodePanel({ code }: { code: string }) {
|
||||||
|
return <MonacoEditor value={code} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (Monaco loads on demand):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
|
const MonacoEditor = dynamic(
|
||||||
|
() => import('./monaco-editor').then(m => m.MonacoEditor),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
function CodePanel({ code }: { code: string }) {
|
||||||
|
return <MonacoEditor value={code} />
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: Preload Based on User Intent
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: reduces perceived latency
|
||||||
|
tags: bundle, preload, user-intent, hover
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preload Based on User Intent
|
||||||
|
|
||||||
|
Preload heavy bundles before they're needed to reduce perceived latency.
|
||||||
|
|
||||||
|
**Example (preload on hover/focus):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function EditorButton({ onClick }: { onClick: () => void }) {
|
||||||
|
const preload = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
void import('./monaco-editor')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onMouseEnter={preload}
|
||||||
|
onFocus={preload}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
Open Editor
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (preload when feature flag is enabled):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function FlagsProvider({ children, flags }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (flags.editorEnabled && typeof window !== 'undefined') {
|
||||||
|
void import('./monaco-editor').then(mod => mod.init())
|
||||||
|
}
|
||||||
|
}, [flags.editorEnabled])
|
||||||
|
|
||||||
|
return <FlagsContext.Provider value={flags}>
|
||||||
|
{children}
|
||||||
|
</FlagsContext.Provider>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: Deduplicate Global Event Listeners
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: single listener for N components
|
||||||
|
tags: client, swr, event-listeners, subscription
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deduplicate Global Event Listeners
|
||||||
|
|
||||||
|
Use `useSWRSubscription()` to share global event listeners across component instances.
|
||||||
|
|
||||||
|
**Incorrect (N instances = N listeners):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useKeyboardShortcut(key: string, callback: () => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.metaKey && e.key === key) {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [key, callback])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener.
|
||||||
|
|
||||||
|
**Correct (N instances = 1 listener):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import useSWRSubscription from 'swr/subscription'
|
||||||
|
|
||||||
|
// Module-level Map to track callbacks per key
|
||||||
|
const keyCallbacks = new Map<string, Set<() => void>>()
|
||||||
|
|
||||||
|
function useKeyboardShortcut(key: string, callback: () => void) {
|
||||||
|
// Register this callback in the Map
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyCallbacks.has(key)) {
|
||||||
|
keyCallbacks.set(key, new Set())
|
||||||
|
}
|
||||||
|
keyCallbacks.get(key)!.add(callback)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const set = keyCallbacks.get(key)
|
||||||
|
if (set) {
|
||||||
|
set.delete(callback)
|
||||||
|
if (set.size === 0) {
|
||||||
|
keyCallbacks.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [key, callback])
|
||||||
|
|
||||||
|
useSWRSubscription('global-keydown', () => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.metaKey && keyCallbacks.has(e.key)) {
|
||||||
|
keyCallbacks.get(e.key)!.forEach(cb => cb())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function Profile() {
|
||||||
|
// Multiple shortcuts will share the same listener
|
||||||
|
useKeyboardShortcut('p', () => { /* ... */ })
|
||||||
|
useKeyboardShortcut('k', () => { /* ... */ })
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
title: Version and Minimize localStorage Data
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: prevents schema conflicts, reduces storage size
|
||||||
|
tags: client, localStorage, storage, versioning, data-minimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version and Minimize localStorage Data
|
||||||
|
|
||||||
|
Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// No version, stores everything, no error handling
|
||||||
|
localStorage.setItem('userConfig', JSON.stringify(fullUserObject))
|
||||||
|
const data = localStorage.getItem('userConfig')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const VERSION = 'v2'
|
||||||
|
|
||||||
|
function saveConfig(config: { theme: string; language: string }) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))
|
||||||
|
} catch {
|
||||||
|
// Throws in incognito/private browsing, quota exceeded, or disabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(`userConfig:${VERSION}`)
|
||||||
|
return data ? JSON.parse(data) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration from v1 to v2
|
||||||
|
function migrate() {
|
||||||
|
try {
|
||||||
|
const v1 = localStorage.getItem('userConfig:v1')
|
||||||
|
if (v1) {
|
||||||
|
const old = JSON.parse(v1)
|
||||||
|
saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang })
|
||||||
|
localStorage.removeItem('userConfig:v1')
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Store minimal fields from server responses:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// User object has 20+ fields, only store what UI needs
|
||||||
|
function cachePrefs(user: FullUser) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('prefs:v1', JSON.stringify({
|
||||||
|
theme: user.preferences.theme,
|
||||||
|
notifications: user.preferences.notifications
|
||||||
|
}))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled.
|
||||||
|
|
||||||
|
**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
title: Use Passive Event Listeners for Scrolling Performance
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: eliminates scroll delay caused by event listeners
|
||||||
|
tags: client, event-listeners, scrolling, performance, touch, wheel
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Passive Event Listeners for Scrolling Performance
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
|
||||||
|
const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', handleTouch)
|
||||||
|
document.addEventListener('wheel', handleWheel)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('touchstart', handleTouch)
|
||||||
|
document.removeEventListener('wheel', handleWheel)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
|
||||||
|
const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', handleTouch, { passive: true })
|
||||||
|
document.addEventListener('wheel', handleWheel, { passive: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('touchstart', handleTouch)
|
||||||
|
document.removeEventListener('wheel', handleWheel)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.
|
||||||
|
|
||||||
|
**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
title: Use SWR for Automatic Deduplication
|
||||||
|
impact: MEDIUM-HIGH
|
||||||
|
impactDescription: automatic deduplication
|
||||||
|
tags: client, swr, deduplication, data-fetching
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use SWR for Automatic Deduplication
|
||||||
|
|
||||||
|
SWR enables request deduplication, caching, and revalidation across component instances.
|
||||||
|
|
||||||
|
**Incorrect (no deduplication, each instance fetches):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function UserList() {
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/users')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setUsers)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (multiple instances share one request):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import useSWR from 'swr'
|
||||||
|
|
||||||
|
function UserList() {
|
||||||
|
const { data: users } = useSWR('/api/users', fetcher)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**For immutable data:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useImmutableSWR } from '@/lib/swr'
|
||||||
|
|
||||||
|
function StaticContent() {
|
||||||
|
const { data } = useImmutableSWR('/api/config', fetcher)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**For mutations:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useSWRMutation } from 'swr/mutation'
|
||||||
|
|
||||||
|
function UpdateButton() {
|
||||||
|
const { trigger } = useSWRMutation('/api/user', updateUser)
|
||||||
|
return <button onClick={() => trigger()}>Update</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [https://swr.vercel.app](https://swr.vercel.app)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
title: Avoid Layout Thrashing
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: prevents forced synchronous layouts and reduces performance bottlenecks
|
||||||
|
tags: javascript, dom, css, performance, reflow, layout-thrashing
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avoid Layout Thrashing
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**This is OK (browser batches style changes):**
|
||||||
|
```typescript
|
||||||
|
function updateElementStyles(element: HTMLElement) {
|
||||||
|
// Each line invalidates style, but browser batches the recalculation
|
||||||
|
element.style.width = '100px'
|
||||||
|
element.style.height = '200px'
|
||||||
|
element.style.backgroundColor = 'blue'
|
||||||
|
element.style.border = '1px solid black'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incorrect (interleaved reads and writes force reflows):**
|
||||||
|
```typescript
|
||||||
|
function layoutThrashing(element: HTMLElement) {
|
||||||
|
element.style.width = '100px'
|
||||||
|
const width = element.offsetWidth // Forces reflow
|
||||||
|
element.style.height = '200px'
|
||||||
|
const height = element.offsetHeight // Forces another reflow
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (batch writes, then read once):**
|
||||||
|
```typescript
|
||||||
|
function updateElementStyles(element: HTMLElement) {
|
||||||
|
// Batch all writes together
|
||||||
|
element.style.width = '100px'
|
||||||
|
element.style.height = '200px'
|
||||||
|
element.style.backgroundColor = 'blue'
|
||||||
|
element.style.border = '1px solid black'
|
||||||
|
|
||||||
|
// Read after all writes are done (single reflow)
|
||||||
|
const { width, height } = element.getBoundingClientRect()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (batch reads, then writes):**
|
||||||
|
```typescript
|
||||||
|
function avoidThrashing(element: HTMLElement) {
|
||||||
|
// Read phase - all layout queries first
|
||||||
|
const rect1 = element.getBoundingClientRect()
|
||||||
|
const offsetWidth = element.offsetWidth
|
||||||
|
const offsetHeight = element.offsetHeight
|
||||||
|
|
||||||
|
// Write phase - all style changes after
|
||||||
|
element.style.width = '100px'
|
||||||
|
element.style.height = '200px'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better: use CSS classes**
|
||||||
|
```css
|
||||||
|
.highlighted-box {
|
||||||
|
width: 100px;
|
||||||
|
height: 200px;
|
||||||
|
background-color: blue;
|
||||||
|
border: 1px solid black;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```typescript
|
||||||
|
function updateElementStyles(element: HTMLElement) {
|
||||||
|
element.classList.add('highlighted-box')
|
||||||
|
|
||||||
|
const { width, height } = element.getBoundingClientRect()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**React example:**
|
||||||
|
```tsx
|
||||||
|
// Incorrect: interleaving style changes with layout queries
|
||||||
|
function Box({ isHighlighted }: { isHighlighted: boolean }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current && isHighlighted) {
|
||||||
|
ref.current.style.width = '100px'
|
||||||
|
const width = ref.current.offsetWidth // Forces layout
|
||||||
|
ref.current.style.height = '200px'
|
||||||
|
}
|
||||||
|
}, [isHighlighted])
|
||||||
|
|
||||||
|
return <div ref={ref}>Content</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct: toggle class
|
||||||
|
function Box({ isHighlighted }: { isHighlighted: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={isHighlighted ? 'highlighted-box' : ''}>
|
||||||
|
Content
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
title: Cache Repeated Function Calls
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoid redundant computation
|
||||||
|
tags: javascript, cache, memoization, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cache Repeated Function Calls
|
||||||
|
|
||||||
|
Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.
|
||||||
|
|
||||||
|
**Incorrect (redundant computation):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function ProjectList({ projects }: { projects: Project[] }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{projects.map(project => {
|
||||||
|
// slugify() called 100+ times for same project names
|
||||||
|
const slug = slugify(project.name)
|
||||||
|
|
||||||
|
return <ProjectCard key={project.id} slug={slug} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (cached results):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Module-level cache
|
||||||
|
const slugifyCache = new Map<string, string>()
|
||||||
|
|
||||||
|
function cachedSlugify(text: string): string {
|
||||||
|
if (slugifyCache.has(text)) {
|
||||||
|
return slugifyCache.get(text)!
|
||||||
|
}
|
||||||
|
const result = slugify(text)
|
||||||
|
slugifyCache.set(text, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectList({ projects }: { projects: Project[] }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{projects.map(project => {
|
||||||
|
// Computed only once per unique project name
|
||||||
|
const slug = cachedSlugify(project.name)
|
||||||
|
|
||||||
|
return <ProjectCard key={project.id} slug={slug} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Simpler pattern for single-value functions:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let isLoggedInCache: boolean | null = null
|
||||||
|
|
||||||
|
function isLoggedIn(): boolean {
|
||||||
|
if (isLoggedInCache !== null) {
|
||||||
|
return isLoggedInCache
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoggedInCache = document.cookie.includes('auth=')
|
||||||
|
return isLoggedInCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache when auth changes
|
||||||
|
function onAuthChange() {
|
||||||
|
isLoggedInCache = null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
|
||||||
|
|
||||||
|
Reference: [How we made the Vercel Dashboard twice as fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Cache Property Access in Loops
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: reduces lookups
|
||||||
|
tags: javascript, loops, optimization, caching
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cache Property Access in Loops
|
||||||
|
|
||||||
|
Cache object property lookups in hot paths.
|
||||||
|
|
||||||
|
**Incorrect (3 lookups × N iterations):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
process(obj.config.settings.value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (1 lookup total):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const value = obj.config.settings.value
|
||||||
|
const len = arr.length
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
process(value)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
title: Cache Storage API Calls
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: reduces expensive I/O
|
||||||
|
tags: javascript, localStorage, storage, caching, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cache Storage API Calls
|
||||||
|
|
||||||
|
`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.
|
||||||
|
|
||||||
|
**Incorrect (reads storage on every call):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getTheme() {
|
||||||
|
return localStorage.getItem('theme') ?? 'light'
|
||||||
|
}
|
||||||
|
// Called 10 times = 10 storage reads
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (Map cache):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const storageCache = new Map<string, string | null>()
|
||||||
|
|
||||||
|
function getLocalStorage(key: string) {
|
||||||
|
if (!storageCache.has(key)) {
|
||||||
|
storageCache.set(key, localStorage.getItem(key))
|
||||||
|
}
|
||||||
|
return storageCache.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLocalStorage(key: string, value: string) {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
storageCache.set(key, value) // keep cache in sync
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
|
||||||
|
|
||||||
|
**Cookie caching:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let cookieCache: Record<string, string> | null = null
|
||||||
|
|
||||||
|
function getCookie(name: string) {
|
||||||
|
if (!cookieCache) {
|
||||||
|
cookieCache = Object.fromEntries(
|
||||||
|
document.cookie.split('; ').map(c => c.split('='))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cookieCache[name]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important (invalidate on external changes):**
|
||||||
|
|
||||||
|
If storage can change externally (another tab, server-set cookies), invalidate cache:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
window.addEventListener('storage', (e) => {
|
||||||
|
if (e.key) storageCache.delete(e.key)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
storageCache.clear()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
title: Combine Multiple Array Iterations
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: reduces iterations
|
||||||
|
tags: javascript, arrays, loops, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Combine Multiple Array Iterations
|
||||||
|
|
||||||
|
Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.
|
||||||
|
|
||||||
|
**Incorrect (3 iterations):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const admins = users.filter(u => u.isAdmin)
|
||||||
|
const testers = users.filter(u => u.isTester)
|
||||||
|
const inactive = users.filter(u => !u.isActive)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (1 iteration):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const admins: User[] = []
|
||||||
|
const testers: User[] = []
|
||||||
|
const inactive: User[] = []
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
if (user.isAdmin) admins.push(user)
|
||||||
|
if (user.isTester) testers.push(user)
|
||||||
|
if (!user.isActive) inactive.push(user)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: Early Return from Functions
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: avoids unnecessary computation
|
||||||
|
tags: javascript, functions, optimization, early-return
|
||||||
|
---
|
||||||
|
|
||||||
|
## Early Return from Functions
|
||||||
|
|
||||||
|
Return early when result is determined to skip unnecessary processing.
|
||||||
|
|
||||||
|
**Incorrect (processes all items even after finding answer):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function validateUsers(users: User[]) {
|
||||||
|
let hasError = false
|
||||||
|
let errorMessage = ''
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
if (!user.email) {
|
||||||
|
hasError = true
|
||||||
|
errorMessage = 'Email required'
|
||||||
|
}
|
||||||
|
if (!user.name) {
|
||||||
|
hasError = true
|
||||||
|
errorMessage = 'Name required'
|
||||||
|
}
|
||||||
|
// Continues checking all users even after error found
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasError ? { valid: false, error: errorMessage } : { valid: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (returns immediately on first error):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function validateUsers(users: User[]) {
|
||||||
|
for (const user of users) {
|
||||||
|
if (!user.email) {
|
||||||
|
return { valid: false, error: 'Email required' }
|
||||||
|
}
|
||||||
|
if (!user.name) {
|
||||||
|
return { valid: false, error: 'Name required' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
title: Use flatMap to Map and Filter in One Pass
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: eliminates intermediate array
|
||||||
|
tags: javascript, arrays, flatMap, filter, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use flatMap to Map and Filter in One Pass
|
||||||
|
|
||||||
|
**Impact: LOW-MEDIUM (eliminates intermediate array)**
|
||||||
|
|
||||||
|
Chaining `.map().filter(Boolean)` creates an intermediate array and iterates twice. Use `.flatMap()` to transform and filter in a single pass.
|
||||||
|
|
||||||
|
**Incorrect (2 iterations, intermediate array):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const userNames = users
|
||||||
|
.map(user => user.isActive ? user.name : null)
|
||||||
|
.filter(Boolean)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (1 iteration, no intermediate array):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const userNames = users.flatMap(user =>
|
||||||
|
user.isActive ? [user.name] : []
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**More examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Extract valid emails from responses
|
||||||
|
// Before
|
||||||
|
const emails = responses
|
||||||
|
.map(r => r.success ? r.data.email : null)
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
// After
|
||||||
|
const emails = responses.flatMap(r =>
|
||||||
|
r.success ? [r.data.email] : []
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse and filter valid numbers
|
||||||
|
// Before
|
||||||
|
const numbers = strings
|
||||||
|
.map(s => parseInt(s, 10))
|
||||||
|
.filter(n => !isNaN(n))
|
||||||
|
|
||||||
|
// After
|
||||||
|
const numbers = strings.flatMap(s => {
|
||||||
|
const n = parseInt(s, 10)
|
||||||
|
return isNaN(n) ? [] : [n]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- Transforming items while filtering some out
|
||||||
|
- Conditional mapping where some inputs produce no output
|
||||||
|
- Parsing/validating where invalid inputs should be skipped
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Hoist RegExp Creation
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: avoids recreation
|
||||||
|
tags: javascript, regexp, optimization, memoization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hoist RegExp Creation
|
||||||
|
|
||||||
|
Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.
|
||||||
|
|
||||||
|
**Incorrect (new RegExp every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Highlighter({ text, query }: Props) {
|
||||||
|
const regex = new RegExp(`(${query})`, 'gi')
|
||||||
|
const parts = text.split(regex)
|
||||||
|
return <>{parts.map((part, i) => ...)}</>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (memoize or hoist):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
function Highlighter({ text, query }: Props) {
|
||||||
|
const regex = useMemo(
|
||||||
|
() => new RegExp(`(${escapeRegex(query)})`, 'gi'),
|
||||||
|
[query]
|
||||||
|
)
|
||||||
|
const parts = text.split(regex)
|
||||||
|
return <>{parts.map((part, i) => ...)}</>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning (global regex has mutable state):**
|
||||||
|
|
||||||
|
Global regex (`/g`) has mutable `lastIndex` state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const regex = /foo/g
|
||||||
|
regex.test('foo') // true, lastIndex = 3
|
||||||
|
regex.test('foo') // false, lastIndex = 0
|
||||||
|
```
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Build Index Maps for Repeated Lookups
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: 1M ops to 2K ops
|
||||||
|
tags: javascript, map, indexing, optimization, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Index Maps for Repeated Lookups
|
||||||
|
|
||||||
|
Multiple `.find()` calls by the same key should use a Map.
|
||||||
|
|
||||||
|
**Incorrect (O(n) per lookup):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function processOrders(orders: Order[], users: User[]) {
|
||||||
|
return orders.map(order => ({
|
||||||
|
...order,
|
||||||
|
user: users.find(u => u.id === order.userId)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (O(1) per lookup):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function processOrders(orders: Order[], users: User[]) {
|
||||||
|
const userById = new Map(users.map(u => [u.id, u]))
|
||||||
|
|
||||||
|
return orders.map(order => ({
|
||||||
|
...order,
|
||||||
|
user: userById.get(order.userId)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Build map once (O(n)), then all lookups are O(1).
|
||||||
|
For 1000 orders × 1000 users: 1M ops → 2K ops.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
title: Early Length Check for Array Comparisons
|
||||||
|
impact: MEDIUM-HIGH
|
||||||
|
impactDescription: avoids expensive operations when lengths differ
|
||||||
|
tags: javascript, arrays, performance, optimization, comparison
|
||||||
|
---
|
||||||
|
|
||||||
|
## Early Length Check for Array Comparisons
|
||||||
|
|
||||||
|
When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
|
||||||
|
|
||||||
|
In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
|
||||||
|
|
||||||
|
**Incorrect (always runs expensive comparison):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function hasChanges(current: string[], original: string[]) {
|
||||||
|
// Always sorts and joins, even when lengths differ
|
||||||
|
return current.sort().join() !== original.sort().join()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Correct (O(1) length check first):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function hasChanges(current: string[], original: string[]) {
|
||||||
|
// Early return if lengths differ
|
||||||
|
if (current.length !== original.length) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Only sort when lengths match
|
||||||
|
const currentSorted = current.toSorted()
|
||||||
|
const originalSorted = original.toSorted()
|
||||||
|
for (let i = 0; i < currentSorted.length; i++) {
|
||||||
|
if (currentSorted[i] !== originalSorted[i]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This new approach is more efficient because:
|
||||||
|
- It avoids the overhead of sorting and joining the arrays when lengths differ
|
||||||
|
- It avoids consuming memory for the joined strings (especially important for large arrays)
|
||||||
|
- It avoids mutating the original arrays
|
||||||
|
- It returns early when a difference is found
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: Use Loop for Min/Max Instead of Sort
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: O(n) instead of O(n log n)
|
||||||
|
tags: javascript, arrays, performance, sorting, algorithms
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Loop for Min/Max Instead of Sort
|
||||||
|
|
||||||
|
Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.
|
||||||
|
|
||||||
|
**Incorrect (O(n log n) - sort to find latest):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Project {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestProject(projects: Project[]) {
|
||||||
|
const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
return sorted[0]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sorts the entire array just to find the maximum value.
|
||||||
|
|
||||||
|
**Incorrect (O(n log n) - sort for oldest and newest):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getOldestAndNewest(projects: Project[]) {
|
||||||
|
const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
|
||||||
|
return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Still sorts unnecessarily when only min/max are needed.
|
||||||
|
|
||||||
|
**Correct (O(n) - single loop):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getLatestProject(projects: Project[]) {
|
||||||
|
if (projects.length === 0) return null
|
||||||
|
|
||||||
|
let latest = projects[0]
|
||||||
|
|
||||||
|
for (let i = 1; i < projects.length; i++) {
|
||||||
|
if (projects[i].updatedAt > latest.updatedAt) {
|
||||||
|
latest = projects[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOldestAndNewest(projects: Project[]) {
|
||||||
|
if (projects.length === 0) return { oldest: null, newest: null }
|
||||||
|
|
||||||
|
let oldest = projects[0]
|
||||||
|
let newest = projects[0]
|
||||||
|
|
||||||
|
for (let i = 1; i < projects.length; i++) {
|
||||||
|
if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
|
||||||
|
if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return { oldest, newest }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Single pass through the array, no copying, no sorting.
|
||||||
|
|
||||||
|
**Alternative (Math.min/Math.max for small arrays):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const numbers = [5, 2, 8, 1, 9]
|
||||||
|
const min = Math.min(...numbers)
|
||||||
|
const max = Math.max(...numbers)
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
title: Defer Non-Critical Work with requestIdleCallback
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: keeps UI responsive during background tasks
|
||||||
|
tags: javascript, performance, idle, scheduling, analytics
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defer Non-Critical Work with requestIdleCallback
|
||||||
|
|
||||||
|
**Impact: MEDIUM (keeps UI responsive during background tasks)**
|
||||||
|
|
||||||
|
Use `requestIdleCallback()` to schedule non-critical work during browser idle periods. This keeps the main thread free for user interactions and animations, reducing jank and improving perceived performance.
|
||||||
|
|
||||||
|
**Incorrect (blocks main thread during user interaction):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleSearch(query: string) {
|
||||||
|
const results = searchItems(query)
|
||||||
|
setResults(results)
|
||||||
|
|
||||||
|
// These block the main thread immediately
|
||||||
|
analytics.track('search', { query })
|
||||||
|
saveToRecentSearches(query)
|
||||||
|
prefetchTopResults(results.slice(0, 3))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (defers non-critical work to idle time):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleSearch(query: string) {
|
||||||
|
const results = searchItems(query)
|
||||||
|
setResults(results)
|
||||||
|
|
||||||
|
// Defer non-critical work to idle periods
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
analytics.track('search', { query })
|
||||||
|
})
|
||||||
|
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
saveToRecentSearches(query)
|
||||||
|
})
|
||||||
|
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
prefetchTopResults(results.slice(0, 3))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**With timeout for required work:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Ensure analytics fires within 2 seconds even if browser stays busy
|
||||||
|
requestIdleCallback(
|
||||||
|
() => analytics.track('page_view', { path: location.pathname }),
|
||||||
|
{ timeout: 2000 }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chunking large tasks:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function processLargeDataset(items: Item[]) {
|
||||||
|
let index = 0
|
||||||
|
|
||||||
|
function processChunk(deadline: IdleDeadline) {
|
||||||
|
// Process items while we have idle time (aim for <50ms chunks)
|
||||||
|
while (index < items.length && deadline.timeRemaining() > 0) {
|
||||||
|
processItem(items[index])
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next chunk if more items remain
|
||||||
|
if (index < items.length) {
|
||||||
|
requestIdleCallback(processChunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestIdleCallback(processChunk)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**With fallback for unsupported browsers:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const scheduleIdleWork = window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1))
|
||||||
|
|
||||||
|
scheduleIdleWork(() => {
|
||||||
|
// Non-critical work
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
|
||||||
|
- Analytics and telemetry
|
||||||
|
- Saving state to localStorage/IndexedDB
|
||||||
|
- Prefetching resources for likely next actions
|
||||||
|
- Processing non-urgent data transformations
|
||||||
|
- Lazy initialization of non-critical features
|
||||||
|
|
||||||
|
**When NOT to use:**
|
||||||
|
|
||||||
|
- User-initiated actions that need immediate feedback
|
||||||
|
- Rendering updates the user is waiting for
|
||||||
|
- Time-sensitive operations
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
title: Use Set/Map for O(1) Lookups
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: O(n) to O(1)
|
||||||
|
tags: javascript, set, map, data-structures, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Set/Map for O(1) Lookups
|
||||||
|
|
||||||
|
Convert arrays to Set/Map for repeated membership checks.
|
||||||
|
|
||||||
|
**Incorrect (O(n) per check):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const allowedIds = ['a', 'b', 'c', ...]
|
||||||
|
items.filter(item => allowedIds.includes(item.id))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (O(1) per check):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const allowedIds = new Set(['a', 'b', 'c', ...])
|
||||||
|
items.filter(item => allowedIds.has(item.id))
|
||||||
|
```
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
title: Use toSorted() Instead of sort() for Immutability
|
||||||
|
impact: MEDIUM-HIGH
|
||||||
|
impactDescription: prevents mutation bugs in React state
|
||||||
|
tags: javascript, arrays, immutability, react, state, mutation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use toSorted() Instead of sort() for Immutability
|
||||||
|
|
||||||
|
`.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.
|
||||||
|
|
||||||
|
**Incorrect (mutates original array):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function UserList({ users }: { users: User[] }) {
|
||||||
|
// Mutates the users prop array!
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => users.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[users]
|
||||||
|
)
|
||||||
|
return <div>{sorted.map(renderUser)}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (creates new array):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function UserList({ users }: { users: User[] }) {
|
||||||
|
// Creates new sorted array, original unchanged
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => users.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[users]
|
||||||
|
)
|
||||||
|
return <div>{sorted.map(renderUser)}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this matters in React:**
|
||||||
|
|
||||||
|
1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only
|
||||||
|
2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
|
||||||
|
|
||||||
|
**Browser support (fallback for older browsers):**
|
||||||
|
|
||||||
|
`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Fallback for older browsers
|
||||||
|
const sorted = [...items].sort((a, b) => a.value - b.value)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Other immutable array methods:**
|
||||||
|
|
||||||
|
- `.toSorted()` - immutable sort
|
||||||
|
- `.toReversed()` - immutable reverse
|
||||||
|
- `.toSpliced()` - immutable splice
|
||||||
|
- `.with()` - immutable element replacement
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
title: Use Activity Component for Show/Hide
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: preserves state/DOM
|
||||||
|
tags: rendering, activity, visibility, state-preservation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Activity Component for Show/Hide
|
||||||
|
|
||||||
|
Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Activity } from 'react'
|
||||||
|
|
||||||
|
function Dropdown({ isOpen }: Props) {
|
||||||
|
return (
|
||||||
|
<Activity mode={isOpen ? 'visible' : 'hidden'}>
|
||||||
|
<ExpensiveMenu />
|
||||||
|
</Activity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoids expensive re-renders and state loss.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
title: Animate SVG Wrapper Instead of SVG Element
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: enables hardware acceleration
|
||||||
|
tags: rendering, svg, css, animation, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animate SVG Wrapper Instead of SVG Element
|
||||||
|
|
||||||
|
Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.
|
||||||
|
|
||||||
|
**Incorrect (animating SVG directly - no hardware acceleration):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="animate-spin"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (animating wrapper div - hardware accelerated):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="animate-spin">
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: Use Explicit Conditional Rendering
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: prevents rendering 0 or NaN
|
||||||
|
tags: rendering, conditional, jsx, falsy-values
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Explicit Conditional Rendering
|
||||||
|
|
||||||
|
Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
|
||||||
|
|
||||||
|
**Incorrect (renders "0" when count is 0):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Badge({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{count && <span className="badge">{count}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When count = 0, renders: <div>0</div>
|
||||||
|
// When count = 5, renders: <div><span class="badge">5</span></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (renders nothing when count is 0):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Badge({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{count > 0 ? <span className="badge">{count}</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When count = 0, renders: <div></div>
|
||||||
|
// When count = 5, renders: <div><span class="badge">5</span></div>
|
||||||
|
```
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: CSS content-visibility for Long Lists
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: faster initial render
|
||||||
|
tags: rendering, css, content-visibility, long-lists
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS content-visibility for Long Lists
|
||||||
|
|
||||||
|
Apply `content-visibility: auto` to defer off-screen rendering.
|
||||||
|
|
||||||
|
**CSS:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.message-item {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 0 80px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function MessageList({ messages }: { messages: Message[] }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto h-screen">
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} className="message-item">
|
||||||
|
<Avatar user={msg.author} />
|
||||||
|
<div>{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: Hoist Static JSX Elements
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: avoids re-creation
|
||||||
|
tags: rendering, jsx, static, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hoist Static JSX Elements
|
||||||
|
|
||||||
|
Extract static JSX outside components to avoid re-creation.
|
||||||
|
|
||||||
|
**Incorrect (recreates element every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return <div className="animate-pulse h-20 bg-gray-200" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function Container() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{loading && <LoadingSkeleton />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (reuses same element):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const loadingSkeleton = (
|
||||||
|
<div className="animate-pulse h-20 bg-gray-200" />
|
||||||
|
)
|
||||||
|
|
||||||
|
function Container() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{loading && loadingSkeleton}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: Prevent Hydration Mismatch Without Flickering
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids visual flicker and hydration errors
|
||||||
|
tags: rendering, ssr, hydration, localStorage, flicker
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prevent Hydration Mismatch Without Flickering
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect (breaks SSR):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||||
|
// localStorage is not available on server - throws error
|
||||||
|
const theme = localStorage.getItem('theme') || 'light'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={theme}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Server-side rendering will fail because `localStorage` is undefined.
|
||||||
|
|
||||||
|
**Incorrect (visual flickering):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState('light')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Runs after hydration - causes visible flash
|
||||||
|
const stored = localStorage.getItem('theme')
|
||||||
|
if (stored) {
|
||||||
|
setTheme(stored)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={theme}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
|
||||||
|
|
||||||
|
**Correct (no flicker, no hydration mismatch):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div id="theme-wrapper">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var theme = localStorage.getItem('theme') || 'light';
|
||||||
|
var el = document.getElementById('theme-wrapper');
|
||||||
|
if (el) el.className = theme;
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
title: Suppress Expected Hydration Mismatches
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: avoids noisy hydration warnings for known differences
|
||||||
|
tags: rendering, hydration, ssr, nextjs
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suppress Expected Hydration Mismatches
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect (known mismatch warnings):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Timestamp() {
|
||||||
|
return <span>{new Date().toLocaleString()}</span>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (suppress expected mismatch only):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Timestamp() {
|
||||||
|
return (
|
||||||
|
<span suppressHydrationWarning>
|
||||||
|
{new Date().toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
title: Use React DOM Resource Hints
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: reduces load time for critical resources
|
||||||
|
tags: rendering, preload, preconnect, prefetch, resource-hints
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use React DOM Resource Hints
|
||||||
|
|
||||||
|
**Impact: HIGH (reduces load time for critical resources)**
|
||||||
|
|
||||||
|
React DOM provides APIs to hint the browser about resources it will need. These are especially useful in server components to start loading resources before the client even receives the HTML.
|
||||||
|
|
||||||
|
- **`prefetchDNS(href)`**: Resolve DNS for a domain you expect to connect to
|
||||||
|
- **`preconnect(href)`**: Establish connection (DNS + TCP + TLS) to a server
|
||||||
|
- **`preload(href, options)`**: Fetch a resource (stylesheet, font, script, image) you'll use soon
|
||||||
|
- **`preloadModule(href)`**: Fetch an ES module you'll use soon
|
||||||
|
- **`preinit(href, options)`**: Fetch and evaluate a stylesheet or script
|
||||||
|
- **`preinitModule(href)`**: Fetch and evaluate an ES module
|
||||||
|
|
||||||
|
**Example (preconnect to third-party APIs):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { preconnect, prefetchDNS } from 'react-dom'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
prefetchDNS('https://analytics.example.com')
|
||||||
|
preconnect('https://api.example.com')
|
||||||
|
|
||||||
|
return <main>{/* content */}</main>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (preload critical fonts and styles):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { preload, preinit } from 'react-dom'
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
// Preload font file
|
||||||
|
preload('/fonts/inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' })
|
||||||
|
|
||||||
|
// Fetch and apply critical stylesheet immediately
|
||||||
|
preinit('/styles/critical.css', { as: 'style' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (preload modules for code-split routes):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { preloadModule, preinitModule } from 'react-dom'
|
||||||
|
|
||||||
|
function Navigation() {
|
||||||
|
const preloadDashboard = () => {
|
||||||
|
preloadModule('/dashboard.js', { as: 'script' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
<a href="/dashboard" onMouseEnter={preloadDashboard}>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use each:**
|
||||||
|
|
||||||
|
| API | Use case |
|
||||||
|
|-----|----------|
|
||||||
|
| `prefetchDNS` | Third-party domains you'll connect to later |
|
||||||
|
| `preconnect` | APIs or CDNs you'll fetch from immediately |
|
||||||
|
| `preload` | Critical resources needed for current page |
|
||||||
|
| `preloadModule` | JS modules for likely next navigation |
|
||||||
|
| `preinit` | Stylesheets/scripts that must execute early |
|
||||||
|
| `preinitModule` | ES modules that must execute early |
|
||||||
|
|
||||||
|
Reference: [React DOM Resource Preloading APIs](https://react.dev/reference/react-dom#resource-preloading-apis)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
title: Use defer or async on Script Tags
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: eliminates render-blocking
|
||||||
|
tags: rendering, script, defer, async, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use defer or async on Script Tags
|
||||||
|
|
||||||
|
**Impact: HIGH (eliminates render-blocking)**
|
||||||
|
|
||||||
|
Script tags without `defer` or `async` block HTML parsing while the script downloads and executes. This delays First Contentful Paint and Time to Interactive.
|
||||||
|
|
||||||
|
- **`defer`**: Downloads in parallel, executes after HTML parsing completes, maintains execution order
|
||||||
|
- **`async`**: Downloads in parallel, executes immediately when ready, no guaranteed order
|
||||||
|
|
||||||
|
Use `defer` for scripts that depend on DOM or other scripts. Use `async` for independent scripts like analytics.
|
||||||
|
|
||||||
|
**Incorrect (blocks rendering):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function Document() {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script src="https://example.com/analytics.js" />
|
||||||
|
<script src="/scripts/utils.js" />
|
||||||
|
</head>
|
||||||
|
<body>{/* content */}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (non-blocking):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function Document() {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
{/* Independent script - use async */}
|
||||||
|
<script src="https://example.com/analytics.js" async />
|
||||||
|
{/* DOM-dependent script - use defer */}
|
||||||
|
<script src="/scripts/utils.js" defer />
|
||||||
|
</head>
|
||||||
|
<body>{/* content */}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** In Next.js, prefer the `next/script` component with `strategy` prop instead of raw script tags:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Script from 'next/script'
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Script src="https://example.com/analytics.js" strategy="afterInteractive" />
|
||||||
|
<Script src="/scripts/utils.js" strategy="beforeInteractive" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [MDN - Script element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Optimize SVG Precision
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: reduces file size
|
||||||
|
tags: rendering, svg, optimization, svgo
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimize SVG Precision
|
||||||
|
|
||||||
|
Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
|
||||||
|
|
||||||
|
**Incorrect (excessive precision):**
|
||||||
|
|
||||||
|
```svg
|
||||||
|
<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (1 decimal place):**
|
||||||
|
|
||||||
|
```svg
|
||||||
|
<path d="M 10.3 20.8 L 30.9 40.2" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automate with SVGO:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx svgo --precision=1 --multipass icon.svg
|
||||||
|
```
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
title: Use useTransition Over Manual Loading States
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: reduces re-renders and improves code clarity
|
||||||
|
tags: rendering, transitions, useTransition, loading, state
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use useTransition Over Manual Loading States
|
||||||
|
|
||||||
|
Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.
|
||||||
|
|
||||||
|
**Incorrect (manual loading state):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchResults() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSearch = async (value: string) => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setQuery(value)
|
||||||
|
const data = await fetchResults(value)
|
||||||
|
setResults(data)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input onChange={(e) => handleSearch(e.target.value)} />
|
||||||
|
{isLoading && <Spinner />}
|
||||||
|
<ResultsList results={results} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (useTransition with built-in pending state):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useTransition, useState } from 'react'
|
||||||
|
|
||||||
|
function SearchResults() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState([])
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setQuery(value) // Update input immediately
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
// Fetch and update results
|
||||||
|
const data = await fetchResults(value)
|
||||||
|
setResults(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input onChange={(e) => handleSearch(e.target.value)} />
|
||||||
|
{isPending && <Spinner />}
|
||||||
|
<ResultsList results={results} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`
|
||||||
|
- **Error resilience**: Pending state correctly resets even if the transition throws
|
||||||
|
- **Better responsiveness**: Keeps the UI responsive during updates
|
||||||
|
- **Interrupt handling**: New transitions automatically cancel pending ones
|
||||||
|
|
||||||
|
Reference: [useTransition](https://react.dev/reference/react/useTransition)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: Defer State Reads to Usage Point
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids unnecessary subscriptions
|
||||||
|
tags: rerender, searchParams, localStorage, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defer State Reads to Usage Point
|
||||||
|
|
||||||
|
Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
|
||||||
|
|
||||||
|
**Incorrect (subscribes to all searchParams changes):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ShareButton({ chatId }: { chatId: string }) {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
const ref = searchParams.get('ref')
|
||||||
|
shareChat(chatId, { ref })
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button onClick={handleShare}>Share</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (reads on demand, no subscription):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ShareButton({ chatId }: { chatId: string }) {
|
||||||
|
const handleShare = () => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const ref = params.get('ref')
|
||||||
|
shareChat(chatId, { ref })
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button onClick={handleShare}>Share</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Narrow Effect Dependencies
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: minimizes effect re-runs
|
||||||
|
tags: rerender, useEffect, dependencies, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Narrow Effect Dependencies
|
||||||
|
|
||||||
|
Specify primitive dependencies instead of objects to minimize effect re-runs.
|
||||||
|
|
||||||
|
**Incorrect (re-runs on any user field change):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(user.id)
|
||||||
|
}, [user])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (re-runs only when id changes):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(user.id)
|
||||||
|
}, [user.id])
|
||||||
|
```
|
||||||
|
|
||||||
|
**For derived state, compute outside effect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Incorrect: runs on width=767, 766, 765...
|
||||||
|
useEffect(() => {
|
||||||
|
if (width < 768) {
|
||||||
|
enableMobileMode()
|
||||||
|
}
|
||||||
|
}, [width])
|
||||||
|
|
||||||
|
// Correct: runs only on boolean transition
|
||||||
|
const isMobile = width < 768
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
enableMobileMode()
|
||||||
|
}
|
||||||
|
}, [isMobile])
|
||||||
|
```
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: Calculate Derived State During Rendering
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids redundant renders and state drift
|
||||||
|
tags: rerender, derived-state, useEffect, state
|
||||||
|
---
|
||||||
|
|
||||||
|
## Calculate Derived State During Rendering
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect (redundant state and effect):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Form() {
|
||||||
|
const [firstName, setFirstName] = useState('First')
|
||||||
|
const [lastName, setLastName] = useState('Last')
|
||||||
|
const [fullName, setFullName] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFullName(firstName + ' ' + lastName)
|
||||||
|
}, [firstName, lastName])
|
||||||
|
|
||||||
|
return <p>{fullName}</p>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (derive during render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Form() {
|
||||||
|
const [firstName, setFirstName] = useState('First')
|
||||||
|
const [lastName, setLastName] = useState('Last')
|
||||||
|
const fullName = firstName + ' ' + lastName
|
||||||
|
|
||||||
|
return <p>{fullName}</p>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
References: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: Subscribe to Derived State
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: reduces re-render frequency
|
||||||
|
tags: rerender, derived-state, media-query, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subscribe to Derived State
|
||||||
|
|
||||||
|
Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
|
||||||
|
|
||||||
|
**Incorrect (re-renders on every pixel change):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Sidebar() {
|
||||||
|
const width = useWindowWidth() // updates continuously
|
||||||
|
const isMobile = width < 768
|
||||||
|
return <nav className={isMobile ? 'mobile' : 'desktop'} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (re-renders only when boolean changes):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Sidebar() {
|
||||||
|
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||||
|
return <nav className={isMobile ? 'mobile' : 'desktop'} />
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: Use Functional setState Updates
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: prevents stale closures and unnecessary callback recreations
|
||||||
|
tags: react, hooks, useState, useCallback, callbacks, closures
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Functional setState Updates
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect (requires state as dependency):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function TodoList() {
|
||||||
|
const [items, setItems] = useState(initialItems)
|
||||||
|
|
||||||
|
// Callback must depend on items, recreated on every items change
|
||||||
|
const addItems = useCallback((newItems: Item[]) => {
|
||||||
|
setItems([...items, ...newItems])
|
||||||
|
}, [items]) // ❌ items dependency causes recreations
|
||||||
|
|
||||||
|
// Risk of stale closure if dependency is forgotten
|
||||||
|
const removeItem = useCallback((id: string) => {
|
||||||
|
setItems(items.filter(item => item.id !== id))
|
||||||
|
}, []) // ❌ Missing items dependency - will use stale items!
|
||||||
|
|
||||||
|
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Correct (stable callbacks, no stale closures):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function TodoList() {
|
||||||
|
const [items, setItems] = useState(initialItems)
|
||||||
|
|
||||||
|
// Stable callback, never recreated
|
||||||
|
const addItems = useCallback((newItems: Item[]) => {
|
||||||
|
setItems(curr => [...curr, ...newItems])
|
||||||
|
}, []) // ✅ No dependencies needed
|
||||||
|
|
||||||
|
// Always uses latest state, no stale closure risk
|
||||||
|
const removeItem = useCallback((id: string) => {
|
||||||
|
setItems(curr => curr.filter(item => item.id !== id))
|
||||||
|
}, []) // ✅ Safe and stable
|
||||||
|
|
||||||
|
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
1. **Stable callback references** - Callbacks don't need to be recreated when state changes
|
||||||
|
2. **No stale closures** - Always operates on the latest state value
|
||||||
|
3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
|
||||||
|
4. **Prevents bugs** - Eliminates the most common source of React closure bugs
|
||||||
|
|
||||||
|
**When to use functional updates:**
|
||||||
|
|
||||||
|
- Any setState that depends on the current state value
|
||||||
|
- Inside useCallback/useMemo when state is needed
|
||||||
|
- Event handlers that reference state
|
||||||
|
- Async operations that update state
|
||||||
|
|
||||||
|
**When direct updates are fine:**
|
||||||
|
|
||||||
|
- Setting state to a static value: `setCount(0)`
|
||||||
|
- Setting state from props/arguments only: `setName(newName)`
|
||||||
|
- State doesn't depend on previous value
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
title: Use Lazy State Initialization
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: wasted computation on every render
|
||||||
|
tags: react, hooks, useState, performance, initialization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Lazy State Initialization
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect (runs on every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function FilteredList({ items }: { items: Item[] }) {
|
||||||
|
// buildSearchIndex() runs on EVERY render, even after initialization
|
||||||
|
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
// When query changes, buildSearchIndex runs again unnecessarily
|
||||||
|
return <SearchResults index={searchIndex} query={query} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserProfile() {
|
||||||
|
// JSON.parse runs on every render
|
||||||
|
const [settings, setSettings] = useState(
|
||||||
|
JSON.parse(localStorage.getItem('settings') || '{}')
|
||||||
|
)
|
||||||
|
|
||||||
|
return <SettingsForm settings={settings} onChange={setSettings} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (runs only once):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function FilteredList({ items }: { items: Item[] }) {
|
||||||
|
// buildSearchIndex() runs ONLY on initial render
|
||||||
|
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
return <SearchResults index={searchIndex} query={query} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserProfile() {
|
||||||
|
// JSON.parse runs only on initial render
|
||||||
|
const [settings, setSettings] = useState(() => {
|
||||||
|
const stored = localStorage.getItem('settings')
|
||||||
|
return stored ? JSON.parse(stored) : {}
|
||||||
|
})
|
||||||
|
|
||||||
|
return <SettingsForm settings={settings} onChange={setSettings} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
|
||||||
|
|
||||||
|
For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
title: Extract Default Non-primitive Parameter Value from Memoized Component to Constant
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: restores memoization by using a constant for default value
|
||||||
|
tags: rerender, memo, optimization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extract Default Non-primitive Parameter Value from Memoized Component to Constant
|
||||||
|
|
||||||
|
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()`.
|
||||||
|
|
||||||
|
To address this issue, extract the default value into a constant.
|
||||||
|
|
||||||
|
**Incorrect (`onClick` has different values on every rerender):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
// Used without optional onClick
|
||||||
|
<UserAvatar />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (stable default value):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const NOOP = () => {};
|
||||||
|
|
||||||
|
const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
// Used without optional onClick
|
||||||
|
<UserAvatar />
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
title: Extract to Memoized Components
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: enables early returns
|
||||||
|
tags: rerender, memo, useMemo, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extract to Memoized Components
|
||||||
|
|
||||||
|
Extract expensive work into memoized components to enable early returns before computation.
|
||||||
|
|
||||||
|
**Incorrect (computes avatar even when loading):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Profile({ user, loading }: Props) {
|
||||||
|
const avatar = useMemo(() => {
|
||||||
|
const id = computeAvatarId(user)
|
||||||
|
return <Avatar id={id} />
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
if (loading) return <Skeleton />
|
||||||
|
return <div>{avatar}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (skips computation when loading):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
|
||||||
|
const id = useMemo(() => computeAvatarId(user), [user])
|
||||||
|
return <Avatar id={id} />
|
||||||
|
})
|
||||||
|
|
||||||
|
function Profile({ user, loading }: Props) {
|
||||||
|
if (loading) return <Skeleton />
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<UserAvatar user={user} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Put Interaction Logic in Event Handlers
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids effect re-runs and duplicate side effects
|
||||||
|
tags: rerender, useEffect, events, side-effects, dependencies
|
||||||
|
---
|
||||||
|
|
||||||
|
## Put Interaction Logic in Event Handlers
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect (event modeled as state + effect):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Form() {
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const theme = useContext(ThemeContext)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (submitted) {
|
||||||
|
post('/api/register')
|
||||||
|
showToast('Registered', theme)
|
||||||
|
}
|
||||||
|
}, [submitted, theme])
|
||||||
|
|
||||||
|
return <button onClick={() => setSubmitted(true)}>Submit</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (do it in the handler):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Form() {
|
||||||
|
const theme = useContext(ThemeContext)
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
post('/api/register')
|
||||||
|
showToast('Registered', theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button onClick={handleSubmit}>Submit</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [Should this code move to an event handler?](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: Don't Define Components Inside Components
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: prevents remount on every render
|
||||||
|
tags: rerender, components, remount, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Define Components Inside Components
|
||||||
|
|
||||||
|
**Impact: HIGH (prevents remount on every render)**
|
||||||
|
|
||||||
|
Defining a component inside another component creates a new component type on every render. React sees a different component each time and fully remounts it, destroying all state and DOM.
|
||||||
|
|
||||||
|
A common reason developers do this is to access parent variables without passing props. Always pass props instead.
|
||||||
|
|
||||||
|
**Incorrect (remounts on every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function UserProfile({ user, theme }) {
|
||||||
|
// Defined inside to access `theme` - BAD
|
||||||
|
const Avatar = () => (
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defined inside to access `user` - BAD
|
||||||
|
const Stats = () => (
|
||||||
|
<div>
|
||||||
|
<span>{user.followers} followers</span>
|
||||||
|
<span>{user.posts} posts</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Avatar />
|
||||||
|
<Stats />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every time `UserProfile` renders, `Avatar` and `Stats` are new component types. React unmounts the old instances and mounts new ones, losing any internal state, running effects again, and recreating DOM nodes.
|
||||||
|
|
||||||
|
**Correct (pass props instead):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Avatar({ src, theme }: { src: string; theme: string }) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stats({ followers, posts }: { followers: number; posts: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span>{followers} followers</span>
|
||||||
|
<span>{posts} posts</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserProfile({ user, theme }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Avatar src={user.avatarUrl} theme={theme} />
|
||||||
|
<Stats followers={user.followers} posts={user.posts} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Symptoms of this bug:**
|
||||||
|
- Input fields lose focus on every keystroke
|
||||||
|
- Animations restart unexpectedly
|
||||||
|
- `useEffect` cleanup/setup runs on every parent render
|
||||||
|
- Scroll position resets inside the component
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: Do not wrap a simple expression with a primitive result type in useMemo
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: wasted computation on every render
|
||||||
|
tags: rerender, useMemo, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Do not wrap a simple expression with a primitive result type in useMemo
|
||||||
|
|
||||||
|
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`.
|
||||||
|
Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Header({ user, notifications }: Props) {
|
||||||
|
const isLoading = useMemo(() => {
|
||||||
|
return user.isLoading || notifications.isLoading
|
||||||
|
}, [user.isLoading, notifications.isLoading])
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton />
|
||||||
|
// return some markup
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Header({ user, notifications }: Props) {
|
||||||
|
const isLoading = user.isLoading || notifications.isLoading
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton />
|
||||||
|
// return some markup
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
title: Split Combined Hook Computations
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids recomputing independent steps
|
||||||
|
tags: rerender, useMemo, useEffect, dependencies, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Split Combined Hook Computations
|
||||||
|
|
||||||
|
When a hook contains multiple independent tasks with different dependencies, split them into separate hooks. A combined hook reruns all tasks when any dependency changes, even if some tasks don't use the changed value.
|
||||||
|
|
||||||
|
**Incorrect (changing `sortOrder` recomputes filtering):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const sortedProducts = useMemo(() => {
|
||||||
|
const filtered = products.filter((p) => p.category === category)
|
||||||
|
const sorted = filtered.toSorted((a, b) =>
|
||||||
|
sortOrder === "asc" ? a.price - b.price : b.price - a.price
|
||||||
|
)
|
||||||
|
return sorted
|
||||||
|
}, [products, category, sortOrder])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (filtering only recomputes when products or category change):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const filteredProducts = useMemo(
|
||||||
|
() => products.filter((p) => p.category === category),
|
||||||
|
[products, category]
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedProducts = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredProducts.toSorted((a, b) =>
|
||||||
|
sortOrder === "asc" ? a.price - b.price : b.price - a.price
|
||||||
|
),
|
||||||
|
[filteredProducts, sortOrder]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern also applies to `useEffect` when combining unrelated side effects:
|
||||||
|
|
||||||
|
**Incorrect (both effects run when either dependency changes):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
analytics.trackPageView(pathname)
|
||||||
|
document.title = `${pageTitle} | My App`
|
||||||
|
}, [pathname, pageTitle])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (effects run independently):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
analytics.trackPageView(pathname)
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `${pageTitle} | My App`
|
||||||
|
}, [pageTitle])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, it automatically optimizes dependency tracking and may handle some of these cases for you.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: Use Transitions for Non-Urgent Updates
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: maintains UI responsiveness
|
||||||
|
tags: rerender, transitions, startTransition, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Transitions for Non-Urgent Updates
|
||||||
|
|
||||||
|
Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
|
||||||
|
|
||||||
|
**Incorrect (blocks UI on every scroll):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ScrollTracker() {
|
||||||
|
const [scrollY, setScrollY] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setScrollY(window.scrollY)
|
||||||
|
window.addEventListener('scroll', handler, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', handler)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (non-blocking updates):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { startTransition } from 'react'
|
||||||
|
|
||||||
|
function ScrollTracker() {
|
||||||
|
const [scrollY, setScrollY] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
startTransition(() => setScrollY(window.scrollY))
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', handler, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', handler)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
title: Use useDeferredValue for Expensive Derived Renders
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: keeps input responsive during heavy computation
|
||||||
|
tags: rerender, useDeferredValue, optimization, concurrent
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use useDeferredValue for Expensive Derived Renders
|
||||||
|
|
||||||
|
When user input triggers expensive computations or renders, use `useDeferredValue` to keep the input responsive. The deferred value lags behind, allowing React to prioritize the input update and render the expensive result when idle.
|
||||||
|
|
||||||
|
**Incorrect (input feels laggy while filtering):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Search({ items }: { items: Item[] }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const filtered = items.filter(item => fuzzyMatch(item, query))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input value={query} onChange={e => setQuery(e.target.value)} />
|
||||||
|
<ResultsList results={filtered} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (input stays snappy, results render when ready):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Search({ items }: { items: Item[] }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const deferredQuery = useDeferredValue(query)
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => items.filter(item => fuzzyMatch(item, deferredQuery)),
|
||||||
|
[items, deferredQuery]
|
||||||
|
)
|
||||||
|
const isStale = query !== deferredQuery
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input value={query} onChange={e => setQuery(e.target.value)} />
|
||||||
|
<div style={{ opacity: isStale ? 0.7 : 1 }}>
|
||||||
|
<ResultsList results={filtered} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
|
||||||
|
- Filtering/searching large lists
|
||||||
|
- Expensive visualizations (charts, graphs) reacting to input
|
||||||
|
- Any derived state that causes noticeable render delays
|
||||||
|
|
||||||
|
**Note:** Wrap the expensive computation in `useMemo` with the deferred value as a dependency, otherwise it still runs on every render.
|
||||||
|
|
||||||
|
Reference: [React useDeferredValue](https://react.dev/reference/react/useDeferredValue)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
title: Use useRef for Transient Values
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids unnecessary re-renders on frequent updates
|
||||||
|
tags: rerender, useref, state, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use useRef for Transient Values
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect (renders every update):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Tracker() {
|
||||||
|
const [lastX, setLastX] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (e: MouseEvent) => setLastX(e.clientX)
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
return () => window.removeEventListener('mousemove', onMove)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: lastX,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
background: 'black',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (no re-render for tracking):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Tracker() {
|
||||||
|
const lastXRef = useRef(0)
|
||||||
|
const dotRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
lastXRef.current = e.clientX
|
||||||
|
const node = dotRef.current
|
||||||
|
if (node) {
|
||||||
|
node.style.transform = `translateX(${e.clientX}px)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
return () => window.removeEventListener('mousemove', onMove)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dotRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
background: 'black',
|
||||||
|
transform: 'translateX(0px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
title: Use after() for Non-Blocking Operations
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: faster response times
|
||||||
|
tags: server, async, logging, analytics, side-effects
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use after() for Non-Blocking Operations
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect (blocks response):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { logUserAction } from '@/app/utils'
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// Perform mutation
|
||||||
|
await updateDatabase(request)
|
||||||
|
|
||||||
|
// Logging blocks the response
|
||||||
|
const userAgent = request.headers.get('user-agent') || 'unknown'
|
||||||
|
await logUserAction({ userAgent })
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ status: 'success' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (non-blocking):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { after } from 'next/server'
|
||||||
|
import { headers, cookies } from 'next/headers'
|
||||||
|
import { logUserAction } from '@/app/utils'
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// Perform mutation
|
||||||
|
await updateDatabase(request)
|
||||||
|
|
||||||
|
// Log after response is sent
|
||||||
|
after(async () => {
|
||||||
|
const userAgent = (await headers()).get('user-agent') || 'unknown'
|
||||||
|
const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
|
||||||
|
|
||||||
|
logUserAction({ sessionCookie, userAgent })
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ status: 'success' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The response is sent immediately while logging happens in the background.
|
||||||
|
|
||||||
|
**Common use cases:**
|
||||||
|
|
||||||
|
- Analytics tracking
|
||||||
|
- Audit logging
|
||||||
|
- Sending notifications
|
||||||
|
- Cache invalidation
|
||||||
|
- Cleanup tasks
|
||||||
|
|
||||||
|
**Important notes:**
|
||||||
|
|
||||||
|
- `after()` runs even if the response fails or redirects
|
||||||
|
- Works in Server Actions, Route Handlers, and Server Components
|
||||||
|
|
||||||
|
Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
title: Authenticate Server Actions Like API Routes
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: prevents unauthorized access to server mutations
|
||||||
|
tags: server, server-actions, authentication, security, authorization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authenticate Server Actions Like API Routes
|
||||||
|
|
||||||
|
**Impact: CRITICAL (prevents unauthorized access to server mutations)**
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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."
|
||||||
|
|
||||||
|
**Incorrect (no authentication check):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
export async function deleteUser(userId: string) {
|
||||||
|
// Anyone can call this! No auth check
|
||||||
|
await db.user.delete({ where: { id: userId } })
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (authentication inside the action):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { verifySession } from '@/lib/auth'
|
||||||
|
import { unauthorized } from '@/lib/errors'
|
||||||
|
|
||||||
|
export async function deleteUser(userId: string) {
|
||||||
|
// Always check auth inside the action
|
||||||
|
const session = await verifySession()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw unauthorized('Must be logged in')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization too
|
||||||
|
if (session.user.role !== 'admin' && session.user.id !== userId) {
|
||||||
|
throw unauthorized('Cannot delete other users')
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.user.delete({ where: { id: userId } })
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**With input validation:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { verifySession } from '@/lib/auth'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const updateProfileSchema = z.object({
|
||||||
|
userId: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
email: z.string().email()
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function updateProfile(data: unknown) {
|
||||||
|
// Validate input first
|
||||||
|
const validated = updateProfileSchema.parse(data)
|
||||||
|
|
||||||
|
// Then authenticate
|
||||||
|
const session = await verifySession()
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then authorize
|
||||||
|
if (session.user.id !== validated.userId) {
|
||||||
|
throw new Error('Can only update own profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally perform the mutation
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: validated.userId },
|
||||||
|
data: {
|
||||||
|
name: validated.name,
|
||||||
|
email: validated.email
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
title: Cross-Request LRU Caching
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: caches across requests
|
||||||
|
tags: server, cache, lru, cross-request
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Request LRU Caching
|
||||||
|
|
||||||
|
`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { LRUCache } from 'lru-cache'
|
||||||
|
|
||||||
|
const cache = new LRUCache<string, any>({
|
||||||
|
max: 1000,
|
||||||
|
ttl: 5 * 60 * 1000 // 5 minutes
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function getUser(id: string) {
|
||||||
|
const cached = cache.get(id)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({ where: { id } })
|
||||||
|
cache.set(id, user)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request 1: DB query, result cached
|
||||||
|
// Request 2: cache hit, no DB query
|
||||||
|
```
|
||||||
|
|
||||||
|
Use when sequential user actions hit multiple endpoints needing the same data within seconds.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.
|
||||||
|
|
||||||
|
Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
title: Per-Request Deduplication with React.cache()
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: deduplicates within request
|
||||||
|
tags: server, cache, react-cache, deduplication
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Request Deduplication with React.cache()
|
||||||
|
|
||||||
|
Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { cache } from 'react'
|
||||||
|
|
||||||
|
export const getCurrentUser = cache(async () => {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return null
|
||||||
|
return await db.user.findUnique({
|
||||||
|
where: { id: session.user.id }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
|
||||||
|
|
||||||
|
**Avoid inline objects as arguments:**
|
||||||
|
|
||||||
|
`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits.
|
||||||
|
|
||||||
|
**Incorrect (always cache miss):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const getUser = cache(async (params: { uid: number }) => {
|
||||||
|
return await db.user.findUnique({ where: { id: params.uid } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each call creates new object, never hits cache
|
||||||
|
getUser({ uid: 1 })
|
||||||
|
getUser({ uid: 1 }) // Cache miss, runs query again
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (cache hit):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const getUser = cache(async (uid: number) => {
|
||||||
|
return await db.user.findUnique({ where: { id: uid } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Primitive args use value equality
|
||||||
|
getUser(1)
|
||||||
|
getUser(1) // Cache hit, returns cached result
|
||||||
|
```
|
||||||
|
|
||||||
|
If you must pass objects, pass the same reference:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const params = { uid: 1 }
|
||||||
|
getUser(params) // Query runs
|
||||||
|
getUser(params) // Cache hit (same reference)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Next.js-Specific Note:**
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- Database queries (Prisma, Drizzle, etc.)
|
||||||
|
- Heavy computations
|
||||||
|
- Authentication checks
|
||||||
|
- File system operations
|
||||||
|
- Any non-fetch async work
|
||||||
|
|
||||||
|
Use `React.cache()` to deduplicate these operations across your component tree.
|
||||||
|
|
||||||
|
Reference: [React.cache documentation](https://react.dev/reference/react/cache)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
title: Avoid Duplicate Serialization in RSC Props
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: reduces network payload by avoiding duplicate serialization
|
||||||
|
tags: server, rsc, serialization, props, client-components
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avoid Duplicate Serialization in RSC Props
|
||||||
|
|
||||||
|
**Impact: LOW (reduces network payload by avoiding duplicate serialization)**
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect (duplicates array):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// RSC: sends 6 strings (2 arrays × 3 items)
|
||||||
|
<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (sends 3 strings):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// RSC: send once
|
||||||
|
<ClientList usernames={usernames} />
|
||||||
|
|
||||||
|
// Client: transform there
|
||||||
|
'use client'
|
||||||
|
const sorted = useMemo(() => [...usernames].sort(), [usernames])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nested deduplication behavior:**
|
||||||
|
|
||||||
|
Deduplication works recursively. Impact varies by data type:
|
||||||
|
|
||||||
|
- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated
|
||||||
|
- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// string[] - duplicates everything
|
||||||
|
usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings
|
||||||
|
|
||||||
|
// object[] - duplicates array structure only
|
||||||
|
users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Operations breaking deduplication (create new references):**
|
||||||
|
|
||||||
|
- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`
|
||||||
|
- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`
|
||||||
|
|
||||||
|
**More examples:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Bad
|
||||||
|
<C users={users} active={users.filter(u => u.active)} />
|
||||||
|
<C product={product} productName={product.name} />
|
||||||
|
|
||||||
|
// ✅ Good
|
||||||
|
<C users={users} />
|
||||||
|
<C product={product} />
|
||||||
|
// Do filtering/destructuring in client
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exception:** Pass derived data when transformation is expensive or client doesn't need original.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
title: Hoist Static I/O to Module Level
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: avoids repeated file/network I/O per request
|
||||||
|
tags: server, io, performance, next.js, route-handlers, og-image
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hoist Static I/O to Module Level
|
||||||
|
|
||||||
|
**Impact: HIGH (avoids repeated file/network I/O per request)**
|
||||||
|
|
||||||
|
When loading static assets (fonts, logos, images, config files) in route handlers or server functions, hoist the I/O operation to module level. Module-level code runs once when the module is first imported, not on every request. This eliminates redundant file system reads or network fetches that would otherwise run on every invocation.
|
||||||
|
|
||||||
|
**Incorrect (reads font file on every request):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/og/route.tsx
|
||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
// Runs on EVERY request - expensive!
|
||||||
|
const fontData = await fetch(
|
||||||
|
new URL('./fonts/Inter.ttf', import.meta.url)
|
||||||
|
).then(res => res.arrayBuffer())
|
||||||
|
|
||||||
|
const logoData = await fetch(
|
||||||
|
new URL('./images/logo.png', import.meta.url)
|
||||||
|
).then(res => res.arrayBuffer())
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<div style={{ fontFamily: 'Inter' }}>
|
||||||
|
<img src={logoData} />
|
||||||
|
Hello World
|
||||||
|
</div>,
|
||||||
|
{ fonts: [{ name: 'Inter', data: fontData }] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (loads once at module initialization):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/og/route.tsx
|
||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
|
||||||
|
// Module-level: runs ONCE when module is first imported
|
||||||
|
const fontData = fetch(
|
||||||
|
new URL('./fonts/Inter.ttf', import.meta.url)
|
||||||
|
).then(res => res.arrayBuffer())
|
||||||
|
|
||||||
|
const logoData = fetch(
|
||||||
|
new URL('./images/logo.png', import.meta.url)
|
||||||
|
).then(res => res.arrayBuffer())
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
// Await the already-started promises
|
||||||
|
const [font, logo] = await Promise.all([fontData, logoData])
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<div style={{ fontFamily: 'Inter' }}>
|
||||||
|
<img src={logo} />
|
||||||
|
Hello World
|
||||||
|
</div>,
|
||||||
|
{ fonts: [{ name: 'Inter', data: font }] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (synchronous fs at module level):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/og/route.tsx
|
||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
// Synchronous read at module level - blocks only during module init
|
||||||
|
const fontData = readFileSync(
|
||||||
|
join(process.cwd(), 'public/fonts/Inter.ttf')
|
||||||
|
)
|
||||||
|
|
||||||
|
const logoData = readFileSync(
|
||||||
|
join(process.cwd(), 'public/images/logo.png')
|
||||||
|
)
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
return new ImageResponse(
|
||||||
|
<div style={{ fontFamily: 'Inter' }}>
|
||||||
|
<img src={logoData} />
|
||||||
|
Hello World
|
||||||
|
</div>,
|
||||||
|
{ fonts: [{ name: 'Inter', data: fontData }] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incorrect (reads config on every call):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
|
||||||
|
export async function processRequest(data: Data) {
|
||||||
|
const config = JSON.parse(
|
||||||
|
await fs.readFile('./config.json', 'utf-8')
|
||||||
|
)
|
||||||
|
const template = await fs.readFile('./template.html', 'utf-8')
|
||||||
|
|
||||||
|
return render(template, data, config)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (hoists config and template to module level):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
|
||||||
|
const configPromise = fs
|
||||||
|
.readFile('./config.json', 'utf-8')
|
||||||
|
.then(JSON.parse)
|
||||||
|
const templatePromise = fs.readFile('./template.html', 'utf-8')
|
||||||
|
|
||||||
|
export async function processRequest(data: Data) {
|
||||||
|
const [config, template] = await Promise.all([
|
||||||
|
configPromise,
|
||||||
|
templatePromise,
|
||||||
|
])
|
||||||
|
|
||||||
|
return render(template, data, config)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When to use this pattern:
|
||||||
|
|
||||||
|
- Loading fonts for OG image generation
|
||||||
|
- Loading static logos, icons, or watermarks
|
||||||
|
- Reading configuration files that don't change at runtime
|
||||||
|
- Loading email templates or other static templates
|
||||||
|
- Any static asset that's the same across all requests
|
||||||
|
|
||||||
|
When not to use this pattern:
|
||||||
|
|
||||||
|
- Assets that vary per request or user
|
||||||
|
- Files that may change during runtime (use caching with TTL instead)
|
||||||
|
- Large files that would consume too much memory if kept loaded
|
||||||
|
- Sensitive data that shouldn't persist in memory
|
||||||
|
|
||||||
|
With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute), module-level caching is especially effective because multiple concurrent requests share the same function instance. The static assets stay loaded in memory across requests without cold start penalties.
|
||||||
|
|
||||||
|
In traditional serverless, each cold start re-executes module-level code, but subsequent warm invocations reuse the loaded assets until the instance is recycled.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: Avoid Shared Module State for Request Data
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: prevents concurrency bugs and request data leaks
|
||||||
|
tags: server, rsc, ssr, concurrency, security, state
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avoid Shared Module State for Request Data
|
||||||
|
|
||||||
|
For React Server Components and client components rendered during SSR, avoid using mutable module-level variables to share request-scoped data. Server renders can run concurrently in the same process. If one render writes to shared module state and another render reads it, you can get race conditions, cross-request contamination, and security bugs where one user's data appears in another user's response.
|
||||||
|
|
||||||
|
Treat module scope on the server as process-wide shared memory, not request-local state.
|
||||||
|
|
||||||
|
**Incorrect (request data leaks across concurrent renders):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
let currentUser: User | null = null
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
currentUser = await auth()
|
||||||
|
return <Dashboard />
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Dashboard() {
|
||||||
|
return <div>{currentUser?.name}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If two requests overlap, request A can set `currentUser`, then request B overwrites it before request A finishes rendering `Dashboard`.
|
||||||
|
|
||||||
|
**Correct (keep request data local to the render tree):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default async function Page() {
|
||||||
|
const user = await auth()
|
||||||
|
return <Dashboard user={user} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dashboard({ user }: { user: User | null }) {
|
||||||
|
return <div>{user?.name}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe exceptions:
|
||||||
|
|
||||||
|
- Immutable static assets or config loaded once at module scope
|
||||||
|
- Shared caches intentionally designed for cross-request reuse and keyed correctly
|
||||||
|
- Process-wide singletons that do not store request- or user-specific mutable data
|
||||||
|
|
||||||
|
For static assets and config, see [Hoist Static I/O to Module Level](./server-hoist-static-io.md).
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
title: Parallel Data Fetching with Component Composition
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: eliminates server-side waterfalls
|
||||||
|
tags: server, rsc, parallel-fetching, composition
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Data Fetching with Component Composition
|
||||||
|
|
||||||
|
React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
|
||||||
|
|
||||||
|
**Incorrect (Sidebar waits for Page's fetch to complete):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default async function Page() {
|
||||||
|
const header = await fetchHeader()
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>{header}</div>
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Sidebar() {
|
||||||
|
const items = await fetchSidebarItems()
|
||||||
|
return <nav>{items.map(renderItem)}</nav>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (both fetch simultaneously):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function Header() {
|
||||||
|
const data = await fetchHeader()
|
||||||
|
return <div>{data}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Sidebar() {
|
||||||
|
const items = await fetchSidebarItems()
|
||||||
|
return <nav>{items.map(renderItem)}</nav>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header />
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative with children prop:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function Header() {
|
||||||
|
const data = await fetchHeader()
|
||||||
|
return <div>{data}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Sidebar() {
|
||||||
|
const items = await fetchSidebarItems()
|
||||||
|
return <nav>{items.map(renderItem)}</nav>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Layout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Sidebar />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Parallel Nested Data Fetching
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: eliminates server-side waterfalls
|
||||||
|
tags: server, rsc, parallel-fetching, promise-chaining
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Nested Data Fetching
|
||||||
|
|
||||||
|
When fetching nested data in parallel, chain dependent fetches within each item's promise so a slow item doesn't block the rest.
|
||||||
|
|
||||||
|
**Incorrect (a single slow item blocks all nested fetches):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const chats = await Promise.all(
|
||||||
|
chatIds.map(id => getChat(id))
|
||||||
|
)
|
||||||
|
|
||||||
|
const chatAuthors = await Promise.all(
|
||||||
|
chats.map(chat => getUser(chat.author))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
If one `getChat(id)` out of 100 is extremely slow, the authors of the other 99 chats can't start loading even though their data is ready.
|
||||||
|
|
||||||
|
**Correct (each item chains its own nested fetch):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const chatAuthors = await Promise.all(
|
||||||
|
chatIds.map(id => getChat(id).then(chat => getUser(chat.author)))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each item independently chains `getChat` → `getUser`, so a slow chat doesn't block author fetches for the others.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: Minimize Serialization at RSC Boundaries
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: reduces data transfer size
|
||||||
|
tags: server, rsc, serialization, props
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minimize Serialization at RSC Boundaries
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Incorrect (serializes all 50 fields):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function Page() {
|
||||||
|
const user = await fetchUser() // 50 fields
|
||||||
|
return <Profile user={user} />
|
||||||
|
}
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
function Profile({ user }: { user: User }) {
|
||||||
|
return <div>{user.name}</div> // uses 1 field
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (serializes only 1 field):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function Page() {
|
||||||
|
const user = await fetchUser()
|
||||||
|
return <Profile name={user.name} />
|
||||||
|
}
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
function Profile({ name }: { name: string }) {
|
||||||
|
return <div>{name}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -12,6 +12,12 @@
|
|||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev", "--", "-p", "3100"],
|
"runtimeArgs": ["run", "dev", "--", "-p", "3100"],
|
||||||
"port": 3100
|
"port": 3100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "prod-3100",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["start", "-p", "3100"],
|
||||||
|
"port": 3100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
20
.claude/settings.json
Normal file
20
.claude/settings.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"superpowers@superpowers-marketplace": false,
|
||||||
|
"prd-development@pm-skills": false
|
||||||
|
},
|
||||||
|
"extraKnownMarketplaces": {
|
||||||
|
"superpowers-marketplace": {
|
||||||
|
"source": {
|
||||||
|
"source": "github",
|
||||||
|
"repo": "obra/superpowers-marketplace"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pm-skills": {
|
||||||
|
"source": {
|
||||||
|
"source": "github",
|
||||||
|
"repo": "deanpeters/Product-Manager-Skills"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.claude/skills/find-skills
Symbolic link
1
.claude/skills/find-skills
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/find-skills
|
||||||
101
.claude/skills/nextjs-app-architecture/SKILL.md
Normal file
101
.claude/skills/nextjs-app-architecture/SKILL.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: nextjs-app-architecture
|
||||||
|
description: Build or audit Next.js 16 App Router apps using a next-beats-style React Server Components architecture. Use when scaffolding a new app, adding a feature, reviewing an existing app, refactoring route-loader-shaped pages into feature-owned async server components, deciding where queries/actions/components live, keeping pages synchronous with `params.then()`, placing Suspense boundaries, choosing the client/server boundary, designing skeletons, preventing CLS, or enabling Cache Components. Also use when the user asks about RSC composition, components receiving IDs instead of route params, `'use cache'`, `cacheTag`, `updateTag`, static-shell prerendering, or making an app easier for AI agents to modify.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: aurorascharff
|
||||||
|
version: "1.3.7"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Next.js App Architecture
|
||||||
|
|
||||||
|
A workflow for building and auditing Next.js 16+ App Router apps so they follow one consistent, feature-sliced RSC architecture like `next-beats`.
|
||||||
|
|
||||||
|
**Follow the workflow below step by step** — it produces the invariants by construction. Load the reference a step names for the decision it depends on. Get framework _mechanics_ (API signatures, config options, hook contracts) from the linked docs — don't restate or improvise them.
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
Before changing a Next.js app, make sure the project is set up for AI agents to read version-matched docs. Follow the [AI Coding Agents guide](https://preview.nextjs.org/docs/app/guides/ai-agents): prefer the project's `AGENTS.md` / bundled docs, and create or refresh them when missing. Then use this skill for architecture decisions.
|
||||||
|
|
||||||
|
## Architecture target
|
||||||
|
|
||||||
|
Build pages that describe the loading experience, not pages that act like route loaders:
|
||||||
|
|
||||||
|
- `app/**/page.tsx` and `layout.tsx` are synchronous composition surfaces: static chrome, section headings, `<Suspense>` boundaries, error boundaries, and transition wrappers.
|
||||||
|
- Feature components own their reads on the server. They receive minimal stable inputs (`id`, `slug`, `handle`, parsed filter values) or already-fetched records, never raw `params` / `searchParams`.
|
||||||
|
- Queries and actions live in the feature folder. Components import queries; client leaves import actions directly.
|
||||||
|
- When server tags and client query keys describe the same feature data, a pure feature-local cache contract owns those identities.
|
||||||
|
- Skeletons mirror the component tree and live beside the component they represent.
|
||||||
|
|
||||||
|
## Invariants (what every change must satisfy)
|
||||||
|
|
||||||
|
The non-negotiables. The workflow produces them; the final check verifies them.
|
||||||
|
|
||||||
|
1. **Pages compose, they never fetch.** A page/layout imports feature components and places `<Suspense>`. No queries, no domain logic, no route-specific components defined inline.
|
||||||
|
2. **Pages stay synchronous.** Use `params.then()` / `searchParams.then()`, never `await params` at the top — so chrome paints into the static shell and only data-dependent sections suspend.
|
||||||
|
3. **Feature components receive IDs, not route props.** Resolve `params` / `searchParams` at the page boundary and pass plain values (`id`, `slug`, `query`) into features.
|
||||||
|
4. **Async server component is the default.** `'use client'` only for hooks, event handlers, or browser APIs — and only on leaves, never on parents of server content.
|
||||||
|
5. **The page owns the Suspense boundary; the feature owns the skeleton.** Features never pre-wrap themselves in `<Suspense>`.
|
||||||
|
6. **Skeletons live in the same file as the component**, exported alongside it, defined at the end. `Feed` and `FeedSkeleton` are siblings.
|
||||||
|
7. **Queries live in `<domain>-queries.ts`** (`import 'server-only'`); **actions live in `<domain>-actions.ts`** (`'use server'`). The file name matches the folder, even for sub-concepts.
|
||||||
|
8. **One feature folder per real domain noun.** Sub-concepts (favorite, like, vote, bookmark, search) fold into the parent feature, never their own folder.
|
||||||
|
9. **Client components import actions directly** — never receive a server action as a prop just to call it.
|
||||||
|
10. **Feature-local cache coordination stays with its domain.** Put pure tags/keys in `<domain>-cache.ts`, client query definitions in `<domain>-query-options.ts`, hook wrappers in `hooks/use-*.ts`, and tiny client leaves in `components/`; promote support code only after real cross-feature reuse.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
Run these in order for build-from-scratch, feature work, or audits. Each step names the reference to consult and the check it must pass.
|
||||||
|
|
||||||
|
1. **Choose mode.**
|
||||||
|
- **Build from scratch:** sketch routes, real domain nouns, static shell, and expected loading groups before writing code.
|
||||||
|
- **Audit/refactor:** scan current `app/` pages first; list every async page, page-level query import, route prop leak, missing Suspense boundary, and feature folder mismatch.
|
||||||
|
→ `references/example.md` for the target shape; `references/feature-folders.md` for placement.
|
||||||
|
✓ You know whether you are creating the architecture or converting loader-shaped code into it.
|
||||||
|
2. **Place the work.** Decide the feature folder before writing anything.
|
||||||
|
→ `references/feature-folders.md` (decision tree + merge rules).
|
||||||
|
✓ A real domain, or folded into the right parent.
|
||||||
|
3. **Write the query and, when a client cache shares its data, the cache contract.** Put server reads in `<domain>-queries.ts` with `import 'server-only'`; keep shared tag/key identities in a pure `<domain>-cache.ts`.
|
||||||
|
→ `references/queries-actions.md`; for SWR/TanStack Query → `references/single-page-applications.md`; with `cacheComponents: true`, also → `references/cache-components.md`.
|
||||||
|
✓ Cache identities are defined once; server reads are server-only, cached/tagged/lifetimed under Cache Components, and return domain types rather than ORM rows.
|
||||||
|
4. **Write the action** (if there's a mutation). `features/<domain>/<domain>-actions.ts`, `'use server'` at the top.
|
||||||
|
→ `references/queries-actions.md`.
|
||||||
|
✓ Re-checks auth, validates input, invalidates matching cache tags under Cache Components (`refresh()` only for justified dynamic reads), returns a discriminated union.
|
||||||
|
5. **Build the component + skeleton.** `features/<domain>/components/<name>.tsx`: an async server component that awaits its own query from minimal props; `'use client'` only on interactive leaves.
|
||||||
|
→ `references/components.md`; for a client data library or strict-SPA/CSR feature → `references/single-page-applications.md`.
|
||||||
|
✓ Component receives IDs/handles/parsed filters or already-resolved records, not `params`; skeleton is a sibling export at the end; no alias skeleton wrappers.
|
||||||
|
6. **Compose the page.** `app/<route>/page.tsx`: synchronous, `params.then()`, place `<Suspense fallback={<NameSkeleton />}><Name /></Suspense>`, and wrap fallible sections in an error boundary.
|
||||||
|
→ `references/pages-suspense.md`.
|
||||||
|
✓ The page only composes; the boundary lives here, not in the feature; route props are resolved to plain values before reaching feature components.
|
||||||
|
7. **Add interaction** (if any): optimistic updates, pending state, toasts, confirmation.
|
||||||
|
→ `references/ux-patterns.md`.
|
||||||
|
✓ Feedback isn't doubled; destructive actions confirm; feature-owned client coordination stays with that feature instead of leaking into unrelated domains.
|
||||||
|
8. **Verify** against the checklist below before declaring done.
|
||||||
|
|
||||||
|
## Verify before done
|
||||||
|
|
||||||
|
Inspect the diff against every invariant — each is checkable by reading the changed files:
|
||||||
|
|
||||||
|
- [ ] No page/layout imports a `*-queries` file or defines a route-specific component inline.
|
||||||
|
- [ ] Every page with params is synchronous and uses `params.then()` / `searchParams.then()`.
|
||||||
|
- [ ] Feature components receive plain IDs/handles/parsed filters or resolved records; no feature prop is named `params` or `searchParams`.
|
||||||
|
- [ ] Every `<Suspense>` for page data sits in the page; no feature pre-wraps itself.
|
||||||
|
- [ ] Every component has its real `*Skeleton` in the same file, at the end; no tiny skeleton aliases just to pass props.
|
||||||
|
- [ ] Every `*-queries.ts` starts with `import 'server-only'`; every `*-actions.ts` with `'use server'`.
|
||||||
|
- [ ] With `cacheComponents: true`, reusable reads use `'use cache'` / `cacheTag` / `cacheLife`, or `'use cache: private'` / `'use cache: remote'` when appropriate; any dynamic read is intentional and justified.
|
||||||
|
- [ ] Mutations touching cached reads call `updateTag()` / `revalidateTag(..., 'max')` for the matching tags; `refresh()` is not a substitute for tag invalidation.
|
||||||
|
- [ ] Action files are named `<folder>-actions.ts`; no sub-concept spawned its own folder.
|
||||||
|
- [ ] Features with both server tags and client query keys define them once in a pure `<domain>-cache.ts`; queries, actions, hydration, query options, and hooks import from it.
|
||||||
|
- [ ] Feature-local client-support files sit in the smallest fitting place: query options at the feature root, `use-*` hook wrappers in `hooks/`, leaf components in `components/`, and shared support only after real cross-feature reuse.
|
||||||
|
- [ ] `'use client'` components are leaves — they import actions/hooks/providers, not async server components.
|
||||||
|
- [ ] Mutations validate their input and invalidate the affected data.
|
||||||
|
|
||||||
|
## Reference index
|
||||||
|
|
||||||
|
- **`references/feature-folders.md`** — where code goes: folder layout, cache contracts, naming, and merging sub-concepts.
|
||||||
|
- **`references/queries-actions.md`** — query/action rules: server-only, dedup, validation, invalidation, return shape.
|
||||||
|
- **`references/components.md`** — server/client boundary, skeletons, `use()`, single-use helpers, live data.
|
||||||
|
- **`references/pages-suspense.md`** — page composition, `params.then()`, Suspense placement, CLS, error boundaries, prefetch.
|
||||||
|
- **`references/cache-components.md`** — the `cacheComponents` decisions: which reads to cache, which directive to use, how to invalidate.
|
||||||
|
- **`references/single-page-applications.md`** — client cache decisions: placement, server seeding, Cache Components coordination, hydration, and mutations.
|
||||||
|
- **`references/ux-patterns.md`** — interaction decisions: optimistic vs pending vs inline error, toasts, action-prop, confirmations.
|
||||||
|
- **`references/example.md`** — the next-beats reference app: invariant → file map, for seeing any rule in real code.
|
||||||
20
.claude/skills/nextjs-app-architecture/metadata.json
Normal file
20
.claude/skills/nextjs-app-architecture/metadata.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "1.3.7",
|
||||||
|
"organization": "Aurora Scharff",
|
||||||
|
"date": "July 2026",
|
||||||
|
"abstract": "Build and audit Next.js 16 App Router apps with a next-beats-style RSC architecture: synchronous pages that compose static shells, Suspense, and error boundaries; feature-owned async server components that receive IDs or parsed values; co-located skeletons; server-only queries; server actions; enforced Cache Components practice when enabled; and build/runtime verification of the resulting loading shape. References cover each topic in depth so the agent only loads what's needed for the task.",
|
||||||
|
"references": [
|
||||||
|
"https://preview.nextjs.org/docs/app",
|
||||||
|
"https://preview.nextjs.org/docs/app/guides/ai-agents",
|
||||||
|
"https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents",
|
||||||
|
"https://preview.nextjs.org/docs/app/api-reference/directives/use-cache",
|
||||||
|
"https://preview.nextjs.org/docs/app/api-reference/functions/cacheTag",
|
||||||
|
"https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheLife",
|
||||||
|
"https://preview.nextjs.org/docs/app/api-reference/functions/updateTag",
|
||||||
|
"https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching",
|
||||||
|
"https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components",
|
||||||
|
"https://preview.nextjs.org/docs/app/guides/interactive-apps",
|
||||||
|
"https://aurorascharff.no/posts/component-architecture-for-react-server-components/",
|
||||||
|
"https://github.com/vercel-labs/next-beats"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Cache Components
|
||||||
|
|
||||||
|
Decisions for when [`cacheComponents: true`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents) is set in `next.config.ts`. This file is about *which reads to cache, which directive to use, and how to invalidate* — for the mechanics of each directive, follow the doc links.
|
||||||
|
|
||||||
|
## When this reference applies
|
||||||
|
|
||||||
|
Use this reference when an app already has `cacheComponents: true`, the user wants this architecture while enabling it, or you are reviewing/refactoring an app that targets Cache Components.
|
||||||
|
|
||||||
|
If the project has not adopted Cache Components yet and the user asks to enable, migrate, or work through adoption blockers, use the `next-cache-components-adoption` skill first. It owns the route-by-route migration loop, opt-out strategy, and build/dev overlay workflow. Then return here for steady-state query/action/component architecture.
|
||||||
|
|
||||||
|
If `cacheComponents` is not enabled and the task is ordinary feature work, follow the core references without adding cache directives. Do not recommend skipping Cache Components based on app category alone; adoption is a migration/project decision, not a per-feature shortcut.
|
||||||
|
|
||||||
|
Adopting these in an existing app: follow [Migrating to Cache Components](https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components) and [Adopting Partial Prefetching](https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching) — they cover the incremental path (per-route `prefetch = 'partial'`, fixing dynamic-usage build errors) rather than a big-bang switch.
|
||||||
|
|
||||||
|
## The model
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// next.config.ts
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
cacheComponents: true,
|
||||||
|
partialPrefetching: true, // prefetch the static shell of linked routes
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Static shell** — synchronous content, `'use cache'` output, and Suspense fallbacks prerender at build time.
|
||||||
|
- **Dynamic holes** — async work without `'use cache'` streams in behind `<Suspense>` at request time.
|
||||||
|
- **Build constraint** — any async work without `'use cache'` must sit inside `<Suspense>`, or the build fails (wrap it, or add `'use cache'`).
|
||||||
|
|
||||||
|
`cacheComponents` implies Partial Prerendering — it replaced `experimental.ppr` / `dynamicIO` / `useCache`, so don't set those. See [caching](https://preview.nextjs.org/docs/app/getting-started/caching).
|
||||||
|
|
||||||
|
With `cacheComponents: true`, the skill practice is **cache reusable reads**. Do not leave a database/API read dynamic just because Suspense makes the build pass. If a read has a stable key and a mutation can name what changed, give it a cache directive, tags, and a lifetime.
|
||||||
|
|
||||||
|
Dynamic reads are the exception: use them for values that must be recomputed for every request or cannot be invalidated coherently. When you leave a read dynamic, note the reason in the surrounding code/review and invalidate its mutations with `refresh()` because there is no tag to update.
|
||||||
|
|
||||||
|
## Decide what to cache
|
||||||
|
|
||||||
|
| Data | Directive | Notes |
|
||||||
|
| ---- | --------- | ----- |
|
||||||
|
| Cacheable across users (public listings, computed pages) | [`'use cache'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache) | Add [`cacheTag`](https://preview.nextjs.org/docs/app/api-reference/functions/cacheTag) (a global + a scoped tag) and a [`cacheLife`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheLife) profile. |
|
||||||
|
| Per-user / reads cookies, headers, session | [`'use cache: private'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache-private) | Cached in the browser only, doesn't persist across reloads; never stored on the server. |
|
||||||
|
| Remote service, safe across users, worth durable storage | [`'use cache: remote'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache-remote) | Protects against rate-limited third-party APIs. |
|
||||||
|
| Genuinely dynamic per request | none | Must be justified. Read inside `<Suspense>`; mutations use `refresh()` because no tag exists. |
|
||||||
|
|
||||||
|
Cache the **query** when its result should be reused across requests. Cache the **component** when rendering is expensive and props are stable (a nav, a trending sidebar). Don't `'use cache'` a component that already calls a `'use cache'` query — double-caching, no benefit.
|
||||||
|
|
||||||
|
## Keep a synchronous value out of the shell
|
||||||
|
|
||||||
|
You usually don't need this. A query that reads `cookies()`/`headers()` or awaits a DB/`fetch` inside `<Suspense>` already stays out of the shell on its own. Only a *synchronous* request-time read (`new Date()`, `Math.random()`, a sync sqlite read) needs help: `await` [`io()`](https://preview.nextjs.org/docs/app/api-reference/functions/io) before it, with the caller inside `<Suspense>`.
|
||||||
|
|
||||||
|
Prefer `io()` over [`connection()`](https://preview.nextjs.org/docs/app/api-reference/functions/connection): both exclude what follows from the shell, but `connection()` blocks prefetches while `io()` stays prefetchable. Reach for `connection()` only when rendering must wait for a real user request.
|
||||||
|
|
||||||
|
## Decide how to invalidate
|
||||||
|
|
||||||
|
- [`updateTag(tag)`](https://preview.nextjs.org/docs/app/api-reference/functions/updateTag) — in **server actions**, when the user should see the result immediately (read-your-own-writes). Requires the query to carry a matching `cacheTag`.
|
||||||
|
- [`revalidateTag(tag, 'max')`](https://preview.nextjs.org/docs/app/api-reference/functions/revalidateTag) — in **route handlers** (webhooks, cron) for stale-while-revalidate. The single-arg `revalidateTag(tag)` form is deprecated.
|
||||||
|
- [`refresh()`](https://preview.nextjs.org/docs/app/api-reference/functions/refresh) — re-render the current route for the current user. Use it for deliberately dynamic reads with no tag; don't use it instead of `updateTag()` for cached reads.
|
||||||
|
|
||||||
|
Tag, cache, invalidate: the `cacheTag` in the query and the `updateTag` in the action use the same string and live in the same feature folder.
|
||||||
|
|
||||||
|
## Coordinate hydrated client data
|
||||||
|
|
||||||
|
When cached server data seeds SWR, TanStack Query, or another browser cache, follow `references/single-page-applications.md`. Server and client freshness policies are independent; hydration adds library-specific constraints.
|
||||||
|
|
||||||
|
## Build failure map
|
||||||
|
|
||||||
|
When `next build` fails under Cache Components, map the error back to an architecture rule instead of patching locally:
|
||||||
|
|
||||||
|
- Async work without `'use cache'` and without an ancestor `<Suspense>` → cache the reusable read, or wrap a justified dynamic read in a page-owned `<Suspense>`.
|
||||||
|
- Request data inside `'use cache'` → switch to `'use cache: private'` when it is per-user cacheable, or keep it dynamic with a documented reason.
|
||||||
|
- `await params` / `await searchParams` at the top of a page → keep the page synchronous and move the read into `params.then()` / `searchParams.then()`.
|
||||||
|
- Sync request-time values (`new Date()`, `Math.random()`, sync storage reads) captured in the shell → cache stable values, or use [`io()`](https://preview.nextjs.org/docs/app/api-reference/functions/io) for per-request values.
|
||||||
|
|
||||||
|
For adoption-wide blocker triage, use `next-cache-components-adoption`. For API-specific recipes, follow the [Migrating to Cache Components guide](https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components).
|
||||||
|
|
||||||
|
## Without Cache Components
|
||||||
|
|
||||||
|
- Don't use `'use cache'` / `cacheTag` / `cacheLife` — they require the flag.
|
||||||
|
- Use React `cache()` only for proven same-request dedup needs; plain `server-only` async queries are the default.
|
||||||
|
- Invalidate with `refresh()` from server actions.
|
||||||
|
- Pages still use `params.then()` in this architecture. Without Cache Components there is no build-time static shell to preserve, but keeping pages synchronous still lets chrome paint before route-specific data resolves and keeps the app consistent.
|
||||||
214
.claude/skills/nextjs-app-architecture/references/components.md
Normal file
214
.claude/skills/nextjs-app-architecture/references/components.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# Components
|
||||||
|
|
||||||
|
How to build server and client components inside a feature folder.
|
||||||
|
|
||||||
|
## Default: async server component
|
||||||
|
|
||||||
|
Server components await their own queries directly — no `useEffect`, no client-side fetching, no manual loading state. See the [Server Components docs](https://preview.nextjs.org/docs/app/getting-started/server-and-client-components) for the model.
|
||||||
|
|
||||||
|
Prefer minimal, stable props: IDs, slugs, handles, parsed filters, or records the parent already fetched. Do not pass raw route `params` or `searchParams` into feature components. Pages resolve those promises and pass plain values.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/notifications/components/notifications-badge.tsx
|
||||||
|
import { getUnreadNotificationCount } from "@/features/notifications/notifications-queries";
|
||||||
|
|
||||||
|
export async function NotificationsBadge() {
|
||||||
|
const count = await getUnreadNotificationCount();
|
||||||
|
if (count === 0) return null;
|
||||||
|
return <span aria-label={`${count} unread`}>{count}</span>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The page (not this file) wraps it in `<Suspense fallback={<NotificationsBadgeSkeleton />}>` — see `references/pages-suspense.md`.
|
||||||
|
|
||||||
|
For parameterized routes, the page resolves `params` and the feature receives an ID:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/post/[id]/page.tsx
|
||||||
|
<Suspense fallback={<PostDetailSkeleton />}>
|
||||||
|
{params.then(({ id }) => (
|
||||||
|
<PostDetail id={id} />
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/post/components/post-detail.tsx
|
||||||
|
export async function PostDetail({ id }: { id: string }) {
|
||||||
|
const post = await getPost(id);
|
||||||
|
return <article>{post.body}</article>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Skeletons live in the same file
|
||||||
|
|
||||||
|
Export the main component and its skeleton from the same file. Pages import both. Define the skeleton **at the end of the file**, below the real component(s) — never above. Function declarations are hoisted, so a skeleton referenced by a component earlier in the file still works when defined last.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export async function Feed({ userId }: { userId: string }) {
|
||||||
|
const posts = await getFeed(userId);
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map((p) => (
|
||||||
|
<Post key={p.id} post={p} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedSkeleton() {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Skeleton className="h-24" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't export a second skeleton whose whole job is to rename or preconfigure another skeleton:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong — alias wrapper adds an import surface but no behavior
|
||||||
|
export function CompactGridSkeleton() {
|
||||||
|
return <GridSkeleton dense />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Import the real skeleton and pass the prop inline at the `<Suspense>` boundary: `fallback={<GridSkeleton dense />}`.
|
||||||
|
|
||||||
|
### Skeleton design checklist
|
||||||
|
|
||||||
|
1. Match the real component's layout: flex direction, gaps, padding, breakpoints.
|
||||||
|
2. Include all structural elements: avatar circles, action button placeholders, image squares.
|
||||||
|
3. Responsive visibility must match (`hidden sm:block` in the real component → same in the skeleton).
|
||||||
|
4. Show 2–5 placeholders for variable-length lists, not the real count.
|
||||||
|
5. Don't include skeletons for inner Suspense content — those have their own boundaries.
|
||||||
|
6. Reserve the right height. CLS comes from skeletons that are shorter than the real content.
|
||||||
|
|
||||||
|
## Group related components in one file
|
||||||
|
|
||||||
|
A card and its grid live in the same file. For example, `genre-card.tsx` exports `GenrePill`, `GenreCard`, `GenreGrid`, `GenreGridSkeleton`. Variants should reuse that skeleton inline instead of exporting alias skeletons. Don't split shared UI primitives prematurely — wait until three call sites need the same shape before extracting.
|
||||||
|
|
||||||
|
Two sidebar widgets that happen to look similar but render different data shapes are **not** the same component. The visuals diverge as soon as one needs an extra slot.
|
||||||
|
|
||||||
|
### Single-use sub-components stay inlined
|
||||||
|
|
||||||
|
For a metadata strip inside one card, a header used only by one detail view, a list item only rendered by its list — inline them as **non-exported** functions in the same file:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export async function EventDetails({ slug }: { slug: string }) {
|
||||||
|
const event = await getEventBySlug(slug);
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<MetaStrip event={event} />
|
||||||
|
<Speaker speaker={event.speaker} />
|
||||||
|
<p>{event.description}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaStrip({ event }: { event: Event }) { ... }
|
||||||
|
function Speaker({ speaker }: { speaker: string }) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Exports are for things other files will import. Internal structure is for readability inside one file.
|
||||||
|
|
||||||
|
## The server/client boundary
|
||||||
|
|
||||||
|
`'use client'` only when you need:
|
||||||
|
|
||||||
|
- Hooks (`useState`, `useReducer`, `useOptimistic`, `useTransition`, `useEffect`)
|
||||||
|
- Event handlers (`onClick`, `onChange`, `onSubmit`)
|
||||||
|
- Browser APIs (`window`, `localStorage`, refs to DOM)
|
||||||
|
|
||||||
|
If the component needs interactive pieces, keep the server component as the parent and render client leaves:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function PostDetail({ id }: { id: string }) {
|
||||||
|
const [post, userState] = await Promise.all([
|
||||||
|
getPost(id),
|
||||||
|
getPostUserState(id),
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<PostBody body={post.body} />
|
||||||
|
<PostActions userState={userState} /> {/* 'use client' leaf */}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server content as children of client components
|
||||||
|
|
||||||
|
Composition crosses the boundary. A client component can accept server-rendered JSX as children or props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ComposerForm
|
||||||
|
avatar={
|
||||||
|
<Suspense fallback={<AvatarSkeleton />}>
|
||||||
|
<CurrentUserAvatar />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`ComposerForm` is `'use client'`. It doesn't know where the avatar JSX came from. The Suspense boundary streams the avatar in without the form re-rendering.
|
||||||
|
|
||||||
|
### Pass server children resolved values, not promises
|
||||||
|
|
||||||
|
Prefer passing plain values (strings, IDs, resolved data) to a server child. A server component _can_ `await` a promise prop, but resolve route promises in the page instead — pass an unresolved promise down only to a _client_ component that reads it with `use()` (see below). When a parent already has the data from its own query, pass it as a prop instead of having the child refetch.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Right — parent fetches the list, passes each item
|
||||||
|
async function Feed({ userId }: { userId: string }) {
|
||||||
|
const posts = await getFeed(userId);
|
||||||
|
return posts.map((post) => <Post key={post.id} post={post} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Post({ post }: { post: Post }) {
|
||||||
|
return <article>{post.body}</article>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong — child refetches what the parent already had
|
||||||
|
async function Post({ id }: { id: string }) {
|
||||||
|
const post = await getPost(id);
|
||||||
|
return <article>{post.body}</article>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client components that own their loading state
|
||||||
|
|
||||||
|
When a client component needs server data but should manage its own loading (a sidebar badge, a popover that opens on hover), pass an **unresolved promise** from the server and resolve it with [`use()`](https://react.dev/reference/react/use) on the client. Wrap the consumer in `<Suspense>`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// page: pass the unresolved promise, wrap in Suspense
|
||||||
|
<Suspense fallback={<TagListSkeleton />}>
|
||||||
|
<TagPicker itemsPromise={getTags()} />
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
import { use } from "react";
|
||||||
|
|
||||||
|
export function TagPicker({ itemsPromise }: { itemsPromise: Promise<Tag[]> }) {
|
||||||
|
const items = use(itemsPromise);
|
||||||
|
// render interactive UI from items
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The opinionated bit: name promise props with a `Promise` suffix (`itemsPromise`, `userPromise`) so the contract is obvious at the call site.
|
||||||
|
|
||||||
|
### Client data libraries (SWR, TanStack Query)
|
||||||
|
|
||||||
|
Follow `references/single-page-applications.md` when a feature uses a browser data cache or needs externally authored updates. It covers when to use a library, where its files live, server seeding, Cache Components coordination, hydration, and mutations.
|
||||||
|
|
||||||
|
## Mutations
|
||||||
|
|
||||||
|
For client-side reactions to a server mutation (instant feedback, pending state, success/error toasts), see `references/ux-patterns.md`. To cache rendered output across requests, see `references/cache-components.md`.
|
||||||
34
.claude/skills/nextjs-app-architecture/references/example.md
Normal file
34
.claude/skills/nextjs-app-architecture/references/example.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Reference app: next-beats
|
||||||
|
|
||||||
|
A working app that follows this architecture: **<https://github.com/vercel-labs/next-beats>** (Next.js 16, `cacheComponents` + `partialPrefetching`, a music player). Use it to see any invariant in real code rather than restating the code here. Paths are as of this writing — verify against the current repo.
|
||||||
|
|
||||||
|
For the reasoning behind this architecture, read [Component Architecture for React Server Components](https://aurorascharff.no/posts/component-architecture-for-react-server-components/). The skill's target is the same shape: pages describe layout and loading; feature components own server reads; route params become IDs before they reach components.
|
||||||
|
|
||||||
|
## Invariant → where to see it
|
||||||
|
|
||||||
|
| Invariant | File(s) |
|
||||||
|
| --------- | ------- |
|
||||||
|
| 1. Pages compose, never fetch | `app/(app)/search/page.tsx`, `app/(app)/genre/[genre]/page.tsx` — import feature components, place `<Suspense>`, no queries. |
|
||||||
|
| 2. Pages stay synchronous (`params.then` / `searchParams.then`) | `app/(app)/track/[id]/page.tsx`, `app/(app)/genre/[genre]/page.tsx`, `app/(app)/search/page.tsx`. |
|
||||||
|
| 3. Feature components receive IDs, not route props | Track/genre pages resolve `params` / `searchParams` and pass `id`, `genre`, or parsed values into feature components. |
|
||||||
|
| 4. Async server component default; `'use client'` on leaves | Server: `features/track/components/discover.tsx`, `most-played.tsx`. Client leaves: `features/track/components/track-interactions.tsx`, `play-button.tsx`. |
|
||||||
|
| 5. Page owns Suspense; feature owns skeleton | Pages place the boundary (e.g. `app/(app)/genre/[genre]/page.tsx`); features export the skeleton (below). |
|
||||||
|
| 6. Skeleton in the same file, at the end | `features/track/components/track-row.tsx` (`TrackRow` … `TrackListSkeleton`), `features/genre/components/genre-card.tsx`. |
|
||||||
|
| 7. `<domain>-queries.ts` (`server-only`) / `<domain>-actions.ts` (`'use server'`) | `features/track/track-queries.ts`, `features/playlist/playlist-actions.ts`. |
|
||||||
|
| 8. One folder per domain; sub-concepts folded in | `toggleFavorite` in `features/track/track-actions.ts` (no `favorite` folder); `searchTracks` in `features/track/track-queries.ts` (no `search` folder). |
|
||||||
|
| 9. Client components import actions directly | `features/track/components/track-interactions.tsx` imports `toggleFavorite` directly. |
|
||||||
|
|
||||||
|
## Supporting patterns
|
||||||
|
|
||||||
|
| Pattern | File |
|
||||||
|
| ------- | ---- |
|
||||||
|
| Error boundary on `catchError` (`ErrorInfo` `retry`) | `components/ui/error-boundary.tsx` |
|
||||||
|
| `useOptimistic` for an unlikely-to-fail toggle | `features/track/components/track-interactions.tsx` |
|
||||||
|
| Action-prop / `*Action` convention + confirm dialog | `features/playlist/components/playlist-interactions.tsx`, `components/ui/confirm-dialog.tsx` |
|
||||||
|
| `useFormStatus` submit button | `components/ui/button.tsx` |
|
||||||
|
| `useActionState` inline field errors | `features/user/components/sign-in-form.tsx` |
|
||||||
|
| Client-owned live data via a provider (not `<Poller>`) | `providers/player-provider.tsx` → `components/now-playing-bar.tsx` |
|
||||||
|
| `use()` on an unresolved promise prop | `features/playlist/components/add-to-playlist-menu.tsx` |
|
||||||
|
| Purpose-named `components/scripts/` subfolder | `components/scripts/` |
|
||||||
|
|
||||||
|
> The repo is a live app, not a golden reference — spots may drift from the invariants (a page may fetch inline, a route `error.tsx` may lag an API rename). When the app and the invariants disagree, the invariants win; treat the mismatch as a fix for the app.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Feature folders
|
||||||
|
|
||||||
|
How to organize code under `features/` and `app/`.
|
||||||
|
|
||||||
|
## Folder layout
|
||||||
|
|
||||||
|
```
|
||||||
|
features/<domain>/
|
||||||
|
<domain>-cache.ts # Pure server tags + client query keys, when shared
|
||||||
|
<domain>-queries.ts # Server-only queries
|
||||||
|
<domain>-actions.ts # Server actions
|
||||||
|
<domain>-query-options.ts # Client data-library query definitions, when needed
|
||||||
|
components/ # Server + client components, each with its skeleton
|
||||||
|
types/ # Feature-local public types, when needed by multiple files
|
||||||
|
hooks/ # Actual feature-local React hooks and hook wrappers
|
||||||
|
providers/ # Feature-local providers, only when the provider belongs to this domain
|
||||||
|
```
|
||||||
|
|
||||||
|
The folder name **is** the domain. The query and action filenames match the folder.
|
||||||
|
|
||||||
|
## How many features?
|
||||||
|
|
||||||
|
Keep the feature list short. One folder per **domain noun a user would recognize**, not per database table or technical concern.
|
||||||
|
|
||||||
|
A new folder is justified when **all three** are true:
|
||||||
|
|
||||||
|
1. The concept has its own queries.
|
||||||
|
2. The concept has its own pages or routes.
|
||||||
|
3. The concept is referenced from at least two other features.
|
||||||
|
|
||||||
|
If you find yourself making a feature folder with one query, one action, and one button, fold it into the parent feature instead.
|
||||||
|
|
||||||
|
### Merge aggressively
|
||||||
|
|
||||||
|
Concepts that exist only in service of a parent entity belong inside the parent's feature folder:
|
||||||
|
|
||||||
|
- A `favorite` or `bookmark` concept that only attaches to one parent entity (events, posts) → inside that parent's folder.
|
||||||
|
- A `like`, `repost`, `vote`, or `reaction` concept on a piece of content → with that content's feature.
|
||||||
|
- `auth` / `session` / `current user` → a single `user` folder, not split.
|
||||||
|
- A cross-cutting concern like `search` folds into the primary content feature it queries (`searchTracks` in `features/track/`), not a `features/search/` folder — the page composes it.
|
||||||
|
|
||||||
|
Concrete example: `toggleFavorite` is a mutation about events ("I favorite an event"), not its own domain. It lives in `features/event/event-actions.ts`, not `features/favorite/favorite-actions.ts`.
|
||||||
|
|
||||||
|
## File naming
|
||||||
|
|
||||||
|
Filenames inside the folder always start with the folder name:
|
||||||
|
|
||||||
|
```
|
||||||
|
features/event/
|
||||||
|
event-queries.ts
|
||||||
|
event-actions.ts
|
||||||
|
components/
|
||||||
|
event-grid.tsx
|
||||||
|
event-details.tsx
|
||||||
|
favorite-button.tsx ← OK: a component, not a "favorite" feature
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<folder>-queries.ts` — even if the file has only one query.
|
||||||
|
- `<folder>-actions.ts` — even if a mutation is about a sub-concept.
|
||||||
|
- Other `<folder>-*.ts` files are fine when the folder needs them (`playlist-constants.ts`, `<folder>-schema.ts`), as long as they keep the folder-name prefix. Don't put reusable domain types in a root `*-types.ts` file; use `features/<domain>/types/` once a type is imported by multiple files.
|
||||||
|
- Component files use any descriptive name. The component (not the feature) is the unit here.
|
||||||
|
|
||||||
|
## Local vs shared support folders
|
||||||
|
|
||||||
|
Use a local support folder when the code belongs to one feature:
|
||||||
|
|
||||||
|
```
|
||||||
|
features/message/
|
||||||
|
message-cache.ts
|
||||||
|
message-query-options.ts
|
||||||
|
types/
|
||||||
|
message.ts
|
||||||
|
hooks/
|
||||||
|
use-message-mutations.ts
|
||||||
|
use-message-draft.ts
|
||||||
|
providers/
|
||||||
|
message-draft-provider.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Feature-owned client coordination stays with the feature. Place each file by the shape it exports and the domain it belongs to:
|
||||||
|
|
||||||
|
- Client data-library cache contracts and query definitions follow `references/single-page-applications.md`.
|
||||||
|
- Mutation wrappers that export hooks live in `hooks/use-*.ts` (`use-message-mutations.ts` exporting `useSendMessage`).
|
||||||
|
- Browser-only state helpers live in `hooks/` when their public API is a hook (`use-thread.ts`, `use-message-draft.ts`).
|
||||||
|
- Client leaf components that coordinate a server write live in `components/` next to the UI they support (`mark-activity-read.tsx` posts read activity in the background while the current `/activity` tree stays stable).
|
||||||
|
|
||||||
|
Keep the file prefix aligned with the feature folder when a file exports a grouped feature contract (`workspace-cache.ts` and `workspace-query-options.ts`, not `activity-cache.ts` in `features/workspace/`). Support code for a sub-concept still lives with the parent feature: reactions on messages belong in `features/message/`; unread activity chrome belongs in `features/workspace/`.
|
||||||
|
|
||||||
|
Promote only when there are real cross-feature consumers:
|
||||||
|
|
||||||
|
- `types/` at the project root — shared domain/application types imported across features.
|
||||||
|
- `hooks/` at the project root — shared client hooks used across features.
|
||||||
|
- `app/providers.tsx` or `components/*-provider.tsx` — app-shell providers that wrap the whole app.
|
||||||
|
|
||||||
|
Avoid root-level miscellany like `message-types.ts`, `shared-hooks.ts`, or `common-provider.tsx`; the folder name should explain the scope.
|
||||||
|
|
||||||
|
## What goes in `components/`
|
||||||
|
|
||||||
|
Each component file exports the main component **plus its skeleton**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/event/components/event-grid.tsx
|
||||||
|
export async function EventGrid(...) { ... }
|
||||||
|
export function EventGridSkeleton() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Group related components in one file when they're always used together or one is a natural building block for another. A card and its grid live together. For example, `genre-card.tsx` exports `GenrePill`, `GenreCard`, `GenreGrid`, `GenreGridSkeleton`.
|
||||||
|
|
||||||
|
Split into separate files only when:
|
||||||
|
|
||||||
|
- A component is consumed by multiple sibling components (one shared use is not enough — wait until three call sites need it).
|
||||||
|
- A component is `'use client'` and a sibling is a server component (the server/client boundary forbids sharing a file).
|
||||||
|
|
||||||
|
See `references/components.md` for inlining rules and the skeleton design checklist.
|
||||||
|
|
||||||
|
## What pages do
|
||||||
|
|
||||||
|
Pages in `app/` compose feature components with Suspense and transition wrappers. They never:
|
||||||
|
|
||||||
|
- Contain domain logic
|
||||||
|
- Define new components except thin transition wrappers (e.g. `<ViewTransition>`)
|
||||||
|
- Fetch data directly
|
||||||
|
- Inline route-specific components — extract them into the feature folder
|
||||||
|
|
||||||
|
See `references/pages-suspense.md` for page composition details.
|
||||||
|
|
||||||
|
## Top-level layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ # Pages and layouts
|
||||||
|
features/ # Domain folders
|
||||||
|
components/ # UI primitives, theme, and app-shell singletons
|
||||||
|
hooks/ # Shared client hooks used across features
|
||||||
|
types/ # Shared cross-feature types only
|
||||||
|
lib/ # Utilities and cohesive non-domain subsystems
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib/` holds flat helpers (`db.ts`, `utils.ts`) but may also group a cohesive non-domain subsystem in its own subfolder (e.g. `lib/audio/` for an audio engine). Cross-feature client hooks live in top-level `hooks/`; a hook used by a single feature co-locates in that feature's `hooks/`. Types follow the same rule: shared types at top-level `types/`, feature-only exported types in `features/<domain>/types/`.
|
||||||
|
|
||||||
|
`components/` holds:
|
||||||
|
|
||||||
|
- **`components/ui/`** — primitives. Low-level building blocks and action-prop components.
|
||||||
|
- **`components/theme/`** — theme provider and toggle, paired.
|
||||||
|
- **Top-level files** (`site-header.tsx`, `auth-gate.tsx`, `poller.tsx`) — app-shell singletons used once each. No `common/` folder — "common" is not a category. If a component is used everywhere it's a primitive (→ `ui/`); if it's used once it lives at the top level.
|
||||||
|
- **Purpose-named subfolders** are fine when several files share a clear technical role — e.g. `components/scripts/` for pre-hydration inline `<script>` seed components. This is distinct from the rejected `common/`: a `scripts/` folder names _what the files are_, not "miscellaneous."
|
||||||
|
|
||||||
|
Conventions for filenames and casing live in the project's `AGENTS.md`. This skill doesn't impose one.
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# Pages and Suspense
|
||||||
|
|
||||||
|
How to compose pages, place Suspense boundaries, and prevent layout shift.
|
||||||
|
|
||||||
|
## Pages are composition only
|
||||||
|
|
||||||
|
Pages in `app/` import feature components and place `<Suspense>` boundaries. They never:
|
||||||
|
|
||||||
|
- Fetch data directly (queries live in feature folders)
|
||||||
|
- Define new components except thin transition wrappers (e.g. `<ViewTransition>`)
|
||||||
|
- Inline route-specific UI (extract it into the feature folder)
|
||||||
|
- Pass raw `params` / `searchParams` to features
|
||||||
|
|
||||||
|
## Page function signatures
|
||||||
|
|
||||||
|
Type page and layout functions with the auto-generated `PageProps<'/route'>` / `LayoutProps<'/route'>` helpers — no import, regenerated on `next dev` / `next build` / `next typegen`. See [route type helpers](https://preview.nextjs.org/docs/app/api-reference/config/typescript#route-type-helpers).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function PostPage({ params }: PageProps<'/post/[id]'>) { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't hand-write `{ params: Promise<{ id: string }> }` — the generated types stay in sync with the route (catch-all, optional segments). Route handlers use `RouteContext<'/api/...'>`. `typedRoutes: true` is a *separate* feature (statically-typed `href`s), not the source of these helpers.
|
||||||
|
|
||||||
|
## Keep pages synchronous
|
||||||
|
|
||||||
|
Use `params.then()` instead of `await params`. Content above the `.then()` pre-renders into the static shell; content inside it suspends.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { PostDetail, PostDetailSkeleton } from '@/features/post/components/post-detail';
|
||||||
|
|
||||||
|
export default function PostPage({ params }: PageProps<'/post/[id]'>) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Post</h1>
|
||||||
|
<Suspense fallback={<PostDetailSkeleton />}>
|
||||||
|
{params.then(({ id }) => (
|
||||||
|
<PostDetail id={id} />
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<h1>` sits **above** the `params.then()` so it paints instantly. The `Suspense` fallback covers only the dynamic section.
|
||||||
|
|
||||||
|
Resolve route props to plain values at this boundary. Feature components receive `id`, `slug`, `query`, or parsed filter values — not `params`, `searchParams`, or unresolved server promises.
|
||||||
|
|
||||||
|
### Implicit return inside `.then()`
|
||||||
|
|
||||||
|
Use an implicit-return arrow function when the callback just renders JSX — e.g. `({ id }) => <PostDetail id={id} />`. Only switch to a block body with `return` when you need to do work first (destructure with defaults, parse a `searchParams` value, branch on a condition). This keeps the JSX-in-page shape readable and matches how the resolved tree will look.
|
||||||
|
|
||||||
|
### `searchParams` and combined params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// searchParams only
|
||||||
|
export default function SearchPage({ searchParams }: PageProps<'/search'>) {
|
||||||
|
return searchParams.then(sp => {
|
||||||
|
const q = typeof sp.q === 'string' ? sp.q : '';
|
||||||
|
return q ? <SearchResults query={q} /> : <EmptyState />;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both params and searchParams
|
||||||
|
export default function ProfilePage({ params, searchParams }: PageProps<'/u/[handle]'>) {
|
||||||
|
return Promise.all([params, searchParams]).then(([{ handle }, sp]) => (
|
||||||
|
<ProfileFeed handle={handle} tab={parseTab(sp.tab)} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metadata, static params, and `notFound()`
|
||||||
|
|
||||||
|
- [`generateMetadata`](https://preview.nextjs.org/docs/app/api-reference/functions/generate-metadata) runs before render, so `await params` is fine there — it's a separate async function, not the page body, so it doesn't make the page dynamic.
|
||||||
|
- Export [`generateStaticParams`](https://preview.nextjs.org/docs/app/api-reference/functions/generate-static-params) from a `[slug]` page/layout to pre-build a known set of slugs; with `cacheComponents` + `'use cache'` they land in the static shell. It does **not** change the page signature — `params` is still a Promise, still consumed with `params.then()`.
|
||||||
|
- A query that can't find its resource calls [`notFound()`](https://preview.nextjs.org/docs/app/api-reference/functions/not-found), which bubbles to the nearest [`not-found.tsx`](https://preview.nextjs.org/docs/app/api-reference/file-conventions/not-found). Don't try/catch it — use [`unstable_rethrow`](https://preview.nextjs.org/docs/app/api-reference/functions/unstable_rethrow) if you must catch nearby.
|
||||||
|
|
||||||
|
## The page owns the Suspense boundary
|
||||||
|
|
||||||
|
The feature exports the async component **and** its skeleton. The page imports both and places the boundary. Don't pre-wrap inside the feature — that hides the boundary and prevents grouping siblings.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/post/components/post-detail.tsx
|
||||||
|
export async function PostDetail({ id }: { id: string }) { ... }
|
||||||
|
export function PostDetailSkeleton() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/post/[id]/page.tsx
|
||||||
|
<Suspense fallback={<PostDetailSkeleton />}>
|
||||||
|
{params.then(({ id }) => (
|
||||||
|
<>
|
||||||
|
<PostDetail id={id} />
|
||||||
|
<ErrorBoundary title="Replies didn't load">
|
||||||
|
<Suspense fallback={<RepliesSkeleton />}>
|
||||||
|
<Replies postId={id} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
If a page uses a transition wrapper (e.g. `<ViewTransition>`), place it in the page next to the `<Suspense>` boundary. Feature components render content and skeletons, not transition wrappers.
|
||||||
|
|
||||||
|
## Audit smells
|
||||||
|
|
||||||
|
When auditing an existing app, flag and fix these first:
|
||||||
|
|
||||||
|
- `export default async function Page(...)` that only awaits `params`, `searchParams`, or page-level queries.
|
||||||
|
- `import { getSomething } from '@/features/.../*-queries'` inside `app/**/page.tsx` or `layout.tsx`.
|
||||||
|
- Feature components whose props are `params`, `searchParams`, or a route-shaped object.
|
||||||
|
- Page-local components like `HomeContent`, `PostShell`, or `ResultsSection` that only exist to fetch data or group a Suspense fallback.
|
||||||
|
- `<Suspense>` inside feature components that prevents the page from grouping reveal behavior.
|
||||||
|
|
||||||
|
## Don't create page-local wrapper components
|
||||||
|
|
||||||
|
Avoid components whose only job is to group boundary content, like `HomeLists` or `HomeListsSkeleton`. Keep the resolved JSX and fallback JSX **inline in the page** so the loading shape, headings, and grouped reveal behavior are visible at the boundary.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong — hides the structure behind a wrapper
|
||||||
|
<Suspense fallback={<HomeListsSkeleton />}>
|
||||||
|
<HomeLists searchParams={searchParams} />
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Right — structure visible at the page level
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<FeaturedSkeleton />
|
||||||
|
<RecentSkeleton />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{searchParams.then(sp => (
|
||||||
|
<>
|
||||||
|
<Featured filter={sp.filter} />
|
||||||
|
<Recent filter={sp.filter} />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
The same applies to feature-level skeleton aliases. If a variant only passes props to a base skeleton, import the base skeleton and pass those props inline in `fallback={...}`.
|
||||||
|
|
||||||
|
## Suspense boundary placement rules
|
||||||
|
|
||||||
|
1. **First section gets its own Suspense** with a known-height skeleton fallback.
|
||||||
|
2. **Section headings stay outside Suspense** when their final position is stable.
|
||||||
|
3. **Variable-height sections: group everything below them** in the same Suspense, including any headings that would otherwise paint in the wrong vertical position.
|
||||||
|
4. **Fixed-height sections: own boundary is safe.**
|
||||||
|
5. **Variable-length lists: show 2–5 skeleton items**, not the real count.
|
||||||
|
6. **Inner Suspense content stays out of the outer skeleton.** Each boundary owns its own.
|
||||||
|
7. **Never `fallback={null}` for visible UI.** If a boundary covers UI, give it a real shaped fallback, or group it with a sibling boundary that already has the correct fallback.
|
||||||
|
8. **If the top section's final height is unknown, group the following sections** in the same boundary so they reveal together and don't jump underneath.
|
||||||
|
|
||||||
|
## Error boundaries
|
||||||
|
|
||||||
|
Wrap fallible sections in a Next.js-aware error boundary so one failure doesn't take down the page. Build it on [`catchError`](https://preview.nextjs.org/docs/app/api-reference/functions/catchError) from `next/error` (its `ErrorInfo` gives you a `retry()` that re-fetches server data) — it understands Next's control-flow throws (`notFound()`, `redirect()`, `unauthorized()`, `forbidden()`) and won't swallow them. Place the boundary around the suspending section, in the page:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ErrorBoundary title="Replies didn't load">
|
||||||
|
<Suspense fallback={<RepliesSkeleton />}>
|
||||||
|
<Replies postId={id} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
```
|
||||||
|
|
||||||
|
Why not plain `react-error-boundary`? It catches Next's framework throws (so `notFound()` never reaches `not-found.tsx`), and its reset doesn't re-fetch server data. Background: [Error Handling in Next.js with catchError](https://aurorascharff.no/posts/error-handling-in-nextjs-with-catch-error/).
|
||||||
|
|
||||||
|
Pair component-level boundaries with route-segment [`error.tsx`](https://preview.nextjs.org/docs/app/api-reference/file-conventions/error) for unrecoverable errors; it also receives a `retry` callback.
|
||||||
|
|
||||||
|
## Layout-level Suspense
|
||||||
|
|
||||||
|
Layouts compose feature components the same way pages do. Use `<Suspense>` for slots that fetch data (auth badge, sidebar):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function RootLayout({ children }: LayoutProps<'/'>) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<Suspense>
|
||||||
|
<AuthGate userPromise={getCurrentUser()} />
|
||||||
|
</Suspense>
|
||||||
|
<main>{children}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AuthGate` is a client component that resolves the promise with `use()` so the dialog can render conditionally without server-side branching.
|
||||||
|
|
||||||
|
## CLS prevention
|
||||||
|
|
||||||
|
Layout shift happens when:
|
||||||
|
|
||||||
|
- A skeleton is shorter than the real content
|
||||||
|
- A heading sits inside a Suspense boundary whose final height is unknown — it paints in the wrong place, then jumps
|
||||||
|
- A variable-length list streams in without a fallback that reserves space
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- Match skeleton height to the typical real content height.
|
||||||
|
- Move headings **outside** boundaries when their position depends on data above them.
|
||||||
|
- For unknown-height top sections, group everything below in one boundary so siblings stream together.
|
||||||
|
|
||||||
|
To audit CLS, use React DevTools' Suspense panel to pin each boundary in its loading state and check vertical positions.
|
||||||
|
|
||||||
|
## Runtime prefetch for high-value routes
|
||||||
|
|
||||||
|
With `cacheComponents` + [`partialPrefetching`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/partialPrefetching) enabled, a visible `<Link>` prefetches the destination's shared [App Shell](https://preview.nextjs.org/docs/app/glossary#app-shell) — enough to commit navigation instantly, with link-specific content streaming after. The default (`'auto'`) already does this; don't write `prefetch = 'auto'`.
|
||||||
|
|
||||||
|
Use `<Link prefetch={true}>` on high-value links to also resolve the destination's per-link runtime data (`params`, `searchParams`, the full URL) before the click. Each such link can wake the server for a runtime prerender, so reserve it for routes users predictably visit next. See [runtime prefetching](https://preview.nextjs.org/docs/app/guides/runtime-prefetching).
|
||||||
|
|
||||||
|
Can't enable `partialPrefetching` app-wide yet? Opt in per route with `export const prefetch = 'partial'` on the destination, then drop the per-route exports once the global flag is on — see [Adopting Partial Prefetching](https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching) for the incremental path and [prefetch config](https://preview.nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/prefetch) for the options. To validate navigation feels instant, see the [`instant` config](https://preview.nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/instant) and [Instant Navigation guide](https://preview.nextjs.org/docs/app/guides/instant-navigation).
|
||||||
|
|
||||||
|
## Never wrap the entire page in a Suspense fallback
|
||||||
|
|
||||||
|
Page chrome (header, nav, surrounding layout) should paint instantly. Only data-dependent sections suspend. If you find yourself wrapping `<div>` and everything in it with `<Suspense fallback={<FullPageSkeleton />}>`, restructure: pull static elements out, narrow the boundary to just the dynamic part.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Queries and actions
|
||||||
|
|
||||||
|
The data layer. Every feature has both: queries to read, actions to write.
|
||||||
|
|
||||||
|
This page covers the universal data layer that applies to every Next.js App Router app. When `cacheComponents: true` is enabled, follow `references/cache-components.md`: reusable reads are cached/tagged/lifetimed, and mutations update matching tags.
|
||||||
|
|
||||||
|
## Cache identities
|
||||||
|
|
||||||
|
When a server read also seeds a browser data cache, follow `references/single-page-applications.md` for the feature-local cache contract and client-library placement.
|
||||||
|
|
||||||
|
## Queries
|
||||||
|
|
||||||
|
Create `features/<domain>/<domain>-queries.ts`. Mark it `import 'server-only'` — that's the invariant. Default to plain async exports.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
export async function getFeed(userId: string) {
|
||||||
|
return db.post.findMany({ where: { userId } });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use [`cache()`](https://react.dev/reference/react/cache) from React only for **request-level deduplication** when the same dynamic query is called multiple times with the same arguments in one render. Highest-value cases: a session/user lookup used by many queries, or a shared expensive read used by metadata + page sections. Don't wrap every query "just in case" — it adds indirection and can hide when data is intentionally dynamic.
|
||||||
|
|
||||||
|
`cache()` dedups within a request; `'use cache'` + `cacheTag` (Cache Components) shares results *across* requests. Don't add React `cache()` to a function only because it already uses `'use cache'`; that is double-caching unless you have a separate, proven same-request duplication problem. See `references/cache-components.md`.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
Create `features/<domain>/<domain>-actions.ts`. Mark with `'use server'` at the top. Always:
|
||||||
|
|
||||||
|
1. Verify auth.
|
||||||
|
2. Validate input with your schema validator.
|
||||||
|
3. Run the mutation.
|
||||||
|
4. Invalidate cached data so the next render sees the new state.
|
||||||
|
5. Return a result (`{ ok }` or `{ error }`).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { refresh } from 'next/cache';
|
||||||
|
|
||||||
|
export async function createPost(formData: FormData) {
|
||||||
|
const user = await verifyUser();
|
||||||
|
const parsed = schema.safeParse({ body: formData.get('body') });
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false as const, error: parsed.error.issues[0].message };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.post.create({ data: { body: parsed.data.body, userId: user.id } });
|
||||||
|
refresh();
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[`refresh()`](https://preview.nextjs.org/docs/app/api-reference/functions/refresh) re-renders the current route for the current user. Use it when the affected read is deliberately dynamic and has no tag. With Cache Components enabled, reusable reads should have matching `cacheTag()` calls, so server actions normally call `updateTag()` for read-your-own-writes. See `references/cache-components.md`.
|
||||||
|
|
||||||
|
### Action file naming
|
||||||
|
|
||||||
|
Actions for a feature always go in `<folder>-actions.ts`, matching the folder name — even when the mutation operates on a sub-concept. `toggleFavorite` in `features/event/` lives in `event-actions.ts`, not `favorite-actions.ts`. The folder is the source of truth for the name.
|
||||||
|
|
||||||
|
## Calling actions from client components
|
||||||
|
|
||||||
|
Client components import server actions directly. **Don't** pass an action as a prop just to call it:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Right
|
||||||
|
'use client';
|
||||||
|
import { likePost } from '@/features/post/post-actions';
|
||||||
|
|
||||||
|
export function LikeButton({ postId }: { postId: string }) {
|
||||||
|
return <button onClick={() => likePost(postId)}>Like</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong — adds indirection with no benefit
|
||||||
|
async function Post({ id }: { id: string }) {
|
||||||
|
return <LikeButton postId={id} onLike={likePost} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Design components (`<BottomNav>`, `<ToggleGroup>`, `<SubmitButton>`) take this further with the **action-prop pattern** — `action` is a callback wrapped in `useTransition` / `useOptimistic` internally. See `references/ux-patterns.md`.
|
||||||
|
|
||||||
|
## Form actions vs onClick handlers
|
||||||
|
|
||||||
|
Prefer [`<form action={serverAction}>`](https://react.dev/reference/react-dom/components/form#action) for form mutations — React wraps the call in a transition and surfaces pending state automatically.
|
||||||
|
|
||||||
|
For one-off buttons, `onClick={() => action(args)}` is fine. Wrap in [`startTransition`](https://react.dev/reference/react/startTransition) if you need pending state.
|
||||||
|
|
||||||
|
## Return shape
|
||||||
|
|
||||||
|
Return a discriminated union from actions that can fail:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export type ActionResult<T = void> = { ok: true; data?: T } | { ok: false; error: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
Toast on `ok: false` from the client. Skip success toasts when an optimistic UI already shows the result.
|
||||||
|
|
||||||
|
A shared `ActionResult<T>` is optional — a per-action inline union is just as good, and often clearer when the payload has a natural name: `return { ok: true as const, playlist }` reads better than a generic `data`. What matters is that fallible actions return a discriminated union the client can narrow on, not that every action shares one type.
|
||||||
|
|
||||||
|
## Mappers and domain types
|
||||||
|
|
||||||
|
If your DB rows have shapes you don't want to leak to components (extra columns, ORM-specific types), write a mapper inside the query:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export async function getPost(id: string) {
|
||||||
|
const row = await db.post.findUnique({ where: { id }, include: { author: true } });
|
||||||
|
if (!row) notFound();
|
||||||
|
return toPost(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPost(row: PostRow & { author: UserRow }): Post {
|
||||||
|
return { id: row.id, body: row.body, author: row.author.handle };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Components see `Post`, not the ORM row. If that type is imported by multiple files in the feature, put it under `features/<domain>/types/` (for example `features/post/types/post.ts`). Promote it to top-level `types/` only when multiple features import it.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Single-page application patterns
|
||||||
|
|
||||||
|
Use this reference when a feature adds SWR, TanStack Query, or another browser data cache. For complete library APIs and runnable examples, follow the [Single-page applications guide](https://preview.nextjs.org/docs/app/guides/single-page-applications).
|
||||||
|
|
||||||
|
## Decide whether a client cache is needed
|
||||||
|
|
||||||
|
Use a client data library when the browser needs revalidation, optimistic mutations, request deduplication, or shared live data. If a Client Component only reads server data once, pass a Promise from its Server Component and unwrap it with `use()` instead.
|
||||||
|
|
||||||
|
## Keep ownership with the feature
|
||||||
|
|
||||||
|
```text
|
||||||
|
features/<domain>/
|
||||||
|
<domain>-cache.ts # Pure server tags + client keys
|
||||||
|
<domain>-queries.ts # Server reads and cacheLife
|
||||||
|
<domain>-query-options.ts # Client fetcher/query options
|
||||||
|
hooks/use-*.ts # Client mutations and coordination
|
||||||
|
components/ # Async server owner + client leaves
|
||||||
|
```
|
||||||
|
|
||||||
|
The cache contract imports neither Next.js nor the client library. Queries, actions, route handlers, hydration code, query options, and hooks import identities from it. This prevents a key or tag spelling from drifting between a read and its invalidation.
|
||||||
|
|
||||||
|
Keep behavior in the layer that owns it:
|
||||||
|
|
||||||
|
- Server `cacheLife`, `cacheTag`, and database reads belong in `<domain>-queries.ts`.
|
||||||
|
- Browser freshness and refetch behavior belong in `<domain>-query-options.ts` or the SWR hook.
|
||||||
|
- Optimistic mutation behavior belongs in `hooks/use-*.ts`.
|
||||||
|
- Tiny effect-only or interactive leaves belong in `components/`.
|
||||||
|
|
||||||
|
## Seed from the server
|
||||||
|
|
||||||
|
The async feature component owns the initial read and the library's hydration provider. The page remains a synchronous composition surface and owns the feature's Suspense boundary.
|
||||||
|
|
||||||
|
- With SWR, seed the exact key read by `useSWR`. Use `preload` with `cacheData` when later `mutate(key)` calls must update the seeded entry itself.
|
||||||
|
- With TanStack Query, seed the same query key read by the client query and render the client subtree inside `HydrationBoundary`.
|
||||||
|
|
||||||
|
Do not move the initial read to the browser just because the feature also has a client cache.
|
||||||
|
|
||||||
|
## Coordinate Cache Components
|
||||||
|
|
||||||
|
The server cache and browser cache have independent freshness policies. Do not mirror `cacheLife` into `staleTime`, polling intervals, or SWR revalidation settings. Coordinate identities and invalidation, not durations.
|
||||||
|
|
||||||
|
For tag-driven data, a mutation updates the client cache for immediate feedback and invalidates the same server tag used by the seeded read. For a time-driven server read, choose its `cacheLife` from the server data's freshness requirement.
|
||||||
|
|
||||||
|
TanStack Query hydration adds one coupling: the hydration timestamp must advance whenever the seeded data advances. For tag-driven reads, cache the timestamp with the same tags as the data. For time-driven reads, derive the data and timestamp from the same cached snapshot. Do not cache a `QueryClient` or dehydrated payload.
|
||||||
|
|
||||||
|
## Mutate without drift
|
||||||
|
|
||||||
|
Let the client library own the optimistic browser update, rollback, and authoritative response. Let the write invalidate the server tag only after stored data changes. Do not add polling as a cache-coordination mechanism; add focus revalidation, intervals, SSE, or WebSockets only when the product actually needs external updates to appear automatically.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# UX patterns
|
||||||
|
|
||||||
|
Interaction decisions on top of the architecture: which feedback mechanism to reach for, and the boundary edge-cases that trip agents up. Hook mechanics live in the React / Next docs — linked, not restated. The deeper end-to-end picture is the [Building interactive apps guide](https://preview.nextjs.org/docs/app/guides/interactive-apps).
|
||||||
|
|
||||||
|
## Choose the feedback mechanism
|
||||||
|
|
||||||
|
| Situation | Reach for | Key rule |
|
||||||
|
| --------- | --------- | -------- |
|
||||||
|
| Mutation unlikely to fail (favorite, vote, follow) | [`useOptimistic`](https://react.dev/reference/react/useOptimistic) | Update immediately, roll back on throw. Set it inside a transition; inside `<form action>` React opens the transition for you. Use a reducer for counters. |
|
||||||
|
| No optimistic fit (filters, sort, navigation) | [`useTransition`](https://react.dev/reference/react/useTransition) + `data-pending` | Put `data-pending` on the pending node; let ancestors react with CSS (`has-data-pending:` for a direct parent, `group-has-data-pending:` further up) so it bubbles without prop drilling. |
|
||||||
|
| Form field validation ("fix this field") | [`useActionState`](https://react.dev/reference/react/useActionState) | Action returns `{ error }`; render inline with `aria-invalid` + `role="alert"`. |
|
||||||
|
| Submit disable + spinner | [`useFormStatus`](https://react.dev/reference/react-dom/hooks/useFormStatus) | Call it from a child of `<form>`, not the form component itself. |
|
||||||
|
| One-shot result with no visible change | toast | See below. |
|
||||||
|
|
||||||
|
`useOptimistic(false)` also works as a transition-scoped **pending flag** that resets automatically when the transition settles — handy when you don't need the `data-pending` bubbling.
|
||||||
|
|
||||||
|
## Toasts
|
||||||
|
|
||||||
|
- **Toast only on error** when an optimistic UI already shows the result — a success toast next to an optimistic checkmark/removal is double feedback, which is noise.
|
||||||
|
- **Toast on success** only for non-visible side effects (email sent, link copied, file uploaded).
|
||||||
|
- **Don't toast for routine navigation** — the page change is the feedback.
|
||||||
|
- **Don't toast inside a server action.** Toasts are client-side; return a result and toast at the call site.
|
||||||
|
|
||||||
|
## View transitions: portaled / floating UI
|
||||||
|
|
||||||
|
Portaled elements (toasts, dialogs, popovers, dropdowns, tooltips) flicker during route transitions unless excluded. Apply `viewTransitionName: 'none'` to the portal root. When the portal also needs stacking control (z-index) or has translucent layers (backdrop-blur), give it a *named* transition and neutralize it in CSS instead — `::view-transition-group(name) { animation: none; z-index: … }` can do things `'none'` can't. See the [React View Transitions skill](https://github.com/vercel-labs/agent-skills/tree/main/skills/react-view-transitions).
|
||||||
|
|
||||||
|
## Destructive actions (delete / leave / unsubscribe)
|
||||||
|
|
||||||
|
Gate behind a confirmation dialog, and mind two edge cases:
|
||||||
|
|
||||||
|
- **Don't `redirect()` inside the action.** It throws, which stops the client from toasting or closing the dialog. Return `{ ok: true }` and navigate with `router.push()`.
|
||||||
|
- **Don't wrap the whole action call in `useTransition`** inside the dialog — with view transitions on, that animates the background UI behind the dialog. Track pending with `useState` / `useOptimistic(false)` and reserve `startTransition` for the post-success navigation only.
|
||||||
|
|
||||||
|
## The action-prop pattern
|
||||||
|
|
||||||
|
A reusable design component (`<ToggleGroup>`, `<BottomNav>`, `<SubmitButton>`) can take an action-style prop and own the async coordination (optimistic update, pending, dimming) so consumers pass a plain callback. Convention: an `action` / `*Action` prop signals "triggers a mutation this component coordinates," versus a plain `onChange` / `onClick` — renaming between them is a contract change. Not every such prop is transition-wrapped: a destructive `confirmAction` is awaited *without* a transition (see above). Transition-wrapping is the default for optimistic/navigation actions, not a rule tied to the name.
|
||||||
|
|
||||||
|
## URL-based pagination
|
||||||
|
|
||||||
|
Drive the page number through `searchParams`, render each page as its own `<Suspense>` boundary so pages stream independently, and add "load more" with `<Link scroll={false}>`. See [linking and navigating](https://preview.nextjs.org/docs/app/getting-started/linking-and-navigating).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
export function Feed({ page = 1 }: { page?: number }) {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{Array.from({ length: page }).map((_, i) => {
|
||||||
|
const p = i + 1;
|
||||||
|
return p === 1 ? (
|
||||||
|
<FeedPage key={p} page={p} />
|
||||||
|
) : (
|
||||||
|
<Suspense key={p} fallback={<FeedPageSkeleton />}>
|
||||||
|
<FeedPage page={p} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The first page can render in the parent boundary; later pages get their own fallbacks so "load more" streams only the newly requested page. If the URL update should preserve scroll, use [`<Link scroll={false}>`](https://preview.nextjs.org/docs/app/api-reference/components/link).
|
||||||
|
|
||||||
|
## Global client state
|
||||||
|
|
||||||
|
For truly global client state (audio player, cart, system-reactive theme), wrap a [context provider](https://react.dev/reference/react/createContext) at the root; the provider is `'use client'` but `children` stays server-rendered, and only leaf components read the context. **Don't push server data into it** — server data stays in queries; client state is for ephemeral UI (open menus, playback position, optimistic drafts). See the live-data decision in `references/components.md`.
|
||||||
1
.claude/skills/prototype
Symbolic link
1
.claude/skills/prototype
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/prototype
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: ui-ux-pro-max
|
name: ui-ux-pro-max
|
||||||
description: "UI/UX design intelligence. 50 styles, 21 palettes, 50 font pairings, 20 charts, 9 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind, shadcn/ui). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, mobile app, .html, .tsx, .vue, .svelte. Elements: button, modal, navbar, sidebar, card, table, form, chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, flat design. Topics: color palette, accessibility, animation, layout, typography, font pairing, spacing, hover, shadow, gradient. Integrations: shadcn/ui MCP for component search and examples."
|
description: "UI/UX design intelligence. 50 styles, 21 palettes, 50 font pairings, 20 charts, 4 stacks (Next.js, React, Tailwind, shadcn/ui). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, .html, .tsx. Elements: button, modal, navbar, sidebar, card, table, form, chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, flat design. Topics: color palette, accessibility, animation, layout, typography, font pairing, spacing, hover, shadow, gradient. Integrations: shadcn/ui MCP for component search and examples."
|
||||||
---
|
---
|
||||||
|
|
||||||
# UI/UX Pro Max - Design Intelligence
|
# UI/UX Pro Max - Design Intelligence
|
||||||
|
|
||||||
Comprehensive design guide for web and mobile applications. Contains 50+ styles, 97 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 9 technology stacks. Searchable database with priority-based recommendations.
|
Comprehensive design guide for this Next.js + React + Tailwind + shadcn/ui codebase. Contains 50+ styles, 97 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 4 technology stacks. Searchable database with priority-based recommendations.
|
||||||
|
|
||||||
## When to Apply
|
## When to Apply
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ Extract key information from user request:
|
|||||||
- **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
|
- **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
|
||||||
- **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
|
- **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
|
||||||
- **Industry**: healthcare, fintech, gaming, education, etc.
|
- **Industry**: healthcare, fintech, gaming, education, etc.
|
||||||
- **Stack**: React, Vue, Next.js, or default to `html-tailwind`
|
- **Stack**: Next.js / React / shadcn (default: `nextjs`)
|
||||||
|
|
||||||
### Step 2: Generate Design System (REQUIRED)
|
### Step 2: Generate Design System (REQUIRED)
|
||||||
|
|
||||||
@@ -167,15 +167,15 @@ python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<keyword>" --domain <dom
|
|||||||
| Alternative fonts | `typography` | `--domain typography "elegant luxury"` |
|
| Alternative fonts | `typography` | `--domain typography "elegant luxury"` |
|
||||||
| Landing structure | `landing` | `--domain landing "hero social-proof"` |
|
| Landing structure | `landing` | `--domain landing "hero social-proof"` |
|
||||||
|
|
||||||
### Step 4: Stack Guidelines (Default: html-tailwind)
|
### Step 4: Stack Guidelines (Default: nextjs)
|
||||||
|
|
||||||
Get implementation-specific best practices. If user doesn't specify a stack, **default to `html-tailwind`**.
|
Get implementation-specific best practices. If user doesn't specify a stack, **default to `nextjs`**. For component patterns use `shadcn`; for Tailwind utilities use `html-tailwind`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack html-tailwind
|
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack nextjs
|
||||||
```
|
```
|
||||||
|
|
||||||
Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`, `react-native`, `flutter`, `shadcn`
|
Available stacks: `nextjs`, `react`, `shadcn`, `html-tailwind`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -200,15 +200,10 @@ Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`
|
|||||||
|
|
||||||
| Stack | Focus |
|
| Stack | Focus |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) |
|
| `nextjs` | SSR, routing, images, API routes (DEFAULT) |
|
||||||
| `react` | State, hooks, performance, patterns |
|
| `react` | State, hooks, performance, patterns |
|
||||||
| `nextjs` | SSR, routing, images, API routes |
|
|
||||||
| `vue` | Composition API, Pinia, Vue Router |
|
|
||||||
| `svelte` | Runes, stores, SvelteKit |
|
|
||||||
| `swiftui` | Views, State, Navigation, Animation |
|
|
||||||
| `react-native` | Components, Navigation, Lists |
|
|
||||||
| `flutter` | Widgets, State, Layout, Theming |
|
|
||||||
| `shadcn` | shadcn/ui components, theming, forms, patterns |
|
| `shadcn` | shadcn/ui components, theming, forms, patterns |
|
||||||
|
| `html-tailwind` | Tailwind utilities, responsive, a11y |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -220,7 +215,7 @@ Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`
|
|||||||
- Product type: Beauty/Spa service
|
- Product type: Beauty/Spa service
|
||||||
- Style keywords: elegant, professional, soft
|
- Style keywords: elegant, professional, soft
|
||||||
- Industry: Beauty/Wellness
|
- Industry: Beauty/Wellness
|
||||||
- Stack: html-tailwind (default)
|
- Stack: nextjs (default)
|
||||||
|
|
||||||
### Step 2: Generate Design System (REQUIRED)
|
### Step 2: Generate Design System (REQUIRED)
|
||||||
|
|
||||||
@@ -243,7 +238,7 @@ python3 .claude/skills/ui-ux-pro-max/scripts/search.py "elegant luxury serif" --
|
|||||||
### Step 4: Stack Guidelines
|
### Step 4: Stack Guidelines
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "layout responsive form" --stack html-tailwind
|
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "layout responsive form" --stack nextjs
|
||||||
```
|
```
|
||||||
|
|
||||||
**Then:** Synthesize design system + detailed searches and implement the design.
|
**Then:** Synthesize design system + detailed searches and implement the design.
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
|
|
||||||
1,Widgets,Use StatelessWidget when possible,Immutable widgets are simpler,StatelessWidget for static UI,StatefulWidget for everything,class MyWidget extends StatelessWidget,class MyWidget extends StatefulWidget (static),Medium,https://api.flutter.dev/flutter/widgets/StatelessWidget-class.html
|
|
||||||
2,Widgets,Keep widgets small,Single responsibility principle,Extract widgets into smaller pieces,Large build methods,Column(children: [Header() Content()]),500+ line build method,Medium,
|
|
||||||
3,Widgets,Use const constructors,Compile-time constants for performance,const MyWidget() when possible,Non-const for static widgets,const Text('Hello'),Text('Hello') for literals,High,https://dart.dev/guides/language/language-tour#constant-constructors
|
|
||||||
4,Widgets,Prefer composition over inheritance,Combine widgets using children,Compose widgets,Extend widget classes,Container(child: MyContent()),class MyContainer extends Container,Medium,
|
|
||||||
5,State,Use setState correctly,Minimal state in StatefulWidget,setState for UI state changes,setState for business logic,setState(() { _counter++; }),Complex logic in setState,Medium,https://api.flutter.dev/flutter/widgets/State/setState.html
|
|
||||||
6,State,Avoid setState in build,Never call setState during build,setState in callbacks only,setState in build method,onPressed: () => setState(() {}),build() { setState(); },High,
|
|
||||||
7,State,Use state management for complex apps,Provider Riverpod BLoC,State management for shared state,setState for global state,Provider.of<MyState>(context),Global setState calls,Medium,
|
|
||||||
8,State,Prefer Riverpod or Provider,Recommended state solutions,Riverpod for new projects,InheritedWidget manually,ref.watch(myProvider),Custom InheritedWidget,Medium,https://riverpod.dev/
|
|
||||||
9,State,Dispose resources,Clean up controllers and subscriptions,dispose() for cleanup,Memory leaks from subscriptions,@override void dispose() { controller.dispose(); },No dispose implementation,High,
|
|
||||||
10,Layout,Use Column and Row,Basic layout widgets,Column Row for linear layouts,Stack for simple layouts,"Column(children: [Text(), Button()])",Stack for vertical list,Medium,https://api.flutter.dev/flutter/widgets/Column-class.html
|
|
||||||
11,Layout,Use Expanded and Flexible,Control flex behavior,Expanded to fill space,Fixed sizes in flex containers,Expanded(child: Container()),Container(width: 200) in Row,Medium,
|
|
||||||
12,Layout,Use SizedBox for spacing,Consistent spacing,SizedBox for gaps,Container for spacing only,SizedBox(height: 16),Container(height: 16),Low,
|
|
||||||
13,Layout,Use LayoutBuilder for responsive,Respond to constraints,LayoutBuilder for adaptive layouts,Fixed sizes for responsive,LayoutBuilder(builder: (context constraints) {}),Container(width: 375),Medium,https://api.flutter.dev/flutter/widgets/LayoutBuilder-class.html
|
|
||||||
14,Layout,Avoid deep nesting,Keep widget tree shallow,Extract deeply nested widgets,10+ levels of nesting,Extract widget to method or class,Column(Row(Column(Row(...)))),Medium,
|
|
||||||
15,Lists,Use ListView.builder,Lazy list building,ListView.builder for long lists,ListView with children for large lists,"ListView.builder(itemCount: 100, itemBuilder: ...)",ListView(children: items.map(...).toList()),High,https://api.flutter.dev/flutter/widgets/ListView-class.html
|
|
||||||
16,Lists,Provide itemExtent when known,Skip measurement,itemExtent for fixed height items,No itemExtent for uniform lists,ListView.builder(itemExtent: 50),ListView.builder without itemExtent,Medium,
|
|
||||||
17,Lists,Use keys for stateful items,Preserve widget state,Key for stateful list items,No key for dynamic lists,ListTile(key: ValueKey(item.id)),ListTile without key,High,
|
|
||||||
18,Lists,Use SliverList for custom scroll,Custom scroll effects,CustomScrollView with Slivers,Nested ListViews,CustomScrollView(slivers: [SliverList()]),ListView inside ListView,Medium,https://api.flutter.dev/flutter/widgets/SliverList-class.html
|
|
||||||
19,Navigation,Use Navigator 2.0 or GoRouter,Declarative routing,go_router for navigation,Navigator.push for complex apps,GoRouter(routes: [...]),Navigator.push everywhere,Medium,https://pub.dev/packages/go_router
|
|
||||||
20,Navigation,Use named routes,Organized navigation,Named routes for clarity,Anonymous routes,Navigator.pushNamed(context '/home'),Navigator.push(context MaterialPageRoute()),Low,
|
|
||||||
21,Navigation,Handle back button (PopScope),Android back behavior and predictive back (Android 14+),Use PopScope widget (WillPopScope is deprecated),Use WillPopScope,"PopScope(canPop: false, onPopInvoked: (didPop) => ...)",WillPopScope(onWillPop: ...),High,https://api.flutter.dev/flutter/widgets/PopScope-class.html
|
|
||||||
22,Navigation,Pass typed arguments,Type-safe route arguments,Typed route arguments,Dynamic arguments,MyRoute(id: '123'),arguments: {'id': '123'},Medium,
|
|
||||||
23,Async,Use FutureBuilder,Async UI building,FutureBuilder for async data,setState for async,FutureBuilder(future: fetchData()),fetchData().then((d) => setState()),Medium,https://api.flutter.dev/flutter/widgets/FutureBuilder-class.html
|
|
||||||
24,Async,Use StreamBuilder,Stream UI building,StreamBuilder for streams,Manual stream subscription,StreamBuilder(stream: myStream),stream.listen in initState,Medium,https://api.flutter.dev/flutter/widgets/StreamBuilder-class.html
|
|
||||||
25,Async,Handle loading and error states,Complete async UI states,ConnectionState checks,Only success state,if (snapshot.connectionState == ConnectionState.waiting),No loading indicator,High,
|
|
||||||
26,Async,Cancel subscriptions,Clean up stream subscriptions,Cancel in dispose,Memory leaks,subscription.cancel() in dispose,No subscription cleanup,High,
|
|
||||||
27,Theming,Use ThemeData,Consistent theming,ThemeData for app theme,Hardcoded colors,Theme.of(context).primaryColor,Color(0xFF123456) everywhere,Medium,https://api.flutter.dev/flutter/material/ThemeData-class.html
|
|
||||||
28,Theming,Use ColorScheme,Material 3 color system,ColorScheme for colors,Individual color properties,colorScheme: ColorScheme.fromSeed(),primaryColor: Colors.blue,Medium,
|
|
||||||
29,Theming,Access theme via context,Dynamic theme access,Theme.of(context),Static theme reference,Theme.of(context).textTheme.bodyLarge,TextStyle(fontSize: 16),Medium,
|
|
||||||
30,Theming,Support dark mode,Respect system theme,darkTheme in MaterialApp,Light theme only,"MaterialApp(theme: light, darkTheme: dark)",MaterialApp(theme: light),Medium,
|
|
||||||
31,Animation,Use implicit animations,Simple animations,AnimatedContainer AnimatedOpacity,Explicit for simple transitions,AnimatedContainer(duration: Duration()),AnimationController for fade,Low,https://api.flutter.dev/flutter/widgets/AnimatedContainer-class.html
|
|
||||||
32,Animation,Use AnimationController for complex,Fine-grained control,AnimationController with Ticker,Implicit for complex sequences,AnimationController(vsync: this),AnimatedContainer for staggered,Medium,
|
|
||||||
33,Animation,Dispose AnimationControllers,Clean up animation resources,dispose() for controllers,Memory leaks,controller.dispose() in dispose,No controller disposal,High,
|
|
||||||
34,Animation,Use Hero for transitions,Shared element transitions,Hero for navigation animations,Manual shared element,Hero(tag: 'image' child: Image()),Custom shared element animation,Low,https://api.flutter.dev/flutter/widgets/Hero-class.html
|
|
||||||
35,Forms,Use Form widget,Form validation,Form with GlobalKey,Individual validation,Form(key: _formKey child: ...),TextField without Form,Medium,https://api.flutter.dev/flutter/widgets/Form-class.html
|
|
||||||
36,Forms,Use TextEditingController,Control text input,Controller for text fields,onChanged for all text,final controller = TextEditingController(),onChanged: (v) => setState(),Medium,
|
|
||||||
37,Forms,Validate on submit,Form validation flow,_formKey.currentState!.validate(),Skip validation,if (_formKey.currentState!.validate()),Submit without validation,High,
|
|
||||||
38,Forms,Dispose controllers,Clean up text controllers,dispose() for controllers,Memory leaks,controller.dispose() in dispose,No controller disposal,High,
|
|
||||||
39,Performance,Use const widgets,Reduce rebuilds,const for static widgets,No const for literals,const Icon(Icons.add),Icon(Icons.add),High,
|
|
||||||
40,Performance,Avoid rebuilding entire tree,Minimal rebuild scope,Isolate changing widgets,setState on parent,Consumer only around changing widget,setState on root widget,High,
|
|
||||||
41,Performance,Use RepaintBoundary,Isolate repaints,RepaintBoundary for animations,Full screen repaints,RepaintBoundary(child: AnimatedWidget()),Animation without boundary,Medium,https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html
|
|
||||||
42,Performance,Profile with DevTools,Measure before optimizing,Flutter DevTools profiling,Guess at performance,DevTools performance tab,Optimize without measuring,Medium,https://docs.flutter.dev/tools/devtools
|
|
||||||
43,Accessibility,Use Semantics widget,Screen reader support,Semantics for accessibility,Missing accessibility info,Semantics(label: 'Submit button'),GestureDetector without semantics,High,https://api.flutter.dev/flutter/widgets/Semantics-class.html
|
|
||||||
44,Accessibility,Support large fonts,MediaQuery text scaling,MediaQuery.textScaleFactor,Fixed font sizes,style: Theme.of(context).textTheme,TextStyle(fontSize: 14),High,
|
|
||||||
45,Accessibility,Test with screen readers,TalkBack and VoiceOver,Test accessibility regularly,Skip accessibility testing,Regular TalkBack testing,No screen reader testing,High,
|
|
||||||
46,Testing,Use widget tests,Test widget behavior,WidgetTester for UI tests,Unit tests only,testWidgets('...' (tester) async {}),Only test() for UI,Medium,https://docs.flutter.dev/testing
|
|
||||||
47,Testing,Use integration tests,Full app testing,integration_test package,Manual testing only,IntegrationTestWidgetsFlutterBinding,Manual E2E testing,Medium,
|
|
||||||
48,Testing,Mock dependencies,Isolate tests,Mockito or mocktail,Real dependencies in tests,when(mock.method()).thenReturn(),Real API calls in tests,Medium,
|
|
||||||
49,Platform,Use Platform checks,Platform-specific code,Platform.isIOS Platform.isAndroid,Same code for all platforms,if (Platform.isIOS) {},Hardcoded iOS behavior,Medium,
|
|
||||||
50,Platform,Use kIsWeb for web,Web platform detection,kIsWeb for web checks,Platform for web,if (kIsWeb) {},Platform.isWeb (doesn't exist),Medium,
|
|
||||||
51,Packages,Use pub.dev packages,Community packages,Popular maintained packages,Custom implementations,cached_network_image,Custom image cache,Medium,https://pub.dev/
|
|
||||||
52,Packages,Check package quality,Quality before adding,Pub points and popularity,Any package without review,100+ pub points,Unmaintained packages,Medium,
|
|
||||||
|
@@ -1,51 +0,0 @@
|
|||||||
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
|
|
||||||
1,Installation,Add Nuxt UI module,Install and configure Nuxt UI in your Nuxt project,pnpm add @nuxt/ui and add to modules,Manual component imports,"modules: ['@nuxt/ui']","import { UButton } from '@nuxt/ui'",High,https://ui.nuxt.com/docs/getting-started/installation/nuxt
|
|
||||||
2,Installation,Import Tailwind and Nuxt UI CSS,Required CSS imports in main.css file,@import tailwindcss and @import @nuxt/ui,Skip CSS imports,"@import ""tailwindcss""; @import ""@nuxt/ui"";",No CSS imports,High,https://ui.nuxt.com/docs/getting-started/installation/nuxt
|
|
||||||
3,Installation,Wrap app with UApp component,UApp provides global configs for Toast Tooltip and overlays,<UApp> wrapper in app.vue,Skip UApp wrapper,<UApp><NuxtPage/></UApp>,<NuxtPage/> without wrapper,High,https://ui.nuxt.com/docs/components/app
|
|
||||||
4,Components,Use U prefix for components,All Nuxt UI components use U prefix by default,UButton UInput UModal,Button Input Modal,<UButton>Click</UButton>,<Button>Click</Button>,Medium,https://ui.nuxt.com/docs/getting-started/installation/nuxt
|
|
||||||
5,Components,Use semantic color props,Use semantic colors like primary secondary error,color="primary" color="error",Hardcoded colors,"<UButton color=""primary"">","<UButton class=""bg-green-500"">",Medium,https://ui.nuxt.com/docs/getting-started/theme/design-system
|
|
||||||
6,Components,Use variant prop for styling,Nuxt UI provides solid outline soft subtle ghost link variants,variant="soft" variant="outline",Custom button classes,"<UButton variant=""soft"">","<UButton class=""border bg-transparent"">",Medium,https://ui.nuxt.com/docs/components/button
|
|
||||||
7,Components,Use size prop consistently,Components support xs sm md lg xl sizes,size="sm" size="lg",Arbitrary sizing classes,"<UButton size=""lg"">","<UButton class=""text-xl px-6"">",Low,https://ui.nuxt.com/docs/components/button
|
|
||||||
8,Icons,Use icon prop with Iconify format,Nuxt UI supports Iconify icons via icon prop,icon="lucide:home" icon="heroicons:user",i-lucide-home format,"<UButton icon=""lucide:home"">","<UButton icon=""i-lucide-home"">",Medium,https://ui.nuxt.com/docs/getting-started/integrations/icons/nuxt
|
|
||||||
9,Icons,Use leadingIcon and trailingIcon,Position icons with dedicated props for clarity,leadingIcon="lucide:plus" trailingIcon="lucide:arrow-right",Manual icon positioning,"<UButton leadingIcon=""lucide:plus"">","<UButton><Icon name=""lucide:plus""/>Add</UButton>",Low,https://ui.nuxt.com/docs/components/button
|
|
||||||
10,Theming,Configure colors in app.config.ts,Runtime color configuration without restart,ui.colors.primary in app.config.ts,Hardcoded colors in components,"defineAppConfig({ ui: { colors: { primary: 'blue' } } })","<UButton class=""bg-blue-500"">",High,https://ui.nuxt.com/docs/getting-started/theme/design-system
|
|
||||||
11,Theming,Use @theme directive for custom colors,Define design tokens in CSS with Tailwind @theme,@theme { --color-brand-500: #xxx },Inline color definitions,@theme { --color-brand-500: #ef4444; },:style="{ color: '#ef4444' }",Medium,https://ui.nuxt.com/docs/getting-started/theme/design-system
|
|
||||||
12,Theming,Extend semantic colors in nuxt.config,Register new colors like tertiary in theme.colors,theme.colors array in ui config,Use undefined colors,"ui: { theme: { colors: ['primary', 'tertiary'] } }","<UButton color=""tertiary""> without config",Medium,https://ui.nuxt.com/docs/getting-started/theme/design-system
|
|
||||||
13,Forms,Use UForm with schema validation,UForm supports Zod Yup Joi Valibot schemas,:schema prop with validation schema,Manual form validation,"<UForm :schema=""schema"" :state=""state"">",Manual @blur validation,High,https://ui.nuxt.com/docs/components/form
|
|
||||||
14,Forms,Use UFormField for field wrapper,Provides label error message and validation display,UFormField with name prop,Manual error handling,"<UFormField name=""email"" label=""Email"">",<div><label>Email</label><UInput/><span>error</span></div>,Medium,https://ui.nuxt.com/docs/components/form-field
|
|
||||||
15,Forms,Handle form submit with @submit,UForm emits submit event with validated data,@submit handler on UForm,@click on submit button,"<UForm @submit=""onSubmit"">","<UButton @click=""onSubmit"">",Medium,https://ui.nuxt.com/docs/components/form
|
|
||||||
16,Forms,Use validateOn prop for validation timing,Control when validation triggers (blur change input),validateOn="['blur']" for performance,Always validate on input,"<UForm :validateOn=""['blur', 'change']"">","<UForm> (validates on every keystroke)",Low,https://ui.nuxt.com/docs/components/form
|
|
||||||
17,Overlays,Use v-model:open for overlay control,Modal Slideover Drawer use v-model:open,v-model:open for controlled state,Manual show/hide logic,"<UModal v-model:open=""isOpen"">",<UModal v-if="isOpen">,Medium,https://ui.nuxt.com/docs/components/modal
|
|
||||||
18,Overlays,Use useOverlay composable for programmatic overlays,Open overlays programmatically without template refs,useOverlay().open(MyModal),Template ref and manual control,"const overlay = useOverlay(); overlay.open(MyModal, { props })","const modal = ref(); modal.value.open()",Medium,https://ui.nuxt.com/docs/components/modal
|
|
||||||
19,Overlays,Use title and description props,Built-in header support for overlays,title="Confirm" description="Are you sure?",Manual header content,"<UModal title=""Confirm"" description=""Are you sure?"">","<UModal><template #header><h2>Confirm</h2></template>",Low,https://ui.nuxt.com/docs/components/modal
|
|
||||||
20,Dashboard,Use UDashboardSidebar for navigation,Provides collapsible resizable sidebar with mobile support,UDashboardSidebar with header default footer slots,Custom sidebar implementation,<UDashboardSidebar><template #header>...</template></UDashboardSidebar>,<aside class="w-64 border-r">,Medium,https://ui.nuxt.com/docs/components/dashboard-sidebar
|
|
||||||
21,Dashboard,Use UDashboardGroup for layout,Wraps dashboard components with sidebar state management,UDashboardGroup > UDashboardSidebar + UDashboardPanel,Manual layout flex containers,<UDashboardGroup><UDashboardSidebar/><UDashboardPanel/></UDashboardGroup>,"<div class=""flex""><aside/><main/></div>",Medium,https://ui.nuxt.com/docs/components/dashboard-group
|
|
||||||
22,Dashboard,Use UDashboardNavbar for top navigation,Responsive navbar with mobile menu support,UDashboardNavbar in dashboard layout,Custom navbar implementation,<UDashboardNavbar :links="navLinks"/>,<nav class="border-b">,Low,https://ui.nuxt.com/docs/components/dashboard-navbar
|
|
||||||
23,Tables,Use UTable with data and columns props,Powered by TanStack Table with built-in features,:data and :columns props,Manual table markup,"<UTable :data=""users"" :columns=""columns""/>","<table><tr v-for=""user in users"">",High,https://ui.nuxt.com/docs/components/table
|
|
||||||
24,Tables,Define columns with accessorKey,Column definitions use accessorKey for data binding,accessorKey: 'email' in column def,String column names only,"{ accessorKey: 'email', header: 'Email' }","['name', 'email']",Medium,https://ui.nuxt.com/docs/components/table
|
|
||||||
25,Tables,Use cell slot for custom rendering,Customize cell content with scoped slots,#cell-columnName slot,Override entire table,<template #cell-status="{ row }">,Manual column render function,Medium,https://ui.nuxt.com/docs/components/table
|
|
||||||
26,Tables,Enable sorting with sortable column option,Add sortable: true to column definition,sortable: true in column,Manual sort implementation,"{ accessorKey: 'name', sortable: true }",@click="sortBy('name')",Low,https://ui.nuxt.com/docs/components/table
|
|
||||||
27,Navigation,Use UNavigationMenu for nav links,Horizontal or vertical navigation with dropdown support,UNavigationMenu with items array,Manual nav with v-for,"<UNavigationMenu :items=""navItems""/>","<nav><a v-for=""item in items"">",Medium,https://ui.nuxt.com/docs/components/navigation-menu
|
|
||||||
28,Navigation,Use UBreadcrumb for page hierarchy,Automatic breadcrumb with NuxtLink support,:items array with label and to,Manual breadcrumb links,"<UBreadcrumb :items=""breadcrumbs""/>","<nav><span v-for=""crumb in crumbs"">",Low,https://ui.nuxt.com/docs/components/breadcrumb
|
|
||||||
29,Navigation,Use UTabs for tabbed content,Tab navigation with content panels,UTabs with items containing slot content,Manual tab state,"<UTabs :items=""tabs""/>","<div><button @click=""tab=1"">",Medium,https://ui.nuxt.com/docs/components/tabs
|
|
||||||
30,Feedback,Use useToast for notifications,Composable for toast notifications,useToast().add({ title description }),Alert components for toasts,"const toast = useToast(); toast.add({ title: 'Saved' })",<UAlert v-if="showSuccess">,High,https://ui.nuxt.com/docs/components/toast
|
|
||||||
31,Feedback,Use UAlert for inline messages,Static alert messages with icon and actions,UAlert with title description color,Toast for static messages,"<UAlert title=""Warning"" color=""warning""/>",useToast for inline alerts,Medium,https://ui.nuxt.com/docs/components/alert
|
|
||||||
32,Feedback,Use USkeleton for loading states,Placeholder content during data loading,USkeleton with appropriate size,Spinner for content loading,<USkeleton class="h-4 w-32"/>,<UIcon name="lucide:loader" class="animate-spin"/>,Low,https://ui.nuxt.com/docs/components/skeleton
|
|
||||||
33,Color Mode,Use UColorModeButton for theme toggle,Built-in light/dark mode toggle button,UColorModeButton component,Manual color mode logic,<UColorModeButton/>,"<button @click=""toggleColorMode"">",Low,https://ui.nuxt.com/docs/components/color-mode-button
|
|
||||||
34,Color Mode,Use UColorModeSelect for theme picker,Dropdown to select system light or dark mode,UColorModeSelect component,Custom select for theme,<UColorModeSelect/>,"<USelect v-model=""colorMode"" :items=""modes""/>",Low,https://ui.nuxt.com/docs/components/color-mode-select
|
|
||||||
35,Customization,Use ui prop for component styling,Override component styles via ui prop,ui prop with slot class overrides,Global CSS overrides,"<UButton :ui=""{ base: 'rounded-full' }""/>",<UButton class="!rounded-full"/>,Medium,https://ui.nuxt.com/docs/getting-started/theme/components
|
|
||||||
36,Customization,Configure default variants in nuxt.config,Set default color and size for all components,theme.defaultVariants in ui config,Repeat props on every component,"ui: { theme: { defaultVariants: { color: 'neutral' } } }","<UButton color=""neutral""> everywhere",Medium,https://ui.nuxt.com/docs/getting-started/installation/nuxt
|
|
||||||
37,Customization,Use app.config.ts for theme overrides,Runtime theme customization,defineAppConfig with ui key,nuxt.config for runtime values,"defineAppConfig({ ui: { button: { defaultVariants: { size: 'sm' } } } })","nuxt.config ui.button.size: 'sm'",Medium,https://ui.nuxt.com/docs/getting-started/theme/components
|
|
||||||
38,Performance,Enable component detection,Tree-shake unused component CSS,experimental.componentDetection: true,Include all component CSS,"ui: { experimental: { componentDetection: true } }","ui: {} (includes all CSS)",Low,https://ui.nuxt.com/docs/getting-started/installation/nuxt
|
|
||||||
39,Performance,Use UTable virtualize for large data,Enable virtualization for 1000+ rows,:virtualize prop on UTable,Render all rows,"<UTable :data=""largeData"" virtualize/>","<UTable :data=""largeData""/>",Medium,https://ui.nuxt.com/docs/components/table
|
|
||||||
40,Accessibility,Use semantic component props,Components have built-in ARIA support,Use title description label props,Skip accessibility props,"<UModal title=""Settings"">","<UModal><h2>Settings</h2>",Medium,https://ui.nuxt.com/docs/components/modal
|
|
||||||
41,Accessibility,Use UFormField for form accessibility,Automatic label-input association,UFormField wraps inputs,Manual id and for attributes,"<UFormField label=""Email""><UInput/></UFormField>","<label for=""email"">Email</label><UInput id=""email""/>",High,https://ui.nuxt.com/docs/components/form-field
|
|
||||||
42,Content,Use UContentToc for table of contents,Automatic TOC with active heading highlight,UContentToc with :links,Manual TOC implementation,"<UContentToc :links=""toc""/>","<nav><a v-for=""heading in headings"">",Low,https://ui.nuxt.com/docs/components/content-toc
|
|
||||||
43,Content,Use UContentSearch for docs search,Command palette for documentation search,UContentSearch with Nuxt Content,Custom search implementation,<UContentSearch/>,<UCommandPalette :groups="searchResults"/>,Low,https://ui.nuxt.com/docs/components/content-search
|
|
||||||
44,AI/Chat,Use UChatMessages for chat UI,Designed for Vercel AI SDK integration,UChatMessages with messages array,Custom chat message list,"<UChatMessages :messages=""messages""/>","<div v-for=""msg in messages"">",Medium,https://ui.nuxt.com/docs/components/chat-messages
|
|
||||||
45,AI/Chat,Use UChatPrompt for input,Enhanced textarea for AI prompts,UChatPrompt with v-model,Basic textarea,<UChatPrompt v-model="prompt"/>,<UTextarea v-model="prompt"/>,Medium,https://ui.nuxt.com/docs/components/chat-prompt
|
|
||||||
46,Editor,Use UEditor for rich text,TipTap-based editor with toolbar support,UEditor with v-model:content,Custom TipTap setup,"<UEditor v-model:content=""content""/>",Manual TipTap initialization,Medium,https://ui.nuxt.com/docs/components/editor
|
|
||||||
47,Links,Use to prop for navigation,UButton and ULink support NuxtLink to prop,to="/dashboard" for internal links,href for internal navigation,"<UButton to=""/dashboard"">","<UButton href=""/dashboard"">",Medium,https://ui.nuxt.com/docs/components/button
|
|
||||||
48,Links,Use external prop for outside links,Explicitly mark external links,target="_blank" with external URLs,Forget rel="noopener","<UButton to=""https://example.com"" target=""_blank"">","<UButton href=""https://..."">",Low,https://ui.nuxt.com/docs/components/link
|
|
||||||
49,Loading,Use loadingAuto on buttons,Automatic loading state from @click promise,loadingAuto prop on UButton,Manual loading state,"<UButton loadingAuto @click=""async () => await save()"">","<UButton :loading=""isLoading"" @click=""save"">",Low,https://ui.nuxt.com/docs/components/button
|
|
||||||
50,Loading,Use UForm loadingAuto,Auto-disable form during submit,loadingAuto on UForm (default true),Manual form disabled state,"<UForm @submit=""handleSubmit"">","<UForm :disabled=""isSubmitting"">",Low,https://ui.nuxt.com/docs/components/form
|
|
||||||
|
Can't render this file because it contains an unexpected character in line 6 and column 94.
|
@@ -1,59 +0,0 @@
|
|||||||
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
|
|
||||||
1,Routing,Use file-based routing,Create routes by adding files in pages directory,pages/ directory with index.vue,Manual route configuration,pages/dashboard/index.vue,Custom router setup,Medium,https://nuxt.com/docs/getting-started/routing
|
|
||||||
2,Routing,Use dynamic route parameters,Create dynamic routes with bracket syntax,[id].vue for dynamic params,Hardcoded routes for dynamic content,pages/posts/[id].vue,pages/posts/post1.vue,Medium,https://nuxt.com/docs/getting-started/routing
|
|
||||||
3,Routing,Use catch-all routes,Handle multiple path segments with [...slug],[...slug].vue for catch-all,Multiple nested dynamic routes,pages/[...slug].vue,pages/[a]/[b]/[c].vue,Low,https://nuxt.com/docs/getting-started/routing
|
|
||||||
4,Routing,Define page metadata with definePageMeta,Set page-level configuration and middleware,definePageMeta for layout middleware title,Manual route meta configuration,"definePageMeta({ layout: 'admin', middleware: 'auth' })",router.beforeEach for page config,High,https://nuxt.com/docs/api/utils/define-page-meta
|
|
||||||
5,Routing,Use validate for route params,Validate dynamic route parameters before rendering,validate function in definePageMeta,Manual validation in setup,"definePageMeta({ validate: (route) => /^\d+$/.test(route.params.id) })",if (!valid) navigateTo('/404'),Medium,https://nuxt.com/docs/api/utils/define-page-meta
|
|
||||||
6,Rendering,Use SSR by default,Server-side rendering is enabled by default,Keep ssr: true (default),Disable SSR unnecessarily,ssr: true (default),ssr: false for all pages,High,https://nuxt.com/docs/guide/concepts/rendering
|
|
||||||
7,Rendering,Use .client suffix for client-only components,Mark components to render only on client,ComponentName.client.vue suffix,v-if with process.client check,Comments.client.vue,<div v-if="process.client"><Comments/></div>,Medium,https://nuxt.com/docs/guide/directory-structure/components
|
|
||||||
8,Rendering,Use .server suffix for server-only components,Mark components to render only on server,ComponentName.server.vue suffix,Manual server check,HeavyMarkdown.server.vue,v-if="process.server",Low,https://nuxt.com/docs/guide/directory-structure/components
|
|
||||||
9,DataFetching,Use useFetch for simple data fetching,Wrapper around useAsyncData for URL fetching,useFetch for API calls,$fetch in onMounted,"const { data } = await useFetch('/api/posts')","onMounted(async () => { data.value = await $fetch('/api/posts') })",High,https://nuxt.com/docs/api/composables/use-fetch
|
|
||||||
10,DataFetching,Use useAsyncData for complex fetching,Fine-grained control over async data,useAsyncData for CMS or custom fetching,useFetch for non-URL data sources,"const { data } = await useAsyncData('posts', () => cms.getPosts())","const { data } = await useFetch(() => cms.getPosts())",Medium,https://nuxt.com/docs/api/composables/use-async-data
|
|
||||||
11,DataFetching,Use $fetch for non-reactive requests,$fetch for event handlers and non-component code,$fetch in event handlers or server routes,useFetch in click handlers,"async function submit() { await $fetch('/api/submit', { method: 'POST' }) }","async function submit() { await useFetch('/api/submit') }",High,https://nuxt.com/docs/api/utils/dollarfetch
|
|
||||||
12,DataFetching,Use lazy option for non-blocking fetch,Defer data fetching for better initial load,lazy: true for below-fold content,Blocking fetch for non-critical data,"useFetch('/api/comments', { lazy: true })",await useFetch('/api/comments') for footer,Medium,https://nuxt.com/docs/api/composables/use-fetch
|
|
||||||
13,DataFetching,Use server option to control fetch location,Choose where data is fetched,server: false for client-only data,Server fetch for user-specific client data,"useFetch('/api/user-preferences', { server: false })",useFetch for localStorage-dependent data,Medium,https://nuxt.com/docs/api/composables/use-fetch
|
|
||||||
14,DataFetching,Use pick to reduce payload size,Select only needed fields from response,pick option for large responses,Fetching entire objects when few fields needed,"useFetch('/api/user', { pick: ['id', 'name'] })",useFetch('/api/user') then destructure,Low,https://nuxt.com/docs/api/composables/use-fetch
|
|
||||||
15,DataFetching,Use transform for data manipulation,Transform data before storing in state,transform option for data shaping,Manual transformation after fetch,"useFetch('/api/posts', { transform: (posts) => posts.map(p => p.title) })",const titles = data.value.map(p => p.title),Low,https://nuxt.com/docs/api/composables/use-fetch
|
|
||||||
16,DataFetching,Handle loading and error states,Always handle pending and error states,Check status pending error refs,Ignoring loading states,"<div v-if=""status === 'pending'"">Loading...</div>",No loading indicator,High,https://nuxt.com/docs/getting-started/data-fetching
|
|
||||||
17,Lifecycle,Avoid side effects in script setup root,Move side effects to lifecycle hooks,Side effects in onMounted,setInterval in root script setup,"onMounted(() => { interval = setInterval(...) })","<script setup>setInterval(...)</script>",High,https://nuxt.com/docs/guide/concepts/nuxt-lifecycle
|
|
||||||
18,Lifecycle,Use onMounted for DOM access,Access DOM only after component is mounted,onMounted for DOM manipulation,Direct DOM access in setup,"onMounted(() => { document.getElementById('el') })","<script setup>document.getElementById('el')</script>",High,https://nuxt.com/docs/api/composables/on-mounted
|
|
||||||
19,Lifecycle,Use nextTick for post-render access,Wait for DOM updates before accessing elements,await nextTick() after state changes,Immediate DOM access after state change,"count.value++; await nextTick(); el.value.focus()","count.value++; el.value.focus()",Medium,https://nuxt.com/docs/api/utils/next-tick
|
|
||||||
20,Lifecycle,Use onPrehydrate for pre-hydration logic,Run code before Nuxt hydrates the page,onPrehydrate for client setup,onMounted for hydration-critical code,"onPrehydrate(() => { console.log(window) })",onMounted for pre-hydration needs,Low,https://nuxt.com/docs/api/composables/on-prehydrate
|
|
||||||
21,Server,Use server/api for API routes,Create API endpoints in server/api directory,server/api/users.ts for /api/users,Manual Express setup,server/api/hello.ts -> /api/hello,app.get('/api/hello'),High,https://nuxt.com/docs/guide/directory-structure/server
|
|
||||||
22,Server,Use defineEventHandler for handlers,Define server route handlers,defineEventHandler for all handlers,export default function,"export default defineEventHandler((event) => { return { hello: 'world' } })","export default function(req, res) {}",High,https://nuxt.com/docs/guide/directory-structure/server
|
|
||||||
23,Server,Use server/routes for non-api routes,Routes without /api prefix,server/routes for custom paths,server/api for non-api routes,server/routes/sitemap.xml.ts,server/api/sitemap.xml.ts,Medium,https://nuxt.com/docs/guide/directory-structure/server
|
|
||||||
24,Server,Use getQuery and readBody for input,Access query params and request body,getQuery(event) readBody(event),Direct event access,"const { id } = getQuery(event)",event.node.req.query,Medium,https://nuxt.com/docs/guide/directory-structure/server
|
|
||||||
25,Server,Validate server input,Always validate input in server handlers,Zod or similar for validation,Trust client input,"const body = await readBody(event); schema.parse(body)",const body = await readBody(event),High,https://nuxt.com/docs/guide/directory-structure/server
|
|
||||||
26,State,Use useState for shared reactive state,SSR-friendly shared state across components,useState for cross-component state,ref for shared state,"const count = useState('count', () => 0)",const count = ref(0) in composable,High,https://nuxt.com/docs/api/composables/use-state
|
|
||||||
27,State,Use unique keys for useState,Prevent state conflicts with unique keys,Descriptive unique keys for each state,Generic or duplicate keys,"useState('user-preferences', () => ({}))",useState('data') in multiple places,Medium,https://nuxt.com/docs/api/composables/use-state
|
|
||||||
28,State,Use Pinia for complex state,Pinia for advanced state management,@pinia/nuxt for complex apps,Custom state management,useMainStore() with Pinia,Custom reactive store implementation,Medium,https://nuxt.com/docs/getting-started/state-management
|
|
||||||
29,State,Use callOnce for one-time async operations,Ensure async operations run only once,callOnce for store initialization,Direct await in component,"await callOnce(store.fetch)",await store.fetch() on every render,Medium,https://nuxt.com/docs/api/utils/call-once
|
|
||||||
30,SEO,Use useSeoMeta for SEO tags,Type-safe SEO meta tag management,useSeoMeta for meta tags,useHead for simple meta,"useSeoMeta({ title: 'Home', ogTitle: 'Home', description: '...' })","useHead({ meta: [{ name: 'description', content: '...' }] })",High,https://nuxt.com/docs/api/composables/use-seo-meta
|
|
||||||
31,SEO,Use reactive values in useSeoMeta,Dynamic SEO tags with refs or getters,Computed getters for dynamic values,Static values for dynamic content,"useSeoMeta({ title: () => post.value.title })","useSeoMeta({ title: post.value.title })",Medium,https://nuxt.com/docs/api/composables/use-seo-meta
|
|
||||||
32,SEO,Use useHead for non-meta head elements,Scripts styles links in head,useHead for scripts and links,useSeoMeta for scripts,"useHead({ script: [{ src: '/analytics.js' }] })","useSeoMeta({ script: '...' })",Medium,https://nuxt.com/docs/api/composables/use-head
|
|
||||||
33,SEO,Include OpenGraph tags,Add OG tags for social sharing,ogTitle ogDescription ogImage,Missing social preview,"useSeoMeta({ ogImage: '/og.png', twitterCard: 'summary_large_image' })",No OG configuration,Medium,https://nuxt.com/docs/api/composables/use-seo-meta
|
|
||||||
34,Middleware,Use defineNuxtRouteMiddleware,Define route middleware properly,defineNuxtRouteMiddleware wrapper,export default function,"export default defineNuxtRouteMiddleware((to, from) => {})","export default function(to, from) {}",High,https://nuxt.com/docs/guide/directory-structure/middleware
|
|
||||||
35,Middleware,Use navigateTo for redirects,Redirect in middleware with navigateTo,return navigateTo('/login'),router.push in middleware,"if (!auth) return navigateTo('/login')","if (!auth) router.push('/login')",High,https://nuxt.com/docs/api/utils/navigate-to
|
|
||||||
36,Middleware,Reference middleware in definePageMeta,Apply middleware to specific pages,middleware array in definePageMeta,Global middleware for page-specific,definePageMeta({ middleware: ['auth'] }),Global auth check for one page,Medium,https://nuxt.com/docs/guide/directory-structure/middleware
|
|
||||||
37,Middleware,Use .global suffix for global middleware,Apply middleware to all routes,auth.global.ts for app-wide auth,Manual middleware on every page,middleware/auth.global.ts,middleware: ['auth'] on every page,Medium,https://nuxt.com/docs/guide/directory-structure/middleware
|
|
||||||
38,ErrorHandling,Use createError for errors,Create errors with proper status codes,createError with statusCode,throw new Error,"throw createError({ statusCode: 404, statusMessage: 'Not Found' })",throw new Error('Not Found'),High,https://nuxt.com/docs/api/utils/create-error
|
|
||||||
39,ErrorHandling,Use NuxtErrorBoundary for local errors,Handle errors within component subtree,NuxtErrorBoundary for component errors,Global error page for local errors,"<NuxtErrorBoundary @error=""log""><template #error=""{ error }"">",error.vue for component errors,Medium,https://nuxt.com/docs/getting-started/error-handling
|
|
||||||
40,ErrorHandling,Use clearError to recover from errors,Clear error state and optionally redirect,clearError({ redirect: '/' }),Manual error state reset,clearError({ redirect: '/home' }),error.value = null,Medium,https://nuxt.com/docs/api/utils/clear-error
|
|
||||||
41,ErrorHandling,Use short statusMessage,Keep statusMessage brief for security,Short generic messages,Detailed error info in statusMessage,"createError({ statusCode: 400, statusMessage: 'Bad Request' })","createError({ statusMessage: 'Invalid user ID: 123' })",High,https://nuxt.com/docs/getting-started/error-handling
|
|
||||||
42,Link,Use NuxtLink for internal navigation,Client-side navigation with prefetching,<NuxtLink to> for internal links,<a href> for internal links,<NuxtLink to="/about">About</NuxtLink>,<a href="/about">About</a>,High,https://nuxt.com/docs/api/components/nuxt-link
|
|
||||||
43,Link,Configure prefetch behavior,Control when prefetching occurs,prefetchOn for interaction-based,Default prefetch for low-priority,"<NuxtLink prefetch-on=""interaction"">",Always default prefetch,Low,https://nuxt.com/docs/api/components/nuxt-link
|
|
||||||
44,Link,Use useRouter for programmatic navigation,Navigate programmatically,useRouter().push() for navigation,Direct window.location,"const router = useRouter(); router.push('/dashboard')",window.location.href = '/dashboard',Medium,https://nuxt.com/docs/api/composables/use-router
|
|
||||||
45,Link,Use navigateTo in composables,Navigate outside components,navigateTo() in middleware or plugins,useRouter in non-component code,return navigateTo('/login'),router.push in middleware,Medium,https://nuxt.com/docs/api/utils/navigate-to
|
|
||||||
46,AutoImports,Leverage auto-imports,Use auto-imported composables directly,Direct use of ref computed useFetch,Manual imports for Nuxt composables,"const count = ref(0)","import { ref } from 'vue'; const count = ref(0)",Medium,https://nuxt.com/docs/guide/concepts/auto-imports
|
|
||||||
47,AutoImports,Use #imports for explicit imports,Explicit imports when needed,#imports for clarity or disabled auto-imports,"import from 'vue' when auto-import enabled","import { ref } from '#imports'","import { ref } from 'vue'",Low,https://nuxt.com/docs/guide/concepts/auto-imports
|
|
||||||
48,AutoImports,Configure third-party auto-imports,Add external package auto-imports,imports.presets in nuxt.config,Manual imports everywhere,"imports: { presets: [{ from: 'vue-i18n', imports: ['useI18n'] }] }",import { useI18n } everywhere,Low,https://nuxt.com/docs/guide/concepts/auto-imports
|
|
||||||
49,Plugins,Use defineNuxtPlugin,Define plugins properly,defineNuxtPlugin wrapper,export default function,"export default defineNuxtPlugin((nuxtApp) => {})","export default function(ctx) {}",High,https://nuxt.com/docs/guide/directory-structure/plugins
|
|
||||||
50,Plugins,Use provide for injection,Provide helpers across app,return { provide: {} } for type safety,nuxtApp.provide without types,"return { provide: { hello: (name) => `Hello ${name}!` } }","nuxtApp.provide('hello', fn)",Medium,https://nuxt.com/docs/guide/directory-structure/plugins
|
|
||||||
51,Plugins,Use .client or .server suffix,Control plugin execution environment,plugin.client.ts for client-only,if (process.client) checks,analytics.client.ts,"if (process.client) { // analytics }",Medium,https://nuxt.com/docs/guide/directory-structure/plugins
|
|
||||||
52,Environment,Use runtimeConfig for env vars,Access environment variables safely,runtimeConfig in nuxt.config,process.env directly,"runtimeConfig: { apiSecret: '', public: { apiBase: '' } }",process.env.API_SECRET in components,High,https://nuxt.com/docs/guide/going-further/runtime-config
|
|
||||||
53,Environment,Use NUXT_ prefix for env override,Override config with environment variables,NUXT_API_SECRET NUXT_PUBLIC_API_BASE,Custom env var names,NUXT_PUBLIC_API_BASE=https://api.example.com,API_BASE=https://api.example.com,High,https://nuxt.com/docs/guide/going-further/runtime-config
|
|
||||||
54,Environment,Access public config with useRuntimeConfig,Get public config in components,useRuntimeConfig().public,Direct process.env access,const config = useRuntimeConfig(); config.public.apiBase,process.env.NUXT_PUBLIC_API_BASE,High,https://nuxt.com/docs/api/composables/use-runtime-config
|
|
||||||
55,Environment,Keep secrets in private config,Server-only secrets in runtimeConfig root,runtimeConfig.apiSecret (server only),Secrets in public config,runtimeConfig: { dbPassword: '' },runtimeConfig: { public: { dbPassword: '' } },High,https://nuxt.com/docs/guide/going-further/runtime-config
|
|
||||||
56,Performance,Use Lazy prefix for code splitting,Lazy load components with Lazy prefix,<LazyComponent> for below-fold,Eager load all components,<LazyMountainsList v-if="show"/>,<MountainsList/> for hidden content,Medium,https://nuxt.com/docs/guide/directory-structure/components
|
|
||||||
57,Performance,Use useLazyFetch for non-blocking data,Alias for useFetch with lazy: true,useLazyFetch for secondary data,useFetch for all requests,"const { data } = useLazyFetch('/api/comments')",await useFetch for comments section,Medium,https://nuxt.com/docs/api/composables/use-lazy-fetch
|
|
||||||
58,Performance,Use lazy hydration for interactivity,Delay component hydration until needed,LazyComponent with hydration strategy,Immediate hydration for all,<LazyModal hydrate-on-visible/>,<Modal/> in footer,Low,https://nuxt.com/docs/guide/going-further/experimental-features
|
|
||||||
|
Can't render this file because it contains an unexpected character in line 8 and column 193.
|
@@ -1,52 +0,0 @@
|
|||||||
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
|
|
||||||
1,Components,Use functional components,Hooks-based components are standard,Functional components with hooks,Class components,const App = () => { },class App extends Component,Medium,https://reactnative.dev/docs/intro-react
|
|
||||||
2,Components,Keep components small,Single responsibility principle,Split into smaller components,Large monolithic components,<Header /><Content /><Footer />,500+ line component,Medium,
|
|
||||||
3,Components,Use TypeScript,Type safety for props and state,TypeScript for new projects,JavaScript without types,const Button: FC<Props> = () => { },const Button = (props) => { },Medium,
|
|
||||||
4,Components,Colocate component files,Keep related files together,Component folder with styles,Flat structure,components/Button/index.tsx styles.ts,components/Button.tsx styles/button.ts,Low,
|
|
||||||
5,Styling,Use StyleSheet.create,Optimized style objects,StyleSheet for all styles,Inline style objects,StyleSheet.create({ container: {} }),style={{ margin: 10 }},High,https://reactnative.dev/docs/stylesheet
|
|
||||||
6,Styling,Avoid inline styles,Prevent object recreation,Styles in StyleSheet,Inline style objects in render,style={styles.container},"style={{ margin: 10, padding: 5 }}",Medium,
|
|
||||||
7,Styling,Use flexbox for layout,React Native uses flexbox,flexDirection alignItems justifyContent,Absolute positioning everywhere,flexDirection: 'row',position: 'absolute' everywhere,Medium,https://reactnative.dev/docs/flexbox
|
|
||||||
8,Styling,Handle platform differences,Platform-specific styles,Platform.select or .ios/.android files,Same styles for both platforms,"Platform.select({ ios: {}, android: {} })",Hardcoded iOS values,Medium,https://reactnative.dev/docs/platform-specific-code
|
|
||||||
9,Styling,Use responsive dimensions,Scale for different screens,Dimensions or useWindowDimensions,Fixed pixel values,useWindowDimensions(),width: 375,Medium,
|
|
||||||
10,Navigation,Use React Navigation,Standard navigation library,React Navigation for routing,Manual navigation management,createStackNavigator(),Custom navigation state,Medium,https://reactnavigation.org/
|
|
||||||
11,Navigation,Type navigation params,Type-safe navigation,Typed navigation props,Untyped navigation,"navigation.navigate<RootStackParamList>('Home', { id })","navigation.navigate('Home', { id })",Medium,
|
|
||||||
12,Navigation,Use deep linking,Support URL-based navigation,Configure linking prop,No deep link support,linking: { prefixes: [] },No linking configuration,Medium,https://reactnavigation.org/docs/deep-linking/
|
|
||||||
13,Navigation,Handle back button,Android back button handling,useFocusEffect with BackHandler,Ignore back button,BackHandler.addEventListener,No back handler,High,
|
|
||||||
14,State,Use useState for local state,Simple component state,useState for UI state,Class component state,"const [count, setCount] = useState(0)",this.state = { count: 0 },Medium,
|
|
||||||
15,State,Use useReducer for complex state,Complex state logic,useReducer for related state,Multiple useState for related values,useReducer(reducer initialState),5+ useState calls,Medium,
|
|
||||||
16,State,Use context sparingly,Context for global state,Context for theme auth locale,Context for frequently changing data,ThemeContext for app theme,Context for list item data,Medium,
|
|
||||||
17,State,Consider Zustand or Redux,External state management,Zustand for simple Redux for complex,useState for global state,create((set) => ({ })),Prop drilling global state,Medium,
|
|
||||||
18,Lists,Use FlatList for long lists,Virtualized list rendering,FlatList for 50+ items,ScrollView with map,<FlatList data={items} />,<ScrollView>{items.map()}</ScrollView>,High,https://reactnative.dev/docs/flatlist
|
|
||||||
19,Lists,Provide keyExtractor,Unique keys for list items,keyExtractor with stable ID,Index as key,keyExtractor={(item) => item.id},"keyExtractor={(_, index) => index}",High,
|
|
||||||
20,Lists,Optimize renderItem,Memoize list item components,React.memo for list items,Inline render function,renderItem={({ item }) => <MemoizedItem item={item} />},renderItem={({ item }) => <View>...</View>},High,
|
|
||||||
21,Lists,Use getItemLayout for fixed height,Skip measurement for performance,getItemLayout when height known,Dynamic measurement for fixed items,"getItemLayout={(_, index) => ({ length: 50, offset: 50 * index, index })}",No getItemLayout for fixed height,Medium,
|
|
||||||
22,Lists,Implement windowSize,Control render window,Smaller windowSize for memory,Default windowSize for large lists,windowSize={5},windowSize={21} for huge lists,Medium,
|
|
||||||
23,Performance,Use React.memo,Prevent unnecessary re-renders,memo for pure components,No memoization,export default memo(MyComponent),export default MyComponent,Medium,
|
|
||||||
24,Performance,Use useCallback for handlers,Stable function references,useCallback for props,New function on every render,"useCallback(() => {}, [deps])",() => handlePress(),Medium,
|
|
||||||
25,Performance,Use useMemo for expensive ops,Cache expensive calculations,useMemo for heavy computations,Recalculate every render,"useMemo(() => expensive(), [deps])",const result = expensive(),Medium,
|
|
||||||
26,Performance,Avoid anonymous functions in JSX,Prevent re-renders,Named handlers or useCallback,Inline arrow functions,onPress={handlePress},onPress={() => doSomething()},Medium,
|
|
||||||
27,Performance,Use Hermes engine,Improved startup and memory,Enable Hermes in build,JavaScriptCore for new projects,hermes_enabled: true,hermes_enabled: false,Medium,https://reactnative.dev/docs/hermes
|
|
||||||
28,Images,Use expo-image,Modern performant image component for React Native,"Use expo-image for caching, blurring, and performance",Use default Image for heavy lists or unmaintained libraries,<Image source={url} cachePolicy='memory-disk' /> (expo-image),<FastImage source={url} />,Medium,https://docs.expo.dev/versions/latest/sdk/image/
|
|
||||||
29,Images,Specify image dimensions,Prevent layout shifts,width and height for remote images,No dimensions for network images,<Image style={{ width: 100 height: 100 }} />,<Image source={{ uri }} /> no size,High,
|
|
||||||
30,Images,Use resizeMode,Control image scaling,resizeMode cover contain,Stretch images,"resizeMode=""cover""",No resizeMode,Low,
|
|
||||||
31,Forms,Use controlled inputs,State-controlled form fields,value + onChangeText,Uncontrolled inputs,<TextInput value={text} onChangeText={setText} />,<TextInput defaultValue={text} />,Medium,
|
|
||||||
32,Forms,Handle keyboard,Manage keyboard visibility,KeyboardAvoidingView,Content hidden by keyboard,"<KeyboardAvoidingView behavior=""padding"">",No keyboard handling,High,https://reactnative.dev/docs/keyboardavoidingview
|
|
||||||
33,Forms,Use proper keyboard types,Appropriate keyboard for input,keyboardType for input type,Default keyboard for all,"keyboardType=""email-address""","keyboardType=""default"" for email",Low,
|
|
||||||
34,Touch,Use Pressable,Modern touch handling,Pressable for touch interactions,TouchableOpacity for new code,<Pressable onPress={} />,<TouchableOpacity onPress={} />,Low,https://reactnative.dev/docs/pressable
|
|
||||||
35,Touch,Provide touch feedback,Visual feedback on press,Ripple or opacity change,No feedback on press,android_ripple={{ color: 'gray' }},No press feedback,Medium,
|
|
||||||
36,Touch,Set hitSlop for small targets,Increase touch area,hitSlop for icons and small buttons,Tiny touch targets,hitSlop={{ top: 10 bottom: 10 }},44x44 with no hitSlop,Medium,
|
|
||||||
37,Animation,Use Reanimated,High-performance animations,react-native-reanimated,Animated API for complex,useSharedValue useAnimatedStyle,Animated.timing for gesture,Medium,https://docs.swmansion.com/react-native-reanimated/
|
|
||||||
38,Animation,Run on UI thread,worklets for smooth animation,Run animations on UI thread,JS thread animations,runOnUI(() => {}),Animated on JS thread,High,
|
|
||||||
39,Animation,Use gesture handler,Native gesture recognition,react-native-gesture-handler,JS-based gesture handling,<GestureDetector>,<View onTouchMove={} />,Medium,https://docs.swmansion.com/react-native-gesture-handler/
|
|
||||||
40,Async,Handle loading states,Show loading indicators,ActivityIndicator during load,Empty screen during load,{isLoading ? <ActivityIndicator /> : <Content />},No loading state,Medium,
|
|
||||||
41,Async,Handle errors gracefully,Error boundaries and fallbacks,Error UI for failed requests,Crash on error,{error ? <ErrorView /> : <Content />},No error handling,High,
|
|
||||||
42,Async,Cancel async operations,Cleanup on unmount,AbortController or cleanup,Memory leaks from async,useEffect cleanup,No cleanup for subscriptions,High,
|
|
||||||
43,Accessibility,Add accessibility labels,Describe UI elements,accessibilityLabel for all interactive,Missing labels,"accessibilityLabel=""Submit form""",<Pressable> without label,High,https://reactnative.dev/docs/accessibility
|
|
||||||
44,Accessibility,Use accessibility roles,Semantic meaning,accessibilityRole for elements,Wrong roles,"accessibilityRole=""button""",No role for button,Medium,
|
|
||||||
45,Accessibility,Support screen readers,Test with TalkBack/VoiceOver,Test with screen readers,Skip accessibility testing,Regular TalkBack testing,No screen reader testing,High,
|
|
||||||
46,Testing,Use React Native Testing Library,Component testing,render and fireEvent,Enzyme or manual testing,render(<Component />),shallow(<Component />),Medium,https://callstack.github.io/react-native-testing-library/
|
|
||||||
47,Testing,Test on real devices,Real device behavior,Test on iOS and Android devices,Simulator only,Device testing in CI,Simulator only testing,High,
|
|
||||||
48,Testing,Use Detox for E2E,End-to-end testing,Detox for critical flows,Manual E2E testing,detox test,Manual testing only,Medium,https://wix.github.io/Detox/
|
|
||||||
49,Native,Use native modules carefully,Bridge has overhead,Batch native calls,Frequent bridge crossing,Batch updates,Call native on every keystroke,High,
|
|
||||||
50,Native,Use Expo when possible,Simplified development,Expo for standard features,Bare RN for simple apps,expo install package,react-native link package,Low,https://docs.expo.dev/
|
|
||||||
51,Native,Handle permissions,Request permissions properly,Check and request permissions,Assume permissions granted,PermissionsAndroid.request(),Access without permission check,High,https://reactnative.dev/docs/permissionsandroid
|
|
||||||
|
@@ -1,54 +0,0 @@
|
|||||||
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
|
|
||||||
1,Reactivity,Use $: for reactive statements,Automatic dependency tracking,$: for derived values,Manual recalculation,$: doubled = count * 2,let doubled; count && (doubled = count * 2),Medium,https://svelte.dev/docs/svelte-components#script-3-$-marks-a-statement-as-reactive
|
|
||||||
2,Reactivity,Trigger reactivity with assignment,Svelte tracks assignments not mutations,Reassign arrays/objects to trigger update,Mutate without reassignment,"items = [...items, newItem]",items.push(newItem),High,https://svelte.dev/docs/svelte-components#script-2-assignments-are-reactive
|
|
||||||
3,Reactivity,Use $state in Svelte 5,Runes for explicit reactivity,let count = $state(0),Implicit reactivity in Svelte 5,let count = $state(0),let count = 0 (Svelte 5),Medium,https://svelte.dev/blog/runes
|
|
||||||
4,Reactivity,Use $derived for computed values,$derived replaces $: in Svelte 5,let doubled = $derived(count * 2),$: in Svelte 5,let doubled = $derived(count * 2),$: doubled = count * 2 (Svelte 5),Medium,
|
|
||||||
5,Reactivity,Use $effect for side effects,$effect replaces $: side effects,Use $effect for subscriptions,$: for side effects in Svelte 5,$effect(() => console.log(count)),$: console.log(count) (Svelte 5),Medium,
|
|
||||||
6,Props,Export let for props,Declare props with export let,export let propName,Props without export,export let count = 0,let count = 0,High,https://svelte.dev/docs/svelte-components#script-1-export-creates-a-component-prop
|
|
||||||
7,Props,Use $props in Svelte 5,$props rune for prop access,let { name } = $props(),export let in Svelte 5,"let { name, age = 0 } = $props()",export let name; export let age = 0,Medium,
|
|
||||||
8,Props,Provide default values,Default props with assignment,export let count = 0,Required props without defaults,export let count = 0,export let count,Low,
|
|
||||||
9,Props,Use spread props,Pass through unknown props,{...$$restProps} on elements,Manual prop forwarding,<button {...$$restProps}>,<button class={$$props.class}>,Low,https://svelte.dev/docs/basic-markup#attributes-and-props
|
|
||||||
10,Bindings,Use bind: for two-way binding,Simplified input handling,bind:value for inputs,on:input with manual update,<input bind:value={name}>,<input value={name} on:input={e => name = e.target.value}>,Low,https://svelte.dev/docs/element-directives#bind-property
|
|
||||||
11,Bindings,Bind to DOM elements,Reference DOM nodes,bind:this for element reference,querySelector in onMount,<div bind:this={el}>,onMount(() => el = document.querySelector()),Medium,
|
|
||||||
12,Bindings,Use bind:group for radios/checkboxes,Simplified group handling,bind:group for radio/checkbox groups,Manual checked handling,"<input type=""radio"" bind:group={selected}>","<input type=""radio"" checked={selected === value}>",Low,
|
|
||||||
13,Events,Use on: for event handlers,Event directive syntax,on:click={handler},addEventListener in onMount,<button on:click={handleClick}>,onMount(() => btn.addEventListener()),Medium,https://svelte.dev/docs/element-directives#on-eventname
|
|
||||||
14,Events,Forward events with on:event,Pass events to parent,on:click without handler,createEventDispatcher for DOM events,<button on:click>,"dispatch('click', event)",Low,
|
|
||||||
15,Events,Use createEventDispatcher,Custom component events,dispatch for custom events,on:event for custom events,"dispatch('save', { data })",on:save without dispatch,Medium,https://svelte.dev/docs/svelte#createeventdispatcher
|
|
||||||
16,Lifecycle,Use onMount for initialization,Run code after component mounts,onMount for setup and data fetching,Code in script body for side effects,onMount(() => fetchData()),fetchData() in script body,High,https://svelte.dev/docs/svelte#onmount
|
|
||||||
17,Lifecycle,Return cleanup from onMount,Automatic cleanup on destroy,Return function from onMount,Separate onDestroy for paired cleanup,onMount(() => { sub(); return unsub }),onMount(sub); onDestroy(unsub),Medium,
|
|
||||||
18,Lifecycle,Use onDestroy sparingly,Only when onMount cleanup not possible,onDestroy for non-mount cleanup,onDestroy for mount-related cleanup,onDestroy for store unsubscribe,onDestroy(() => clearInterval(id)),Low,
|
|
||||||
19,Lifecycle,Avoid beforeUpdate/afterUpdate,Usually not needed,Reactive statements instead,beforeUpdate for derived state,$: if (x) doSomething(),beforeUpdate(() => doSomething()),Low,
|
|
||||||
20,Stores,Use writable for mutable state,Basic reactive store,writable for shared mutable state,Local variables for shared state,const count = writable(0),let count = 0 in module,Medium,https://svelte.dev/docs/svelte-store#writable
|
|
||||||
21,Stores,Use readable for read-only state,External data sources,readable for derived/external data,writable for read-only data,"readable(0, set => interval(set))",writable(0) for timer,Low,https://svelte.dev/docs/svelte-store#readable
|
|
||||||
22,Stores,Use derived for computed stores,Combine or transform stores,derived for computed values,Manual subscription for derived,"derived(count, $c => $c * 2)",count.subscribe(c => doubled = c * 2),Medium,https://svelte.dev/docs/svelte-store#derived
|
|
||||||
23,Stores,Use $ prefix for auto-subscription,Automatic subscribe/unsubscribe,$storeName in components,Manual subscription,{$count},count.subscribe(c => value = c),High,
|
|
||||||
24,Stores,Clean up custom subscriptions,Unsubscribe when component destroys,Return unsubscribe from onMount,Leave subscriptions open,onMount(() => store.subscribe(fn)),store.subscribe(fn) in script,High,
|
|
||||||
25,Slots,Use slots for composition,Content projection,<slot> for flexible content,Props for all content,<slot>Default</slot>,"<Component content=""text""/>",Medium,https://svelte.dev/docs/special-elements#slot
|
|
||||||
26,Slots,Name slots for multiple areas,Multiple content areas,"<slot name=""header"">",Single slot for complex layouts,"<slot name=""header""><slot name=""footer"">",<slot> with complex conditionals,Low,
|
|
||||||
27,Slots,Check slot content with $$slots,Conditional slot rendering,$$slots.name for conditional rendering,Always render slot wrapper,"{#if $$slots.footer}<slot name=""footer""/>{/if}","<div><slot name=""footer""/></div>",Low,
|
|
||||||
28,Styling,Use scoped styles by default,Styles scoped to component,<style> for component styles,Global styles for component,:global() only when needed,<style> all global,Medium,https://svelte.dev/docs/svelte-components#style
|
|
||||||
29,Styling,Use :global() sparingly,Escape scoping when needed,:global for third-party styling,Global for all styles,:global(.external-lib),<style> without scoping,Medium,
|
|
||||||
30,Styling,Use CSS variables for theming,Dynamic styling,CSS custom properties,Inline styles for themes,"style=""--color: {color}""","style=""color: {color}""",Low,
|
|
||||||
31,Transitions,Use built-in transitions,Svelte transition directives,transition:fade for simple effects,Manual CSS transitions,<div transition:fade>,<div class:fade={visible}>,Low,https://svelte.dev/docs/element-directives#transition-fn
|
|
||||||
32,Transitions,Use in: and out: separately,Different enter/exit animations,in:fly out:fade for asymmetric,Same transition for both,<div in:fly out:fade>,<div transition:fly>,Low,
|
|
||||||
33,Transitions,Add local modifier,Prevent ancestor trigger,transition:fade|local,Global transitions for lists,<div transition:slide|local>,<div transition:slide>,Medium,
|
|
||||||
34,Actions,Use actions for DOM behavior,Reusable DOM logic,use:action for DOM enhancements,onMount for each usage,<div use:clickOutside>,onMount(() => setupClickOutside(el)),Medium,https://svelte.dev/docs/element-directives#use-action
|
|
||||||
35,Actions,Return update and destroy,Lifecycle methods for actions,"Return { update, destroy }",Only initial setup,"return { update(params) {}, destroy() {} }",return destroy only,Medium,
|
|
||||||
36,Actions,Pass parameters to actions,Configure action behavior,use:action={params},Hardcoded action behavior,<div use:tooltip={options}>,<div use:tooltip>,Low,
|
|
||||||
37,Logic,Use {#if} for conditionals,Template conditionals,{#if} {:else if} {:else},Ternary in expressions,{#if cond}...{:else}...{/if},{cond ? a : b} for complex,Low,https://svelte.dev/docs/logic-blocks#if
|
|
||||||
38,Logic,Use {#each} for lists,List rendering,{#each} with key,Map in expression,{#each items as item (item.id)},{items.map(i => `<div>${i}</div>`)},Medium,
|
|
||||||
39,Logic,Always use keys in {#each},Proper list reconciliation,(item.id) for unique key,Index as key or no key,{#each items as item (item.id)},"{#each items as item, i (i)}",High,
|
|
||||||
40,Logic,Use {#await} for promises,Handle async states,{#await} for loading/error states,Manual promise handling,{#await promise}...{:then}...{:catch},{#if loading}...{#if error},Medium,https://svelte.dev/docs/logic-blocks#await
|
|
||||||
41,SvelteKit,Use +page.svelte for routes,File-based routing,+page.svelte for route components,Custom routing setup,routes/about/+page.svelte,routes/About.svelte,Medium,https://kit.svelte.dev/docs/routing
|
|
||||||
42,SvelteKit,Use +page.js for data loading,Load data before render,load function in +page.js,onMount for data fetching,export function load() {},onMount(() => fetchData()),High,https://kit.svelte.dev/docs/load
|
|
||||||
43,SvelteKit,Use +page.server.js for server-only,Server-side data loading,+page.server.js for sensitive data,+page.js for API keys,+page.server.js with DB access,+page.js with DB access,High,
|
|
||||||
44,SvelteKit,Use form actions,Server-side form handling,+page.server.js actions,API routes for forms,export const actions = { default },fetch('/api/submit'),Medium,https://kit.svelte.dev/docs/form-actions
|
|
||||||
45,SvelteKit,Use $app/stores for app state,$page $navigating $updated,$page for current page data,Manual URL parsing,import { page } from '$app/stores',window.location.pathname,Medium,https://kit.svelte.dev/docs/modules#$app-stores
|
|
||||||
46,Performance,Use {#key} for forced re-render,Reset component state,{#key id} for fresh instance,Manual destroy/create,{#key item.id}<Component/>{/key},on:change={() => component = null},Low,https://svelte.dev/docs/logic-blocks#key
|
|
||||||
47,Performance,Avoid unnecessary reactivity,Not everything needs $:,$: only for side effects,$: for simple assignments,$: if (x) console.log(x),$: y = x (when y = x works),Low,
|
|
||||||
48,Performance,Use immutable compiler option,Skip equality checks,immutable: true for large lists,Default for all components,<svelte:options immutable/>,Default without immutable,Low,
|
|
||||||
49,TypeScript,"Use lang=""ts"" in script",TypeScript support,"<script lang=""ts"">",JavaScript for typed projects,"<script lang=""ts"">",<script> with JSDoc,Medium,https://svelte.dev/docs/typescript
|
|
||||||
50,TypeScript,Type props with interface,Explicit prop types,interface $$Props for types,Untyped props,interface $$Props { name: string },export let name,Medium,
|
|
||||||
51,TypeScript,Type events with createEventDispatcher,Type-safe events,createEventDispatcher<Events>(),Untyped dispatch,createEventDispatcher<{ save: Data }>(),createEventDispatcher(),Medium,
|
|
||||||
52,Accessibility,Use semantic elements,Proper HTML in templates,button nav main appropriately,div for everything,<button on:click>,<div on:click>,High,
|
|
||||||
53,Accessibility,Add aria to dynamic content,Accessible state changes,aria-live for updates,Silent dynamic updates,"<div aria-live=""polite"">{message}</div>",<div>{message}</div>,Medium,
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user