chore: agentic upgrade
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: fronted-designer
|
||||||
|
description: Use this agent for web design decisions, frontend visual direction, UI critique, redesigns, layout, typography, color, spacing, interaction states, responsive behavior, screenshots, mockups, landing pages, dashboards, and post-implementation design review. It is based on the project-local kimi skill and should consult Kimi Code for focused second opinions when useful.
|
||||||
|
model: inherit
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a senior frontend/product designer for this repository. Your job is to make practical design decisions that can be implemented cleanly in the existing codebase.
|
||||||
|
|
||||||
|
Start by reading and following `.agents/skills/kimi/SKILL.md`. Use the Kimi workflow before substantial design calls unless the task is tiny or Kimi is unavailable.
|
||||||
|
|
||||||
|
When consulting Kimi, prefer non-interactive CLI usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kimi --quiet -p "Act as a senior product/frontend designer. Review this design decision..."
|
||||||
|
```
|
||||||
|
|
||||||
|
For visual QA, Kimi can use the `agent-browser` skill. Ask Kimi to use it when screenshots, real browser layout, responsive states, or interaction behavior would materially improve the design decision.
|
||||||
|
|
||||||
|
Design stance:
|
||||||
|
|
||||||
|
- Preserve the existing product language, tokens, components, and FSD boundaries.
|
||||||
|
- Prefer calm, precise, product-specific UI over generic decorative patterns.
|
||||||
|
- Make decisions about hierarchy, density, typography, spacing, color, responsive behavior, accessibility, loading/empty/error states, and interaction affordances.
|
||||||
|
- Return concrete implementation guidance tied to files, components, CSS modules, or routes when possible.
|
||||||
|
- If you review a rendered UI, cite the viewport and visible issue, then give the smallest high-impact fix.
|
||||||
|
|
||||||
|
Output concise, actionable recommendations first. Include tradeoffs only when they affect implementation or user experience.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: chrome-testing
|
||||||
|
description: Rules for visual or browser testing
|
||||||
|
metadata:
|
||||||
|
tags: chrome, testing, visual, browser, server, ui, ux
|
||||||
|
---
|
||||||
|
|
||||||
|
# Chrome Browser Testing Rules
|
||||||
|
|
||||||
|
## Dev Server Port
|
||||||
|
|
||||||
|
When testing the frontend using Chrome browser automation, start the dev server with the port 3000:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn start -- --port 3000 --no-open
|
||||||
|
```
|
||||||
|
|
||||||
|
Use agent-browser sub-skill to execute browser testing
|
||||||
|
Admin account credentials: ilichenko.a@winsolutions.ru:aXID0AeGWbJ1
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Chrome Testing Rules"
|
||||||
|
short_description: "Rules when executing visual browser testing"
|
||||||
|
default_prompt: "Use chrome testing skill on visual or browser testing."
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
name: composition-patterns
|
||||||
|
description: Use when writing, reviewing, or refactoring React component APIs that risk boolean prop proliferation, renderHeader/renderFooter-style slots, prop drilling, trapped local state, context-provider architecture, compound components, explicit variants, or React 19 ref/context composition patterns.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Composition Patterns
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Apply scalable React composition patterns from Vercel's `composition-patterns`
|
||||||
|
skill, adapted for this Next.js/FSD repository. Prefer explicit composed APIs
|
||||||
|
over configuration-heavy components, and keep reusable UI parts decoupled from
|
||||||
|
state implementations.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Identify whether the component is becoming a mode switcher.
|
||||||
|
Look for multiple boolean props, conditional branches that choose whole UI
|
||||||
|
regions, `renderHeader`/`renderFooter` props, or impossible prop
|
||||||
|
combinations.
|
||||||
|
2. Split behavior into explicit variants when the rendered structure differs.
|
||||||
|
Prefer names such as `ThreadComposer`, `EditComposer`, or
|
||||||
|
`ForwardComposer` over one `Composer` with `isThread`, `isEditing`, and
|
||||||
|
`isForwarding`.
|
||||||
|
3. Extract shared pieces into compound components when consumers need to
|
||||||
|
arrange the parts themselves. Use `Root`/`Provider`, `Frame`, `Header`,
|
||||||
|
`Body`, `Footer`, `Trigger`, `Content`, `Action`, or domain-specific names
|
||||||
|
that fit the existing component.
|
||||||
|
4. Lift shared state into a provider boundary when siblings or custom outer UI
|
||||||
|
need the same state/actions. Keep visual nesting separate from state access:
|
||||||
|
components only need to be inside the provider, not inside the same DOM box.
|
||||||
|
5. Define a context contract with `state`, `actions`, and `meta` when multiple
|
||||||
|
providers can drive the same UI. UI subcomponents consume the contract, while
|
||||||
|
providers decide whether state comes from local hooks, server-synced data,
|
||||||
|
forms, or feature-specific stores.
|
||||||
|
6. In this React 19 repo, pass `ref` as a normal prop and use `use(Context)` for
|
||||||
|
context reads where the surrounding codebase allows it. Preserve existing
|
||||||
|
conventions if a nearby component has not migrated yet.
|
||||||
|
|
||||||
|
## Decision Rules
|
||||||
|
|
||||||
|
- Do not add a boolean prop to control a large behavior branch until checking
|
||||||
|
whether an explicit variant or child composition would make the state space
|
||||||
|
clearer.
|
||||||
|
- Use children for static structure composition. Keep render props for cases
|
||||||
|
where the parent must pass item data, measurements, or callback-local state
|
||||||
|
back into the child.
|
||||||
|
- Keep providers as the only layer that knows a concrete state implementation.
|
||||||
|
Subcomponents should not import feature stores or synchronization hooks unless
|
||||||
|
they are provider components.
|
||||||
|
- Avoid prop drilling through compound components. Put shared state/actions in a
|
||||||
|
typed context and expose narrow subcomponents.
|
||||||
|
- Keep FSD boundaries intact: shared compound UI belongs under `src/shared/ui`;
|
||||||
|
feature-specific variants and providers belong in the appropriate feature,
|
||||||
|
entity, widget, or page layer.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
Read `references/rules.md` when you need examples, a review checklist, or the
|
||||||
|
upstream rule inventory. It summarizes all files from:
|
||||||
|
`https://github.com/vercel-labs/agent-skills/tree/main/skills/composition-patterns`
|
||||||
|
at commit `ce3e64e468f8fa09a2d075d102771838061fdac0`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Composition Patterns"
|
||||||
|
short_description: "Apply scalable React composition patterns."
|
||||||
|
default_prompt: "Use composition patterns to refactor or design a React component API."
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# React Composition Rules
|
||||||
|
|
||||||
|
This reference distills the upstream Vercel `composition-patterns` skill. The
|
||||||
|
upstream directory was inspected as a whole: `SKILL.md`, `README.md`,
|
||||||
|
`metadata.json`, generated `AGENTS.md`, `_sections.md`, `_template.md`, and all
|
||||||
|
rule files under `rules/`.
|
||||||
|
|
||||||
|
## Rule Inventory
|
||||||
|
|
||||||
|
| Area | Rule | Use it to |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Architecture | Avoid boolean prop proliferation | Replace mode booleans with explicit composed variants. |
|
||||||
|
| Architecture | Use compound components | Let consumers arrange subcomponents that share context. |
|
||||||
|
| State | Decouple state management from UI | Keep concrete hooks/stores inside providers. |
|
||||||
|
| State | Define generic context interfaces | Share UI across providers using `state`, `actions`, `meta`. |
|
||||||
|
| State | Lift state into providers | Let outer/sibling components access composer-like state without refs or effects. |
|
||||||
|
| Patterns | Create explicit component variants | Make each mode self-documenting and impossible-state-free. |
|
||||||
|
| Patterns | Prefer children over render props | Use children for structure; reserve render props for data callbacks. |
|
||||||
|
| React 19 | Ref and context API changes | Use ref as a prop and `use(Context)` when the codebase is React 19+. |
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
- Composition over configuration: let consumers assemble behavior from parts
|
||||||
|
instead of adding props for every mode.
|
||||||
|
- State belongs at the provider boundary when more than one child or sibling
|
||||||
|
needs it.
|
||||||
|
- Compound internals should read shared context, not receive long prop chains.
|
||||||
|
- Explicit variants are clearer than a single component with many conditional
|
||||||
|
branches.
|
||||||
|
|
||||||
|
## Refactoring Checklist
|
||||||
|
|
||||||
|
1. Count behavior flags and conditional branches. Two or more mode booleans are
|
||||||
|
a strong signal to split variants.
|
||||||
|
2. Identify shared primitives. Extract the stable pieces first: frame, input,
|
||||||
|
header, footer, trigger, content, action, item, or slot-like sections.
|
||||||
|
3. Decide the provider boundary. Put it around every component that needs the
|
||||||
|
shared state, even if some of those components are visually outside the main
|
||||||
|
frame.
|
||||||
|
4. Define the context shape before wiring UI. Prefer:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface ComponentContextValue {
|
||||||
|
state: ComponentState
|
||||||
|
actions: ComponentActions
|
||||||
|
meta: ComponentMeta
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Move implementation-specific hooks into provider variants. For example,
|
||||||
|
`LocalComposerProvider`, `ChannelComposerProvider`, and
|
||||||
|
`EditComposerProvider` can implement the same UI contract.
|
||||||
|
6. Replace `renderX` props with children when the consumer is only placing
|
||||||
|
static structure.
|
||||||
|
7. Keep render props when the parent must provide data to each render, such as
|
||||||
|
list items, indices, or measured layout values.
|
||||||
|
8. For React 19 code, avoid adding new `forwardRef` wrappers unless compatibility
|
||||||
|
with React 18 or an existing library API requires it.
|
||||||
|
|
||||||
|
## Review Smells
|
||||||
|
|
||||||
|
- A component accepts several props named `is*`, `show*`, `has*`, or `mode` and
|
||||||
|
uses them to render different major sections.
|
||||||
|
- A caller can pass contradictory props, such as `isEditing` and `isForwarding`.
|
||||||
|
- A reusable UI component imports a feature store, route-specific hook, or sync
|
||||||
|
mechanism directly.
|
||||||
|
- State is copied upward through `useEffect` solely so another sibling can read
|
||||||
|
it.
|
||||||
|
- A submit button reads state from a ref because the state is trapped inside a
|
||||||
|
child component.
|
||||||
|
- `renderHeader`, `renderFooter`, or `renderActions` props are used only to place
|
||||||
|
static nodes.
|
||||||
|
|
||||||
|
## Repo Adaptation
|
||||||
|
|
||||||
|
- Put generic primitives in `src/shared/ui/<Component>/` with the repo's usual
|
||||||
|
source, SCSS module, test, story, and barrel layout.
|
||||||
|
- Keep feature-specific provider variants out of `shared` if they import
|
||||||
|
feature/entity/page state.
|
||||||
|
- Export public compound pieces through `index.ts` instead of deep imports.
|
||||||
|
- Coordinate with `react-best-practices` for performance, Server Component
|
||||||
|
boundaries, serialization, and client bundle impact.
|
||||||
|
- Coordinate with `storybook-ai-best-practices` when adding stories so each
|
||||||
|
compound part, variant, and state has focused examples.
|
||||||
|
|
||||||
|
## Source Notes
|
||||||
|
|
||||||
|
The upstream skill is version `1.0.0`, dated January 2026, and references React
|
||||||
|
documentation for context and the `use` API. Its generated `AGENTS.md` is a
|
||||||
|
compiled form of the individual rules, so prefer this distilled reference for
|
||||||
|
day-to-day work and consult the upstream repo only when refreshing the skill.
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
---
|
||||||
|
name: feature-sliced-design
|
||||||
|
description: >
|
||||||
|
Official Feature-Sliced Design (FSD) v2.1 skill for applying the methodology
|
||||||
|
to frontend projects. Use when the task involves organizing project structure
|
||||||
|
with FSD layers, deciding where code belongs, placing static assets (images,
|
||||||
|
icons, fonts, PDFs), grouping closely related slices, defining public APIs
|
||||||
|
and import boundaries, resolving cross-imports or evaluating the @x pattern,
|
||||||
|
deciding whether to create or remove an entity, evaluating whether the
|
||||||
|
entities layer is needed at all, deciding whether logic should remain local
|
||||||
|
or be extracted, migrating from FSD v2.0 or a non-FSD codebase, integrating
|
||||||
|
FSD with frameworks (Next.js App Router and Pages Router, Nuxt, Vite,
|
||||||
|
Astro), or implementing common patterns such as authentication, API
|
||||||
|
handling, Redux, and TanStack Query (React Query) within FSD.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Feature-Sliced Design (FSD) v2.1
|
||||||
|
|
||||||
|
> **Source**: [fsd.how](https://fsd.how) | Strictness can be adjusted based on
|
||||||
|
> project scale and team context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Core Philosophy & Layer Overview
|
||||||
|
|
||||||
|
FSD v2.1 core principle: **"Start simple, extract when needed."**
|
||||||
|
|
||||||
|
Place code in `pages/` first. Duplication across pages is acceptable and does
|
||||||
|
not automatically require extraction to a lower layer. Extract only when the
|
||||||
|
same code is currently being used in multiple places (not hypothetically),
|
||||||
|
the usages do not always change together, and the boundary has a focused
|
||||||
|
responsibility.
|
||||||
|
|
||||||
|
**Not all layers are required.** Most projects can start with only `shared/`,
|
||||||
|
`pages/`, and `app/`. Add `widgets/`, `features/`, `entities/` only when they
|
||||||
|
provide clear value. Do not create empty layer folders "just in case."
|
||||||
|
|
||||||
|
FSD uses 6 standardized layers, listed here from highest to lowest:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/ → App initialization, providers, routing
|
||||||
|
pages/ → Route-level composition, owns its own logic
|
||||||
|
widgets/ → Large composite UI blocks reused across multiple pages
|
||||||
|
features/ → Reusable user interactions (only when used in 2+ places)
|
||||||
|
entities/ → Reusable business domain models (only when used in 2+ places)
|
||||||
|
shared/ → Infrastructure with no business logic (UI kit, utils, API client)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import rule**: A module may only import from layers strictly below it.
|
||||||
|
Cross-imports between slices on the same layer are forbidden.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Allowed
|
||||||
|
import { Button } from "@/shared/ui/Button"; // features → shared
|
||||||
|
import { useUser } from "@/entities/user"; // pages → entities
|
||||||
|
|
||||||
|
// ❌ Violation
|
||||||
|
import { loginUser } from "@/features/auth"; // entities → features
|
||||||
|
import { likePost } from "@/features/like-post"; // features → features
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The `processes/` layer is **deprecated** in v2.1. For migration
|
||||||
|
details, read `references/migration-guide.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decision Framework
|
||||||
|
|
||||||
|
When writing new code, follow this tree:
|
||||||
|
|
||||||
|
**Step 1: Where is this code used?**
|
||||||
|
|
||||||
|
- Used in only one page → keep it in that `pages/` slice.
|
||||||
|
- Used in 2+ pages but duplication is manageable → keeping separate copies
|
||||||
|
in each page is also valid.
|
||||||
|
- An entity or feature used in only one page → keep it in that page
|
||||||
|
(Steiger: `insignificant-slice`).
|
||||||
|
|
||||||
|
**Step 2: Is it reusable infrastructure with no business logic?**
|
||||||
|
|
||||||
|
- UI components → `shared/ui/`
|
||||||
|
- Utility functions → `shared/lib/`
|
||||||
|
- API client, route constants → `shared/api/` or `shared/config/`
|
||||||
|
- Auth tokens, session management → `shared/auth/`
|
||||||
|
- CRUD operations → `shared/api/`
|
||||||
|
|
||||||
|
**Step 3: Is it a complete user action currently used in multiple places,
|
||||||
|
with stable boundaries?**
|
||||||
|
|
||||||
|
- Yes → `features/`
|
||||||
|
- Uncertain, single use, or speculative reuse → keep in the page.
|
||||||
|
|
||||||
|
**Step 4: Is it a business domain model currently used in multiple places,
|
||||||
|
with stable boundaries?**
|
||||||
|
|
||||||
|
- Yes → `entities/`
|
||||||
|
- Uncertain, single use, or speculative reuse → keep in the page.
|
||||||
|
|
||||||
|
**Step 5: Is it app-wide configuration?**
|
||||||
|
|
||||||
|
- Global providers, router, theme → `app/`
|
||||||
|
|
||||||
|
**Golden Rule: When in doubt, keep it in `pages/`. Extract only when the
|
||||||
|
same code is actively used in multiple places and the boundary is clear.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Quick Placement Table
|
||||||
|
|
||||||
|
| Scenario | Single use | Confirmed multi-use |
|
||||||
|
| --------------------- | ------------------------------------------- | ------------------------------------- |
|
||||||
|
| User profile form | `pages/profile/ui/ProfileForm.tsx` | `features/profile-form/` |
|
||||||
|
| Product card | `pages/products/ui/ProductCard.tsx` | `entities/product/ui/ProductCard.tsx` |
|
||||||
|
| Product data fetching | `pages/product-detail/api/fetch-product.ts` | `entities/product/api/` |
|
||||||
|
| Auth token/session | `shared/auth/` (always) | `shared/auth/` (always) |
|
||||||
|
| Auth login form | `pages/login/ui/LoginForm.tsx` | `features/auth/` |
|
||||||
|
| CRUD operations | `shared/api/` (always) | `shared/api/` (always) |
|
||||||
|
| Generic Card layout | | `shared/ui/Card/` |
|
||||||
|
| Modal manager | | `shared/ui/modal-manager/` |
|
||||||
|
| Modal content | `pages/[page]/ui/SomeModal.tsx` | |
|
||||||
|
| Date formatting util | | `shared/lib/format-date.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architectural Rules (MUST)
|
||||||
|
|
||||||
|
These rules are the foundation of FSD. Violations weaken the architecture.
|
||||||
|
If you must break a rule, ensure it is an intentional design decision and
|
||||||
|
document the reason in code (a comment or ADR).
|
||||||
|
|
||||||
|
### 4-1. Import only from lower layers
|
||||||
|
|
||||||
|
`app → pages → widgets → features → entities → shared`.
|
||||||
|
Upward imports and cross-imports between slices on the same layer are
|
||||||
|
forbidden.
|
||||||
|
|
||||||
|
### 4-2. Public API: every slice exports through index.ts
|
||||||
|
|
||||||
|
External consumers may only import from a slice's `index.ts`. Direct imports
|
||||||
|
of internal files are forbidden.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct
|
||||||
|
import { LoginForm } from "@/features/auth";
|
||||||
|
|
||||||
|
// ❌ Violation: bypasses public API
|
||||||
|
import { LoginForm } from "@/features/auth/ui/LoginForm";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shared layer:** Shared has no slices. Define a separate public API per
|
||||||
|
segment (`shared/ui/index.ts`, `shared/api/index.ts`, etc.) rather than
|
||||||
|
one top-level `shared/index.ts`. This keeps imports from Shared
|
||||||
|
organized by intent.
|
||||||
|
|
||||||
|
**RSC / meta-framework exception:** Split entry points
|
||||||
|
(`index.client.ts`, `index.server.ts`) are permitted. Details and rules:
|
||||||
|
`references/framework-integration.md`.
|
||||||
|
|
||||||
|
### 4-3. No cross-imports between slices on the same layer
|
||||||
|
|
||||||
|
If two slices on the same layer need to share logic, follow the resolution
|
||||||
|
order in Section 7. Do not create direct imports.
|
||||||
|
|
||||||
|
### 4-4. Domain-based file naming (no desegmentation)
|
||||||
|
|
||||||
|
Name files after the business domain they represent, not their technical role.
|
||||||
|
Technical-role names like `types.ts`, `utils.ts`, `helpers.ts` mix unrelated
|
||||||
|
domains in a single file and reduce cohesion.
|
||||||
|
|
||||||
|
```text
|
||||||
|
// ❌ Technical-role naming
|
||||||
|
model/types.ts ← Which types? User? Order? Mixed?
|
||||||
|
model/utils.ts
|
||||||
|
|
||||||
|
// ✅ Domain-based naming
|
||||||
|
model/user.ts ← User types + related logic
|
||||||
|
model/order.ts ← Order types + related logic
|
||||||
|
api/fetch-profile.ts ← Clear purpose
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4-5. No business logic in shared/
|
||||||
|
|
||||||
|
Shared contains only infrastructure: UI kit, utilities, API client setup,
|
||||||
|
route constants, assets. Business calculations, domain rules, and workflows
|
||||||
|
belong in `entities/` or higher layers.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Business logic in shared
|
||||||
|
// shared/lib/userHelpers.ts
|
||||||
|
export const calculateUserReputation = (user) => { ... };
|
||||||
|
|
||||||
|
// ✅ Move to the owning domain
|
||||||
|
// entities/user/lib/reputation.ts
|
||||||
|
export const calculateUserReputation = (user) => { ... };
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Recommendations (SHOULD)
|
||||||
|
|
||||||
|
### 5-1. Pages First: place code where it is used
|
||||||
|
|
||||||
|
Place code in `pages/` first. Extract to lower layers only when truly needed.
|
||||||
|
Extraction is a design decision that affects the whole project, so the
|
||||||
|
threshold should be high.
|
||||||
|
|
||||||
|
**What stays in pages:**
|
||||||
|
|
||||||
|
- Large UI blocks used only in one page
|
||||||
|
- Page-specific forms, validation, data fetching, state management
|
||||||
|
- Page-specific business logic and API integrations
|
||||||
|
- Code that looks reusable but is simpler to keep local
|
||||||
|
|
||||||
|
**Evolution pattern:** Start with everything in `pages/profile/`. When the
|
||||||
|
same user data is being consumed by another page (not hypothetically),
|
||||||
|
extract the shared model to `entities/user/`. Keep page-specific API calls
|
||||||
|
and UI in the page.
|
||||||
|
|
||||||
|
### 5-2. Be conservative with entities
|
||||||
|
|
||||||
|
The entities layer is highly accessible (almost every other layer can import
|
||||||
|
from it), so changes propagate widely.
|
||||||
|
|
||||||
|
1. **Start without entities.** `shared/` + `pages/` + `app/` is valid FSD.
|
||||||
|
Thin-client apps rarely need entities.
|
||||||
|
2. **Do not split slices prematurely.** Keep code in pages. Extract to
|
||||||
|
entities only when the same code is currently used by multiple
|
||||||
|
consumers and the boundary is stable.
|
||||||
|
3. **Business logic does not automatically require an entity.** Keeping types
|
||||||
|
in `shared/api` and logic in the current slice's `model/` segment may
|
||||||
|
be sufficient.
|
||||||
|
4. **Place CRUD in `shared/api/`.** CRUD is infrastructure, not entities.
|
||||||
|
5. **Place auth data in `shared/auth/` or `shared/api/`.** Tokens and login
|
||||||
|
DTOs are auth-context-dependent and rarely reused outside authentication.
|
||||||
|
|
||||||
|
For detailed guidance on keeping the entities layer clean (when to skip
|
||||||
|
it entirely, how to isolate business contexts, why CRUD belongs in
|
||||||
|
`shared/api`), see `references/excessive-entities.md`.
|
||||||
|
|
||||||
|
### 5-3. Start with minimal layers
|
||||||
|
|
||||||
|
```text
|
||||||
|
// ✅ Valid minimal FSD project
|
||||||
|
src/
|
||||||
|
app/ ← Providers, routing
|
||||||
|
pages/ ← All page-level code
|
||||||
|
shared/ ← UI kit, utils, API client
|
||||||
|
|
||||||
|
// Add layers only when an actual use case requires them:
|
||||||
|
// + widgets/ ← UI blocks currently reused across multiple pages
|
||||||
|
// + features/ ← User interactions currently reused across multiple pages
|
||||||
|
// + entities/ ← Domain models currently reused across pages or features
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5-4. Validate with the Steiger linter
|
||||||
|
|
||||||
|
[Steiger](https://github.com/feature-sliced/steiger) is the official FSD
|
||||||
|
linter. Key rules:
|
||||||
|
|
||||||
|
- **`insignificant-slice`**: Suggests merging an entity/feature into its page
|
||||||
|
if only one page uses it.
|
||||||
|
- **`excessive-slicing`**: Suggests merging or grouping when a layer has too
|
||||||
|
many slices.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D @feature-sliced/steiger
|
||||||
|
npx steiger src
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Anti-patterns (AVOID)
|
||||||
|
|
||||||
|
- **Do not create entities prematurely.** Data structures used in only one
|
||||||
|
place belong in that place.
|
||||||
|
- **Do not put CRUD in entities.** Use `shared/api/`. Consider entities only
|
||||||
|
for complex transactional logic.
|
||||||
|
- **Do not create a `user` entity just for auth data.** Tokens and login DTOs
|
||||||
|
belong in `shared/auth/` or `shared/api/`.
|
||||||
|
- **Do not abuse `@x`.** It is a necessary compromise, not a recommended
|
||||||
|
pattern. The notation is for the entities layer only, and only when
|
||||||
|
boundary merge is genuinely impossible. Features and widgets handle
|
||||||
|
cross-imports through strategies A–D (see Section 7).
|
||||||
|
- **Do not extract single-use code.** A feature or entity used by only one
|
||||||
|
page should stay in that page.
|
||||||
|
- **Do not use technical-role file names.** Use domain-based names
|
||||||
|
(see Rule 4-4).
|
||||||
|
- **Be cautious adding UI to entities.** Entity UI tempts cross-imports from
|
||||||
|
other entities. If you add UI segments to entities, only import them from
|
||||||
|
higher layers (features, widgets, pages), never from other entities.
|
||||||
|
- **Do not create god slices.** Slices with excessively broad responsibilities
|
||||||
|
should be split into focused slices (e.g., split `user-management/` into
|
||||||
|
`auth/`, `profile-edit/`, `password-reset/`).
|
||||||
|
- **Do not create a top-level `assets/` segment.** Place static assets next
|
||||||
|
to the code that uses them. See `references/asset-handling.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Cross-Import Resolution
|
||||||
|
|
||||||
|
Cross-imports are a code smell, not an absolute prohibition. The right
|
||||||
|
strategy depends on the layer and the situation.
|
||||||
|
|
||||||
|
### Entities layer: prefer boundary merge, @x is last resort
|
||||||
|
|
||||||
|
Cross-imports in `entities` are usually caused by splitting entities too
|
||||||
|
granularly. Before reaching for `@x`, consider whether the boundaries
|
||||||
|
should be merged.
|
||||||
|
|
||||||
|
`@x` is a **necessary compromise, not a recommended approach**. Use it only
|
||||||
|
when boundaries genuinely cannot be merged, and document why. Overuse locks
|
||||||
|
entity boundaries together and increases refactoring cost.
|
||||||
|
|
||||||
|
### Features and widgets: four strategies (A, B, C, D)
|
||||||
|
|
||||||
|
In `features` and `widgets`, choose based on context:
|
||||||
|
|
||||||
|
- **Strategy A: Slice merge.** Two slices always change together → merge.
|
||||||
|
- **Strategy B: Push to entities.** Shared domain logic → move to
|
||||||
|
`entities/`, keep UI in features/widgets.
|
||||||
|
- **Strategy C: Compose from upper layer (IoC).** The parent (pages or app)
|
||||||
|
imports both slices and connects them via render props, slots, or DI.
|
||||||
|
- **Strategy D: Public API access.** When reuse is genuinely unavoidable,
|
||||||
|
allow it only through the slice's `index.ts`. Never reach into `model/`,
|
||||||
|
`store/`, or internal files.
|
||||||
|
|
||||||
|
The `@x` notation is for the entities layer only. Features and widgets use
|
||||||
|
strategies A–D above.
|
||||||
|
|
||||||
|
### Strictness depends on project context
|
||||||
|
|
||||||
|
Cross-imports are dependencies that are generally best avoided, but
|
||||||
|
sometimes used intentionally. Strictness varies by project context:
|
||||||
|
|
||||||
|
- **Early-stage products** with heavy experimentation: allowing some
|
||||||
|
cross-imports may be a pragmatic speed trade-off.
|
||||||
|
- **Long-lived or regulated systems** (fintech, large-scale services):
|
||||||
|
stricter boundaries pay off in maintainability and stability.
|
||||||
|
|
||||||
|
If a cross-import is introduced, treat it as a deliberate choice and
|
||||||
|
document the reasoning in code (a comment explaining why other strategies
|
||||||
|
do not apply).
|
||||||
|
|
||||||
|
For detailed code examples of each strategy, read
|
||||||
|
`references/cross-import-patterns.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Segments & Structure Rules
|
||||||
|
|
||||||
|
### Standard segments
|
||||||
|
|
||||||
|
Segments group code within a slice by technical purpose:
|
||||||
|
|
||||||
|
- **`ui/`**: UI components, styles, display-related code
|
||||||
|
- **`model/`**: Data models, state stores, business logic, validation
|
||||||
|
- **`api/`**: Backend integration, request functions, API-specific types
|
||||||
|
- **`lib/`**: Internal utility functions for this slice
|
||||||
|
- **`config/`**: Configuration, feature flags
|
||||||
|
|
||||||
|
### Layer structure rules
|
||||||
|
|
||||||
|
- **App and Shared**: No slices, organized directly by segments. Segments
|
||||||
|
within these layers may import from each other.
|
||||||
|
- **Pages, Widgets, Features, Entities**: Slices first, then segments inside
|
||||||
|
each slice.
|
||||||
|
- **Slice groups (optional)**: A group folder may contain related slices on
|
||||||
|
the same layer for navigation purposes only. The group has no segments and
|
||||||
|
no public API. See `references/layer-structure.md` for details.
|
||||||
|
|
||||||
|
### File naming within segments
|
||||||
|
|
||||||
|
Always use domain-based names that describe what the code is about:
|
||||||
|
|
||||||
|
```text
|
||||||
|
model/user.ts ← User types + logic + store
|
||||||
|
model/order.ts ← Order types + logic + store
|
||||||
|
api/fetch-profile.ts ← Profile fetching
|
||||||
|
api/update-settings.ts ← Settings update
|
||||||
|
```
|
||||||
|
|
||||||
|
If a segment has only one domain concern, the filename may match the slice
|
||||||
|
name (e.g., `features/auth/model/auth.ts`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Shared Layer Guide
|
||||||
|
|
||||||
|
Shared contains infrastructure with **no business logic**. It is organized by
|
||||||
|
segments only (no slices). Segments within shared may import from each other.
|
||||||
|
|
||||||
|
**Allowed in shared:**
|
||||||
|
|
||||||
|
- `ui/`: UI kit (Button, Input, Modal, Card)
|
||||||
|
- `lib/`: Utilities (formatDate, debounce, classnames)
|
||||||
|
- `api/`: API client, route constants, CRUD helpers, base types
|
||||||
|
- `auth/`: Auth tokens, login utilities, session management
|
||||||
|
- `config/`: Environment variables, app settings
|
||||||
|
- `assets/`: Branding assets shared across the app (use sparingly; see
|
||||||
|
`references/asset-handling.md`)
|
||||||
|
|
||||||
|
Shared **may** contain application-aware code (route constants, API endpoints,
|
||||||
|
branding assets, common types). It must **never** contain business logic,
|
||||||
|
feature-specific code, or entity-specific code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Quick Reference
|
||||||
|
|
||||||
|
- **Import direction**: `app → pages → widgets → features → entities → shared`
|
||||||
|
- **Minimal FSD**: `app/` + `pages/` + `shared/`
|
||||||
|
- **Create entities when**: the same business domain model is currently
|
||||||
|
used across multiple pages, features, or widgets, with stable
|
||||||
|
boundaries.
|
||||||
|
- **Create features when**: the same user interaction is currently used
|
||||||
|
across multiple pages or widgets, with stable boundaries.
|
||||||
|
- **Breaking rules**: Only as an intentional design choice. Document the
|
||||||
|
reason in code (comment or ADR).
|
||||||
|
- **Cross-import resolution (entities)**: Merge boundaries first; `@x` is a
|
||||||
|
necessary compromise, not recommended.
|
||||||
|
- **Cross-import resolution (features/widgets)**: Strategy A (merge), B
|
||||||
|
(push to entities), C (compose from upper layer), or D (Public API).
|
||||||
|
The `@x` notation is for entities only.
|
||||||
|
- **File naming**: Domain-based (`user.ts`, `order.ts`). Never technical-role
|
||||||
|
(`types.ts`, `utils.ts`).
|
||||||
|
- **Asset placement**: Place next to the code that uses them; reuse goes to
|
||||||
|
`shared/ui/`; global stylesheets and fonts go to `app/`.
|
||||||
|
- **Slice groups**: Optional navigation aid for large layers; group folder
|
||||||
|
has no segments and no public API.
|
||||||
|
- **Processes layer**: Deprecated. See `references/migration-guide.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Conditional References
|
||||||
|
|
||||||
|
Read the following reference files **only** when the specific situation applies.
|
||||||
|
Do **not** preload all references.
|
||||||
|
|
||||||
|
- **When creating, reviewing, or reorganizing folder and file structure** for
|
||||||
|
FSD layers and slices, including grouping closely related slices into a
|
||||||
|
parent folder for navigation (e.g., "set up project structure", "where does
|
||||||
|
this folder go", "how do I group these payment entities"):
|
||||||
|
→ Read `references/layer-structure.md`
|
||||||
|
|
||||||
|
- **When resolving cross-import issues** between slices on the same layer,
|
||||||
|
evaluating the `@x` pattern, choosing between Strategy A/B/C/D for
|
||||||
|
features and widgets, or deciding whether boundaries should be merged:
|
||||||
|
→ Read `references/cross-import-patterns.md`
|
||||||
|
|
||||||
|
- **When deciding whether to create or remove an entity**, dealing with too
|
||||||
|
many entities, evaluating whether to skip the entities layer entirely,
|
||||||
|
placing CRUD operations, deciding where authentication data belongs, or
|
||||||
|
isolating business contexts to avoid `@x` chains:
|
||||||
|
→ Read `references/excessive-entities.md`
|
||||||
|
|
||||||
|
- **When deciding where to place static assets** (images, icons, fonts,
|
||||||
|
PDFs, stylesheets) for a single slice, for sharing across slices, or
|
||||||
|
globally:
|
||||||
|
→ Read `references/asset-handling.md`
|
||||||
|
|
||||||
|
- **When migrating** from FSD v2.0 to v2.1, converting a non-FSD codebase to
|
||||||
|
FSD, or deprecating the processes layer:
|
||||||
|
→ Read `references/migration-guide.md`
|
||||||
|
|
||||||
|
- **When integrating FSD with a specific framework** (Next.js with App Router
|
||||||
|
or Pages Router, Nuxt, Vite, CRA, Astro) for wiring routes to FSD pages,
|
||||||
|
placing middleware/instrumentation files, structuring API route handlers,
|
||||||
|
or configuring path aliases:
|
||||||
|
→ Read `references/framework-integration.md`
|
||||||
|
|
||||||
|
- **When implementing concrete code patterns** for authentication, API request
|
||||||
|
handling, type definitions, or state management (Redux, TanStack Query /
|
||||||
|
React Query, including query factories, infinite scroll, Suspense mode,
|
||||||
|
and `useMutationState`) within FSD structure:
|
||||||
|
→ Read `references/practical-examples.md`
|
||||||
|
Note: If you already loaded `layer-structure.md` in this conversation,
|
||||||
|
avoid loading this file simultaneously. Address structure first, then load
|
||||||
|
patterns in a follow-up step if needed.
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Asset Handling
|
||||||
|
|
||||||
|
How to place static assets (images, icons, fonts, PDFs, stylesheets) inside an
|
||||||
|
FSD project. Assets follow the same placement rules as code: group by use
|
||||||
|
case, not by type, and keep them next to the code that uses them.
|
||||||
|
|
||||||
|
> **Caution:** A custom top-level `assets` segment that aggregates all static
|
||||||
|
> files is **not recommended**. It violates the FSD principles of high
|
||||||
|
> cohesion and locality of changes. Place assets where they are used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Tree
|
||||||
|
|
||||||
|
1. **Used by exactly one slice?** Keep the asset inside that slice, usually
|
||||||
|
in the `ui/` segment, or in `model/` if it is part of business logic.
|
||||||
|
2. **Reused across the app (icons, placeholder images)?** Move to
|
||||||
|
`shared/ui/`.
|
||||||
|
3. **Global stylesheet, font, or app-level resource?** Place in the `app/`
|
||||||
|
layer (`app/styles/`, `app/fonts/`).
|
||||||
|
4. **Served as-is by the bundler (favicon, robots.txt)?** Use the framework's
|
||||||
|
`public/` folder. The `public/` folder is not part of FSD and does not
|
||||||
|
conflict with FSD layers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice-specific Assets
|
||||||
|
|
||||||
|
When an asset belongs to one page, widget, or feature, keep it inside that
|
||||||
|
slice. The asset lives next to the component that renders it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pages/
|
||||||
|
home/
|
||||||
|
ui/
|
||||||
|
hero-image.jpg ← Used only by HomePage
|
||||||
|
HomePage.tsx
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
If a slice uses many static images, group them in a subfolder of `ui/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pages/
|
||||||
|
home/
|
||||||
|
ui/
|
||||||
|
previews/
|
||||||
|
cake.jpg
|
||||||
|
pizza.jpg
|
||||||
|
sushi.jpg
|
||||||
|
HomePage.tsx
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Non-UI Assets
|
||||||
|
|
||||||
|
Some assets are not part of the UI but are coupled to business logic. For
|
||||||
|
example, a PDF template used to generate invoices. Place these in the
|
||||||
|
`model/` segment alongside the logic that consumes them, not in `ui/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
features/
|
||||||
|
billing/
|
||||||
|
model/
|
||||||
|
invoice-template.pdf ← Coupled to create-invoice.ts
|
||||||
|
create-invoice.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The principle is locality of changes: if you delete the slice, every file it
|
||||||
|
owns goes with it. An asset that lives in business logic should sit next to
|
||||||
|
that logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Assets
|
||||||
|
|
||||||
|
When the same asset appears across multiple slices, move it to `shared/ui/`.
|
||||||
|
Place reusable images in a topical subfolder, or place a single asset next to
|
||||||
|
the shared component that uses it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
shared/
|
||||||
|
ui/
|
||||||
|
placeholders/ ← Reused placeholder images
|
||||||
|
cake.jpg
|
||||||
|
pizza.jpg
|
||||||
|
Dropdown.tsx
|
||||||
|
chevron.svg ← Used only by Dropdown, kept next to it
|
||||||
|
```
|
||||||
|
|
||||||
|
A single icon used by exactly one component in the UI kit stays next to that
|
||||||
|
component. A library of icons or images reused across many components goes
|
||||||
|
in a topical subfolder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Assets
|
||||||
|
|
||||||
|
Global stylesheets and fonts belong in the `app/` layer because they are
|
||||||
|
imported by the application entrypoint, not by individual slices:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
styles/
|
||||||
|
reset.css
|
||||||
|
global.css
|
||||||
|
fonts/
|
||||||
|
inter.woff2
|
||||||
|
main.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Theme variables, CSS resets, and font registrations are app-wide concerns.
|
||||||
|
They bootstrap the application's visual layer the same way providers
|
||||||
|
bootstrap the runtime layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Public Folder
|
||||||
|
|
||||||
|
Most bundlers expose a `public/` folder at the project root. Files here are
|
||||||
|
served as-is, without bundling or hashing.
|
||||||
|
|
||||||
|
- Vite, Next.js, Nuxt: `public/` at the project root.
|
||||||
|
- Astro: `public/` at the project root (path is fixed and cannot be changed).
|
||||||
|
|
||||||
|
`public/` is not part of FSD. It does not collide with FSD layers and does
|
||||||
|
not need to live under `src/`. Use it for files that must be served at fixed
|
||||||
|
URLs: favicon, `robots.txt`, `sitemap.xml`, OG images, and similar.
|
||||||
|
|
||||||
|
```text
|
||||||
|
public/
|
||||||
|
favicon.ico
|
||||||
|
robots.txt
|
||||||
|
og-image.png
|
||||||
|
src/
|
||||||
|
app/
|
||||||
|
pages/
|
||||||
|
shared/
|
||||||
|
```
|
||||||
|
|
||||||
|
Some projects keep a project-local `app/public/` folder when the bundler
|
||||||
|
allows assets to live alongside the entrypoint. Both layouts are valid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Table
|
||||||
|
|
||||||
|
| Asset | Location |
|
||||||
|
| -------------------------------------- | ----------------------------------------- |
|
||||||
|
| Image used by one page/widget/feature | Inside the slice's `ui/` segment |
|
||||||
|
| PDF or template tied to business logic | Inside the slice's `model/` segment |
|
||||||
|
| Icon reused across the app | `shared/ui/` (topical subfolder if many) |
|
||||||
|
| Icon used by exactly one shared kit UI | Next to that component in `shared/ui/` |
|
||||||
|
| Global CSS reset, theme variables | `app/styles/` |
|
||||||
|
| Web fonts | `app/fonts/`, `public/`, or `app/public/` |
|
||||||
|
| Favicon, robots.txt, sitemap | `public/` (or `app/public/`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- **Do not create a top-level `assets/` segment** that holds all images,
|
||||||
|
fonts, and icons. It breaks cohesion and forces consumers to import from a
|
||||||
|
folder unrelated to the code they are working on.
|
||||||
|
- **Do not extract a slice-local asset to `shared/` "in case" it gets
|
||||||
|
reused.** Move it only when actual reuse appears.
|
||||||
|
- **Do not place CSS modules in an `assets/` folder.** A component's
|
||||||
|
stylesheet belongs next to that component in `ui/`.
|
||||||
|
- **Do not name an FSD segment `public`.** The framework's `public/` folder
|
||||||
|
is reserved and lives outside `src/`.
|
||||||
|
- **Do not split assets and the components that use them.** A page that
|
||||||
|
ships a hero image should keep that image in the page so removing the page
|
||||||
|
removes the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- `references/layer-structure.md`: segment rules and layer organization
|
||||||
|
- [Desegmentation](https://fsd.how/docs/guides/issues/desegmented/): why
|
||||||
|
technical-role grouping (including a generic `assets/` segment) hurts
|
||||||
|
cohesion
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
# Cross-Import Resolution Patterns
|
||||||
|
|
||||||
|
How to resolve cross-imports between slices on the same layer. Cross-imports
|
||||||
|
are a code smell, not an absolute prohibition. The strategies below are
|
||||||
|
ordered, but the right choice depends on the project context.
|
||||||
|
|
||||||
|
## What is a cross-import?
|
||||||
|
|
||||||
|
A cross-import is an import between different slices within the same layer.
|
||||||
|
For example:
|
||||||
|
|
||||||
|
- importing `features/product` from `features/cart`
|
||||||
|
- importing `widgets/sidebar` from `widgets/header`
|
||||||
|
|
||||||
|
The `shared` and `app` layers do not have slices, so imports within those
|
||||||
|
layers are not cross-imports.
|
||||||
|
|
||||||
|
## Why is this a code smell?
|
||||||
|
|
||||||
|
Cross-imports blur domain boundaries and introduce implicit dependencies.
|
||||||
|
Four concrete problems:
|
||||||
|
|
||||||
|
1. **Unclear ownership and responsibility.** When `cart` imports from
|
||||||
|
`product`, it becomes unclear which slice owns the shared logic.
|
||||||
|
Changes to `product`'s internal implementation can break `cart`
|
||||||
|
without warning. This makes bugs harder to localize and code harder
|
||||||
|
to reason about.
|
||||||
|
2. **Reduced isolation and testability.** A core benefit of sliced
|
||||||
|
architecture is that each slice can be developed, tested, and deployed
|
||||||
|
independently. Cross-imports break this isolation. Testing `cart` now
|
||||||
|
requires setting up `product`, and changes in one slice can cause
|
||||||
|
unexpected test failures in another.
|
||||||
|
3. **Increased cognitive load.** Working on `cart` now requires accounting
|
||||||
|
for how `product` is structured. As cross-imports accumulate, tracing
|
||||||
|
the impact of a change requires following more code across slice
|
||||||
|
boundaries.
|
||||||
|
4. **Path to circular dependencies.** Cross-imports often start as one-way
|
||||||
|
dependencies but evolve into bidirectional ones (A imports B, B imports
|
||||||
|
A). This locks slices together and makes refactoring increasingly costly.
|
||||||
|
|
||||||
|
## Entities layer: prefer boundary merge over @x
|
||||||
|
|
||||||
|
Cross-imports in `entities` are usually caused by splitting entities too
|
||||||
|
granularly. Before reaching for `@x`, consider whether the boundaries should
|
||||||
|
be merged instead.
|
||||||
|
|
||||||
|
The `@x` notation is available as a dedicated cross-import surface for
|
||||||
|
`entities`, but it should be treated as a **last resort**, a **necessary
|
||||||
|
compromise**, not a recommended approach. Think of `@x` as an explicit
|
||||||
|
gateway for unavoidable domain references, not a general-purpose reuse
|
||||||
|
mechanism. Overuse locks entity boundaries together and makes refactoring
|
||||||
|
more costly over time.
|
||||||
|
|
||||||
|
### How @x works (when boundary merge is genuinely impossible)
|
||||||
|
|
||||||
|
Each entity exposes a special `@x/` directory containing files named after
|
||||||
|
the consuming entity. This makes the cross-import explicit and auditable.
|
||||||
|
|
||||||
|
**Direction rule:** in the path `entities/A/@x/B`, **A is the producer and
|
||||||
|
B is the consumer**. Read it as "A crossed with B": the file `A/@x/B.ts`
|
||||||
|
is the public API that A exposes specifically for B. So in the example
|
||||||
|
below, `entities/user/@x/order.ts` is what `user` exposes to `order`, and
|
||||||
|
`order` imports from it.
|
||||||
|
|
||||||
|
```text
|
||||||
|
entities/
|
||||||
|
user/
|
||||||
|
@x/
|
||||||
|
order.ts ← Exposed specifically for the order entity
|
||||||
|
model/
|
||||||
|
user.ts
|
||||||
|
index.ts
|
||||||
|
order/
|
||||||
|
model/
|
||||||
|
order-summary.ts ← Imports from user/@x/order
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// entities/user/@x/order.ts: exposes only what order needs
|
||||||
|
export { getUserDisplayName } from "../model/user";
|
||||||
|
|
||||||
|
// entities/order/model/order-summary.ts
|
||||||
|
import { getUserDisplayName } from "@/entities/user/@x/order";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rules when using @x
|
||||||
|
|
||||||
|
1. Document why `@x` is needed and why merging boundaries does not apply.
|
||||||
|
2. Review periodically. Requirements change and `@x` may become unnecessary.
|
||||||
|
3. Minimize the surface area of `@x` exports.
|
||||||
|
4. Only between entities. Features and widgets should use Strategy C or D
|
||||||
|
below, not `@x`.
|
||||||
|
|
||||||
|
## Features and widgets: four strategies
|
||||||
|
|
||||||
|
In `features` and `widgets`, multiple strategies are available depending on
|
||||||
|
project context. Cross-imports here are not always forbidden; they are
|
||||||
|
dependencies that should be deliberate. The four strategies below are
|
||||||
|
listed in preferred order, but each fits different situations.
|
||||||
|
|
||||||
|
### Strategy A: Slice merge
|
||||||
|
|
||||||
|
If two slices are not truly independent and always change together, merge
|
||||||
|
them into a single larger slice.
|
||||||
|
|
||||||
|
```text
|
||||||
|
// Before: two features that always change together
|
||||||
|
features/profile/
|
||||||
|
features/profile-settings/
|
||||||
|
|
||||||
|
// After: one cohesive feature
|
||||||
|
features/profile/
|
||||||
|
ui/
|
||||||
|
Profile.tsx
|
||||||
|
ProfileSettings.tsx
|
||||||
|
model/
|
||||||
|
profile.ts
|
||||||
|
profile-settings.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
If two slices keep cross-importing each other and effectively move as one
|
||||||
|
unit, they are likely one feature in practice. Merging is often the simpler
|
||||||
|
and cleaner choice.
|
||||||
|
|
||||||
|
### Strategy B: Push shared domain flows down into entities
|
||||||
|
|
||||||
|
If multiple features share a domain-level flow, move that flow into a domain
|
||||||
|
slice inside `entities`. Key principles:
|
||||||
|
|
||||||
|
- `entities` contains domain types and domain logic only.
|
||||||
|
- UI remains in `features` and `widgets`.
|
||||||
|
- Features import and use the domain logic from `entities`.
|
||||||
|
|
||||||
|
For example, if both `features/auth` and `features/profile` need session
|
||||||
|
validation, place session-related domain functions in `entities/session`
|
||||||
|
and reuse them from both features.
|
||||||
|
|
||||||
|
```text
|
||||||
|
entities/
|
||||||
|
session/
|
||||||
|
model/
|
||||||
|
validate-session.ts
|
||||||
|
session.ts
|
||||||
|
index.ts
|
||||||
|
|
||||||
|
features/
|
||||||
|
auth/
|
||||||
|
ui/LoginForm.tsx
|
||||||
|
model/login.ts ← imports validateSession from entities/session
|
||||||
|
index.ts
|
||||||
|
profile/
|
||||||
|
ui/ProfilePanel.tsx
|
||||||
|
model/profile.ts ← imports validateSession from entities/session
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strategy C: Compose from an upper layer (IoC)
|
||||||
|
|
||||||
|
Instead of connecting slices within the same layer via cross-imports,
|
||||||
|
compose them at a higher level (`pages` or `app`). The upper layer assembles
|
||||||
|
and connects the slices; the slices themselves do not know about each other.
|
||||||
|
|
||||||
|
Common Inversion of Control techniques:
|
||||||
|
|
||||||
|
- **Render props (React)**: pass components or render functions as props.
|
||||||
|
- **Slots (Vue)**: use named slots to inject content from parent components.
|
||||||
|
- **Dependency injection**: pass dependencies through props or context.
|
||||||
|
|
||||||
|
#### Basic composition (React)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// features/user-profile/index.ts
|
||||||
|
export { UserProfilePanel } from "./ui/UserProfilePanel";
|
||||||
|
|
||||||
|
// features/activity-feed/index.ts
|
||||||
|
export { ActivityFeed } from "./ui/ActivityFeed";
|
||||||
|
|
||||||
|
// pages/UserDashboardPage.tsx
|
||||||
|
import { UserProfilePanel } from "@/features/user-profile";
|
||||||
|
import { ActivityFeed } from "@/features/activity-feed";
|
||||||
|
|
||||||
|
export const UserDashboardPage = () => (
|
||||||
|
<div>
|
||||||
|
<UserProfilePanel />
|
||||||
|
<ActivityFeed />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`features/user-profile` and `features/activity-feed` do not know about each
|
||||||
|
other. The page composes them.
|
||||||
|
|
||||||
|
#### Render props (React)
|
||||||
|
|
||||||
|
When one feature needs to render content from another, use render props to
|
||||||
|
invert the dependency:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// features/comment-list/ui/CommentList.tsx
|
||||||
|
interface CommentListProps {
|
||||||
|
comments: Comment[];
|
||||||
|
renderUserAvatar?: (userId: string) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommentList = ({ comments, renderUserAvatar }: CommentListProps) => (
|
||||||
|
<ul>
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<li key={comment.id}>
|
||||||
|
{renderUserAvatar?.(comment.userId)}
|
||||||
|
<span>{comment.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
|
// pages/PostPage.tsx
|
||||||
|
import { CommentList } from "@/features/comment-list";
|
||||||
|
import { UserAvatar } from "@/features/user-profile";
|
||||||
|
|
||||||
|
export const PostPage = () => (
|
||||||
|
<CommentList
|
||||||
|
comments={comments}
|
||||||
|
renderUserAvatar={(userId) => <UserAvatar userId={userId} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`CommentList` does not import from `user-profile`. The page injects the
|
||||||
|
avatar component.
|
||||||
|
|
||||||
|
#### Slots (Vue)
|
||||||
|
|
||||||
|
Vue's slot system provides a natural way to compose features without
|
||||||
|
cross-imports:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- features/comment-list/ui/CommentList.vue -->
|
||||||
|
<template>
|
||||||
|
<ul>
|
||||||
|
<li v-for="comment in comments" :key="comment.id">
|
||||||
|
<slot name="avatar" :userId="comment.userId" />
|
||||||
|
<span>{{ comment.text }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- pages/PostPage.vue -->
|
||||||
|
<template>
|
||||||
|
<CommentList :comments="comments">
|
||||||
|
<template #avatar="{ userId }">
|
||||||
|
<UserAvatar :userId="userId" />
|
||||||
|
</template>
|
||||||
|
</CommentList>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strategy D: Cross-feature reuse only via Public API
|
||||||
|
|
||||||
|
If strategies A-C do not fit and cross-feature reuse is genuinely
|
||||||
|
unavoidable, allow it only through an explicit Public API (exported hooks
|
||||||
|
or UI components). Do not access another slice's `store`, `model`, or
|
||||||
|
internal implementation.
|
||||||
|
|
||||||
|
Unlike strategies A-C which aim to eliminate cross-imports, this strategy
|
||||||
|
accepts them while minimizing risk through strict boundaries.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// features/auth/index.ts
|
||||||
|
export { useAuth } from "./model/use-auth";
|
||||||
|
export { AuthButton } from "./ui/AuthButton";
|
||||||
|
|
||||||
|
// features/profile/ui/ProfileMenu.tsx
|
||||||
|
import { useAuth, AuthButton } from "@/features/auth";
|
||||||
|
|
||||||
|
export const ProfileMenu = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
if (!user) return <AuthButton />;
|
||||||
|
return <div>{user.name}</div>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The boundary holds: `features/profile` cannot import from
|
||||||
|
`@/features/auth/model/internal/*`. Only what `features/auth` explicitly
|
||||||
|
exposes through `index.ts` is reachable.
|
||||||
|
|
||||||
|
The `@x` notation is for the entities layer only. Features and widgets use
|
||||||
|
strategies A through D above; their access path is the standard public API
|
||||||
|
(`index.ts`), not a dedicated cross-import surface.
|
||||||
|
|
||||||
|
## When to treat a cross-import as a problem
|
||||||
|
|
||||||
|
After reviewing these strategies, the question is: when is a cross-import
|
||||||
|
acceptable to keep, and when should it be treated as a code smell and
|
||||||
|
refactored?
|
||||||
|
|
||||||
|
Common warning signs:
|
||||||
|
|
||||||
|
- Directly depending on another slice's `store`, `model`, or business logic
|
||||||
|
- Deep imports into another slice's internal files (bypassing the public API)
|
||||||
|
- Bidirectional dependencies (A imports B, and B imports A)
|
||||||
|
- Changes in one slice frequently breaking another slice
|
||||||
|
- Flows that should be composed in `pages` or `app`, but are forced into
|
||||||
|
cross-imports within the same layer
|
||||||
|
|
||||||
|
When these signals appear, treat the cross-import as a code smell and apply
|
||||||
|
one of the strategies above.
|
||||||
|
|
||||||
|
## Strictness depends on project context
|
||||||
|
|
||||||
|
The strictness of cross-import enforcement depends on the project:
|
||||||
|
|
||||||
|
- In **early-stage products** with heavy experimentation, allowing some
|
||||||
|
cross-imports may be a pragmatic speed trade-off.
|
||||||
|
- In **long-lived or regulated systems** (fintech, large-scale services),
|
||||||
|
stricter boundaries pay off in maintainability and stability.
|
||||||
|
|
||||||
|
Cross-imports are not an absolute prohibition. They are dependencies that
|
||||||
|
are generally best avoided, but sometimes used intentionally. If a
|
||||||
|
cross-import is introduced:
|
||||||
|
|
||||||
|
- Treat it as a deliberate architectural choice.
|
||||||
|
- Document the reasoning in code (a comment explaining why other
|
||||||
|
strategies do not apply).
|
||||||
|
- Revisit it periodically as the system evolves; if requirements change,
|
||||||
|
the cross-import may no longer be needed.
|
||||||
|
|
||||||
|
## Decision flow for AI agents
|
||||||
|
|
||||||
|
```text
|
||||||
|
Two slices on the same layer need to share code.
|
||||||
|
│
|
||||||
|
├─ ENTITIES layer?
|
||||||
|
│ ├─ Can boundaries be merged into one entity?
|
||||||
|
│ │ └─ YES → Merge. Stop.
|
||||||
|
│ └─ Boundaries must stay separate?
|
||||||
|
│ └─ Use @x as last resort. Document why merge is not possible.
|
||||||
|
│
|
||||||
|
└─ FEATURES or WIDGETS layer?
|
||||||
|
├─ Strategy A: Do they always change together?
|
||||||
|
│ └─ YES → Merge slices.
|
||||||
|
│
|
||||||
|
├─ Strategy B: Is the shared part domain-only logic?
|
||||||
|
│ └─ YES → Push down to entities. Keep UI in features.
|
||||||
|
│
|
||||||
|
├─ Strategy C: Can the connection be assembled by a higher layer?
|
||||||
|
│ └─ YES → Compose in pages or app via render props, slots, or DI.
|
||||||
|
│
|
||||||
|
└─ Strategy D: Is reuse genuinely unavoidable and the access surface
|
||||||
|
limited to a Public API?
|
||||||
|
└─ YES → Allow, but only through index.ts. Never reach into
|
||||||
|
model/, store/, or internal files. Do not use @x in
|
||||||
|
features or widgets.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- **Reaching for `@x` in features or widgets.** `@x` is for entities only.
|
||||||
|
Use Strategy C (compose) or D (Public API) instead.
|
||||||
|
- **Treating `@x` as a clean solution.** It is a compromise. If you find
|
||||||
|
yourself adding multiple `@x` files between the same entities, the
|
||||||
|
boundaries are probably wrong. Merge them.
|
||||||
|
- **Bypassing the Public API to access internals.** Even when Strategy D is
|
||||||
|
in use, importing from `@/features/auth/model/internal/*` defeats the
|
||||||
|
purpose. Restrict yourself to what `index.ts` exports.
|
||||||
|
- **Bidirectional cross-imports.** A imports B and B imports A is almost
|
||||||
|
always a sign that the slices should be merged.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- `references/excessive-entities.md`: prevent the conditions that lead to
|
||||||
|
entity-layer cross-imports in the first place.
|
||||||
|
- `references/layer-structure.md`: layer rules and import directions.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Excessive Entities
|
||||||
|
|
||||||
|
How to keep the `entities` layer clean and avoid over-extracting business
|
||||||
|
logic into entities. Excessive entities cause ambiguity (what code belongs
|
||||||
|
where), coupling, and constant import dilemmas as code scatters across
|
||||||
|
sibling entities.
|
||||||
|
|
||||||
|
## Why this matters
|
||||||
|
|
||||||
|
The `entities` layer is one of the lower layers and is widely accessible.
|
||||||
|
Every layer except `shared` can import from it. That global nature means
|
||||||
|
changes to `entities` propagate widely, requiring careful design to avoid
|
||||||
|
costly refactors. Adding an entity is cheap; removing one after many
|
||||||
|
consumers depend on it is expensive.
|
||||||
|
|
||||||
|
## How to keep entities clean
|
||||||
|
|
||||||
|
### 0. Consider having no entities layer
|
||||||
|
|
||||||
|
An FSD application without an `entities` layer is still FSD. Skipping the
|
||||||
|
layer simplifies the architecture and keeps it available for future scaling.
|
||||||
|
|
||||||
|
**Thin clients** (where the backend handles most data processing and the
|
||||||
|
client mostly exchanges data) usually do not need an entities layer.
|
||||||
|
**Thick clients** (significant client-side business logic) are better
|
||||||
|
candidates for entities.
|
||||||
|
|
||||||
|
The classification is not strictly binary. Different parts of the same
|
||||||
|
application may behave as thick or thin clients.
|
||||||
|
|
||||||
|
```text
|
||||||
|
// Thin client without entities layer (still valid FSD)
|
||||||
|
src/
|
||||||
|
app/
|
||||||
|
pages/
|
||||||
|
dashboard/
|
||||||
|
profile/
|
||||||
|
shared/
|
||||||
|
api/
|
||||||
|
ui/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Avoid preemptive slicing
|
||||||
|
|
||||||
|
FSD v2.1 encourages **deferred decomposition** of slices. Place code in the
|
||||||
|
`model` segment of the consuming page, widget, or feature first. Move it to
|
||||||
|
`entities` later, when business requirements stabilize and reuse is
|
||||||
|
confirmed across multiple consumers.
|
||||||
|
|
||||||
|
The later code moves to `entities`, the less dangerous the refactor. Code
|
||||||
|
in `entities` can affect every higher-layer slice that imports it.
|
||||||
|
|
||||||
|
```text
|
||||||
|
// Iteration 1: code lives where it is used
|
||||||
|
pages/profile/
|
||||||
|
model/
|
||||||
|
profile-validation.ts ← page-specific for now
|
||||||
|
|
||||||
|
// Iteration 2 (after the same logic is needed in 2+ places):
|
||||||
|
entities/profile/
|
||||||
|
model/
|
||||||
|
profile-validation.ts ← extracted only after reuse is real
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Avoid unnecessary entities
|
||||||
|
|
||||||
|
Do not create an entity for every piece of business logic. Use types from
|
||||||
|
`shared/api` and place logic in the `model` segment of the current slice.
|
||||||
|
For genuinely reusable business logic, use the `model` segment within an
|
||||||
|
entity slice while keeping data definitions in `shared/api`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
shared/
|
||||||
|
api/
|
||||||
|
endpoints/
|
||||||
|
order.ts ← OrderDto type and request functions
|
||||||
|
|
||||||
|
entities/
|
||||||
|
order/
|
||||||
|
model/
|
||||||
|
apply-discount.ts ← Business logic that uses OrderDto
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The DTO lives in `shared/api/endpoints/order.ts`. Business logic that
|
||||||
|
operates on it (calculating discounts, applying promotions) lives in
|
||||||
|
`entities/order/model/`. Do not mirror every API endpoint with a
|
||||||
|
corresponding entity.
|
||||||
|
|
||||||
|
### 3. Exclude CRUD operations from entities
|
||||||
|
|
||||||
|
CRUD operations involve boilerplate code without significant business
|
||||||
|
logic. Putting them in `entities` clutters the layer and obscures the code
|
||||||
|
that genuinely matters. Place CRUD in `shared/api`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
shared/
|
||||||
|
api/
|
||||||
|
client.ts
|
||||||
|
endpoints/
|
||||||
|
order.ts ← getOrder, createOrder, updateOrder, deleteOrder
|
||||||
|
products.ts ← Standard CRUD for products
|
||||||
|
cart.ts ← Standard CRUD for cart
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
For complex CRUD with atomic updates, rollbacks, or transactions, evaluate
|
||||||
|
whether the operation is genuinely business logic. If so, the `entities`
|
||||||
|
layer may be appropriate. If not, keep it in `shared/api`.
|
||||||
|
|
||||||
|
### 4. Store authentication data in shared
|
||||||
|
|
||||||
|
Prefer `shared` over creating a `user` entity for auth tokens and session
|
||||||
|
DTOs. These are context-specific to authentication and unlikely to be
|
||||||
|
reused outside that scope. Wrapping a login response in a `user` entity
|
||||||
|
also tends to drag entities into cross-layer imports or `@x` chains,
|
||||||
|
complicating the architecture.
|
||||||
|
|
||||||
|
The Auth guide also documents **In Entities** (a `user` entity) as a
|
||||||
|
valid placement when the project already has an entities layer and the
|
||||||
|
data is genuinely reused. **In Pages/Widgets** is discouraged for both
|
||||||
|
guides.
|
||||||
|
|
||||||
|
**`shared/auth` (or `shared/api`) is the recommended default.** Choose
|
||||||
|
it when:
|
||||||
|
|
||||||
|
- The project has no entities layer yet
|
||||||
|
- Auth state is just a token plus minimal user info (id, email, role)
|
||||||
|
- Token management logic (refresh, expiration) is the main concern, not
|
||||||
|
user profile data
|
||||||
|
|
||||||
|
```text
|
||||||
|
shared/
|
||||||
|
auth/
|
||||||
|
use-auth.ts ← Token + minimal user info
|
||||||
|
index.ts
|
||||||
|
api/
|
||||||
|
client.ts ← API client reads token from shared/auth
|
||||||
|
endpoints/
|
||||||
|
order.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This approach pairs naturally with an API client middleware that injects
|
||||||
|
the token into authenticated requests.
|
||||||
|
|
||||||
|
**A `user` entity is the right call when:**
|
||||||
|
|
||||||
|
- The project already has an entities layer
|
||||||
|
- Auth and profile data are tightly coupled (current user info is reused
|
||||||
|
across pages for non-auth purposes like comments, posts, mentions)
|
||||||
|
- Token management has complex business logic (invalidation policies,
|
||||||
|
multi-device session tracking) that benefits from co-location with the
|
||||||
|
user model
|
||||||
|
|
||||||
|
```text
|
||||||
|
entities/
|
||||||
|
user/
|
||||||
|
model/
|
||||||
|
current-user.ts ← Token + full user model + business logic
|
||||||
|
user.ts ← Generic user type, used for other users too
|
||||||
|
api/
|
||||||
|
get-current-user.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
When using the entity approach, the API client (in `shared/api`) needs
|
||||||
|
access to the token without violating the import rule. The official Auth
|
||||||
|
guide describes three solutions: pass the token manually on each request,
|
||||||
|
expose it through a context or `localStorage` with the key kept in
|
||||||
|
`shared/api`, or inject the token into the API client whenever the entity
|
||||||
|
store updates.
|
||||||
|
|
||||||
|
**Pages and widgets are discouraged.** Avoid placing the token store in a
|
||||||
|
page's `model/` segment or in a widget. App-wide state belongs in Shared
|
||||||
|
or Entities, not in route-bound or block-bound layers.
|
||||||
|
|
||||||
|
### Decision summary
|
||||||
|
|
||||||
|
| Project state | Recommended location |
|
||||||
|
| --- | --- |
|
||||||
|
| No entities layer (yet), simple token + minimal user info | `shared/auth` |
|
||||||
|
| Entities layer exists, auth and profile tightly coupled | `entities/user` |
|
||||||
|
| Complex token logic, no profile reuse yet | `shared/auth` (split from `shared/api`) |
|
||||||
|
| Token storage in a single page or widget | Avoid; promote to Shared or Entities |
|
||||||
|
|
||||||
|
A `user` entity created **only** to wrap a login response is premature.
|
||||||
|
Wait until profile data is consumed for non-auth purposes (avatars in
|
||||||
|
comments, names in posts) before introducing the entity.
|
||||||
|
|
||||||
|
### 5. Minimize cross-imports
|
||||||
|
|
||||||
|
FSD permits cross-imports between entities via `@x`, but they introduce
|
||||||
|
technical issues including circular dependencies. Design entities within
|
||||||
|
**isolated business contexts** so cross-imports become unnecessary.
|
||||||
|
|
||||||
|
**Non-isolated context (avoid):**
|
||||||
|
|
||||||
|
```text
|
||||||
|
entities/
|
||||||
|
order/
|
||||||
|
@x/
|
||||||
|
model/
|
||||||
|
order-item/
|
||||||
|
@x/
|
||||||
|
model/
|
||||||
|
order-customer-info/
|
||||||
|
@x/
|
||||||
|
model/
|
||||||
|
```
|
||||||
|
|
||||||
|
Three sibling entities all referencing each other through `@x`. This is a
|
||||||
|
sign that the boundaries are wrong.
|
||||||
|
|
||||||
|
**Isolated context (preferred):**
|
||||||
|
|
||||||
|
```text
|
||||||
|
entities/
|
||||||
|
order-info/
|
||||||
|
model/
|
||||||
|
order-info.ts ← order, items, and customer info together
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
One entity encapsulates the related logic. No `@x`, no cross-imports,
|
||||||
|
no circular dependency risk.
|
||||||
|
|
||||||
|
The general rule: when several entities have `@x` dependencies on each
|
||||||
|
other, treat that as a signal to merge the boundaries, not as something to
|
||||||
|
manage.
|
||||||
|
|
||||||
|
## Decision tree for AI agents
|
||||||
|
|
||||||
|
```text
|
||||||
|
A new piece of business logic needs a home.
|
||||||
|
│
|
||||||
|
├─ Is the project a thin client?
|
||||||
|
│ └─ YES → Skip entities. Place in shared/ + page model.
|
||||||
|
│
|
||||||
|
├─ Is the logic used in only one place right now?
|
||||||
|
│ └─ YES → Keep in the consuming slice's model/. Defer extraction.
|
||||||
|
│
|
||||||
|
├─ Is it a CRUD operation without business meaning?
|
||||||
|
│ └─ YES → shared/api/endpoints/<resource>.ts
|
||||||
|
│
|
||||||
|
├─ Is it auth data (tokens, session, login DTOs)?
|
||||||
|
│ ├─ Project has no entities layer yet?
|
||||||
|
│ │ └─ YES → shared/auth/
|
||||||
|
│ ├─ Auth and profile data tightly coupled, entities layer exists?
|
||||||
|
│ │ └─ YES → entities/user/
|
||||||
|
│ └─ Otherwise → shared/auth/ (default).
|
||||||
|
│ Avoid placing in a page or widget.
|
||||||
|
│
|
||||||
|
├─ Is it just a TypeScript type for an API response?
|
||||||
|
│ └─ YES → shared/api/. No entity needed for types alone.
|
||||||
|
│
|
||||||
|
└─ Is it reusable domain logic confirmed in 2+ consumers?
|
||||||
|
└─ YES → Create entities/<name>/model/.
|
||||||
|
Verify the boundary is isolated and does not require @x
|
||||||
|
to communicate with sibling entities.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- **Creating entities preemptively.** Wait for confirmed reuse in 2+
|
||||||
|
consumers, not anticipated reuse.
|
||||||
|
- **Mirroring every API endpoint with an entity.** API endpoints belong in
|
||||||
|
`shared/api`. Entities exist for business logic, not for paralleling the
|
||||||
|
backend structure.
|
||||||
|
- **Creating a `user` entity *only* to wrap a login response.** A `user`
|
||||||
|
entity is justified when profile data is reused across non-auth flows
|
||||||
|
(avatars in comments, names in posts) or when token logic is genuinely
|
||||||
|
tied to user business logic. Until that reuse appears, `shared/auth`
|
||||||
|
is simpler. Storing tokens in a page or widget is discouraged regardless
|
||||||
|
of the project shape.
|
||||||
|
- **Splitting one domain into many entities (`order`, `order-item`,
|
||||||
|
`order-customer-info`).** This produces `@x` chains. Merge into a single
|
||||||
|
isolated context (`order-info` or `order`).
|
||||||
|
- **Putting CRUD wrappers in entities.** They clutter the layer. CRUD goes
|
||||||
|
in `shared/api/endpoints/`.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- `references/cross-import-patterns.md`: how to handle cross-imports when
|
||||||
|
they appear, and why `@x` is a last resort.
|
||||||
|
- `references/layer-structure.md`: layer responsibilities and the entities
|
||||||
|
segment shape.
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
# Framework Integration
|
||||||
|
|
||||||
|
How to set up FSD within specific frameworks. Covers directory placement,
|
||||||
|
routing integration, and framework-specific path alias configuration.
|
||||||
|
|
||||||
|
|
||||||
|
## General Principle
|
||||||
|
|
||||||
|
Place FSD layers inside `src/` to avoid naming conflicts with framework
|
||||||
|
directories. The FSD `app/` and `pages/` layers are **not** the same as
|
||||||
|
framework directories with the same names (e.g., Next.js `app/`).
|
||||||
|
|
||||||
|
All FSD projects follow the same `@/<layer>/*` path alias convention. The
|
||||||
|
exact configuration differs by framework. See each framework section
|
||||||
|
below. Astro is the one exception, using a single `@/*` alias instead.
|
||||||
|
|
||||||
|
|
||||||
|
## Next.js
|
||||||
|
|
||||||
|
FSD is compatible with both the App Router and the Pages Router. The main
|
||||||
|
conflict is that Next.js owns the `app/` and `pages/` folder names, and both
|
||||||
|
collide with FSD layer names. Resolve the conflict by moving the Next.js
|
||||||
|
routing folders to the project root and importing FSD pages from `src/` into
|
||||||
|
them.
|
||||||
|
|
||||||
|
### App Router
|
||||||
|
|
||||||
|
The Next.js `app/` folder lives at the project root. **Always create an
|
||||||
|
empty `pages/` folder at the project root as well, even when you only use
|
||||||
|
the App Router.** Without it, Next.js tries to use `src/pages/` as the Pages
|
||||||
|
Router and the build breaks. Add a `pages/README.md` explaining why the
|
||||||
|
folder exists.
|
||||||
|
|
||||||
|
#### Directory structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-nextjs-project/
|
||||||
|
app/ ← Next.js App Router (routing only)
|
||||||
|
layout.tsx
|
||||||
|
page.tsx
|
||||||
|
profile/
|
||||||
|
page.tsx
|
||||||
|
api/
|
||||||
|
get-example/
|
||||||
|
route.ts
|
||||||
|
pages/ ← Empty, required even for App Router
|
||||||
|
README.md
|
||||||
|
src/
|
||||||
|
app/ ← FSD app layer
|
||||||
|
providers/
|
||||||
|
index.tsx ← All providers (QueryClient, theme, etc.)
|
||||||
|
styles/
|
||||||
|
globals.css
|
||||||
|
api-routes/ ← Route Handler implementations (see below)
|
||||||
|
index.ts
|
||||||
|
get-example-data.ts
|
||||||
|
pages/ ← FSD pages layer
|
||||||
|
home/
|
||||||
|
ui/HomePage.tsx
|
||||||
|
index.ts
|
||||||
|
profile/
|
||||||
|
ui/ProfilePage.tsx
|
||||||
|
model/profile.ts
|
||||||
|
api/fetch-profile.ts
|
||||||
|
index.ts
|
||||||
|
widgets/ ← FSD widgets layer (when needed)
|
||||||
|
features/ ← FSD features layer (when needed)
|
||||||
|
entities/ ← FSD entities layer (when needed)
|
||||||
|
shared/ ← FSD shared layer
|
||||||
|
ui/
|
||||||
|
lib/
|
||||||
|
api/
|
||||||
|
db/ ← Database queries (see below)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Wiring Next.js routes to FSD pages
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/layout.tsx
|
||||||
|
import { Providers } from '@/app/providers';
|
||||||
|
import '@/app/styles/globals.css';
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body><Providers>{children}</Providers></body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// app/example/page.tsx: re-export the FSD page (component + metadata)
|
||||||
|
export { ExamplePage as default, metadata } from '@/pages/example';
|
||||||
|
```
|
||||||
|
|
||||||
|
Always re-export both the component and `metadata`. Route files contain no logic.
|
||||||
|
|
||||||
|
### Pages Router
|
||||||
|
|
||||||
|
The Pages Router uses `pages/` at the project root. The FSD `src/` tree is
|
||||||
|
unchanged. Routes re-export from `src/pages/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-nextjs-project/
|
||||||
|
pages/ ← Next.js Pages Router (routing only)
|
||||||
|
_app.tsx
|
||||||
|
api/example.ts ← API route re-export
|
||||||
|
example/index.tsx
|
||||||
|
src/
|
||||||
|
app/
|
||||||
|
custom-app/ ← Custom App component
|
||||||
|
api-routes/ ← Route Handler implementations
|
||||||
|
pages/
|
||||||
|
example/
|
||||||
|
ui/example.tsx
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// pages/example/index.tsx
|
||||||
|
export { Example as default } from '@/pages/example';
|
||||||
|
|
||||||
|
// pages/_app.tsx: re-export the custom App from src/app/custom-app
|
||||||
|
export { App as default } from '@/app/custom-app';
|
||||||
|
```
|
||||||
|
|
||||||
|
The custom App component itself lives in `src/app/custom-app/` and exports
|
||||||
|
`App` from its public API like any other FSD slice.
|
||||||
|
|
||||||
|
### Middleware and instrumentation
|
||||||
|
|
||||||
|
`middleware.js` and `instrumentation.js` must live at the **project root**,
|
||||||
|
next to the Next.js `app/` and `pages/` folders. Next.js will not detect
|
||||||
|
them inside `src/`.
|
||||||
|
|
||||||
|
### Route Handlers (API routes)
|
||||||
|
|
||||||
|
Use a dedicated `api-routes` segment in the FSD `app/` layer
|
||||||
|
(`src/app/api-routes/`) to host the actual request handlers. The Next.js
|
||||||
|
`app/api/*/route.ts` (App Router) or `pages/api/*.ts` (Pages Router) files
|
||||||
|
become thin re-exports.
|
||||||
|
|
||||||
|
**App Router:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/api-routes/get-example-data.ts
|
||||||
|
import { getExamplesList } from '@/shared/db';
|
||||||
|
|
||||||
|
export const getExampleData = () => {
|
||||||
|
try {
|
||||||
|
const examplesList = getExamplesList();
|
||||||
|
return Response.json({ examplesList });
|
||||||
|
} catch {
|
||||||
|
return Response.json(null, {
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Ouch, something went wrong',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/app/api-routes/index.ts
|
||||||
|
export { getExampleData } from './get-example-data';
|
||||||
|
|
||||||
|
// app/api/example/route.ts
|
||||||
|
export { getExampleData as GET } from '@/app/api-routes';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages Router:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/api-routes/get-example-data.ts
|
||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
const config = { api: { bodyParser: { sizeLimit: '1mb' } }, maxDuration: 5 };
|
||||||
|
const handler = (req: NextApiRequest, res: NextApiResponse) =>
|
||||||
|
res.status(200).json({ message: 'Hello from FSD' });
|
||||||
|
|
||||||
|
export const getExampleData = { config, handler } as const;
|
||||||
|
|
||||||
|
// app/api/example.ts
|
||||||
|
import { getExampleData } from '@/app/api-routes';
|
||||||
|
export const config = getExampleData.config;
|
||||||
|
export default getExampleData.handler;
|
||||||
|
```
|
||||||
|
|
||||||
|
FSD is primarily a frontend methodology. If `api-routes` grows to many
|
||||||
|
endpoints, consider moving the backend to a separate package in a monorepo.
|
||||||
|
|
||||||
|
### Database access
|
||||||
|
|
||||||
|
Place database queries in a `db` segment in `shared/` (`src/shared/db/`).
|
||||||
|
Co-locate caching and revalidation logic with the queries themselves.
|
||||||
|
|
||||||
|
### Path aliases
|
||||||
|
|
||||||
|
```json
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/app/*": ["src/app/*"],
|
||||||
|
"@/pages/*": ["src/pages/*"],
|
||||||
|
"@/widgets/*": ["src/widgets/*"],
|
||||||
|
"@/features/*": ["src/features/*"],
|
||||||
|
"@/entities/*": ["src/entities/*"],
|
||||||
|
"@/shared/*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Next.js reads `tsconfig.json` paths automatically. No `next.config.js`
|
||||||
|
alias configuration is needed.
|
||||||
|
|
||||||
|
### Server Components and Public API splitting
|
||||||
|
|
||||||
|
FSD layers work inside both Server and Client Components. However, the
|
||||||
|
standard single `index.ts` public API can cause problems in RSC environments
|
||||||
|
because re-exporting client and server code from the same entry point may
|
||||||
|
trigger bundler errors or unintended boundary crossings.
|
||||||
|
|
||||||
|
Split the public API into multiple entry points per environment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
entities/user/
|
||||||
|
model/
|
||||||
|
user.ts
|
||||||
|
ui/
|
||||||
|
UserAvatar.tsx ← 'use client', uses hooks
|
||||||
|
UserProfileCard.tsx ← Server Component, no hooks
|
||||||
|
api/
|
||||||
|
user-queries.server.ts ← Server-only data fetching
|
||||||
|
index.ts ← Shared exports (types, pure functions)
|
||||||
|
index.client.ts ← Client component exports
|
||||||
|
index.server.ts ← Server component + server-only exports
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// entities/user/index.ts: shared (types, pure logic, no components)
|
||||||
|
export type { User } from "./model/user";
|
||||||
|
export { formatUserName } from "./model/user";
|
||||||
|
|
||||||
|
// entities/user/index.client.ts: client components only
|
||||||
|
export { UserAvatar } from "./ui/UserAvatar";
|
||||||
|
|
||||||
|
// entities/user/index.server.ts: server components + server-only code
|
||||||
|
export { UserProfileCard } from "./ui/UserProfileCard";
|
||||||
|
export { fetchUser } from "./api/user-queries.server";
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Consumers import from the appropriate entry point:
|
||||||
|
|
||||||
|
// In a Server Component (pages/profile/ui/ProfilePage.tsx)
|
||||||
|
import { UserProfileCard } from "@/entities/user/index.server";
|
||||||
|
import type { User } from "@/entities/user";
|
||||||
|
|
||||||
|
// In a Client Component (features/comment/ui/CommentAuthor.tsx)
|
||||||
|
import { UserAvatar } from "@/entities/user/index.client";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules for split public APIs:**
|
||||||
|
|
||||||
|
1. **`index.ts`**: types, constants, and pure functions that work in both
|
||||||
|
environments. Default import path.
|
||||||
|
2. **`index.client.ts`**: components using `'use client'`, hooks, or
|
||||||
|
browser APIs.
|
||||||
|
3. **`index.server.ts`**: Server Components and server-only data fetching.
|
||||||
|
4. The `index.[env].ts` pattern generalises beyond RSC. Any meta-framework
|
||||||
|
with distinct runtime environments can use it (e.g., `index.edge.ts`).
|
||||||
|
Verified for Next.js App Router; Nuxt and Astro compatibility is under
|
||||||
|
review.
|
||||||
|
5. Steiger support for multiple entry points is available or coming in an
|
||||||
|
upcoming release. If Steiger flags these files, check for version
|
||||||
|
updates.
|
||||||
|
|
||||||
|
**When NOT to split:** A slice with no client/server boundary concerns uses
|
||||||
|
a single `index.ts`. Split only when a slice actually has both client and
|
||||||
|
server exports.
|
||||||
|
|
||||||
|
|
||||||
|
## Nuxt 3
|
||||||
|
|
||||||
|
### Directory structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-nuxt-project/
|
||||||
|
pages/ ← Nuxt file-based routing
|
||||||
|
index.vue ← Route entry, imports from FSD pages layer
|
||||||
|
profile.vue
|
||||||
|
src/
|
||||||
|
app/ ← FSD app layer
|
||||||
|
providers/
|
||||||
|
pages/ ← FSD pages layer
|
||||||
|
home/
|
||||||
|
ui/HomePage.vue
|
||||||
|
index.ts
|
||||||
|
profile/
|
||||||
|
ui/ProfilePage.vue
|
||||||
|
model/profile.ts
|
||||||
|
index.ts
|
||||||
|
shared/ ← FSD shared layer
|
||||||
|
ui/
|
||||||
|
lib/
|
||||||
|
api/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wiring Nuxt routes to FSD pages
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- pages/index.vue: thin route entry -->
|
||||||
|
<template>
|
||||||
|
<HomePage />
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { HomePage } from "@/pages/home";
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path aliases
|
||||||
|
|
||||||
|
In addition to the standard `tsconfig.json` mapping, Nuxt requires explicit
|
||||||
|
runtime aliases in `nuxt.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// nuxt.config.ts
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
alias: {
|
||||||
|
"@/app": resolve(__dirname, "src/app"),
|
||||||
|
"@/pages": resolve(__dirname, "src/pages"),
|
||||||
|
"@/widgets": resolve(__dirname, "src/widgets"),
|
||||||
|
"@/features": resolve(__dirname, "src/features"),
|
||||||
|
"@/entities": resolve(__dirname, "src/entities"),
|
||||||
|
"@/shared": resolve(__dirname, "src/shared"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Vite + React
|
||||||
|
|
||||||
|
### Directory structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-vite-project/
|
||||||
|
src/
|
||||||
|
app/ ← FSD app layer
|
||||||
|
providers/
|
||||||
|
router.tsx
|
||||||
|
styles/
|
||||||
|
main.tsx ← Entry point
|
||||||
|
pages/
|
||||||
|
shared/
|
||||||
|
index.html
|
||||||
|
vite.config.ts
|
||||||
|
tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path aliases
|
||||||
|
|
||||||
|
Mirror the standard `tsconfig.json` mapping in `vite.config.ts` so the
|
||||||
|
Vite resolver agrees with TypeScript:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// vite.config.ts
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@/app": resolve(__dirname, "src/app"),
|
||||||
|
"@/pages": resolve(__dirname, "src/pages"),
|
||||||
|
"@/widgets": resolve(__dirname, "src/widgets"),
|
||||||
|
"@/features": resolve(__dirname, "src/features"),
|
||||||
|
"@/entities": resolve(__dirname, "src/entities"),
|
||||||
|
"@/shared": resolve(__dirname, "src/shared"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Create React App (CRA)
|
||||||
|
|
||||||
|
CRA is no longer actively maintained. **Migrate to Vite for new projects.**
|
||||||
|
|
||||||
|
If you must stay on CRA, path aliases require ejecting or using `craco` to
|
||||||
|
override the webpack config:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// craco.config.js
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
webpack: {
|
||||||
|
alias: {
|
||||||
|
"@/app": path.resolve(__dirname, "src/app"),
|
||||||
|
"@/pages": path.resolve(__dirname, "src/pages"),
|
||||||
|
"@/widgets": path.resolve(__dirname, "src/widgets"),
|
||||||
|
"@/features": path.resolve(__dirname, "src/features"),
|
||||||
|
"@/entities": path.resolve(__dirname, "src/entities"),
|
||||||
|
"@/shared": path.resolve(__dirname, "src/shared"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Astro
|
||||||
|
|
||||||
|
Astro uses `src/pages/` for file-based routing, which collides with the FSD
|
||||||
|
`pages/` layer. Move the FSD pages layer to `src/_pages/` (with the
|
||||||
|
underscore prefix) and reserve `src/pages/` for Astro routes.
|
||||||
|
|
||||||
|
### Directory structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-astro-project/
|
||||||
|
src/
|
||||||
|
pages/ ← Astro routing (thin entry points)
|
||||||
|
404.astro
|
||||||
|
index.astro
|
||||||
|
_pages/ ← FSD pages layer
|
||||||
|
home/
|
||||||
|
ui/HomePage.astro
|
||||||
|
index.ts
|
||||||
|
widgets/
|
||||||
|
features/
|
||||||
|
entities/
|
||||||
|
shared/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wiring Astro routes to FSD pages
|
||||||
|
|
||||||
|
The Astro route file imports and renders the FSD page, nothing else:
|
||||||
|
|
||||||
|
```astro
|
||||||
|
// src/pages/index.astro
|
||||||
|
import { HomePage } from '@/_pages/home';
|
||||||
|
<HomePage />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path aliases (tsconfig.json)
|
||||||
|
|
||||||
|
Astro projects use a single `@/*` alias instead of one alias per layer. This
|
||||||
|
is the convention the FSD Astro guide recommends:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Imports then reference the layer path directly: `@/_pages/home`,
|
||||||
|
`@/shared/ui`, `@/entities/user`.
|
||||||
|
|
||||||
|
### Working with integrations
|
||||||
|
|
||||||
|
Some Astro integrations (for example, Starlight) use content collections
|
||||||
|
that expect content in fixed folders such as `src/content/docs/`. If the
|
||||||
|
integration does not allow the path to be changed, leave it as-is. The
|
||||||
|
content folder lives alongside FSD layers without collision:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
_pages/ ← FSD pages layer
|
||||||
|
content/ ← Integration content (Starlight, etc.)
|
||||||
|
docs/
|
||||||
|
getting-started.md
|
||||||
|
shared/ ← FSD shared layer
|
||||||
|
```
|
||||||
|
|
||||||
|
Let the integration handle its own routing and rendering, while FSD layers
|
||||||
|
manage application-specific code.
|
||||||
|
|
||||||
|
|
||||||
|
## Key Reminders for All Frameworks
|
||||||
|
|
||||||
|
1. **FSD lives in `src/`**: root-level `app/` and `pages/` belong to the
|
||||||
|
framework's routing, not FSD.
|
||||||
|
2. **Framework route files are thin wrappers**: they import and render FSD
|
||||||
|
page components. Business logic stays in FSD pages.
|
||||||
|
3. **Path aliases are required**: configure both the bundler and
|
||||||
|
`tsconfig.json`.
|
||||||
|
4. **Pages First still applies**: regardless of framework, start with code
|
||||||
|
in FSD `pages/` and extract only when needed.
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
# Layer Structure Reference
|
||||||
|
|
||||||
|
Detailed folder structures, code examples, and naming conventions for each
|
||||||
|
FSD layer. Use this reference when creating, reviewing, or reorganizing
|
||||||
|
project structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Layer
|
||||||
|
|
||||||
|
App-wide initialization: providers, routing, global styles, entry point.
|
||||||
|
Organized by segments only, no slices.
|
||||||
|
|
||||||
|
The methodology does not formally standardize App segment names. The
|
||||||
|
common convention list (`ui`, `api`, `model`, `lib`, `config`) applies to
|
||||||
|
all layers but is rarely a good fit here. In practice, projects use names
|
||||||
|
that describe purpose: `routes`, `store`, `styles`, `providers`,
|
||||||
|
`entrypoint`, etc. Choose names that match your stack (for example,
|
||||||
|
`providers` for React/Vue provider components that wrap Redux,
|
||||||
|
QueryClient, or theme contexts):
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
routes/ ← Route configuration (or router.tsx for single file)
|
||||||
|
store/ ← Global state store (Redux configureStore, Zustand root)
|
||||||
|
styles/ ← Global CSS, reset, theme variables
|
||||||
|
providers/ ← Provider components (Redux Provider, QueryClientProvider)
|
||||||
|
entrypoint.tsx ← Application entry point (main.tsx, index.tsx)
|
||||||
|
```
|
||||||
|
|
||||||
|
A smaller project may collapse some of these into single files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
router.tsx ← Route configuration
|
||||||
|
store.ts ← Store configuration
|
||||||
|
styles/
|
||||||
|
global.css
|
||||||
|
providers.tsx ← All providers in one file
|
||||||
|
index.tsx ← Entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/router.tsx
|
||||||
|
import { HomePage } from '@/pages/home';
|
||||||
|
import { ProfilePage } from '@/pages/profile';
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{ path: '/', element: <HomePage /> },
|
||||||
|
{ path: '/profile/:id', element: <ProfilePage /> },
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Belongs in app:** Global providers (Redux store, QueryClient, theme),
|
||||||
|
routing setup, global styles, error boundaries, analytics initialization.
|
||||||
|
|
||||||
|
**Does not belong:** Feature-specific code, business logic, page-level UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages Layer
|
||||||
|
|
||||||
|
Route-level composition. In v2.1, pages **own substantial logic**: they are
|
||||||
|
not thin wrappers. In early project stages, most code lives here.
|
||||||
|
|
||||||
|
```text
|
||||||
|
pages/
|
||||||
|
home/
|
||||||
|
ui/
|
||||||
|
HomePage.tsx
|
||||||
|
HeroSection.tsx
|
||||||
|
FeaturesGrid.tsx
|
||||||
|
model/
|
||||||
|
home-data.ts ← Page-specific state + logic
|
||||||
|
api/
|
||||||
|
fetch-home-data.ts ← Page-specific API calls
|
||||||
|
index.ts
|
||||||
|
profile/
|
||||||
|
ui/
|
||||||
|
ProfilePage.tsx
|
||||||
|
ProfileForm.tsx
|
||||||
|
ProfileStats.tsx
|
||||||
|
model/
|
||||||
|
profile.ts ← Profile state + validation logic
|
||||||
|
api/
|
||||||
|
update-profile.ts
|
||||||
|
fetch-profile.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Belongs in pages:** Page-specific UI, forms, validation, data fetching,
|
||||||
|
state management, business logic, API integrations. Even code that looks
|
||||||
|
reusable stays here if it is simpler to keep local.
|
||||||
|
|
||||||
|
**Does not belong:** Code that is currently being reused across multiple
|
||||||
|
pages with stable boundaries (extract to a lower layer when reuse is
|
||||||
|
confirmed, not anticipated).
|
||||||
|
|
||||||
|
### Page Layout Patterns
|
||||||
|
|
||||||
|
A typical page composes widgets, features, and entities from lower layers,
|
||||||
|
plus its own local UI components:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// pages/product-detail/ui/ProductDetailPage.tsx
|
||||||
|
import { Header } from '@/widgets/header';
|
||||||
|
import { AddToCart } from '@/features/add-to-cart';
|
||||||
|
import { Product } from '@/entities/product';
|
||||||
|
|
||||||
|
export const ProductDetailPage = ({ productId }) => {
|
||||||
|
const product = useProductDetail(productId); // local hook in this page
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<Product.Card data={product} />
|
||||||
|
<AddToCart productId={productId} />
|
||||||
|
<RelatedProducts products={product.related} /> {/* local component */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
For pages that only need shared + page-local code (no extracted layers):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// pages/about/ui/AboutPage.tsx
|
||||||
|
import { Card } from '@/shared/ui/Card';
|
||||||
|
import { TeamSection } from './TeamSection'; // local to this page
|
||||||
|
import { MissionStatement } from './MissionStatement';
|
||||||
|
|
||||||
|
export const AboutPage = () => (
|
||||||
|
<main>
|
||||||
|
<MissionStatement />
|
||||||
|
<Card><TeamSection /></Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widgets Layer
|
||||||
|
|
||||||
|
Composite UI blocks with their own logic, **reused across multiple pages**.
|
||||||
|
Add this layer only when UI blocks actually appear in 2+ pages and sharing
|
||||||
|
provides clear value.
|
||||||
|
|
||||||
|
```text
|
||||||
|
widgets/
|
||||||
|
header/
|
||||||
|
ui/
|
||||||
|
Header.tsx
|
||||||
|
Navigation.tsx
|
||||||
|
UserMenu.tsx
|
||||||
|
model/
|
||||||
|
header.ts ← Widget state
|
||||||
|
api/
|
||||||
|
fetch-notifications.ts
|
||||||
|
index.ts
|
||||||
|
sidebar/
|
||||||
|
ui/
|
||||||
|
Sidebar.tsx
|
||||||
|
model/
|
||||||
|
sidebar.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Belongs in widgets:** Navigation bars, sidebars, dashboards, footers,
|
||||||
|
complex card layouts that combine data from multiple entities/features.
|
||||||
|
|
||||||
|
**Does not belong:** Simple UI primitives (→ `shared/ui/`), single-use
|
||||||
|
page sections (→ keep in the page).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features Layer
|
||||||
|
|
||||||
|
Independent, reusable user interactions. **Create only when used in 2+ places.**
|
||||||
|
|
||||||
|
```text
|
||||||
|
features/
|
||||||
|
auth/
|
||||||
|
ui/
|
||||||
|
LoginForm.tsx
|
||||||
|
RegisterForm.tsx
|
||||||
|
model/
|
||||||
|
auth.ts ← Auth state + logic
|
||||||
|
api/
|
||||||
|
login.ts
|
||||||
|
register.ts
|
||||||
|
index.ts
|
||||||
|
add-to-cart/
|
||||||
|
ui/
|
||||||
|
AddToCartButton.tsx
|
||||||
|
model/
|
||||||
|
cart.ts
|
||||||
|
index.ts
|
||||||
|
like-post/
|
||||||
|
ui/
|
||||||
|
LikeButton.tsx
|
||||||
|
model/
|
||||||
|
like.ts
|
||||||
|
api/
|
||||||
|
toggle-like.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Feature composition**: features consume entities and are composed in
|
||||||
|
higher layers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// widgets/post-card/ui/PostCard.tsx
|
||||||
|
import { UserAvatar } from '@/entities/user';
|
||||||
|
import { LikeButton } from '@/features/like-post';
|
||||||
|
import { CommentButton } from '@/features/comment-create';
|
||||||
|
|
||||||
|
export const PostCard = ({ post }) => (
|
||||||
|
<article>
|
||||||
|
<UserAvatar userId={post.authorId} />
|
||||||
|
<h2>{post.title}</h2>
|
||||||
|
<p>{post.content}</p>
|
||||||
|
<div>
|
||||||
|
<LikeButton postId={post.id} />
|
||||||
|
<CommentButton postId={post.id} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entities Layer
|
||||||
|
|
||||||
|
Reusable business domain models. **Create only when used in 2+ places. Starting
|
||||||
|
without this layer is completely valid.**
|
||||||
|
|
||||||
|
```text
|
||||||
|
// Minimal entity: model only (most common form)
|
||||||
|
entities/user/
|
||||||
|
model/
|
||||||
|
user.ts ← Types + domain logic
|
||||||
|
index.ts
|
||||||
|
|
||||||
|
// Entity with UI (use with caution)
|
||||||
|
// ⚠️ Adding UI to entities increases cross-import risk.
|
||||||
|
// Other entities may want to import this UI, leading to @x dependencies.
|
||||||
|
// Entity UI should only be imported from higher layers (features, widgets,
|
||||||
|
// pages), never from other entities.
|
||||||
|
entities/product/
|
||||||
|
model/
|
||||||
|
product.ts
|
||||||
|
ui/
|
||||||
|
ProductCard.tsx
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Layer Structure
|
||||||
|
|
||||||
|
Infrastructure with no business logic. Organized by segments only (no slices).
|
||||||
|
Segments may import from each other.
|
||||||
|
|
||||||
|
```text
|
||||||
|
shared/
|
||||||
|
ui/ ← UI kit: Button, Input, Modal, Card
|
||||||
|
lib/ ← Utilities: formatDate, debounce, classnames
|
||||||
|
api/ ← API client, route constants, CRUD helpers, base types
|
||||||
|
auth/ ← Auth tokens, login utilities, session management
|
||||||
|
config/ ← Environment variables, app settings
|
||||||
|
assets/ ← Branding assets shared across the app (use sparingly)
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/ui/Button/Button.tsx
|
||||||
|
export const Button = ({ children, onClick, variant = 'primary' }) => (
|
||||||
|
<button className={`btn btn-${variant}`} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// shared/ui/Button/index.ts
|
||||||
|
export { Button } from './Button';
|
||||||
|
export type { ButtonProps } from './Button';
|
||||||
|
```
|
||||||
|
|
||||||
|
Shared **may** contain application-aware code (route constants, API endpoints,
|
||||||
|
branding assets, common types). It must **never** contain business logic,
|
||||||
|
feature-specific code, or entity-specific code.
|
||||||
|
|
||||||
|
For asset placement specifically (images, icons, fonts, PDFs), see
|
||||||
|
`references/asset-handling.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segments
|
||||||
|
|
||||||
|
A segment groups related code within a slice (or within App/Shared). The
|
||||||
|
standard segments cover the most common technical purposes:
|
||||||
|
|
||||||
|
- **`ui`**: UI display (components, date formatters, styles).
|
||||||
|
- **`api`**: backend interactions (request functions, data types, mappers).
|
||||||
|
- **`model`**: data model (schemas, interfaces, stores, business logic).
|
||||||
|
- **`lib`**: library code that other modules in this slice need.
|
||||||
|
- **`config`**: configuration files and feature flags.
|
||||||
|
|
||||||
|
Custom segments are allowed when needed (for example, `routes` and `i18n`
|
||||||
|
in the Shared layer, or `auth` for token storage when split out from
|
||||||
|
`shared/api`).
|
||||||
|
|
||||||
|
### Group by what it is *for*, not by what it *is*
|
||||||
|
|
||||||
|
Segment names describe **purpose**, not the kind of code they hold. This
|
||||||
|
is the desegmentation principle:
|
||||||
|
|
||||||
|
```text
|
||||||
|
// ❌ BAD: grouping by technical kind (what the code is)
|
||||||
|
shared/
|
||||||
|
components/ ← What kind of components?
|
||||||
|
hooks/ ← Which feature do they serve?
|
||||||
|
types/ ← Which domain do they describe?
|
||||||
|
utils/ ← Utility for what?
|
||||||
|
helpers/ ← Same problem
|
||||||
|
actions/ ← Redux actions for what?
|
||||||
|
|
||||||
|
// ✅ GOOD: grouping by purpose (what the code is for)
|
||||||
|
shared/
|
||||||
|
ui/ ← For displaying UI
|
||||||
|
api/ ← For talking to the backend
|
||||||
|
lib/ ← For library code that supports the slice
|
||||||
|
config/ ← For configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
A segment named `types/` cannot answer "types for what?" without inspecting
|
||||||
|
the contents. A segment named `model/` says: this is the data model.
|
||||||
|
Inside `model/`, files are named by domain (`user.ts`, `order.ts`), not by
|
||||||
|
technical role.
|
||||||
|
|
||||||
|
This rule applies everywhere: in `shared/`, in slices, and when designing
|
||||||
|
new custom segments.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
### Domain-based file naming
|
||||||
|
|
||||||
|
Within a segment, name files after the business domain, not the technical
|
||||||
|
role:
|
||||||
|
|
||||||
|
```text
|
||||||
|
// ❌ Technical-role naming: mixes domains
|
||||||
|
model/types.ts ← Which types? User? Order?
|
||||||
|
model/utils.ts
|
||||||
|
api/endpoints.ts
|
||||||
|
model/selectors.ts
|
||||||
|
|
||||||
|
// ✅ Domain-based naming: each file owns one domain
|
||||||
|
model/user.ts ← User types + logic + store
|
||||||
|
model/order.ts ← Order types + logic + store
|
||||||
|
api/fetch-profile.ts ← Clear what this API does
|
||||||
|
model/todo.ts ← Redux slice + selectors + thunks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Single-concern segments
|
||||||
|
|
||||||
|
If a segment contains only one domain concern, the filename may match the
|
||||||
|
slice name:
|
||||||
|
|
||||||
|
```text
|
||||||
|
features/auth/
|
||||||
|
model/
|
||||||
|
auth.ts ← Single concern, matches slice name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Index files as public API
|
||||||
|
|
||||||
|
Every slice must have an `index.ts` that re-exports its public interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// entities/user/index.ts
|
||||||
|
export { UserAvatar } from "./ui/UserAvatar";
|
||||||
|
export { useUser, type User } from "./model/user";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice Groups
|
||||||
|
|
||||||
|
A **slice group** is a folder that contains related slices on the same
|
||||||
|
layer, used purely to make the structure easier to navigate as the number
|
||||||
|
of slices grows. A slice group is **not** a slice itself: it has no
|
||||||
|
segments (`model/`, `ui/`, `api/`), no public API (`index.ts`), and no
|
||||||
|
shared code. Slice isolation rules apply unchanged inside a group: sibling
|
||||||
|
slices in the same group cannot import from each other.
|
||||||
|
|
||||||
|
Slice groups are optional. Use them only when the layer has grown large
|
||||||
|
enough that a flat structure becomes hard to scan and there is an obvious
|
||||||
|
grouping criterion.
|
||||||
|
|
||||||
|
### When to use
|
||||||
|
|
||||||
|
- Several slices share the same business context and are scattered across
|
||||||
|
the layer.
|
||||||
|
- The slice names clearly suggest they belong to the same topic.
|
||||||
|
- The layer has grown to the point where it is hard to scan at a glance.
|
||||||
|
|
||||||
|
### When NOT to use
|
||||||
|
|
||||||
|
- Names alone are enough for quick navigation.
|
||||||
|
- There is no natural grouping criterion.
|
||||||
|
- Only two or three slices would end up in the group.
|
||||||
|
|
||||||
|
### Example: grouping payment-related entities
|
||||||
|
|
||||||
|
```text
|
||||||
|
entities/
|
||||||
|
payment/ ← Slice group (no public API)
|
||||||
|
invoice/ ← Slice
|
||||||
|
model/
|
||||||
|
ui/
|
||||||
|
index.ts
|
||||||
|
receipt/ ← Slice (model/, ui/, index.ts)
|
||||||
|
transaction/ ← Slice (model/, ui/, index.ts)
|
||||||
|
user/ ← Slice (not in any group)
|
||||||
|
product/ ← Slice
|
||||||
|
```
|
||||||
|
|
||||||
|
Imports go through the full path:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Invoice } from "@/entities/payment/invoice";
|
||||||
|
import { Receipt } from "@/entities/payment/receipt";
|
||||||
|
```
|
||||||
|
|
||||||
|
The same pattern applies to the Pages layer. For example, grouping
|
||||||
|
`pages/order/{list,detail,create}` when there are multiple pages on the same
|
||||||
|
topic such as list, detail, create, and edit. This is one possible example
|
||||||
|
and does not represent the default structure for the Pages layer.
|
||||||
|
|
||||||
|
### Features: use with caution
|
||||||
|
|
||||||
|
Slice groups can be applied to Features, but features often span multiple
|
||||||
|
entities and lack a natural grouping criterion. A group like
|
||||||
|
`features/cart/` tends to attract everything cart-related (DTOs, mappers,
|
||||||
|
helpers) until it stops being a navigation aid and starts acting as the
|
||||||
|
home for the entire cart domain, which weakens the principle that
|
||||||
|
features are split by use case. Before grouping features, check that the
|
||||||
|
group contains only feature slices and that two or three slices is not the
|
||||||
|
entire content.
|
||||||
|
|
||||||
|
### Anti-patterns
|
||||||
|
|
||||||
|
- **Do not put `index.ts` on the group folder.** That promotes the group
|
||||||
|
to a slice and breaks the layer's contract.
|
||||||
|
- **Do not put shared `utils.ts`, `constants.ts`, or `types.ts` files
|
||||||
|
inside the group.** A slice group has no shared code. Extract reusable
|
||||||
|
code to `shared/` instead. If the layer is `entities` and the shared
|
||||||
|
logic is genuinely domain logic, consider whether the boundaries are
|
||||||
|
too granular and the slices should be merged into one isolated entity
|
||||||
|
(see `references/excessive-entities.md`). The `@x` notation does not
|
||||||
|
apply to slice groups. It is a cross-import surface between entity
|
||||||
|
slices, not a sharing mechanism for siblings within a group.
|
||||||
|
- **Do not relax slice isolation inside the group.** If two slices in the
|
||||||
|
same group need to share code, extract it one layer down rather than
|
||||||
|
adding a `_common/` file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path Aliases
|
||||||
|
|
||||||
|
Configure path aliases so imports follow the `@/layer/slice` pattern:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/app/*": ["src/app/*"],
|
||||||
|
"@/pages/*": ["src/pages/*"],
|
||||||
|
"@/widgets/*": ["src/widgets/*"],
|
||||||
|
"@/features/*": ["src/features/*"],
|
||||||
|
"@/entities/*": ["src/entities/*"],
|
||||||
|
"@/shared/*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For framework-specific alias configuration (Vite, Next.js, Nuxt, Astro),
|
||||||
|
see `references/framework-integration.md`.
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# Migration Guide
|
||||||
|
|
||||||
|
How to migrate to FSD v2.1 from either FSD v2.0 or a custom (non-FSD)
|
||||||
|
architecture. This guide reflects the official `from-custom` step order:
|
||||||
|
**pages first**, then everything else.
|
||||||
|
|
||||||
|
## Part 1: FSD v2.0 → v2.1 (non-breaking)
|
||||||
|
|
||||||
|
The v2.1 update emphasizes **"pages first"**: most logic stays in pages,
|
||||||
|
reusable foundation in Shared. If reuse is needed across several pages,
|
||||||
|
move it to a layer below. The migration is non-breaking and simplifies
|
||||||
|
the codebase by relocating single-use code back to where it is consumed.
|
||||||
|
|
||||||
|
Another addition in v2.1 is the standardization of cross-imports between
|
||||||
|
entities with the `@x` notation. See `references/cross-import-patterns.md`.
|
||||||
|
|
||||||
|
### Step 1. Audit existing slices
|
||||||
|
|
||||||
|
Use Steiger to detect slices that are used in only one place:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D @feature-sliced/steiger
|
||||||
|
npx steiger src
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for these rules:
|
||||||
|
|
||||||
|
- **`insignificant-slice`**: an entity or feature used by only one page.
|
||||||
|
This rule will suggest merging that entity or feature into the page
|
||||||
|
entirely.
|
||||||
|
- **`excessive-slicing`**: too many slices in a single layer.
|
||||||
|
|
||||||
|
For each flagged slice, decide:
|
||||||
|
|
||||||
|
- Reused in 2+ places → keep in features/entities.
|
||||||
|
- Used only in one page → mark for migration back into that page.
|
||||||
|
|
||||||
|
### Step 2. Move single-use code back to its consumer
|
||||||
|
|
||||||
|
Take single-use features and entities and inline them into the consuming
|
||||||
|
page (or widget if that is the single consumer):
|
||||||
|
|
||||||
|
```text
|
||||||
|
// Before (v2.0): feature used by only one page
|
||||||
|
features/user-profile-form/
|
||||||
|
ui/ProfileForm.tsx
|
||||||
|
model/profile-form.ts
|
||||||
|
api/update-profile.ts
|
||||||
|
index.ts
|
||||||
|
pages/profile/
|
||||||
|
ui/ProfilePage.tsx ← Thin wrapper, just composes
|
||||||
|
|
||||||
|
// After (v2.1): code lives in the page that owns it
|
||||||
|
pages/profile/
|
||||||
|
ui/{ProfilePage,ProfileForm}.tsx
|
||||||
|
model/profile.ts ← Merged form logic
|
||||||
|
api/update-profile.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
For each moved slice:
|
||||||
|
|
||||||
|
1. Copy all files into the consuming page.
|
||||||
|
2. Update the page's `index.ts` to export what is needed externally.
|
||||||
|
3. Update all imports across the codebase to point to the new location.
|
||||||
|
4. Delete the now-empty feature/entity directory.
|
||||||
|
5. Run tests.
|
||||||
|
|
||||||
|
### Step 3. Keep genuinely reused code in place
|
||||||
|
|
||||||
|
Code confirmed to be used in 2+ places stays in features/entities. Do not
|
||||||
|
move it. The point of v2.1 is reducing premature extraction, not removing
|
||||||
|
reuse.
|
||||||
|
|
||||||
|
### Step 4. Deprecate the processes layer
|
||||||
|
|
||||||
|
The `processes` layer is deprecated. Migrate its code:
|
||||||
|
|
||||||
|
- **Multi-page workflows** (checkout, onboarding wizard): move
|
||||||
|
orchestration logic to the page that initiates the workflow. If multiple
|
||||||
|
pages share workflow state, create a feature for it.
|
||||||
|
- **Background processes** (polling, sync): move to `app/` if global, or
|
||||||
|
to the relevant page/feature if scoped.
|
||||||
|
|
||||||
|
```text
|
||||||
|
// Before
|
||||||
|
processes/
|
||||||
|
checkout/model/checkout-flow.ts
|
||||||
|
sync/model/background-sync.ts
|
||||||
|
|
||||||
|
// After
|
||||||
|
features/checkout/model/checkout-flow.ts ← Used in 2+ pages
|
||||||
|
app/sync/background-sync.ts ← Global concern
|
||||||
|
```
|
||||||
|
|
||||||
|
### Post-migration verification
|
||||||
|
|
||||||
|
1. Run `npx steiger src`. All `insignificant-slice` warnings should be gone.
|
||||||
|
2. Verify import directions. No upward or same-layer cross-imports.
|
||||||
|
3. Check that no empty layer directories remain.
|
||||||
|
4. Update documentation to reflect the new structure.
|
||||||
|
|
||||||
|
## Part 2: Custom architecture → FSD
|
||||||
|
|
||||||
|
This part follows the official `from-custom` migration order. The core
|
||||||
|
philosophy is **pages first**: start by dividing the code by pages, then
|
||||||
|
work outward.
|
||||||
|
|
||||||
|
### Before you start
|
||||||
|
|
||||||
|
The most important question to ask the team is: *do you really need it?*
|
||||||
|
Some projects are perfectly fine without FSD. Reasons to consider the
|
||||||
|
switch:
|
||||||
|
|
||||||
|
1. New team members struggle to reach a productive level.
|
||||||
|
2. Modifications to one part of the code **often** break unrelated parts.
|
||||||
|
3. Adding new functionality is difficult due to the volume of context to
|
||||||
|
hold in mind.
|
||||||
|
|
||||||
|
**Avoid switching to FSD against the will of teammates**, even as a lead.
|
||||||
|
Convince the team that the benefits outweigh migration and learning costs.
|
||||||
|
Explain the migration plan to management; architectural changes are not
|
||||||
|
immediately observable to them.
|
||||||
|
|
||||||
|
If the decision is made, set up a path alias for `src/` first. This guide
|
||||||
|
uses `@` as an alias for `./src`.
|
||||||
|
|
||||||
|
### Step 1. Divide the code by pages
|
||||||
|
|
||||||
|
If `pages/` already exists, skip this step. Otherwise, create `pages/` and
|
||||||
|
move as much component code as possible from `routes/` (or equivalent) into
|
||||||
|
it. Aim for tiny route files that just re-export from page slices.
|
||||||
|
|
||||||
|
```text
|
||||||
|
// Route file (thin)
|
||||||
|
src/routes/products.[id].js
|
||||||
|
export { ProductPage as default } from "@/pages/product"
|
||||||
|
|
||||||
|
// Page slice
|
||||||
|
src/pages/product/
|
||||||
|
ui/ProductPage.jsx
|
||||||
|
index.js ← export { ProductPage } from "./ProductPage.jsx"
|
||||||
|
```
|
||||||
|
|
||||||
|
Pages may reference each other for now. Tackle that later. Focus on
|
||||||
|
establishing a prominent division by pages.
|
||||||
|
|
||||||
|
### Step 2. Separate everything else from pages
|
||||||
|
|
||||||
|
Create `src/shared/` and move everything that does **not** import from
|
||||||
|
`pages/` or `routes/` there. Create `src/app/` and move everything that
|
||||||
|
**does** import the pages or routes there, including the routes themselves.
|
||||||
|
|
||||||
|
The Shared layer has no slices, so segments may import from each other.
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
app/
|
||||||
|
routes/
|
||||||
|
products.jsx
|
||||||
|
products.[id].jsx
|
||||||
|
App.jsx
|
||||||
|
index.js
|
||||||
|
pages/
|
||||||
|
product/
|
||||||
|
ui/ProductPage.jsx
|
||||||
|
index.js
|
||||||
|
catalog/
|
||||||
|
shared/
|
||||||
|
actions/, api/, components/, containers/, constants/,
|
||||||
|
i18n/, modules/, helpers/, utils/, reducers/, selectors/, styles/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3. Tackle cross-imports between pages
|
||||||
|
|
||||||
|
Find all cases where one page imports from another. Resolve each in one of
|
||||||
|
two ways:
|
||||||
|
|
||||||
|
1. **Copy-paste** the imported code into the depending page to remove the
|
||||||
|
dependency.
|
||||||
|
2. **Move to a Shared segment**:
|
||||||
|
- UI kit code → `shared/ui/`
|
||||||
|
- configuration constants → `shared/config/`
|
||||||
|
- backend interaction → `shared/api/`
|
||||||
|
|
||||||
|
Copy-pasting is **not architecturally wrong**. Sometimes it is more correct
|
||||||
|
to duplicate than to abstract into a new reusable module, because the
|
||||||
|
shared parts of pages can drift apart over time. Still, the DRY principle
|
||||||
|
holds for business logic: avoid copy-pasting code that must stay in sync
|
||||||
|
across multiple places.
|
||||||
|
|
||||||
|
### Step 4. Unpack the Shared layer
|
||||||
|
|
||||||
|
The Shared layer can become bloated after Step 2. Find every object used in
|
||||||
|
only one page and move it to that page's slice. **This applies to actions,
|
||||||
|
reducers, and selectors too.** There is no benefit in grouping all actions
|
||||||
|
together, but there is benefit in colocating relevant actions close to
|
||||||
|
their usage.
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
pages/
|
||||||
|
product/
|
||||||
|
actions/, reducers/, selectors/, ui/ ← moved from shared
|
||||||
|
index.js
|
||||||
|
catalog/
|
||||||
|
shared/ ← only objects that are reused
|
||||||
|
actions/, api/, components/, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5. Organize code by technical purpose (segments)
|
||||||
|
|
||||||
|
In FSD, division by technical purpose is done with **segments**. The common
|
||||||
|
ones are:
|
||||||
|
|
||||||
|
- **`ui`**: everything related to UI display (components, date formatters,
|
||||||
|
styles).
|
||||||
|
- **`api`**: backend interactions (request functions, data types, mappers).
|
||||||
|
- **`model`**: the data model (schemas, interfaces, stores, business
|
||||||
|
logic).
|
||||||
|
- **`lib`**: library code that other modules in the slice need.
|
||||||
|
- **`config`**: configuration files and feature flags.
|
||||||
|
|
||||||
|
Custom segments are allowed when needed. **Do not create segments that
|
||||||
|
group code by what it is**, like `components`, `actions`, `types`, or
|
||||||
|
`utils`. Group code by what it is **for**, not by what it is. This is the
|
||||||
|
desegmentation principle.
|
||||||
|
|
||||||
|
Reorganize each page to separate code by segments:
|
||||||
|
|
||||||
|
- The existing page UI files become the `ui` segment.
|
||||||
|
- Actions, reducers, and selectors become the `model` segment.
|
||||||
|
- Thunks and mutations become the `api` segment.
|
||||||
|
|
||||||
|
Reorganize the Shared layer too:
|
||||||
|
|
||||||
|
- `components/`, `containers/` → most of it becomes `shared/ui/`.
|
||||||
|
- `helpers/`, `utils/` → group by function (dates, type conversions, etc.)
|
||||||
|
and move groups to `shared/lib/`.
|
||||||
|
- `constants/` → group by function and move to `shared/config/`.
|
||||||
|
|
||||||
|
## Optional steps
|
||||||
|
|
||||||
|
### Step 6. Form entities/features from Redux slices used on several pages
|
||||||
|
|
||||||
|
Reused Redux slices typically describe business concepts (products, users)
|
||||||
|
or user actions (comments, likes):
|
||||||
|
|
||||||
|
- Business entities → **Entities layer**, one entity per folder.
|
||||||
|
- User actions → **Features layer**.
|
||||||
|
|
||||||
|
Entities and features are meant to be independent. If your business domain
|
||||||
|
contains inherent connections between entities (a song belongs to an
|
||||||
|
artist), see the
|
||||||
|
[business entities cross-references guide](https://fsd.how/docs/guides/examples/types#business-entities-and-their-cross-references).
|
||||||
|
|
||||||
|
API functions related to these slices can stay in `shared/api`.
|
||||||
|
|
||||||
|
### Step 7. Refactor your modules
|
||||||
|
|
||||||
|
The `modules/` folder typically holds business logic, similar in nature to
|
||||||
|
the Features layer. Some modules describe large UI chunks (an app header)
|
||||||
|
which belong in the Widgets layer.
|
||||||
|
|
||||||
|
### Step 8. Form a clean UI foundation in `shared/ui`
|
||||||
|
|
||||||
|
`shared/ui` should contain UI elements with no encoded business logic.
|
||||||
|
Refactor components from `components/` and `containers/` to extract their
|
||||||
|
business logic to higher layers. If business logic is not used in many
|
||||||
|
places, copy-pasting back to consumers is an acceptable choice.
|
||||||
|
|
||||||
|
## Common pitfalls during migration
|
||||||
|
|
||||||
|
1. **Extracting too early.** Wait for real reuse, not anticipated reuse.
|
||||||
|
The v2.1 philosophy is "pages first, extract later".
|
||||||
|
2. **Creating empty layers.** Do not create `features/`, `entities/`, or
|
||||||
|
`widgets/` directories until there is content for them.
|
||||||
|
3. **Refactoring while migrating.** Separate relocation from refactoring.
|
||||||
|
Move files first, improve them in separate commits.
|
||||||
|
4. **Ignoring import direction.** Enforce import rules from day one with
|
||||||
|
ESLint or Steiger.
|
||||||
|
5. **Big-bang migration.** Migrate page by page, verifying each step. A
|
||||||
|
hybrid structure (partly FSD, partly legacy) is acceptable during
|
||||||
|
transition.
|
||||||
|
6. **Grouping by technical role.** `components/`, `actions/`, `utils/` as
|
||||||
|
segment names defeat the purpose of FSD. Group by what code is for.
|
||||||
|
|
||||||
|
## Migrating from FSD v1 to v2
|
||||||
|
|
||||||
|
This guide does not cover v1 → v2. See the official
|
||||||
|
[v1 to v2 migration guide](https://fsd.how/docs/guides/migration/from-v1).
|
||||||
|
The v1 → v2 transition introduced the entities and processes layers
|
||||||
|
(processes was later deprecated in v2.1).
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
# Practical Examples
|
||||||
|
|
||||||
|
Concrete code patterns for common scenarios within FSD structure. Covers
|
||||||
|
authentication, type definitions, API request handling, and state management
|
||||||
|
integration (Redux, TanStack Query / React Query).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Auth is one of the most common sources of confusion in FSD. The key question
|
||||||
|
is: what goes in `shared/`, what goes in `features/` or `pages/`?
|
||||||
|
|
||||||
|
### Auth data: `shared/auth/` or `shared/api/`
|
||||||
|
|
||||||
|
Tokens, session state, and login utilities are **infrastructure**, not
|
||||||
|
business logic. Keep them in shared:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/auth/token.ts
|
||||||
|
const TOKEN_KEY = "auth_token";
|
||||||
|
export const getToken = () => localStorage.getItem(TOKEN_KEY);
|
||||||
|
export const setToken = (t: string) => localStorage.setItem(TOKEN_KEY, t);
|
||||||
|
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
|
||||||
|
|
||||||
|
// shared/auth/session.ts
|
||||||
|
export interface Session { userId: string; email: string; role: "admin" | "user" }
|
||||||
|
// useSession depends on the auth provider (React Context, Zustand, etc.)
|
||||||
|
export const useSession = (): Session | null => { /* ... */ };
|
||||||
|
```
|
||||||
|
|
||||||
|
The `shared/auth/index.ts` re-exports from these files following the
|
||||||
|
standard public API pattern.
|
||||||
|
|
||||||
|
### Auth UI: pages (single use) or features (multi-use)
|
||||||
|
|
||||||
|
Place the login form in the slice that consumes it. Single-use (only on the
|
||||||
|
login page) goes in `pages/login/`; multi-use (dedicated page + modal login)
|
||||||
|
goes in `features/auth/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pages/login/ ← Single-use
|
||||||
|
ui/{LoginPage,LoginForm}.tsx
|
||||||
|
model/login.ts ← Form state, validation
|
||||||
|
api/login.ts ← POST /auth/login
|
||||||
|
index.ts
|
||||||
|
|
||||||
|
features/auth/ ← Multi-use
|
||||||
|
ui/{LoginForm,RegisterForm}.tsx
|
||||||
|
model/auth.ts
|
||||||
|
api/{login,register}.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to use shared/auth vs a user entity
|
||||||
|
|
||||||
|
The official Auth guide presents two valid storage locations: **In Shared**
|
||||||
|
(`shared/auth` or `shared/api`) and **In Entities** (a `user` entity).
|
||||||
|
Pages and widgets are discouraged.
|
||||||
|
|
||||||
|
`shared/auth` is the simpler default. Choose it when the project has no
|
||||||
|
entities layer yet, or when auth state is just a token plus minimal user info.
|
||||||
|
|
||||||
|
A `user` entity is the right call when the project already has an
|
||||||
|
entities layer **and** auth and profile data are tightly coupled (profile
|
||||||
|
reused for non-auth purposes like avatars in comments).
|
||||||
|
|
||||||
|
```text
|
||||||
|
// Path A: shared/auth (simpler default)
|
||||||
|
shared/auth/session.ts ← userId, email, role, token
|
||||||
|
|
||||||
|
// Path B: user entity (entities layer exists, profile reuse is real)
|
||||||
|
entities/user/
|
||||||
|
model/
|
||||||
|
current-user.ts ← Current authenticated user + token
|
||||||
|
user.ts ← Generic user type
|
||||||
|
api/get-current-user.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
For the entity approach, the API client in `shared/api` cannot import from
|
||||||
|
`entities/`. The official guide describes three solutions: pass the token
|
||||||
|
manually, expose it through a context with the key kept in `shared/api`,
|
||||||
|
or inject the token into the API client when the entity store updates.
|
||||||
|
|
||||||
|
A `user` entity created **only** to wrap a login response is premature.
|
||||||
|
See `references/excessive-entities.md` for the full decision matrix.
|
||||||
|
|
||||||
|
## Type Definitions
|
||||||
|
|
||||||
|
### Where to define types
|
||||||
|
|
||||||
|
The location of type definitions follows the same rules as any other code:
|
||||||
|
|
||||||
|
| Type scope | Location |
|
||||||
|
| --- | --- |
|
||||||
|
| API response/request shapes shared across the app | Domain-named files in `shared/api/` (e.g., `shared/api/product.ts`) |
|
||||||
|
| Types for a specific entity's domain model | `entities/<name>/model/<name>.ts` |
|
||||||
|
| Types used only within one page | `pages/<name>/model/<name>.ts` |
|
||||||
|
| Types used only within one feature | `features/<name>/model/<name>.ts` |
|
||||||
|
| Generic utility types (e.g., `Nullable<T>`) | Domain-named files in `shared/lib/` (e.g., `shared/lib/nullable.ts`) |
|
||||||
|
|
||||||
|
Per Rule 4-4 (domain-based file naming), avoid grouping all types in
|
||||||
|
`types.ts` or `utils.ts`. A file named `types.ts` cannot answer "types
|
||||||
|
for what?" without inspection; a file named `product.ts` can.
|
||||||
|
|
||||||
|
### Example: API types in shared
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/api/product.ts: raw API response shapes
|
||||||
|
export interface ProductDTO {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
category: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Domain types in entities
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// entities/product/model/product.ts: domain model layered on top
|
||||||
|
import type { ProductDTO } from "@/shared/api/product";
|
||||||
|
|
||||||
|
export interface Product extends ProductDTO {
|
||||||
|
formattedPrice: string;
|
||||||
|
isOnSale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fromDTO = (dto: ProductDTO): Product => ({
|
||||||
|
...dto,
|
||||||
|
formattedPrice: `$${dto.price.toFixed(2)}`,
|
||||||
|
isOnSale: dto.price < 10,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key principle:** Raw API shapes go in `shared/api/`. Domain models with
|
||||||
|
business logic go in `entities/`. If you only need the raw shape, do not
|
||||||
|
create an entity just for types.
|
||||||
|
|
||||||
|
## API Request Handling
|
||||||
|
|
||||||
|
### Basic pattern: API calls in the consuming slice
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// pages/product-detail/api/fetch-product.ts
|
||||||
|
import { apiClient } from "@/shared/api/client";
|
||||||
|
import type { ProductDTO } from "@/shared/api/product";
|
||||||
|
|
||||||
|
export const fetchProduct = (id: string): Promise<ProductDTO> =>
|
||||||
|
apiClient.get(`/products/${id}`).then((r) => r.data);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared API client setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/api/client.ts
|
||||||
|
import axios from "axios";
|
||||||
|
import { getToken } from "@/shared/auth/token";
|
||||||
|
|
||||||
|
export const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_URL });
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = getToken();
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### CRUD helpers in shared
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/api/create-crud-api.ts
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export const createCrudApi = <T>(resource: string) => ({
|
||||||
|
getAll: () => apiClient.get<T[]>(`/${resource}`).then((r) => r.data),
|
||||||
|
getById: (id: string) => apiClient.get<T>(`/${resource}/${id}`).then((r) => r.data),
|
||||||
|
create: (data: Partial<T>) => apiClient.post<T>(`/${resource}`, data).then((r) => r.data),
|
||||||
|
update: (id: string, data: Partial<T>) => apiClient.put<T>(`/${resource}/${id}`, data).then((r) => r.data),
|
||||||
|
remove: (id: string) => apiClient.delete(`/${resource}/${id}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Usage: export const productsApi = createCrudApi<ProductDTO>("products");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request placement rule
|
||||||
|
|
||||||
|
Place each request function in the slice that owns the use case:
|
||||||
|
|
||||||
|
- **Page-specific data fetching** (e.g., dashboard stats only used on the
|
||||||
|
dashboard) → `pages/<name>/api/`
|
||||||
|
- **Feature-specific actions** (e.g., `toggleLike`) → `features/<name>/api/`
|
||||||
|
- **Reusable domain queries** (e.g., `getUserById`) → `entities/<name>/api/`
|
||||||
|
- **CRUD primitives** for a generic resource → `shared/api/create-crud-api.ts`
|
||||||
|
|
||||||
|
Do not put domain-specific request functions in `shared/api/`. Shared is
|
||||||
|
infrastructure; the moment a function knows about a specific resource and
|
||||||
|
its domain rules, it belongs in `entities/` or higher.
|
||||||
|
|
||||||
|
## State Management: Redux
|
||||||
|
|
||||||
|
### Where a Redux slice belongs
|
||||||
|
|
||||||
|
The `from-custom` migration guide draws a clean line: **business
|
||||||
|
entities** (the things your app works with, like `todo`, `product`, `user`)
|
||||||
|
go in the Entities layer; **user actions** (`add-todo`, `toggle-todo`,
|
||||||
|
`like-post`) go in Features.
|
||||||
|
|
||||||
|
In v2.1, also remember the pages-first rule: if the slice is used by a
|
||||||
|
single page, keep it in that page's `model/` segment until reuse appears.
|
||||||
|
|
||||||
|
### Business-entity slice in entities
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// entities/todo/model/todo.ts
|
||||||
|
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
|
||||||
|
import { apiClient } from "@/shared/api/client";
|
||||||
|
|
||||||
|
interface Todo { id: string; title: string; completed: boolean }
|
||||||
|
interface TodoState { items: Todo[]; loading: boolean }
|
||||||
|
|
||||||
|
export const fetchTodos = createAsyncThunk("todos/fetch", async () =>
|
||||||
|
(await apiClient.get<Todo[]>("/todos")).data,
|
||||||
|
);
|
||||||
|
|
||||||
|
const todoSlice = createSlice({
|
||||||
|
name: "todos",
|
||||||
|
initialState: { items: [], loading: false } as TodoState,
|
||||||
|
reducers: {
|
||||||
|
setCompleted: (state, { payload }: { payload: { id: string; completed: boolean } }) => {
|
||||||
|
const todo = state.items.find((t) => t.id === payload.id);
|
||||||
|
if (todo) todo.completed = payload.completed;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchTodos.pending, (state) => { state.loading = true; })
|
||||||
|
.addCase(fetchTodos.fulfilled, (state, action) => {
|
||||||
|
state.items = action.payload;
|
||||||
|
state.loading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setCompleted } = todoSlice.actions;
|
||||||
|
export const selectTodos = (state: RootState) => state.todos.items;
|
||||||
|
export const todoReducer = todoSlice.reducer;
|
||||||
|
```
|
||||||
|
|
||||||
|
The slice's public API re-exports what consumers need:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// entities/todo/index.ts
|
||||||
|
export { todoReducer, selectTodos, setCompleted, fetchTodos } from "./model/todo";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key:** The entire Redux slice (reducer + selectors + thunks) lives in a
|
||||||
|
single domain-named file, not split across `reducers.ts`, `selectors.ts`,
|
||||||
|
`thunks.ts`. That technical-role split reduces cohesion and is an
|
||||||
|
anti-pattern in FSD.
|
||||||
|
|
||||||
|
### User-action slice in features
|
||||||
|
|
||||||
|
A user action that orchestrates the entity exposes a hook through its
|
||||||
|
public API and consumes the entity's reducer:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// features/toggle-todo/model/use-toggle-todo.ts
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
import { setCompleted } from "@/entities/todo";
|
||||||
|
|
||||||
|
export const useToggleTodo = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
return (id: string, current: boolean) =>
|
||||||
|
dispatch(setCompleted({ id, completed: !current }));
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering slices in app
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/providers/store.ts
|
||||||
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
|
import { todoReducer } from "@/entities/todo";
|
||||||
|
import { userReducer } from "@/entities/user";
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
todos: todoReducer,
|
||||||
|
user: userReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The store imports each slice's reducer through its public API
|
||||||
|
(`index.ts`), never reaching into `model/` directly (Rule 4-2). Do not
|
||||||
|
let individual slices create their own stores.
|
||||||
|
|
||||||
|
## State Management: TanStack Query (React Query)
|
||||||
|
|
||||||
|
Guidance applies to `@tanstack/react-query` v5 (formerly React Query). The
|
||||||
|
package name is `@tanstack/react-query`.
|
||||||
|
|
||||||
|
### Where to store query keys
|
||||||
|
|
||||||
|
Three placements are valid. Choose based on project size and whether the
|
||||||
|
project already has an Entities layer.
|
||||||
|
|
||||||
|
**Option 1: Flat in `shared/api/queries/`** (small projects, few endpoints):
|
||||||
|
|
||||||
|
```text
|
||||||
|
shared/api/
|
||||||
|
queries/
|
||||||
|
example.ts
|
||||||
|
another-example.ts
|
||||||
|
index.ts ← export { exampleQueries } from './queries/example';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Per controller in `shared/api/<controller>/`** (many endpoints):
|
||||||
|
|
||||||
|
```text
|
||||||
|
shared/api/example/
|
||||||
|
index.ts ← export { exampleQueries } from './example.query';
|
||||||
|
example.query.ts ← Query factory: keys + functions
|
||||||
|
get-example.ts
|
||||||
|
create-example.ts
|
||||||
|
update-example.ts
|
||||||
|
delete-example.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3: Per entity in `entities/<entity>/api/`** when each request
|
||||||
|
corresponds to a single entity, and the project already has an Entities
|
||||||
|
layer. When entities reference each other, see
|
||||||
|
`references/cross-import-patterns.md` for `@x` notation as a last resort.
|
||||||
|
|
||||||
|
### Where to store mutations
|
||||||
|
|
||||||
|
Do not mix mutations with queries. Two patterns are accepted:
|
||||||
|
|
||||||
|
1. **A mutation hook in the `api/` segment near the place of use.** Use
|
||||||
|
`setQueryData` for cache updates:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/pages/example/api/use-update-example.ts
|
||||||
|
export const useUpdateExample = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, newTitle }) => apiClient.patch(`/posts/${id}`, { title: newTitle }).then((r) => r.data),
|
||||||
|
onSuccess: (newPost, { id }) => queryClient.setQueryData(POST_QUERIES.detail({ id }).queryKey, newPost),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **A `mutationFn` defined in `shared/` or `entities/`** and called from
|
||||||
|
`useMutation` in the component.
|
||||||
|
|
||||||
|
### Query factory pattern
|
||||||
|
|
||||||
|
A query factory is an object whose values return query keys. Each key is
|
||||||
|
wrapped in `queryOptions`, a built-in helper from `@tanstack/react-query` v5
|
||||||
|
that lets you share `queryKey` and `queryFn` between `useQuery`,
|
||||||
|
`useSuspenseQuery`, `prefetchQuery`, `setQueryData`, and similar APIs
|
||||||
|
without rewriting them:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/shared/api/post/post.queries.ts
|
||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
import { getPosts, getDetailPost, type DetailPostQuery } from "./get-posts";
|
||||||
|
|
||||||
|
export const POST_QUERIES = {
|
||||||
|
all: () => ["posts"],
|
||||||
|
lists: () => [...POST_QUERIES.all(), "list"],
|
||||||
|
list: (page: number, limit: number) => queryOptions({
|
||||||
|
queryKey: [...POST_QUERIES.lists(), page, limit],
|
||||||
|
queryFn: () => getPosts(page, limit),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
}),
|
||||||
|
detail: (query?: DetailPostQuery) => queryOptions({
|
||||||
|
queryKey: [...POST_QUERIES.all(), "detail", query?.id],
|
||||||
|
queryFn: () => getDetailPost({ id: query?.id }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Consume with `useQuery(POST_QUERIES.detail({ id }))`. For pagination,
|
||||||
|
`placeholderData: prev => prev` prevents UI flicker when navigating pages.
|
||||||
|
|
||||||
|
**Benefits of a query factory:** all API requests for a domain live in one
|
||||||
|
place (readability), every key and query function is reachable through the
|
||||||
|
same object (convenient access), and refetching is a one-line call
|
||||||
|
(`queryClient.invalidateQueries({ queryKey: POST_QUERIES.all() })`) without
|
||||||
|
hunting down keys across the codebase.
|
||||||
|
|
||||||
|
### Infinite scroll
|
||||||
|
|
||||||
|
Use `infiniteQueryOptions` with `initialPageParam` and `getNextPageParam`.
|
||||||
|
Add the infinite key to the same factory shown above:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { infiniteQueryOptions } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Inside POST_QUERIES:
|
||||||
|
infinite: (limit: number) => infiniteQueryOptions({
|
||||||
|
queryKey: [...POST_QUERIES.lists(), "infinite", limit],
|
||||||
|
queryFn: ({ pageParam }) => getPosts(pageParam, limit),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.skip + lastPage.limit < lastPage.total ? lastPage.skip / lastPage.limit + 1 : undefined,
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
Consume with `useInfiniteQuery` and flatten via `data?.pages.flatMap(...)`.
|
||||||
|
|
||||||
|
### Suspense mode
|
||||||
|
|
||||||
|
`queryOptions` and `useSuspenseQuery` are compatible, and the factory does
|
||||||
|
not change. Components use `useSuspenseQuery` instead of `useQuery` and skip
|
||||||
|
`isLoading` entirely. Wrap interested subtrees with an `ErrorBoundary` +
|
||||||
|
`Suspense` provider in the App layer:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/app/providers/suspense-provider.tsx
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
|
export const SuspenseProvider = ({ children }) => (
|
||||||
|
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading mutation state with useMutationState
|
||||||
|
|
||||||
|
`useMutationState` lets any component read the state of a mutation without
|
||||||
|
passing props, useful for global save indicators. Store mutation keys next
|
||||||
|
to the query factory:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/shared/api/post/post.queries.ts
|
||||||
|
export const POST_MUTATIONS = {
|
||||||
|
updateTitle: () => ["post", "update-title"],
|
||||||
|
create: () => ["post", "create"],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Tag the mutation with `mutationKey`, then read its state from any component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/features/update-post/api/use-update-post-title.ts
|
||||||
|
export const useUpdatePostTitle = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationKey: POST_MUTATIONS.updateTitle(),
|
||||||
|
mutationFn: ({ id, newTitle }) => apiClient.patch(`/posts/${id}`, { title: newTitle }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// src/widgets/save-indicator/ui/save-indicator.tsx
|
||||||
|
import { useMutationState } from "@tanstack/react-query";
|
||||||
|
import { POST_MUTATIONS } from "@/shared/api/post";
|
||||||
|
|
||||||
|
export const SaveIndicator = () => {
|
||||||
|
const isPending = useMutationState({
|
||||||
|
filters: { mutationKey: POST_MUTATIONS.updateTitle(), status: "pending" },
|
||||||
|
select: (m) => m.state.status,
|
||||||
|
}).length > 0;
|
||||||
|
return isPending && <span>Saving...</span>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### QueryProvider in the app layer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/app/providers/query-provider.tsx
|
||||||
|
import { QueryClient, QueryClientProvider, MutationCache, QueryCache } from "@tanstack/react-query";
|
||||||
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({ onError: (e) => toast.error(e.message) }),
|
||||||
|
mutationCache: new MutationCache({ onError: (e) => toast.error(e.message) }),
|
||||||
|
defaultOptions: { queries: { staleTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const QueryProvider = ({ children }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
<ReactQueryDevtools />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`QueryCache.onError` and `MutationCache.onError` give one place to wire up
|
||||||
|
global toast notifications instead of repeating error handling on every hook.
|
||||||
|
|
||||||
|
### Code generation
|
||||||
|
|
||||||
|
Tools that generate clients from an OpenAPI/Swagger spec are less flexible
|
||||||
|
than hand-written factories. If your spec is clean and you adopt a generator,
|
||||||
|
place the generated code in `@/shared/api/`.
|
||||||
|
|
||||||
|
### Custom API client
|
||||||
|
|
||||||
|
Standardize base URL, headers, and JSON handling in a single class in
|
||||||
|
`shared/api/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/shared/api/api-client.ts
|
||||||
|
export class ApiClient {
|
||||||
|
#baseUrl: string;
|
||||||
|
constructor(url: string) { this.#baseUrl = url; }
|
||||||
|
|
||||||
|
async #handle<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
get = <T>(path: string) => fetch(`${this.#baseUrl}${path}`).then((r) => this.#handle<T>(r));
|
||||||
|
// post, put, delete follow the same pattern with method/headers/body.
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient(API_URL);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key principle:** Place query and mutation hooks in the slice that owns the
|
||||||
|
domain. Page-specific queries stay in the page. Shared queries go in
|
||||||
|
`shared/api/` or `entities/<name>/api/` depending on whether the project has
|
||||||
|
an Entities layer.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Sample project on GitHub](https://github.com/ruslan4432013/fsd-react-query-example)
|
||||||
|
- [Query options API (tkdodo blog)](https://tkdodo.eu/blog/the-query-options-api)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
name: kimi
|
||||||
|
description: Use Kimi Code CLI as a design partner for web design decisions, UI direction, frontend visual critique, redesigns, layout, typography, color, spacing, interaction states, responsive behavior, screenshots, mockups, landing pages, dashboards, and overall design judgment. Use before making web design decisions or designs overall, and when invoking Kimi Code non-interactively.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kimi Design Partner
|
||||||
|
|
||||||
|
Use Kimi as a second design brain before committing to visual direction or UI structure. The goal is sharper product decisions, not outsourcing implementation blindly.
|
||||||
|
|
||||||
|
## Default Workflow
|
||||||
|
|
||||||
|
1. Gather the design context first: user goal, audience, page or component purpose, existing design tokens, relevant files, screenshots, routes, constraints, and any brand/product references.
|
||||||
|
2. Ask Kimi for a focused design opinion before implementing a substantial UI, redesign, layout, visual system, or interaction pattern.
|
||||||
|
3. Ask for a critique pass after implementation when screenshots, browser state, or rendered UI are available.
|
||||||
|
4. Keep final decisions with the coordinating agent. Treat Kimi output as expert input to weigh against project constraints, existing code, accessibility, performance, and the user's latest request.
|
||||||
|
|
||||||
|
## Kimi CLI Usage
|
||||||
|
|
||||||
|
Prefer non-interactive mode for design consultation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kimi --quiet -p "Act as a senior product/frontend designer. Review this UI direction and return concise recommendations..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--print` when you want visible intermediate output or streaming:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kimi --print -p "Review the layout in src/widgets/Dashboard and recommend a cleaner information hierarchy." --final-message-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass longer prompts through stdin to avoid shell quoting pain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '%s\n' "Review this design direction. Context: ..." | kimi --print --final-message-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Use JSONL for programmatic integration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '%s\n' '{"role":"user","content":"Review this dashboard layout for hierarchy and density."}' \
|
||||||
|
| kimi --print --input-format=stream-json --output-format=stream-json
|
||||||
|
```
|
||||||
|
|
||||||
|
Helpful flags:
|
||||||
|
|
||||||
|
- `--work-dir PATH`: set the project root for file operations.
|
||||||
|
- `--add-dir PATH`: add extra workspace directories.
|
||||||
|
- `--skills-dir PATH`: set custom skills directories for Kimi; include every directory Kimi should see for that invocation.
|
||||||
|
- `--thinking`: enable deeper reasoning when the model supports it.
|
||||||
|
- `--output-format=stream-json`: stream JSONL output and avoid buffered text output.
|
||||||
|
- `--final-message-only` or `--quiet`: return only the final answer.
|
||||||
|
|
||||||
|
Be careful: Kimi docs describe print mode as non-interactive and auto-approving tool calls. For design advice, explicitly ask Kimi not to edit files unless that is intended. If tool safety must be enforced, use a restrictive Kimi agent file rather than relying only on prompt text.
|
||||||
|
|
||||||
|
## Prompt Shape
|
||||||
|
|
||||||
|
Give Kimi the decision boundary, not a vague taste test:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Act as a senior product/frontend designer.
|
||||||
|
|
||||||
|
Task:
|
||||||
|
Decide the best UI direction for [page/component].
|
||||||
|
|
||||||
|
Context:
|
||||||
|
- Product/domain:
|
||||||
|
- Target user:
|
||||||
|
- Existing visual language:
|
||||||
|
- Files/routes/screenshots:
|
||||||
|
- Constraints:
|
||||||
|
|
||||||
|
Evaluate:
|
||||||
|
- Information hierarchy
|
||||||
|
- Layout density and rhythm
|
||||||
|
- Typography and spacing
|
||||||
|
- Color and contrast
|
||||||
|
- Interaction states
|
||||||
|
- Responsive behavior
|
||||||
|
- Accessibility risks
|
||||||
|
|
||||||
|
Return:
|
||||||
|
1. Recommended direction
|
||||||
|
2. Specific changes to make
|
||||||
|
3. Risks or tradeoffs
|
||||||
|
4. Anything to avoid
|
||||||
|
```
|
||||||
|
|
||||||
|
For implementation-ready feedback, ask Kimi to reference concrete files, components, CSS modules, tokens, or screenshots.
|
||||||
|
|
||||||
|
## Design Guardrails
|
||||||
|
|
||||||
|
- Prefer the repository's existing visual system, tokens, component patterns, and FSD boundaries.
|
||||||
|
- Ask for restrained, product-specific design decisions over generic "AI dashboard" moves.
|
||||||
|
- Request precise, implementable recommendations: spacing, hierarchy, component structure, responsive behavior, empty/loading/error states, and accessible affordances.
|
||||||
|
- For SaaS and operational interfaces, favor dense but calm scanning and repeated-use ergonomics over marketing composition.
|
||||||
|
- For landing pages or brand-first views, ask Kimi to evaluate first-viewport signal, imagery, hierarchy, and how the next section is hinted.
|
||||||
|
|
||||||
|
## Browser And Visual QA
|
||||||
|
|
||||||
|
Kimi Code can use skills, and Kimi can use the `agent-browser` skill when it is available. When rendered UI matters, include this instruction in the Kimi prompt:
|
||||||
|
|
||||||
|
```text
|
||||||
|
If you need to inspect the app visually, use the agent-browser skill to open the route, capture screenshots, and check desktop and mobile layouts before giving design feedback.
|
||||||
|
```
|
||||||
|
|
||||||
|
Use browser evidence when possible: screenshots, viewport sizes, route URLs, component names, and visible issues beat abstract description.
|
||||||
|
|
||||||
|
## Source Notes
|
||||||
|
|
||||||
|
Read `references/sources.md` when updating Kimi CLI flags, skill discovery behavior, or the prompting rules in this skill.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Kimi Design"
|
||||||
|
short_description: "Ask Kimi for sharper web design calls"
|
||||||
|
default_prompt: "Use $kimi to get a focused second opinion on this web design decision."
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Kimi Source Notes
|
||||||
|
|
||||||
|
Sources checked on 2026-05-16:
|
||||||
|
|
||||||
|
- Kimi Code CLI print mode: https://moonshotai.github.io/kimi-cli/en/customization/print-mode.html
|
||||||
|
- Kimi Code CLI command reference: https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-command.html
|
||||||
|
- Kimi Code CLI skills: https://moonshotai.github.io/kimi-cli/en/customization/skills.html
|
||||||
|
- Kimi Code CLI agents and subagents: https://moonshotai.github.io/kimi-cli/en/customization/agents.html
|
||||||
|
- Kimi prompt best practices: https://platform.kimi.ai/docs/guide/prompt-best-practice
|
||||||
|
- Kimi Websites overview: https://www.kimi.com/help/websites/websites-overview
|
||||||
|
|
||||||
|
Key verified points:
|
||||||
|
|
||||||
|
- `kimi --print` runs Kimi Code CLI non-interactively and exits after executing instructions.
|
||||||
|
- Non-interactive input can be supplied with `-p` / `--prompt`, `-c` / `--command`, or stdin.
|
||||||
|
- `--quiet` is shorthand for `--print --output-format text --final-message-only`.
|
||||||
|
- `--input-format=stream-json` and `--output-format=stream-json` use JSONL message streams.
|
||||||
|
- Print mode returns exit code `0` for success, `1` for non-retryable failures, and `75` for retryable transient failures.
|
||||||
|
- `--skills-dir PATH` can expose additional skills; Kimi also discovers user and project skills.
|
||||||
|
- In local `kimi --help` for version 1.44.0, `--skills-dir` is described as repeatable custom skills directories that override default discovery, so include every needed skill directory when using it.
|
||||||
|
- Kimi prompting guidance emphasizes clear instructions, role assignment, delimiters, explicit steps, examples, target length, reference text, and decomposing complex tasks.
|
||||||
|
- Kimi Websites documentation emphasizes goal, audience, design style, visual inputs such as screenshots or mockups, and multi-round revision for website generation.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: react-best-practices
|
||||||
|
description: Use when writing, reviewing, or refactoring React/Next.js code in this repository, especially components, App Router pages, Server Actions, data fetching, bundle optimization, render performance, hydration, or UI responsiveness work.
|
||||||
|
---
|
||||||
|
|
||||||
|
# React Best Practices
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Apply Vercel's React and Next.js performance guidance to this repository. The upstream skill was inspected as a full directory, including `SKILL.md`, `README.md`, `metadata.json`, the generated `AGENTS.md`, and all source rule files under `rules/`.
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Start with the highest-impact category that matches the task:
|
||||||
|
|
||||||
|
1. **Eliminating Waterfalls:** parallelize independent async work, defer awaits until needed, start promises early, and use Suspense boundaries deliberately.
|
||||||
|
2. **Bundle Size Optimization:** avoid broad imports where Next cannot optimize them, use `next/dynamic` for heavy UI, defer third-party client libraries, and keep dynamic paths statically analyzable.
|
||||||
|
3. **Server-Side Performance:** authenticate Server Actions like public API endpoints, avoid mutable request-scoped module state, minimize RSC-to-client serialization, dedupe per request with `React.cache()`, and hoist static I/O.
|
||||||
|
4. **Client Data & Rendering:** dedupe global listeners and requests, use passive scroll listeners, avoid derived-state effects, narrow effect dependencies, move interaction logic into handlers, and avoid inline component definitions.
|
||||||
|
5. **Hot Path JavaScript:** use early returns, `Set`/`Map` lookups, index maps, cached property reads, immutable `toSorted()`, and combined iterations only where the path is performance-sensitive.
|
||||||
|
|
||||||
|
For this Next/FSD repo, preserve the existing layer boundaries and public `index.ts` APIs while applying these patterns.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Full compiled guide: `references/vercel-react-best-practices-full.md`
|
||||||
|
- Source rules: `references/rules/*.md`
|
||||||
|
- Rule categories and impact: `references/rules/_sections.md`
|
||||||
|
- Upstream metadata and maintenance notes: `references/upstream/`
|
||||||
|
|
||||||
|
Read only the relevant rule files for the task. Use the compiled guide when a broader review needs cross-category context or examples.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "React Best Practices"
|
||||||
|
short_description: "Apply Vercel React and Next.js performance guidance."
|
||||||
|
default_prompt: "Use this skill when writing, reviewing, or refactoring React/Next.js code in this repository."
|
||||||
@@ -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).
|
||||||
+37
@@ -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.
|
||||||
+48
@@ -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.
|
||||||
+82
@@ -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.
|
||||||
+30
@@ -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
|
||||||
|
```
|
||||||
+75
@@ -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])
|
||||||
|
```
|
||||||
+40
@@ -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.
|
||||||
+38
@@ -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
|
||||||
+35
@@ -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)
|
||||||
+73
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
+34
@@ -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>
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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).
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
|||||||
|
---
|
||||||
|
name: typescript-best-practices
|
||||||
|
description:
|
||||||
|
Modern TypeScript patterns your AI agent should use. Strict mode, discriminated unions, satisfies
|
||||||
|
operator, const assertions, and type-safe patterns for TypeScript 5.x.
|
||||||
|
metadata:
|
||||||
|
tags: typescript, type-safety, best-practices
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
Use this skill when working with TypeScript code. AI agents frequently generate outdated patterns -
|
||||||
|
using `any` instead of `unknown`, type assertions instead of `satisfies`, optional fields instead of
|
||||||
|
discriminated unions, and missing strict mode options. This skill enforces modern TypeScript 5.x
|
||||||
|
patterns.
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
### 1. Enable Strict Mode with All Checks
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": false,
|
||||||
|
"target": "ES2020"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"target": "ES2022"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Strict mode catches entire categories of bugs. `noUncheckedIndexedAccess` prevents unsafe
|
||||||
|
array/object access. Agents often skip these for "convenience."
|
||||||
|
|
||||||
|
### 2. Use satisfies Instead of Type Assertions
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config = {
|
||||||
|
port: 3000,
|
||||||
|
host: "localhost",
|
||||||
|
} as Config;
|
||||||
|
|
||||||
|
config.port.toFixed(); // No error even if port could be string
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config = {
|
||||||
|
port: 3000,
|
||||||
|
host: "localhost",
|
||||||
|
} satisfies Config;
|
||||||
|
|
||||||
|
config.port.toFixed(); // TypeScript knows port is number
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `satisfies` validates the type without widening it. `as` silences the compiler and can hide
|
||||||
|
bugs. Use `satisfies` for validation, `as` only when you genuinely know more than the compiler.
|
||||||
|
|
||||||
|
### 3. Use Discriminated Unions Over Optional Fields
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ApiResponse {
|
||||||
|
data?: User;
|
||||||
|
error?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ApiResponse =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "success"; data: User }
|
||||||
|
| { status: "error"; error: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Optional fields allow impossible states (data AND error both present). Discriminated unions
|
||||||
|
make each state explicit and exhaustively checkable.
|
||||||
|
|
||||||
|
### 4. Use const Assertions for Literal Types
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ROUTES = {
|
||||||
|
home: "/",
|
||||||
|
about: "/about",
|
||||||
|
contact: "/contact",
|
||||||
|
};
|
||||||
|
// Type: { home: string; about: string; contact: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ROUTES = {
|
||||||
|
home: "/",
|
||||||
|
about: "/about",
|
||||||
|
contact: "/contact",
|
||||||
|
} as const;
|
||||||
|
// Type: { readonly home: "/"; readonly about: "/about"; readonly contact: "/contact" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Without `as const`, TypeScript widens literal types to `string`. With it, you get exact
|
||||||
|
literal types and readonly properties.
|
||||||
|
|
||||||
|
### 5. Use unknown Instead of any
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function parseJson(text: string): any {
|
||||||
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseJson('{"name": "test"}');
|
||||||
|
data.nonExistent.method(); // No error - runtime crash
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function parseJson(text: string): unknown {
|
||||||
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseJson('{"name": "test"}');
|
||||||
|
if (isUser(data)) {
|
||||||
|
data.name; // Safe - type narrowed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `any` disables all type checking. `unknown` forces you to narrow the type before using it,
|
||||||
|
catching bugs at compile time.
|
||||||
|
|
||||||
|
### 6. Use Template Literal Types for String Patterns
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getLocaleMessage(id: string): string { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Locale = 'en' | 'ja' | 'pt';
|
||||||
|
type MessageKey = 'welcome' | 'goodbye';
|
||||||
|
type LocaleMessageId = `${Locale}_${MessageKey}`;
|
||||||
|
|
||||||
|
function getLocaleMessage(id: LocaleMessageId): string { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Template literal types create precise string patterns from unions. The compiler catches
|
||||||
|
typos and invalid combinations at build time.
|
||||||
|
|
||||||
|
### 7. Use NoInfer to Prevent Unwanted Inference
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function createLight<C extends string>(colors: C[], defaultColor?: C) { ... }
|
||||||
|
createLight(['red', 'green', 'blue'], 'purple'); // No error - purple widens C
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function createLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) { ... }
|
||||||
|
createLight(['red', 'green', 'blue'], 'purple'); // Error - 'purple' not in C
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `NoInfer<T>` (TypeScript 5.4+) prevents a parameter from influencing type inference,
|
||||||
|
ensuring stricter checks.
|
||||||
|
|
||||||
|
### 8. Use Branded Types for Type-Safe IDs
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getUser(id: string): User { ... }
|
||||||
|
function getOrder(id: string): Order { ... }
|
||||||
|
|
||||||
|
const userId = getUserId();
|
||||||
|
getOrder(userId); // No error - but wrong!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type UserId = string & { readonly __brand: 'UserId' };
|
||||||
|
type OrderId = string & { readonly __brand: 'OrderId' };
|
||||||
|
|
||||||
|
function getUser(id: UserId): User { ... }
|
||||||
|
function getOrder(id: OrderId): Order { ... }
|
||||||
|
|
||||||
|
const userId = getUserId();
|
||||||
|
getOrder(userId); // Error - UserId is not OrderId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Branded types prevent accidentally passing one ID type where another is expected. The brand
|
||||||
|
exists only at compile time - zero runtime cost.
|
||||||
|
|
||||||
|
### 9. Use Exhaustive Switch with never
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleStatus(status: "active" | "inactive" | "pending") {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "Active";
|
||||||
|
case "inactive":
|
||||||
|
return "Inactive";
|
||||||
|
// 'pending' silently falls through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleStatus(status: "active" | "inactive" | "pending") {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "Active";
|
||||||
|
case "inactive":
|
||||||
|
return "Inactive";
|
||||||
|
case "pending":
|
||||||
|
return "Pending";
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = status;
|
||||||
|
throw new Error(`Unhandled status: ${_exhaustive}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** The `never` check ensures every union member is handled. When a new status is added, the
|
||||||
|
compiler flags the missing case.
|
||||||
|
|
||||||
|
### 10. Use Type Predicates Over Type Assertions
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function processItem(item: unknown) {
|
||||||
|
const user = item as User;
|
||||||
|
console.log(user.name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function isUser(item: unknown): item is User {
|
||||||
|
return typeof item === "object" && item !== null && "name" in item && "email" in item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processItem(item: unknown) {
|
||||||
|
if (isUser(item)) {
|
||||||
|
console.log(item.name); // Safe - narrowed to User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Type predicates (`item is User`) narrow types safely with runtime checks. Type assertions
|
||||||
|
(`as User`) bypass the compiler and can hide bugs.
|
||||||
|
|
||||||
|
### 11. Use import type for Type-Only Imports
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { User, UserService } from "./user";
|
||||||
|
// User is only used as a type, but gets included in the bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { User } from "./user";
|
||||||
|
import { UserService } from "./user";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `import type` is erased at compile time, reducing bundle size. It also makes the intent
|
||||||
|
clear - this import is for types only.
|
||||||
|
|
||||||
|
### 12. Use Record Over Index Signatures
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Config {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Config = Record<string, string>;
|
||||||
|
|
||||||
|
// Or better - use a specific union for keys:
|
||||||
|
type Config = Record<"host" | "port" | "env", string>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** `Record<K, V>` is more readable and composable than index signatures. When possible, use a
|
||||||
|
union for keys to get exhaustive checking.
|
||||||
|
|
||||||
|
### 13. Use using for Resource Management
|
||||||
|
|
||||||
|
**Wrong (agents do this):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const file = openFile("data.txt");
|
||||||
|
try {
|
||||||
|
processFile(file);
|
||||||
|
} finally {
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
using file = openFile("data.txt");
|
||||||
|
processFile(file);
|
||||||
|
// file.close() called automatically via Symbol.dispose
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** The `using` keyword (TypeScript 5.2+) provides deterministic resource cleanup via the
|
||||||
|
Disposable protocol, similar to Python's `with` or C#'s `using`.
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
- Enable `strict: true` and `noUncheckedIndexedAccess: true` in every project
|
||||||
|
- Use `satisfies` for type validation without widening
|
||||||
|
- Use discriminated unions with a `type` or `kind` field for state modeling
|
||||||
|
- Use `as const` for configuration objects and route maps
|
||||||
|
- Use branded types for domain-specific IDs
|
||||||
|
- Use `import type` for all type-only imports
|
||||||
|
- Use exhaustive `switch` with `never` default for union handling
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
- NEVER use `any` - use `unknown` and narrow with type guards
|
||||||
|
- NEVER use `as` for type assertions unless you genuinely know more than the compiler
|
||||||
|
- NEVER use optional fields to model mutually exclusive states - use discriminated unions
|
||||||
|
- NEVER use `// @ts-ignore` or `// @ts-expect-error` without a comment explaining why
|
||||||
|
- NEVER use `enum` - use `as const` objects or union types instead
|
||||||
|
- NEVER use `Function` type - use specific function signatures
|
||||||
|
- NEVER disable strict mode for convenience
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Convert Icons
|
|
||||||
|
|
||||||
Convert raw SVG icons to React TSX components using SVGR.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
/convert-icons
|
|
||||||
```
|
|
||||||
|
|
||||||
## Instructions
|
|
||||||
|
|
||||||
1. Check that raw SVG files exist in `src/shared/assets/raw-icons/`. If the directory doesn't exist or is empty, inform the user and ask them to place SVG files there first.
|
|
||||||
|
|
||||||
2. Run the conversion:
|
|
||||||
```bash
|
|
||||||
bun run gicons
|
|
||||||
```
|
|
||||||
This executes:
|
|
||||||
```
|
|
||||||
npx @svgr/cli --ext tsx --typescript --no-prettier --icon --ref --no-svgo ./src/shared/assets/raw-icons/ --out-dir ./src/shared/ui/Icons/
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Report which icon components were generated in `src/shared/ui/Icons/`.
|
|
||||||
|
|
||||||
4. The generated components can be imported as:
|
|
||||||
```tsx
|
|
||||||
import { IconName } from "@shared/ui/Icons/IconName"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Raw SVGs go in: `src/shared/assets/raw-icons/`
|
|
||||||
- Generated TSX components output to: `src/shared/ui/Icons/`
|
|
||||||
- SVGR flags: TypeScript, icon mode (scales with font-size), forwardRef support, no Prettier formatting, no SVGO optimization
|
|
||||||
- The primary icon library is `lucide-react` — custom SVG icons are for icons not available in Lucide
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Generate Component (gc)
|
|
||||||
|
|
||||||
Generate an FSD component using the project's generator script.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
/gc <layer> <ComponentName>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arguments:**
|
|
||||||
- `$ARGUMENTS` — expects `<layer> <ComponentName>`, e.g. `shared Button`, `entity ProjectCard`, `feature CreateProjectModal`
|
|
||||||
|
|
||||||
## Layers
|
|
||||||
|
|
||||||
| Alias | Path |
|
|
||||||
|-------|------|
|
|
||||||
| `shared` | `src/shared/ui/` |
|
|
||||||
| `entity` / `entities` | `src/entities/` |
|
|
||||||
| `feature` / `features` | `src/features/` |
|
|
||||||
| `widget` / `widgets` | `src/widgets/` |
|
|
||||||
| `page` / `pages` | `src/pages/` |
|
|
||||||
|
|
||||||
## Instructions
|
|
||||||
|
|
||||||
1. Parse `$ARGUMENTS` to extract `<layer>` and `<ComponentName>`. If arguments are missing or unclear, ask the user.
|
|
||||||
|
|
||||||
2. Run the generator:
|
|
||||||
```bash
|
|
||||||
bun run gc <layer> <ComponentName>
|
|
||||||
```
|
|
||||||
|
|
||||||
3. This creates 4 files:
|
|
||||||
- `index.ts` — re-exports the component
|
|
||||||
- `<ComponentName>.tsx` — component implementation with `FunctionComponent`, `JSX.Element`, SCSS module import, `data-testid`
|
|
||||||
- `<ComponentName>.d.ts` — props interface `I<ComponentName>Props` with `className?: string`
|
|
||||||
- `<ComponentName>.module.scss` — empty `.root {}` class
|
|
||||||
|
|
||||||
4. After generation, report the created files and the full path.
|
|
||||||
|
|
||||||
5. If the user provides additional context about what the component should do or look like, modify the generated files accordingly:
|
|
||||||
- Add props to the `.d.ts` file
|
|
||||||
- Implement the component logic in `.tsx`
|
|
||||||
- Add styles to `.module.scss`
|
|
||||||
- Use existing project patterns: `classnames` as `cs`, typography/mixin includes from auto-injected SCSS partials, Radix UI primitives or Themes where appropriate, `lucide-react` icons
|
|
||||||
|
|
||||||
## Project Conventions
|
|
||||||
|
|
||||||
- Props interface: `I<ComponentName>Props` in a `.d.ts` file
|
|
||||||
- Import types with `import type { ... }`
|
|
||||||
- Import order: types → react/libs → path aliases (`@shared/`, `@entities/`, etc.) → local
|
|
||||||
- Use `cs()` from `classnames` for combining classes
|
|
||||||
- Root element gets `className={styles.root}` and `data-testid="<ComponentName>"`
|
|
||||||
- SCSS auto-injected namespaces: `variables`, `breakpoints`, `typography`, `mixins`
|
|
||||||
- Use `"use client"` directive only when component uses hooks or browser APIs
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Generate API Types
|
|
||||||
|
|
||||||
Fetch the OpenAPI schema from the backend and generate TypeScript types.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
/gen-api-types
|
|
||||||
```
|
|
||||||
|
|
||||||
## Instructions
|
|
||||||
|
|
||||||
1. Ensure the backend server is running at `http://127.0.0.1:8000`. If the command fails with a connection error, inform the user that the backend must be running first.
|
|
||||||
|
|
||||||
2. Run the type generation:
|
|
||||||
```bash
|
|
||||||
bun run gen:api-types
|
|
||||||
```
|
|
||||||
This executes:
|
|
||||||
```
|
|
||||||
openapi-typescript http://127.0.0.1:8000/api/schema/ --output src/shared/api/__generated__/openapi.types.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Report success and note that the generated types are at `src/shared/api/__generated__/openapi.types.ts`.
|
|
||||||
|
|
||||||
4. The generated file exports:
|
|
||||||
- `paths` — all API endpoints with request/response types
|
|
||||||
- `components` — schema definitions (used as `components["schemas"]["ModelName"]`)
|
|
||||||
- `operations` — operation-level types
|
|
||||||
|
|
||||||
## Usage in Code
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Import schema types
|
|
||||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
|
||||||
|
|
||||||
type ProjectRead = components["schemas"]["ProjectRead"]
|
|
||||||
|
|
||||||
// Use with the API client
|
|
||||||
import api from "@shared/api"
|
|
||||||
|
|
||||||
api.useQuery("get", "/api/projects/")
|
|
||||||
api.useMutation("post", "/api/projects/", { onSuccess, onError })
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Requires the backend running at `http://127.0.0.1:8000`
|
|
||||||
- Uses `openapi-typescript` to generate types from the `/api/schema/` endpoint
|
|
||||||
- The API client (`src/shared/api/index.ts`) uses `openapi-fetch` + `openapi-react-query` with these generated types
|
|
||||||
- After regeneration, check for any TypeScript errors in components that use the API types, as schema changes may break existing code
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
project_doc_max_bytes = 65536
|
||||||
|
|
||||||
|
[features]
|
||||||
|
hooks = true
|
||||||
|
|
||||||
|
[agents]
|
||||||
|
max_threads = 6
|
||||||
|
max_depth = 1
|
||||||
|
|
||||||
|
[mcp_servers.tavily_search]
|
||||||
|
url = "https://mcp.tavily.com/mcp/?tavilyApiKey=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"
|
||||||
|
enabled = true
|
||||||
|
http_headers = { Authorization = "Bearer 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" }
|
||||||
|
|
||||||
|
[mcp_servers.tavily_search.tools.tavily_extract]
|
||||||
|
approval_mode = "approve"
|
||||||
|
|
||||||
|
[mcp_servers.tavily_search.tools.tavily_search]
|
||||||
|
approval_mode = "approve"
|
||||||
|
|
||||||
|
[mcp_servers.context7]
|
||||||
|
url = "https://mcp.context7.com/mcp"
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[mcp_servers.context7.http_headers]
|
||||||
|
CONTEXT7_API_KEY = "ctx7sk-57f06da8-ba55-44d4-9b71-ce8e1640b0e2"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/snapshot_ts_changes.mjs\"",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/format_ts_changes.mjs\"",
|
||||||
|
"timeout": 30,
|
||||||
|
"statusMessage": "Formatting TypeScript edits"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user