
React Refactor
- 656 installs
- 186 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
react-refactor is a Claude skill that systematically improves React component architecture, reduces re-renders, and eliminates prop drilling using proven patterns for developers maintaining growing React applications.
About
react-refactor is a dot-skills guide (version 1.1.0, May 2026) containing 40 rules across 7 categories for React architectural refactoring. Categories cover component architecture, state architecture, hook patterns, component decomposition, coupling and cohesion, data and side effects, and refactoring safety, each with code smells, before/after transforms, and safe steps. Developers reach for react-refactor when prop drilling, unnecessary re-renders, or tangled component trees slow feature work. Critical rules include interface segregation for props, documented as preventing a large share of unnecessary re-renders when applied during structured refactors.
- 40 rules across 7 categories including component architecture, state, hooks, decomposition, coupling, data flow and refa
- Each rule ships with code smell indicators, before/after code transforms and safe refactoring steps
- CRITICAL rules on interface segregation, feature colocation, render-props-to-hooks conversion, headless components and c
- Quantified impact statements such as preventing 30-50% unnecessary re-renders and reducing prop count by 50-70%
- Hard-gate checklist that must pass before committing major refactors
React Refactor by the numbers
- 656 all-time installs (skills.sh)
- Ranked #201 of 1,356 Code Review & Quality skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill react-refactorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 656 |
|---|---|
| repo stars | ★ 186 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you refactor React components to reduce re-renders?
Systematically improve React component architecture, reduce re-renders, and eliminate prop drilling using proven patterns.
Who is it for?
React developers refactoring mature apps with prop drilling, state sprawl, or performance issues who want rule-based architectural guidance.
Skip if: Greenfield React prototypes with a handful of components and no measurable performance or architecture pain yet.
When should I use this skill?
A developer asks to refactor React components, fix prop drilling, reduce re-renders, or apply structured decomposition patterns.
What you get
Refactored React components with improved architecture, reduced re-renders, and documented before/after transforms
- refactored components
- architecture improvement checklist
By the numbers
- Version 1.1.0 with 40 rules across 7 categories
- Updated May 2026
Files
React Refactor Best Practices
Architectural refactoring guide for React applications. Contains 40 rules across 7 categories, prioritized by impact from critical (component and state architecture) to incremental (refactoring safety).
When to Apply
- Refactoring existing React codebases or planning large-scale restructuring
- Reviewing PRs for architectural issues and code smells
- Decomposing oversized components into focused units
- Extracting reusable hooks from component logic
- Improving testability of React code
- Reducing coupling between feature modules
Rule Categories
| Category | Impact | Rules | Key Topics |
|---|---|---|---|
| Component Architecture | CRITICAL | 8 | Compound components, headless pattern, composition over props, client boundaries |
| State Architecture | CRITICAL | 7 | Colocation, state machines, URL state, derived values |
| Hook Patterns | HIGH | 6 | Single responsibility, naming, dependency stability, composition |
| Component Decomposition | HIGH | 6 | Scroll test, extraction by change reason, view/logic separation |
| Coupling & Cohesion | MEDIUM | 4 | Dependency injection, circular deps, stable imports, barrel-free |
| Data & Side Effects | MEDIUM | 4 | Server-first fetch, TanStack Query, error boundaries |
| Refactoring Safety | LOW-MEDIUM | 5 | Characterization tests, behavior testing, integration tests |
Quick Reference
Critical patterns — get these right first:
- Use compound components instead of props explosion
- Colocate state with the components that use it
- Use state machines for complex UI workflows
- Separate container logic from presentational components
Common mistakes — avoid these anti-patterns:
- Lifting state to App when only one component reads it
- Using context for rapidly-changing values
- Monolithic hooks that fetch + transform + cache
- Testing implementation details instead of behavior
Table of Contents
1. Component Architecture — CRITICAL
- 1.1 Apply Interface Segregation to Component Props — CRITICAL (prevents 30-50% of unnecessary re-renders)
- 1.2 Colocate Files by Feature Instead of Type — CRITICAL (reduces cross-directory navigation by 70%)
- 1.3 Convert Render Props to Custom Hooks — CRITICAL (eliminates 2-4 levels of nesting)
- 1.4 Extract Headless Components for Logic Reuse — CRITICAL (5x more reuse scenarios)
- 1.5 Prefer Composition Over Props Explosion — CRITICAL (reduces prop count by 50-70%)
- 1.6 Separate Container Logic from Presentational Components — CRITICAL (enables independent testing)
- 1.7 Use Compound Components for Implicit State Sharing — CRITICAL (reduces API surface by 60%)
- 1.8 Push Client Boundaries to Leaf Components — HIGH (keeps 60-80% server-rendered)
2. State Architecture — CRITICAL
- 2.1 Colocate State with Components That Use It — CRITICAL (reduces prop passing by 60%)
- 2.2 Derive Values Instead of Syncing State — CRITICAL (eliminates double-render cycle)
- 2.3 Lift State Only When Multiple Components Read It — CRITICAL (eliminates unnecessary parent re-renders)
- 2.4 Use Context for Rarely-Changing Values Only — CRITICAL (5-50x fewer re-renders)
- 2.5 Use State Machines for Complex UI Workflows — CRITICAL (reduces valid states from 2^n to N)
- 2.6 Use URL Parameters as State for Shareable Views — CRITICAL (enables deep linking and sharing)
- 2.7 Use useReducer for Multi-Field State Transitions — CRITICAL (eliminates impossible states)
3. Hook Patterns — HIGH
- 3.1 Avoid Object and Array Dependencies in Custom Hooks — HIGH (prevents effect re-execution every render)
- 3.2 Compose Hooks Instead of Nesting Them — HIGH (flattens dependency graph)
- 3.3 Extract Logic into Custom Hooks When Behavior Is Nameable — HIGH (40-60% shorter components)
- 3.4 Follow Hook Naming Conventions for Discoverability — HIGH (reduces navigation time by 40%)
- 3.5 Keep Custom Hooks to a Single Responsibility — HIGH (3x easier to test)
- 3.6 Stabilize Hook Dependencies with Refs and Callbacks — HIGH (prevents infinite loops)
4. Component Decomposition — HIGH
- 4.1 Apply the Scroll Test to Identify Oversized Components — HIGH (3x faster code review)
- 4.2 Complete Component Extraction Without Half-Measures — HIGH (enables independent testing and reuse)
- 4.3 Extract Components by Independent Change Reasons — HIGH (70% fewer files touched per change)
- 4.4 Extract Pure Functions from Component Bodies — HIGH (10x faster unit tests)
- 4.5 Inline Premature Abstractions Before Re-Extracting — HIGH (40-60% simpler code)
- 4.6 Separate View Layer from Business Logic — HIGH (5x faster test suite)
5. Coupling & Cohesion — MEDIUM
- 5.1 Break Circular Dependencies with Intermediate Modules — MEDIUM (eliminates undefined-at-import-time bugs)
- 5.2 Import from Stable Public API Surfaces Only — MEDIUM (enables internal refactoring)
- 5.3 Use Barrel-Free Feature Modules for Clean Dependencies — MEDIUM (200-800ms build reduction)
- 5.4 Use Dependency Injection for External Services — MEDIUM (3x faster test setup)
6. Data & Side Effects — MEDIUM
- 6.1 Fetch Data on the Server by Default — MEDIUM (reduces client JS by 30-60%)
- 6.2 Place Error Boundaries at Data Fetch Granularity — MEDIUM (errors isolated to affected section)
- 6.3 Use Context Module Pattern for Action Colocation — MEDIUM (centralizes data mutations)
- 6.4 Use TanStack Query for Client-Side Server State — MEDIUM (eliminates 80% of fetch boilerplate)
7. Refactoring Safety — LOW-MEDIUM
- 7.1 Avoid Snapshot Tests for Refactored Components — LOW-MEDIUM (eliminates false test failures)
- 7.2 Extract Pure Functions to Increase Testability — LOW-MEDIUM (10x faster test execution)
- 7.3 Prefer Integration Tests for Component Verification — LOW-MEDIUM (catches 40% more bugs)
- 7.4 Test Component Behavior Not Implementation Details — LOW-MEDIUM (5x fewer test updates)
- 7.5 Write Characterization Tests Before Refactoring — LOW-MEDIUM (catches 90% of unintended changes)
References
1. https://react.dev 2. https://react.dev/learn/thinking-in-react 3. https://kentcdodds.com/blog/application-state-management-with-react 4. https://testing-library.com/docs/guiding-principles 5. https://patterns.dev
Related Skills
- For React 19 API best practices, see
reactskill - For application performance optimization, see
react-optimiseskill - For client-side form handling, see
react-hook-formskill
React
Version 1.1.0 React Refactor Best Practices May 2026
---
Abstract
Architectural refactoring guide for React applications. Contains 40 rules across 7 categories covering component architecture, state architecture, hook patterns, component decomposition, coupling and cohesion, data and side effects, and refactoring safety. Each rule includes code smell indicators, before/after transforms, and safe refactoring steps.
---
Table of Contents
1. Component Architecture — CRITICAL
- 1.1 Apply Interface Segregation to Component Props — CRITICAL (prevents 30-50% of unnecessary re-renders from unrelated prop changes)
- 1.2 Colocate Files by Feature Instead of Type — CRITICAL (reduces cross-directory navigation by 70%, makes features self-contained)
- 1.3 Convert Render Props to Custom Hooks — CRITICAL (eliminates 2-4 levels of nesting, improves readability)
- 1.4 Extract Headless Components for Logic Reuse — CRITICAL (enables 5x more reuse scenarios for the same behavior)
- 1.5 Prefer Composition Over Props Explosion — CRITICAL (reduces prop count by 50-70%, enables independent extension)
- 1.6 Push Client Boundaries to Leaf Components — HIGH (keeps 60-80% of component tree server-rendered)
- 1.7 Separate Container Logic from Presentational Components — CRITICAL (enables independent testing and Storybook preview)
- 1.8 Use Compound Components for Implicit State Sharing — CRITICAL (reduces component API surface by 60%, eliminates prop drilling)
2. State Architecture — CRITICAL
- 2.1 Colocate State with Components That Use It — CRITICAL (reduces prop passing by 60%, improves component isolation)
- 2.2 Derive Values Instead of Syncing State — CRITICAL (eliminates double-render cycle, prevents sync drift)
- 2.3 Lift State Only When Multiple Components Read It — CRITICAL (eliminates unnecessary parent re-renders, clearer ownership)
- 2.4 Use Context for Rarely-Changing Values Only — CRITICAL (5-50x fewer re-renders for context consumers)
- 2.5 Use State Machines for Complex UI Workflows — CRITICAL (reduces valid states from 2^n to exactly N defined states)
- 2.6 Use URL Parameters as State for Shareable Views — CRITICAL (enables deep linking, back/forward navigation, state sharing)
- 2.7 Use useReducer for Multi-Field State Transitions — CRITICAL (eliminates impossible states, centralizes transition logic)
3. Hook Patterns — HIGH
- 3.1 Avoid Object and Array Dependencies in Custom Hooks — HIGH (prevents effect re-execution on every render)
- 3.2 Compose Hooks Instead of Nesting Them — HIGH (flattens dependency graph, eliminates hidden coupling)
- 3.3 Extract Logic into Custom Hooks When Behavior Is Nameable — HIGH (makes component 40-60% shorter, behavior self-documenting)
- 3.4 Follow Hook Naming Conventions for Discoverability — HIGH (reduces codebase navigation time by 40%)
- 3.5 Keep Custom Hooks to a Single Responsibility — HIGH (3× faster to test, 2× wider reuse)
- 3.6 Stabilize Hook Dependencies with Refs and Callbacks — HIGH (prevents infinite loops, eliminates unnecessary re-executions)
4. Component Decomposition — HIGH
- 4.1 Apply the Scroll Test to Identify Oversized Components — HIGH (reduces component size to under 100 lines, 3× faster code review)
- 4.2 Complete Component Extraction Without Half-Measures — HIGH (enables independent testing and reuse of extracted component)
- 4.3 Extract Components by Independent Change Reasons — HIGH (70% fewer files touched per feature change)
- 4.4 Extract Pure Functions from Component Bodies — HIGH (pure functions testable without React, 10× faster unit tests)
- 4.5 Inline Premature Abstractions Before Re-Extracting — HIGH (40-60% simpler code after inlining wrong abstractions)
- 4.6 Separate View Layer from Business Logic — HIGH (business logic testable without rendering, 5× faster test suite)
5. Coupling & Cohesion — MEDIUM
- 5.1 Break Circular Dependencies with Intermediate Modules — MEDIUM (eliminates undefined-at-import-time bugs, enables proper tree shaking)
- 5.2 Import from Stable Public API Surfaces Only — MEDIUM (enables internal refactoring without breaking consumers)
- 5.3 Use Barrel-Free Feature Modules for Clean Dependencies — MEDIUM (200-800ms build time reduction, effective tree shaking)
- 5.4 Use Dependency Injection for External Services — MEDIUM (enables testing without mocking modules, 3x faster test setup)
6. Data & Side Effects — MEDIUM
- 6.1 Fetch Data on the Server by Default — MEDIUM (eliminates client loading spinners, reduces client JS bundle by 30-60%)
- 6.2 Place Error Boundaries at Data Fetch Granularity — MEDIUM (prevents full-page crash from single component failure)
- 6.3 Use Context Module Pattern for Action Colocation — MEDIUM (reduces mutation surface to single file per context)
- 6.4 Use TanStack Query for Client-Side Server State — MEDIUM (eliminates 80% of data fetching boilerplate, built-in cache/retry/deduplication)
7. Refactoring Safety — LOW-MEDIUM
- 7.1 Avoid Snapshot Tests for Refactored Components — MEDIUM (eliminates false test failures during refactoring, tests validate behavior)
- 7.2 Extract Pure Functions to Increase Testability — MEDIUM (10x faster test execution, no React test renderer needed)
- 7.3 Prefer Integration Tests for Component Verification — MEDIUM (catches 40% more bugs than isolated unit tests)
- 7.4 Test Component Behavior Not Implementation Details — MEDIUM (reduces test maintenance by 5× per refactoring cycle)
- 7.5 Write Characterization Tests Before Refactoring — MEDIUM (catches 90% of unintended behavior changes during refactoring)
---
References
1. https://react.dev 2. https://react.dev/learn/thinking-in-react 3. https://kentcdodds.com/blog/application-state-management-with-react 4. https://testing-library.com/docs/guiding-principles 5. https://patterns.dev
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
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):
// Bad code example here
const bad = example()Correct (description of what's right):
// Good code example here
const good = example()Reference: Link to documentation or resource
{
"version": "1.1.0",
"organization": "React Refactor Best Practices",
"technology": "React",
"date": "May 2026",
"abstract": "Architectural refactoring guide for React applications. Contains 40 rules across 7 categories covering component architecture, state architecture, hook patterns, component decomposition, coupling and cohesion, data and side effects, and refactoring safety. Each rule includes code smell indicators, before/after transforms, and safe refactoring steps.",
"references": [
"https://react.dev",
"https://react.dev/learn/thinking-in-react",
"https://kentcdodds.com/blog/application-state-management-with-react",
"https://testing-library.com/docs/guiding-principles",
"https://patterns.dev"
],
"category": "Framework"
}
React Refactor Best Practices
Architectural refactoring guide for React applications, designed for AI agents and LLMs.
Overview
This skill provides 47 rules across 8 categories to guide React architectural refactoring. Each rule includes code smell indicators, before/after transforms, and safe refactoring steps. Covers component architecture, state management, hook design, decomposition, modern React migration, and testing strategies.
Structure
react-refactor/
├── SKILL.md # Entry point with quick reference
├── AGENTS.md # Compiled comprehensive guide
├── metadata.json # Version, references, metadata
├── README.md # This file
├── references/
│ ├── _sections.md # Category definitions
│ ├── arch-*.md # Component architecture rules (7)
│ ├── state-*.md # State architecture rules (7)
│ ├── hook-*.md # Hook patterns rules (6)
│ ├── decomp-*.md # Component decomposition rules (6)
│ ├── migrate-*.md # Modern React migration rules (5)
│ ├── couple-*.md # Coupling & cohesion rules (6)
│ ├── data-*.md # Data & side effects rules (5)
│ └── safety-*.md # Refactoring safety rules (5)
└── assets/
└── templates/
└── _template.md # Rule templateGetting Started
# Install dependencies (if contributing)
pnpm install
# Build AGENTS.md from rules
pnpm build
# Validate skill structure
pnpm validateCreating a New Rule
1. Determine the category based on the rule's primary concern 2. Use the appropriate prefix from the table below 3. Copy assets/templates/_template.md as your starting point 4. Fill in frontmatter and content
Prefix Reference
| Prefix | Category | Impact |
|---|---|---|
arch- | Component Architecture | CRITICAL |
state- | State Architecture | CRITICAL |
hook- | Hook Patterns | HIGH |
decomp- | Component Decomposition | HIGH |
migrate- | Modern React Migration | MEDIUM-HIGH |
couple- | Coupling & Cohesion | MEDIUM |
data- | Data & Side Effects | MEDIUM |
safety- | Refactoring Safety | LOW-MEDIUM |
Rule File Structure
Each rule follows this template:
---
title: Rule Title Here
impact: HIGH
impactDescription: Quantified impact (e.g., "reduces prop passing by 60%")
tags: prefix, technique, related-concepts
---
## Rule Title Here
1-3 sentences explaining WHY this matters for React architecture.
**Incorrect (what's wrong):**
\`\`\`tsx
// Bad example with comments explaining the cost
\`\`\`
**Correct (what's right):**
\`\`\`tsx
// Good example with comments explaining the benefit
\`\`\`
Reference: [Link](https://example.com)File Naming Convention
Rule files follow the pattern: {prefix}-{description}.md
Examples:
arch-compound-components.md— Component architecture, about compound component patternstate-colocate-with-consumers.md— State architecture, about state colocationsafety-characterization-tests.md— Refactoring safety, about characterization tests
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Core architectural issue; affects maintainability, testability, and scalability |
| HIGH | Strong impact on code quality and developer experience |
| MEDIUM-HIGH | Important for migrating to modern React patterns |
| MEDIUM | Improves module boundaries and data flow clarity |
| LOW-MEDIUM | Creates safety nets for aggressive refactoring |
Scripts
| Command | Description |
|---|---|
pnpm build | Compiles rules into AGENTS.md |
pnpm validate | Validates skill structure and rules |
Contributing
1. Check existing rules to avoid duplication 2. Use the rule template (assets/templates/_template.md) 3. Include both incorrect and correct examples 4. Quantify impact where possible 5. Reference authoritative documentation 6. Run validation before submitting
Acknowledgments
This skill draws from:
- React Documentation
- Thinking in React
- Kent C. Dodds Blog
- Testing Library Guiding Principles
- Patterns.dev
License
MIT
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. Component Architecture (arch)
Impact: CRITICAL Description: Component structure determines reusability, testability, and change isolation. Compound components, headless patterns, and composition over props explosion reduce component coupling by 60-80%.
2. State Architecture (state)
Impact: CRITICAL Description: State placement and shape are the #1 source of unnecessary complexity. Colocation, lifting only when shared, and state machines eliminate 70% of prop drilling and sync bugs.
3. Hook Patterns (hook)
Impact: HIGH Description: Custom hooks are the primary abstraction mechanism in React. Single responsibility, stable dependencies, and composition over nesting produce testable, reusable behavior units.
4. Component Decomposition (decomp)
Impact: HIGH Description: Oversized components resist change and testing. The scroll test, extraction by change reason, and view/logic separation keep components focused and independently evolvable.
5. Coupling & Cohesion (couple)
Impact: MEDIUM Description: Feature modules with stable public APIs enable independent development and deletion. Breaking circular dependencies and barrel-free imports prevent cascading change propagation.
6. Data & Side Effects (data)
Impact: MEDIUM Description: Server-first data fetching, granular error boundaries, and eliminating derived-state effects simplify data flow and improve resilience across component boundaries.
7. Refactoring Safety (safety)
Impact: LOW-MEDIUM Description: Characterization tests, behavior-focused testing, and pure function extraction create safety nets that enable aggressive refactoring without regression risk.
Prefer Composition Over Props Explosion
Components with 15+ configuration props become rigid — every new use case requires a new prop. Composition with children and slot props inverts control to the consumer, enabling extension without modifying the component source.
Incorrect (config props — rigid and growing):
interface CardProps {
title: string;
subtitle?: string;
headerIcon?: React.ReactNode;
headerAction?: React.ReactNode;
footer?: React.ReactNode;
footerAlign?: "left" | "center" | "right";
bordered?: boolean;
elevated?: boolean;
collapsible?: boolean;
defaultCollapsed?: boolean;
onCollapse?: (collapsed: boolean) => void;
padding?: "none" | "sm" | "md" | "lg";
className?: string;
}
// 13 props — adding "badge on header" means prop #14
function Card({ title, subtitle, headerIcon, headerAction, bordered, elevated, ...rest }: CardProps) {
return (
<div className={clsx("card", bordered && "card--bordered", elevated && "card--elevated")}>
<div className="card__header">
{headerIcon && <span className="card__icon">{headerIcon}</span>}
<div>
<h3>{title}</h3>
{subtitle && <p>{subtitle}</p>}
</div>
{headerAction}
</div>
<div className="card__body">{rest.children}</div>
{rest.footer && <div className="card__footer">{rest.footer}</div>}
</div>
);
}Correct (composition — consumer controls layout):
interface CardProps {
children: React.ReactNode;
variant?: "bordered" | "elevated" | "flat";
padding?: "none" | "sm" | "md" | "lg";
}
function Card({ children, variant = "flat", padding = "md" }: CardProps) {
return <div className={clsx("card", `card--${variant}`, `card--pad-${padding}`)}>{children}</div>;
}
function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card__header">{children}</div>;
}
function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card__body">{children}</div>;
}
function CardFooter({ children }: { children: React.ReactNode }) {
return <div className="card__footer">{children}</div>;
}
// Usage — consumer composes freely, no new props needed for badges
<Card variant="elevated">
<CardHeader>
<UserAvatar userId={owner.id} />
<h3>{owner.name}</h3>
<Badge count={notifications} /> {/* No prop needed — just compose */}
</CardHeader>
<CardBody><ProjectSummary project={project} /></CardBody>
<CardFooter><EditButton projectId={project.id} /></CardFooter>
</Card>Reference: React Docs - Passing JSX as Children
Use Compound Components for Implicit State Sharing
When a parent passes the same state and callbacks to N children through props, the API surface grows linearly with each new child. Compound components share state implicitly through context, keeping the public API flat and each child independently extensible.
Incorrect (props explosion — API grows with each child):
interface TabsProps {
activeTab: string;
onTabChange: (tab: string) => void;
tabs: Array<{ id: string; label: string; content: React.ReactNode }>;
renderTab?: (tab: { id: string; label: string }) => React.ReactNode;
renderPanel?: (tab: { id: string; content: React.ReactNode }) => React.ReactNode;
tabClassName?: string;
panelClassName?: string;
orientation?: "horizontal" | "vertical";
}
// 8 props and growing — every new feature adds another prop
function Tabs({ activeTab, onTabChange, tabs, renderTab, renderPanel }: TabsProps) {
return (
<div>
<div role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === tab.id}
onClick={() => onTabChange(tab.id)}
>
{renderTab ? renderTab(tab) : tab.label}
</button>
))}
</div>
{tabs.map((tab) => (
<div key={tab.id} role="tabpanel" hidden={activeTab !== tab.id}>
{renderPanel ? renderPanel(tab) : tab.content}
</div>
))}
</div>
);
}Correct (compound pattern — implicit state via context):
const TabsContext = createContext<{
activeTab: string;
onTabChange: (tab: string) => void;
} | null>(null);
function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
// React 19: render the context object directly — no `.Provider`
<TabsContext value={{ activeTab, onTabChange: setActiveTab }}>
<div>{children}</div>
</TabsContext>
);
}
function Tab({ id, children }: { id: string; children: React.ReactNode }) {
const { activeTab, onTabChange } = use(TabsContext)!;
return (
<button role="tab" aria-selected={activeTab === id} onClick={() => onTabChange(id)}>
{children}
</button>
);
}
function TabPanel({ id, children }: { id: string; children: React.ReactNode }) {
const { activeTab } = use(TabsContext)!;
return <div role="tabpanel" hidden={activeTab !== id}>{children}</div>;
}
// Usage — flat API, each child independently composable
<Tabs defaultTab="settings">
<Tab id="settings">Settings</Tab>
<Tab id="billing">Billing</Tab>
<TabPanel id="settings"><SettingsForm /></TabPanel>
<TabPanel id="billing"><BillingForm /></TabPanel>
</Tabs>Separate Container Logic from Presentational Components
Components that mix data fetching, state management, and rendering are hard to test because tests must mock network calls just to verify visual output. Extracting data logic into a container hook lets the presentational component accept plain props, making it independently testable and previewable in Storybook.
Incorrect (fetch + state + render fused in one component):
function InvoiceList() {
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [sortField, setSortField] = useState<"date" | "amount">("date");
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
fetchInvoices(sortField)
.then(setInvoices)
.finally(() => setIsLoading(false));
}, [sortField]);
// Testing the table layout requires mocking fetchInvoices
if (isLoading) return <Skeleton rows={5} />;
return (
<table>
<thead>
<tr>
<th onClick={() => setSortField("date")}>Date</th>
<th onClick={() => setSortField("amount")}>Amount</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr key={inv.id}>
<td>{inv.date.toLocaleDateString()}</td>
<td>{formatCurrency(inv.amount)}</td>
</tr>
))}
</tbody>
</table>
);
}Correct (container hook + pure presentational component):
function useInvoiceList() {
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [sortField, setSortField] = useState<"date" | "amount">("date");
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
fetchInvoices(sortField)
.then(setInvoices)
.finally(() => setIsLoading(false));
}, [sortField]);
return { invoices, sortField, setSortField, isLoading };
}
// Pure presentational — testable with plain props, no mocks needed
function InvoiceTable({ invoices, sortField, onSortChange }: InvoiceTableProps) {
return (
<table>
<thead>
<tr>
<th onClick={() => onSortChange("date")}>Date</th>
<th onClick={() => onSortChange("amount")}>Amount</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr key={inv.id}>
<td>{inv.date.toLocaleDateString()}</td>
<td>{formatCurrency(inv.amount)}</td>
</tr>
))}
</tbody>
</table>
);
}
function InvoiceList() {
const { invoices, sortField, setSortField, isLoading } = useInvoiceList();
if (isLoading) return <Skeleton rows={5} />;
return <InvoiceTable invoices={invoices} sortField={sortField} onSortChange={setSortField} />;
}Reference: React Docs - Reusing Logic with Custom Hooks
Colocate Files by Feature Instead of Type
Organizing by type (components/, hooks/, styles/, tests/) scatters the files for a single feature across the entire tree. Changing one feature requires editing 4-6 directories. Feature-based colocation puts everything for a feature in one folder, so additions and deletions affect a single directory.
Incorrect (type-based — feature scattered across directories):
// Adding "invoice" feature touches 5 directories
// src/
// components/
// InvoiceList.tsx
// InvoiceRow.tsx
// InvoiceForm.tsx
// hooks/
// useInvoices.ts
// useInvoiceForm.ts
// types/
// invoice.ts
// utils/
// invoiceCalculations.ts
// __tests__/
// InvoiceList.test.tsx
// useInvoices.test.ts
// Deleting the feature means hunting across all 5 directories
import { useInvoices } from "../../hooks/useInvoices";
import { Invoice } from "../../types/invoice";
import { calculateTotal } from "../../utils/invoiceCalculations";
import { InvoiceRow } from "./InvoiceRow";Correct (feature-based — self-contained module):
// Deleting "invoice" feature = delete one folder
// src/
// features/
// invoice/
// InvoiceList.tsx
// InvoiceRow.tsx
// InvoiceForm.tsx
// useInvoices.ts
// useInvoiceForm.ts
// invoice.types.ts
// invoiceCalculations.ts
// __tests__/
// InvoiceList.test.tsx
// useInvoices.test.ts
// index.ts <- public API for other features
// All imports are local — no cross-directory navigation
import { useInvoices } from "./useInvoices";
import { Invoice } from "./invoice.types";
import { calculateTotal } from "./invoiceCalculations";
import { InvoiceRow } from "./InvoiceRow";Reference: Kent C. Dodds - Colocation
Extract Headless Components for Logic Reuse
When behavior is welded to a specific UI, reusing the same logic with a different visual design requires duplicating the entire component. A headless hook extracts the behavior into a reusable unit, and thin UI wrappers render it however they need.
Incorrect (behavior locked to specific UI):
function Autocomplete({ options, onSelect }: AutocompleteProps) {
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(-1);
const [isOpen, setIsOpen] = useState(false);
const filtered = options.filter((opt) =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") setHighlightIndex((i) => Math.min(i + 1, filtered.length - 1));
if (e.key === "ArrowUp") setHighlightIndex((i) => Math.max(i - 1, 0));
if (e.key === "Enter" && highlightIndex >= 0) {
onSelect(filtered[highlightIndex]);
setIsOpen(false);
}
};
// Behavior + rendering fused — cannot reuse keyboard nav with a different dropdown UI
return (
<div className="autocomplete">
<input value={query} onChange={(e) => { setQuery(e.target.value); setIsOpen(true); }} onKeyDown={handleKeyDown} />
{isOpen && (
<ul className="autocomplete__list">
{filtered.map((opt, i) => (
<li key={opt.id} className={i === highlightIndex ? "highlighted" : ""} onClick={() => onSelect(opt)}>
{opt.label}
</li>
))}
</ul>
)}
</div>
);
}Correct (headless hook — behavior decoupled from rendering):
function useAutocomplete<T extends { id: string; label: string }>({ options, onSelect }: UseAutocompleteOptions<T>) {
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(-1);
const [isOpen, setIsOpen] = useState(false);
const filtered = options.filter((opt) =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") setHighlightIndex((i) => Math.min(i + 1, filtered.length - 1));
if (e.key === "ArrowUp") setHighlightIndex((i) => Math.max(i - 1, 0));
if (e.key === "Enter" && highlightIndex >= 0) {
onSelect(filtered[highlightIndex]);
setIsOpen(false);
}
};
return { query, setQuery, filtered, highlightIndex, isOpen, setIsOpen, handleKeyDown };
}
// Thin UI wrapper — swap this for any visual design
function Autocomplete<T extends { id: string; label: string }>({ options, onSelect }: AutocompleteProps<T>) {
const { query, setQuery, filtered, highlightIndex, isOpen, setIsOpen, handleKeyDown } =
useAutocomplete({ options, onSelect });
return (
<div className="autocomplete">
<input value={query} onChange={(e) => { setQuery(e.target.value); setIsOpen(true); }} onKeyDown={handleKeyDown} />
{isOpen && (
<ul>{filtered.map((opt, i) => (
<li key={opt.id} className={i === highlightIndex ? "highlighted" : ""} onClick={() => onSelect(opt)}>
{opt.label}
</li>
))}</ul>
)}
</div>
);
}Reference: Headless UI Pattern - React Patterns
Apply Interface Segregation to Component Props
Components accepting a wide union of optional props for different contexts create false dependencies. A change to any prop — even one the component ignores in its current usage — triggers a re-render. Focused prop interfaces per use case narrow the contract and eliminate phantom dependencies.
Incorrect (one wide interface for all contexts):
interface UserCardProps {
userId: string;
userName: string;
avatarUrl: string;
email?: string; // Only used in admin view
lastLoginAt?: Date; // Only used in admin view
onFollow?: () => void; // Only used in social view
followerCount?: number; // Only used in social view
isOnline?: boolean; // Only used in chat view
lastMessage?: string; // Only used in chat view
}
// Admin page passes social/chat props as undefined — still triggers re-renders
function UserCard({ userId, userName, avatarUrl, email, lastLoginAt, onFollow, followerCount, isOnline, lastMessage }: UserCardProps) {
return (
<div className="user-card">
<img src={avatarUrl} alt={userName} />
<h3>{userName}</h3>
{email && <p>{email}</p>}
{lastLoginAt && <p>Last login: {lastLoginAt.toLocaleDateString()}</p>}
{onFollow && <button onClick={onFollow}>Follow ({followerCount})</button>}
{isOnline !== undefined && <span className={isOnline ? "online" : "offline"} />}
{lastMessage && <p>{lastMessage}</p>}
</div>
);
}Correct (focused interfaces per context):
interface UserCardBaseProps {
userId: string;
userName: string;
avatarUrl: string;
}
interface AdminUserCardProps extends UserCardBaseProps {
email: string;
lastLoginAt: Date;
}
interface SocialUserCardProps extends UserCardBaseProps {
onFollow: () => void;
followerCount: number;
}
// Each component only re-renders for props it uses
function AdminUserCard({ userId, userName, avatarUrl, email, lastLoginAt }: AdminUserCardProps) {
return (
<div className="user-card">
<img src={avatarUrl} alt={userName} />
<h3>{userName}</h3>
<p>{email}</p>
<p>Last login: {lastLoginAt.toLocaleDateString()}</p>
</div>
);
}
function SocialUserCard({ userId, userName, avatarUrl, onFollow, followerCount }: SocialUserCardProps) {
return (
<div className="user-card">
<img src={avatarUrl} alt={userName} />
<h3>{userName}</h3>
<button onClick={onFollow}>Follow ({followerCount})</button>
</div>
);
}Push Client Boundaries to Leaf Components
A 'use client' directive makes the component and every component it imports client-side. Placing the boundary high in the tree forces static content, data fetching, and layout logic into the client bundle. Push the boundary down to the smallest interactive leaf to keep the majority of the tree server-rendered.
Incorrect ('use client' on the page-level component forces everything client-side):
// app/dashboard/page.tsx
"use client"; // Every child becomes client-side
import { useState } from "react";
export default function DashboardPage() {
const [activeTab, setActiveTab] = useState("overview");
return (
<div>
{/* Static content — should be server-rendered but is now client JS */}
<header>
<h1>Dashboard</h1>
<p>Welcome back. Here is your account summary.</p>
</header>
{/* Navigation tabs — the only interactive part */}
<nav>
{["overview", "analytics", "settings"].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={activeTab === tab ? "active" : ""}
>
{tab}
</button>
))}
</nav>
{/* Heavy data display — re-fetched on client, adding waterfall */}
{activeTab === "overview" && <OverviewPanel />}
{activeTab === "analytics" && <AnalyticsPanel />}
{activeTab === "settings" && <SettingsPanel />}
</div>
);
}Correct ('use client' pushed down to the interactive tab switcher leaf):
// app/dashboard/page.tsx — Server Component, no directive
export default function DashboardPage() {
return (
<div>
{/* Static content stays server-rendered — zero client JS */}
<header>
<h1>Dashboard</h1>
<p>Welcome back. Here is your account summary.</p>
</header>
{/* Only the tab switcher needs client interactivity */}
<DashboardTabs
overviewPanel={<OverviewPanel />}
analyticsPanel={<AnalyticsPanel />}
settingsPanel={<SettingsPanel />}
/>
</div>
);
}
// components/DashboardTabs.tsx — 'use client' only on the leaf
"use client";
import { type ReactNode, useState } from "react";
export function DashboardTabs({ overviewPanel, analyticsPanel, settingsPanel }: {
overviewPanel: ReactNode; analyticsPanel: ReactNode; settingsPanel: ReactNode;
}) {
const [activeTab, setActiveTab] = useState("overview");
const panels = { overview: overviewPanel, analytics: analyticsPanel, settings: settingsPanel };
return (
<div>
<nav>
{Object.keys(panels).map((tab) => (
<button key={tab} onClick={() => setActiveTab(tab)}
className={activeTab === tab ? "active" : ""}>{tab}</button>
))}
</nav>
{panels[activeTab as keyof typeof panels]}
</div>
);
}Reference: Composing Server and Client Components
Convert Render Props to Custom Hooks
Legacy render prop components create deep nesting when composed together. Each wrapper adds an indentation level, making the component tree hard to follow. Custom hooks provide the same behavior reuse with a flat call structure.
Incorrect (render props — nested callback pyramid):
function UserDashboard() {
return (
<AuthProvider>
{(auth) => (
<ThemeProvider>
{(theme) => (
<FeatureFlagProvider>
{(flags) => (
<NotificationProvider userId={auth.userId}>
{(notifications) => (
// 4 levels deep — adding another provider means level 5
<DashboardLayout
theme={theme}
userName={auth.userName}
unreadCount={notifications.unreadCount}
showBetaBanner={flags.isEnabled("beta-banner")}
/>
)}
</NotificationProvider>
)}
</FeatureFlagProvider>
)}
</ThemeProvider>
)}
</AuthProvider>
);
}Correct (custom hooks — flat composition):
function UserDashboard() {
const auth = useAuth();
const theme = useTheme();
const flags = useFeatureFlags();
const notifications = useNotifications(auth.userId);
// Flat — adding another hook is one line, not another nesting level
return (
<DashboardLayout
theme={theme}
userName={auth.userName}
unreadCount={notifications.unreadCount}
showBetaBanner={flags.isEnabled("beta-banner")}
/>
);
}Reference: React Docs - Reusing Logic with Custom Hooks
Use Barrel-Free Feature Modules for Clean Dependencies
Large barrel index.ts files that re-export everything from a feature force bundlers to parse every module in the directory, even when the consumer uses one export. This defeats tree shaking and inflates dev server startup. Replacing catch-all barrels with direct imports and type-only re-exports preserves clean APIs without the build cost.
Incorrect (barrel re-exports everything — bundler parses entire feature):
// features/analytics/index.ts — barrel that re-exports 20+ modules
export { AnalyticsDashboard } from "./AnalyticsDashboard";
export { AnalyticsChart } from "./AnalyticsChart";
export { AnalyticsTable } from "./AnalyticsTable";
export { AnalyticsFilters } from "./AnalyticsFilters";
export { useAnalyticsQuery } from "./useAnalyticsQuery";
export { useAnalyticsExport } from "./useAnalyticsExport";
export { formatMetric } from "./formatMetric";
export { aggregateTimeSeries } from "./aggregateTimeSeries";
export { parseAnalyticsResponse } from "./parseAnalyticsResponse";
// ... 12 more exports
// Consumer only needs formatMetric, but bundler loads everything
import { formatMetric } from "@/features/analytics";
export function MetricBadge({ value }: { value: number }) {
return <span className="badge">{formatMetric(value)}</span>;
}Correct (direct imports with type-only re-exports):
// features/analytics/index.ts — types only, no runtime re-exports
export type { AnalyticsEvent } from "./analytics.types";
export type { TimeSeriesPoint } from "./analytics.types";
// Consumer imports the specific module directly
import { formatMetric } from "@/features/analytics/formatMetric";
export function MetricBadge({ value }: { value: number }) {
return <span className="badge">{formatMetric(value)}</span>;
}
// Page-level component imports what it needs directly
import { AnalyticsDashboard } from "@/features/analytics/AnalyticsDashboard";
import { AnalyticsFilters } from "@/features/analytics/AnalyticsFilters";
export function AnalyticsPage() {
return (
<div>
<AnalyticsFilters />
<AnalyticsDashboard />
</div>
);
}
// Bundler only parses the files actually imported — tree shaking works correctlyReference: Marvin Hagemeister - Speeding up the JavaScript ecosystem: The barrel file debacle
Break Circular Dependencies with Intermediate Modules
When module A imports from B and B imports from A, one of them receives undefined at load time because the other has not finished executing. This causes silent runtime crashes that only surface in production. Extracting the shared dependency into a third module breaks the cycle and makes the dependency graph acyclic.
Incorrect (circular import — OrderItem is undefined at runtime):
// order.ts
import { OrderItem } from "./orderItem"; // orderItem.ts hasn't finished loading
export interface Order {
id: string;
customer: string;
items: OrderItem[];
}
export function calculateOrderTotal(order: Order): number {
return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// orderItem.ts
import { calculateOrderTotal } from "./order"; // circular: order -> orderItem -> order
export interface OrderItem {
id: string;
productName: string;
price: number;
quantity: number;
}
export function formatItemWithOrderTotal(item: OrderItem, order: Order): string {
const total = calculateOrderTotal(order);
return `${item.productName} (${((item.price * item.quantity) / total * 100).toFixed(1)}% of order)`;
}Correct (shared types extracted — acyclic dependency graph):
// order.types.ts — shared types, no logic, no imports from siblings
export interface Order {
id: string;
customer: string;
items: OrderItem[];
}
export interface OrderItem {
id: string;
productName: string;
price: number;
quantity: number;
}
// order.ts — depends on types only
import type { Order } from "./order.types";
export function calculateOrderTotal(order: Order): number {
return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// orderItem.ts — depends on types and order, no cycle
import type { Order, OrderItem } from "./order.types";
import { calculateOrderTotal } from "./order";
export function formatItemWithOrderTotal(item: OrderItem, order: Order): string {
const total = calculateOrderTotal(order);
return `${item.productName} (${((item.price * item.quantity) / total * 100).toFixed(1)}% of order)`;
}Reference: Node.js Docs - Cycles
Use Dependency Injection for External Services
Direct imports of API clients and external services weld a component to a specific implementation. Every test must mock the module system, and swapping providers requires editing component internals. Injecting services through props or context decouples components from infrastructure and makes tests trivial.
Incorrect (hard import — coupled to specific HTTP client):
import { apiClient } from "@/lib/apiClient";
interface OrderSummaryProps {
orderId: string;
}
// Component is untestable without jest.mock or equivalent module override
export function OrderSummary({ orderId }: OrderSummaryProps) {
const [order, setOrder] = useState<Order | null>(null);
useEffect(() => {
apiClient.get(`/orders/${orderId}`).then(setOrder);
}, [orderId]);
if (!order) return <Skeleton />;
return (
<div>
<h2>{order.vendor}</h2>
<p>Total: ${order.total}</p>
</div>
);
}Correct (injected service — swappable and testable):
interface OrderService {
getOrder: (orderId: string) => Promise<Order>;
}
const OrderServiceContext = createContext<OrderService | null>(null);
export function OrderServiceProvider({
service,
children,
}: {
service: OrderService;
children: React.ReactNode;
}) {
return <OrderServiceContext value={service}>{children}</OrderServiceContext>;
}
export function OrderSummary({ orderId }: { orderId: string }) {
const orderService = use(OrderServiceContext)!;
const [order, setOrder] = useState<Order | null>(null);
useEffect(() => {
orderService.getOrder(orderId).then(setOrder);
}, [orderId, orderService]);
if (!order) return <Skeleton />;
return (
<div>
<h2>{order.vendor}</h2>
<p>Total: ${order.total}</p>
</div>
);
}
// Test — no module mocking needed
const fakeService: OrderService = {
getOrder: async () => ({ id: "1", vendor: "Acme", total: 99 }),
};
render(
<OrderServiceProvider service={fakeService}>
<OrderSummary orderId="1" />
</OrderServiceProvider>
);Reference: Kent C. Dodds - Inversion of Control
Import from Stable Public API Surfaces Only
Reaching into a feature's internal file paths (e.g., ../../components/Button/utils) couples consumers to private implementation details. Renaming or restructuring internal files breaks every deep import. Importing only from a feature's public index.ts creates a stable contract that allows internal changes without ripple effects.
Incorrect (deep imports — coupled to internal file structure):
// Direct import of internal files — breaks if Button renames internals
import { Button } from "../../features/checkout/components/Button/Button";
import { formatCurrency } from "../../features/checkout/utils/formatCurrency";
import { useCartValidation } from "../../features/checkout/hooks/useCartValidation";
import type { CartItem } from "../../features/checkout/types/cart";
export function PaymentSummary({ cartItems }: { cartItems: CartItem[] }) {
const { isValid, errors } = useCartValidation(cartItems);
const total = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
return (
<div>
<p>{formatCurrency(total)}</p>
{errors.map((err) => <p key={err}>{err}</p>)}
<Button disabled={!isValid}>Pay Now</Button>
</div>
);
}Correct (public API imports — stable contract):
// features/checkout/index.ts — public API surface
export { Button } from "./components/Button/Button";
export { formatCurrency } from "./utils/formatCurrency";
export { useCartValidation } from "./hooks/useCartValidation";
export type { CartItem } from "./types/cart";
// Consumer imports from public API only
import { Button, formatCurrency, useCartValidation } from "@/features/checkout";
import type { CartItem } from "@/features/checkout";
export function PaymentSummary({ cartItems }: { cartItems: CartItem[] }) {
const { isValid, errors } = useCartValidation(cartItems);
const total = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
return (
<div>
<p>{formatCurrency(total)}</p>
{errors.map((err) => <p key={err}>{err}</p>)}
<Button disabled={!isValid}>Pay Now</Button>
</div>
);
}
// Internal renames (Button.tsx -> PrimaryButton.tsx) require no consumer changesReference: Patterns.dev - Module Pattern
Use Context Module Pattern for Action Colocation
When actions that mutate shared state are scattered across consuming components, tracing data flow requires searching the entire codebase. Each consumer re-implements dispatch calls with its own payload shapes, creating subtle inconsistencies. Colocating actions alongside the context provider keeps all mutations in one file and exports named functions that encapsulate dispatch details.
Incorrect (actions scattered across consumers — data flow untraceable):
// NotificationContext.tsx
const NotificationContext = createContext<{
notifications: Notification[];
dispatch: React.Dispatch<NotificationAction>;
} | null>(null);
export function NotificationProvider({ children }: { children: React.ReactNode }) {
const [notifications, dispatch] = useReducer(notificationReducer, []);
return (
<NotificationContext value={{ notifications, dispatch }}>{children}</NotificationContext>
);
}
// Header.tsx — consumer builds dispatch calls inline
function Header() {
const { dispatch } = use(NotificationContext)!;
const handleDismiss = (id: string) => {
dispatch({ type: "DISMISS", payload: { id } });
};
// ...
}
// Sidebar.tsx — duplicates dispatch logic with different shape risk
function Sidebar() {
const { dispatch } = use(NotificationContext)!;
const addAlert = (message: string) => {
dispatch({ type: "ADD", payload: { id: crypto.randomUUID(), message, level: "warning" } });
};
// ...
}Correct (context module pattern — actions colocated with provider):
// NotificationContext.tsx — actions live next to the reducer
const NotificationContext = createContext<{
notifications: Notification[];
dispatch: React.Dispatch<NotificationAction>;
} | null>(null);
export function NotificationProvider({ children }: { children: React.ReactNode }) {
const [notifications, dispatch] = useReducer(notificationReducer, []);
return (
<NotificationContext value={{ notifications, dispatch }}>{children}</NotificationContext>
);
}
// Named action creators — single source of truth for mutation shapes
export function dismissNotification(
dispatch: React.Dispatch<NotificationAction>,
id: string,
) {
dispatch({ type: "DISMISS", payload: { id } });
}
export function addAlert(
dispatch: React.Dispatch<NotificationAction>,
message: string,
) {
dispatch({ type: "ADD", payload: { id: crypto.randomUUID(), message, level: "warning" } });
}
// Header.tsx — consumes named action, no dispatch details leaked
import { dismissNotification } from "./NotificationContext";
function Header() {
const { notifications, dispatch } = use(NotificationContext)!;
return (
<ul>
{notifications.map((n) => (
<li key={n.id}>
{n.message}
<button onClick={() => dismissNotification(dispatch, n.id)}>Dismiss</button>
</li>
))}
</ul>
);
}Reference: Kent C. Dodds - The State Reducer Pattern with React Hooks
Place Error Boundaries at Data Fetch Granularity
A single error boundary at the application root kills the entire page when any component throws. If one API call fails, the user loses access to every feature on screen. Placing error boundaries around each independent data-fetching section isolates failures so the rest of the page stays functional.
Incorrect (single root boundary — one failure kills everything):
import { ErrorBoundary } from "react-error-boundary";
export default function DashboardPage() {
return (
<ErrorBoundary fallback={<FullPageError />}>
<Suspense fallback={<PageSkeleton />}>
<DashboardHeader />
<RevenueChart /> {/* if this throws... */}
<RecentOrders /> {/* ...user loses this too */}
<TeamActivity /> {/* ...and this */}
<SystemAlerts /> {/* ...and this */}
</Suspense>
</ErrorBoundary>
);
}
// Any single failure replaces the entire dashboard with FullPageErrorCorrect (per-section boundaries — failures stay contained):
import { ErrorBoundary } from "react-error-boundary";
function SectionError({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert" className="section-error">
<p>Failed to load: {error.message}</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
);
}
export default function DashboardPage() {
return (
<>
<DashboardHeader />
<ErrorBoundary FallbackComponent={SectionError}>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</ErrorBoundary>
<ErrorBoundary FallbackComponent={SectionError}>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</ErrorBoundary>
<ErrorBoundary FallbackComponent={SectionError}>
<Suspense fallback={<FeedSkeleton />}>
<TeamActivity />
</Suspense>
</ErrorBoundary>
<ErrorBoundary FallbackComponent={SectionError}>
<Suspense fallback={<AlertsSkeleton />}>
<SystemAlerts />
</Suspense>
</ErrorBoundary>
</>
);
}
// RevenueChart failure shows retry button; RecentOrders, TeamActivity, SystemAlerts remain usableReference: React Docs - Catching rendering errors with an error boundary
Fetch Data on the Server by Default
Client-side fetching with useEffect creates a waterfall: download JS, parse, render shell, fetch data, render content. The user sees a loading spinner for every request. Server components fetch data before HTML reaches the browser, eliminating the waterfall and removing fetch logic from the client bundle entirely.
Incorrect (client-side fetch — waterfall and spinner):
"use client";
import { useState, useEffect } from "react";
export function ProjectList() {
const [projects, setProjects] = useState<Project[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch starts AFTER component mounts in the browser
useEffect(() => {
fetch("/api/projects")
.then((res) => res.json())
.then(setProjects)
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <Skeleton count={5} />;
if (error) return <ErrorMessage message={error} />;
return (
<ul>
{projects.map((project) => (
<li key={project.id}>{project.name} — {project.status}</li>
))}
</ul>
);
}Correct (server component — data arrives with HTML):
// Server component — no "use client", no useState, no useEffect
import { db } from "@/lib/db";
export async function ProjectList() {
const projects = await db.project.findMany({
orderBy: { updatedAt: "desc" },
});
return (
<ul>
{projects.map((project) => (
<li key={project.id}>{project.name} — {project.status}</li>
))}
</ul>
);
}
// Layout wraps with Suspense for streaming
import { Suspense } from "react";
export default function DashboardPage() {
return (
<Suspense fallback={<Skeleton count={5} />}>
<ProjectList />
</Suspense>
);
}
// Zero client JS for ProjectList — data fetched on the server, streamed as HTMLNext.js 16: params, searchParams, cookies(), headers(), and draftMode() are now async — synchronous access was removed. Read route inputs with await in the server component before fetching:
export default async function ProjectsPage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const { status } = await searchParams; // Promise in Next.js 16 — must await
const projects = await db.project.findMany({ where: status ? { status } : undefined });
return <ProjectTable projects={projects} />;
}Reference: React Docs - Server Components · Next.js 16 upgrade guide
Use TanStack Query for Client-Side Server State
Manual useEffect + useState fetch patterns require hand-rolled loading states, error handling, caching, request deduplication, and retry logic. Every fetch endpoint duplicates this boilerplate. TanStack Query provides all of these as built-in behavior with a declarative API, and its staleTime/gcTime controls eliminate redundant network requests.
Incorrect (manual fetch — no caching, no retry, duplicated per endpoint):
"use client";
import { useState, useEffect } from "react";
export function useWarehouseInventory(warehouseId: string) {
const [inventory, setInventory] = useState<InventoryItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetch(`/api/warehouses/${warehouseId}/inventory`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
if (!cancelled) setInventory(data);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => { cancelled = true; };
}, [warehouseId]);
// No caching — remounting refetches, no deduplication across components
return { inventory, isLoading, error };
}Correct (TanStack Query — caching, retry, deduplication built in):
"use client";
import { useQuery } from "@tanstack/react-query";
async function fetchWarehouseInventory(warehouseId: string): Promise<InventoryItem[]> {
const res = await fetch(`/api/warehouses/${warehouseId}/inventory`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export function useWarehouseInventory(warehouseId: string) {
return useQuery({
queryKey: ["warehouse", warehouseId, "inventory"],
queryFn: () => fetchWarehouseInventory(warehouseId),
staleTime: 30_000, // serves cached data for 30s without refetch
gcTime: 5 * 60_000,
retry: 2,
});
}
// Multiple components using the same warehouseId share one request
export function InventoryCount({ warehouseId }: { warehouseId: string }) {
const { data: inventory } = useWarehouseInventory(warehouseId);
return <span>{inventory?.length ?? 0} items</span>;
}Reference: TanStack Query - Overview
Complete Component Extraction Without Half-Measures
Extracted components that still reach into their parent's state or internals provide no isolation benefit. They create the illusion of separation while keeping the coupling intact, making the code harder to understand than before extraction.
Incorrect (extracted child still reads parent state directly via shared ref):
// Parent holds all state; child reaches back into parent's ref
const formStateRef = useRef<FormState>(null);
function InvoiceForm() {
const [lineItems, setLineItems] = useState<LineItem[]>([]);
const [discount, setDiscount] = useState(0);
const [taxRate, setTaxRate] = useState(0.2);
formStateRef.current = { lineItems, discount, taxRate };
return (
<div>
<LineItemEditor />
<InvoiceSummary />
</div>
);
}
function InvoiceSummary() {
// Reaches into parent's ref — cannot be tested or reused independently
const { lineItems, discount, taxRate } = formStateRef.current!;
const subtotal = lineItems.reduce((sum, li) => sum + li.price * li.quantity, 0);
const tax = subtotal * taxRate;
const total = subtotal - discount + tax;
return (
<div>
<span>Subtotal: {formatCurrency(subtotal)}</span>
<span>Discount: -{formatCurrency(discount)}</span>
<span>Tax: {formatCurrency(tax)}</span>
<strong>Total: {formatCurrency(total)}</strong>
</div>
);
}Correct (extracted child receives all data through props):
function InvoiceForm() {
const [lineItems, setLineItems] = useState<LineItem[]>([]);
const [discount, setDiscount] = useState(0);
const [taxRate, setTaxRate] = useState(0.2);
return (
<div>
<LineItemEditor items={lineItems} onItemsChange={setLineItems} />
<InvoiceSummary
lineItems={lineItems}
discount={discount}
taxRate={taxRate}
/>
</div>
);
}
interface InvoiceSummaryProps {
lineItems: LineItem[];
discount: number;
taxRate: number;
}
// Fully self-contained — testable with any props, reusable anywhere
function InvoiceSummary({ lineItems, discount, taxRate }: InvoiceSummaryProps) {
const subtotal = lineItems.reduce((sum, li) => sum + li.price * li.quantity, 0);
const tax = subtotal * taxRate;
const total = subtotal - discount + tax;
return (
<div>
<span>Subtotal: {formatCurrency(subtotal)}</span>
<span>Discount: -{formatCurrency(discount)}</span>
<span>Tax: {formatCurrency(tax)}</span>
<strong>Total: {formatCurrency(total)}</strong>
</div>
);
}Reference: Passing Props to a Component
Extract Components by Independent Change Reasons
Code that changes for different business reasons should live in different components. When a navigation redesign forces you to touch the same file as a search algorithm change, the component has multiple change reasons that create unnecessary merge conflicts and regression risk.
Incorrect (navigation, search, and user menu change for different reasons but live together):
function AppHeader() {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const navigation = useNavigation();
const currentUser = useCurrentUser();
useEffect(() => {
const controller = new AbortController();
if (searchQuery.length > 2) {
searchProducts(searchQuery, controller.signal).then(setSearchResults);
}
return () => controller.abort();
}, [searchQuery]);
return (
<header>
<nav>
{/* Changes when routes change */}
{navigation.routes.map((route) => (
<a key={route.path} href={route.path}>{route.label}</a>
))}
</nav>
<div>
{/* Changes when search algorithm changes */}
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
{searchResults.map((result) => (
<SearchResultCard key={result.id} result={result} />
))}
</div>
<div>
{/* Changes when auth/profile features change */}
<button onClick={() => setIsMenuOpen(!isMenuOpen)}>
{currentUser.avatarUrl
? <img src={currentUser.avatarUrl} alt={currentUser.name} />
: <span>{currentUser.initials}</span>}
</button>
{isMenuOpen && <UserMenuDropdown user={currentUser} />}
</div>
</header>
);
}Correct (each section extracted by its independent change reason):
function AppHeader() {
return (
<header>
<MainNavigation />
<ProductSearch />
<UserMenu />
</header>
);
}
function MainNavigation() {
// Changes only when routes change
const navigation = useNavigation();
return (
<nav>
{navigation.routes.map((route) => (
<a key={route.path} href={route.path}>{route.label}</a>
))}
</nav>
);
}
function ProductSearch() {
// Changes only when search features change
const [searchQuery, setSearchQuery] = useState("");
const searchResults = useProductSearch(searchQuery);
return (
<div>
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
{searchResults.map((result) => (
<SearchResultCard key={result.id} result={result} />
))}
</div>
);
}
function UserMenu() {
// Changes only when auth/profile features change
const [isMenuOpen, setIsMenuOpen] = useState(false);
const currentUser = useCurrentUser();
return (
<div>
<button onClick={() => setIsMenuOpen(!isMenuOpen)}>
{currentUser.avatarUrl
? <img src={currentUser.avatarUrl} alt={currentUser.name} />
: <span>{currentUser.initials}</span>}
</button>
{isMenuOpen && <UserMenuDropdown user={currentUser} />}
</div>
);
}Extract Pure Functions from Component Bodies
Utility logic defined inside a component body is untestable without rendering the component and re-executes its definition on every render. Extracting pure functions to module scope makes them independently testable with plain assertions and eliminates unnecessary function re-creation.
Incorrect (formatting and calculation logic defined inside the component):
function ShippingEstimate({ cart, destination }: ShippingEstimateProps) {
// Re-created on every render, untestable without rendering ShippingEstimate
function calculateWeight(items: CartItem[]): number {
return items.reduce((total, cartItem) => {
const unitWeight = cartItem.weight ?? DEFAULT_WEIGHT;
return total + unitWeight * cartItem.quantity;
}, 0);
}
function formatDeliveryWindow(minDays: number, maxDays: number): string {
const start = addBusinessDays(new Date(), minDays);
const end = addBusinessDays(new Date(), maxDays);
return `${formatDate(start)} – ${formatDate(end)}`;
}
function getShippingTier(weightKg: number): ShippingTier {
if (weightKg < 1) return { tier: "light", baseCost: 4.99, perKg: 0 };
if (weightKg < 10) return { tier: "standard", baseCost: 7.99, perKg: 1.5 };
return { tier: "heavy", baseCost: 14.99, perKg: 2.5 };
}
const totalWeight = calculateWeight(cart.items);
const tier = getShippingTier(totalWeight);
const cost = tier.baseCost + totalWeight * tier.perKg;
const deliveryWindow = formatDeliveryWindow(tier.minDays, tier.maxDays);
return (
<div>
<span>{tier.tier} shipping: {formatCurrency(cost)}</span>
<span>Estimated delivery: {deliveryWindow}</span>
</div>
);
}Correct (pure functions at module scope, tested independently):
// Testable with plain assertions: calculateWeight([{ weight: 2, quantity: 3 }]) === 6
export function calculateWeight(items: CartItem[]): number {
return items.reduce((total, cartItem) => {
const unitWeight = cartItem.weight ?? DEFAULT_WEIGHT;
return total + unitWeight * cartItem.quantity;
}, 0);
}
export function getShippingTier(weightKg: number): ShippingTier {
if (weightKg < 1) return { tier: "light", baseCost: 4.99, perKg: 0 };
if (weightKg < 10) return { tier: "standard", baseCost: 7.99, perKg: 1.5 };
return { tier: "heavy", baseCost: 14.99, perKg: 2.5 };
}
export function formatDeliveryWindow(minDays: number, maxDays: number): string {
const start = addBusinessDays(new Date(), minDays);
const end = addBusinessDays(new Date(), maxDays);
return `${formatDate(start)} – ${formatDate(end)}`;
}
function ShippingEstimate({ cart, destination }: ShippingEstimateProps) {
const totalWeight = calculateWeight(cart.items);
const tier = getShippingTier(totalWeight);
const cost = tier.baseCost + totalWeight * tier.perKg;
const deliveryWindow = formatDeliveryWindow(tier.minDays, tier.maxDays);
return (
<div>
<span>{tier.tier} shipping: {formatCurrency(cost)}</span>
<span>Estimated delivery: {deliveryWindow}</span>
</div>
);
}Reference: Keeping Components Pure
Inline Premature Abstractions Before Re-Extracting
A wrong abstraction resists change more than duplicated code. When a shared component accumulates mode flags and conditional branches to serve diverging use cases, inline it back into each call site first, then re-extract only the genuinely shared parts.
Incorrect (forced abstraction with mode flags serving three divergent patterns):
interface NotificationCardProps {
notification: Notification;
variant: "banner" | "toast" | "inline";
showAvatar?: boolean;
showTimestamp?: boolean;
showDismiss?: boolean;
showActions?: boolean;
onDismiss?: () => void;
onActionClick?: (action: string) => void;
autoHideDuration?: number;
position?: "top" | "bottom";
compact?: boolean;
}
function NotificationCard({
notification, variant, showAvatar, showTimestamp,
showDismiss, showActions, onDismiss, onActionClick,
autoHideDuration, position, compact,
}: NotificationCardProps) {
// Every variant adds branches, none can evolve independently
const className = variant === "banner"
? `banner ${position}`
: variant === "toast"
? `toast ${position} ${compact ? "compact" : ""}`
: "inline";
return (
<div className={className}>
{showAvatar && variant !== "toast" && <Avatar user={notification.sender} />}
<p>{notification.message}</p>
{showTimestamp && variant !== "banner" && <time>{notification.createdAt}</time>}
{showDismiss && <button onClick={onDismiss}>Dismiss</button>}
{showActions && variant === "inline" && (
<div>{notification.actions?.map((a) => (
<button key={a} onClick={() => onActionClick?.(a)}>{a}</button>
))}</div>
)}
</div>
);
}Correct (inline back to call sites, then extract only genuinely shared logic):
// Step 1: Each variant is its own component — free to evolve independently
function BannerNotification({ notification, position, onDismiss }: BannerProps) {
return (
<div className={`banner ${position}`}>
<Avatar user={notification.sender} />
<p>{notification.message}</p>
<button onClick={onDismiss}>Dismiss</button>
</div>
);
}
function ToastNotification({ notification, compact, onDismiss }: ToastProps) {
return (
<div className={`toast ${compact ? "compact" : ""}`}>
<p>{notification.message}</p>
<button onClick={onDismiss}>Dismiss</button>
</div>
);
}
function InlineNotification({ notification, onActionClick }: InlineProps) {
return (
<div className="inline">
<Avatar user={notification.sender} />
<p>{notification.message}</p>
<time>{notification.createdAt}</time>
{notification.actions?.map((action) => (
<button key={action} onClick={() => onActionClick(action)}>
{action}
</button>
))}
</div>
);
}Reference: The Wrong Abstraction — Sandi Metz
Apply the Scroll Test to Identify Oversized Components
If you must scroll to read a component from top to bottom, it contains multiple responsibilities that should be separate components. Large components accumulate unrelated state, making every change risky and every review slow.
Incorrect (single component owns header, filters, table, and pagination):
function OrderDashboard() {
const [orders, setOrders] = useState<Order[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [sortField, setSortField] = useState<keyof Order>("createdAt");
const [currentPage, setCurrentPage] = useState(1);
const [selectedOrders, setSelectedOrders] = useState<Set<string>>(new Set());
const [isExporting, setIsExporting] = useState(false);
const filteredOrders = orders.filter(
(order) => order.customerName.toLowerCase().includes(searchQuery.toLowerCase())
);
const paginatedOrders = filteredOrders.slice(
(currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE
);
async function handleExport() {
setIsExporting(true);
await exportOrdersCsv(orders.filter((o) => selectedOrders.has(o.id)));
setIsExporting(false);
}
// 7 state variables, filtering, sorting, pagination, export — all in one component
return (
<div>
<header>
<h1>Orders</h1>
<button onClick={handleExport} disabled={isExporting}>Export CSV</button>
</header>
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
<table>
<thead>
<tr>{COLUMNS.map((col) => (
<th key={col} onClick={() => setSortField(col)}>{col}</th>
))}</tr>
</thead>
<tbody>
{paginatedOrders.map((order) => (
<tr key={order.id}>
<td>{order.id}</td>
<td>{order.customerName}</td>
<td>{formatCurrency(order.total)}</td>
</tr>
))}
</tbody>
</table>
<Pagination current={currentPage} total={filteredOrders.length} onChange={setCurrentPage} />
</div>
);
}Correct (each visual boundary becomes its own focused component):
function OrderDashboard() {
const [orders] = useState<Order[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [sortField, setSortField] = useState<keyof Order>("createdAt");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [currentPage, setCurrentPage] = useState(1);
const filteredOrders = filterOrders(orders, searchQuery);
const sortedOrders = sortOrders(filteredOrders, sortField, sortDirection);
const totalPages = Math.ceil(filteredOrders.length / PAGE_SIZE);
const paginatedOrders = paginate(sortedOrders, currentPage, PAGE_SIZE);
return (
<div>
<OrderDashboardHeader orders={orders} />
<OrderSearchBar query={searchQuery} onQueryChange={setSearchQuery} />
<OrderTable
orders={paginatedOrders}
sortField={sortField}
onSortChange={setSortField}
/>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
);
}
function OrderDashboardHeader({ orders }: { orders: Order[] }) {
const [isExporting, setIsExporting] = useState(false);
async function handleExport() {
setIsExporting(true);
await exportOrdersCsv(orders);
setIsExporting(false);
}
return (
<header>
<h1>Orders</h1>
<button onClick={handleExport} disabled={isExporting}>
{isExporting ? "Exporting..." : "Export CSV"}
</button>
</header>
);
}Reference: Thinking in React
Separate View Layer from Business Logic
Business logic embedded in JSX forces every test to render the component, making tests slow and brittle. Extract validation, data transformation, and side effects into a custom hook, leaving the component as a thin view that maps hook return values to JSX.
Incorrect (validation, API calls, and formatting interleaved with JSX):
function SubscriptionManager({ userId }: { userId: string }) {
const [plan, setPlan] = useState<Plan | null>(null);
const [isUpgrading, setIsUpgrading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchCurrentPlan(userId).then(setPlan).catch(() => setError("Failed to load plan"));
}, [userId]);
async function handleUpgrade(targetPlan: PlanTier) {
if (!plan) return;
if (plan.tier === targetPlan) {
setError("Already on this plan");
return;
}
if (plan.tier === "enterprise" && targetPlan !== "enterprise") {
setError("Enterprise downgrades require support");
return;
}
setIsUpgrading(true);
setError(null);
try {
const upgraded = await upgradePlan(userId, targetPlan);
setPlan(upgraded);
} catch (err) {
setError(err instanceof Error ? err.message : "Upgrade failed");
} finally {
setIsUpgrading(false);
}
}
const renewalDate = plan
? new Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(plan.renewsAt)
: "";
const monthlyPrice = plan ? formatCurrency(plan.pricePerMonth) : "";
return (
<div>
{error && <Alert variant="error">{error}</Alert>}
{plan && (
<>
<h2>{plan.tier} Plan</h2>
<p>{monthlyPrice}/month — renews {renewalDate}</p>
<button onClick={() => handleUpgrade("pro")} disabled={isUpgrading}>
{isUpgrading ? "Upgrading..." : "Upgrade to Pro"}
</button>
</>
)}
</div>
);
}Correct (custom hook owns all logic, component is a thin view):
// Testable without rendering — call the hook in a test harness
function useSubscription(userId: string) {
const [plan, setPlan] = useState<Plan | null>(null);
const [isUpgrading, setIsUpgrading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchCurrentPlan(userId).then(setPlan).catch(() => setError("Failed to load plan"));
}, [userId]);
async function handleUpgrade(targetPlan: PlanTier) {
if (!plan || plan.tier === targetPlan) return;
setIsUpgrading(true);
setError(null);
try {
setPlan(await upgradePlan(userId, targetPlan));
} catch (err) {
setError(err instanceof Error ? err.message : "Upgrade failed");
} finally {
setIsUpgrading(false);
}
}
return { plan, isUpgrading, error, handleUpgrade };
}
// Thin view — maps hook return values to JSX
function SubscriptionManager({ userId }: { userId: string }) {
const { plan, isUpgrading, error, handleUpgrade } = useSubscription(userId);
return (
<div>
{error && <Alert variant="error">{error}</Alert>}
{plan && (
<>
<h2>{plan.tier} Plan</h2>
<p>{formatCurrency(plan.pricePerMonth)}/month</p>
<button onClick={() => handleUpgrade("pro")} disabled={isUpgrading}>
{isUpgrading ? "Upgrading..." : "Upgrade to Pro"}
</button>
</>
)}
</div>
);
}Reference: Reusing Logic with Custom Hooks
Avoid Object and Array Dependencies in Custom Hooks
Object and array literals in dependency arrays fail referential equality on every render, even when their contents are identical. The effect re-executes every render because {} !== {}. Extracting primitive values from objects or using stable references prevents phantom re-executions.
The React Compiler does not fix this: it memoizes render output but leaves useEffect dependency arrays untouched, so an unstable object dependency still re-fires the effect. This rule applies whether or not the Compiler is enabled.
Incorrect (object dep — effect fires every render):
interface MapViewport {
latitude: number;
longitude: number;
zoom: number;
}
function useMapMarkers(viewport: MapViewport) {
const [markers, setMarkers] = useState<Marker[]>([]);
useEffect(() => {
// Fires every render — viewport is a new object reference each time
fetchMarkersInBounds(viewport).then(setMarkers);
}, [viewport]);
return markers;
}
function MapContainer() {
const [viewport, setViewport] = useState<MapViewport>({ latitude: 51.5074, longitude: -0.1278, zoom: 12 });
// Even when viewport values haven't changed, passing the object creates a new reference
const markers = useMapMarkers(viewport);
return <Map viewport={viewport} markers={markers} onMove={setViewport} />;
}Correct (primitive deps — effect fires only when values change):
function useMapMarkers(viewport: MapViewport) {
const [markers, setMarkers] = useState<Marker[]>([]);
const { latitude, longitude, zoom } = viewport;
useEffect(() => {
// Fires only when lat, lng, or zoom actually changes
fetchMarkersInBounds({ latitude, longitude, zoom }).then(setMarkers);
}, [latitude, longitude, zoom]);
return markers;
}
function MapContainer() {
const [viewport, setViewport] = useState<MapViewport>({ latitude: 51.5074, longitude: -0.1278, zoom: 12 });
const markers = useMapMarkers(viewport);
return <Map viewport={viewport} markers={markers} onMove={setViewport} />;
}Compose Hooks Instead of Nesting Them
When hooks call other hooks internally, the dependency chain becomes invisible to the consuming component. A change in a deeply nested hook silently alters the behavior of every hook above it. Composing independent hooks at the component level makes the dependency graph explicit and each hook independently replaceable.
Incorrect (nested hooks — hidden dependency chain):
function useOrganizationMembers(orgId: string) {
// Hidden: internally calls useAuth, usePermissions, and usePagination
const { token } = useAuth(); // If useAuth changes, this hook silently breaks
const { canViewMembers } = usePermissions(token);
const { page, pageSize, nextPage } = usePagination();
const [members, setMembers] = useState<Member[]>([]);
useEffect(() => {
if (!canViewMembers) return;
fetchMembers(orgId, { page, pageSize }, token).then(setMembers);
}, [orgId, page, pageSize, token, canViewMembers]);
return { members, nextPage, canViewMembers };
}
// Consumer has no visibility into auth/permission/pagination dependencies
function MemberList({ orgId }: { orgId: string }) {
const { members, nextPage, canViewMembers } = useOrganizationMembers(orgId);
if (!canViewMembers) return <AccessDenied />;
return <MemberTable members={members} onLoadMore={nextPage} />;
}Correct (flat composition — dependencies visible at call site):
function useMembers(orgId: string, token: string, page: number, pageSize: number) {
const [members, setMembers] = useState<Member[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
fetchMembers(orgId, { page, pageSize }, token).then(setMembers).finally(() => setIsLoading(false));
}, [orgId, page, pageSize, token]);
return { members, isLoading };
}
// All dependencies visible — each hook independently replaceable
function MemberList({ orgId }: { orgId: string }) {
const { token } = useAuth();
const { canViewMembers } = usePermissions(token);
const { page, pageSize, nextPage } = usePagination();
const { members, isLoading } = useMembers(orgId, token, page, pageSize);
if (!canViewMembers) return <AccessDenied />;
if (isLoading) return <Skeleton rows={5} />;
return <MemberTable members={members} onLoadMore={nextPage} />;
}Reference: React Docs - Custom Hooks: Sharing Logic Between Components
Stabilize Hook Dependencies with Refs and Callbacks
Unstable dependencies — callback props that are recreated every render — cause useEffect to re-execute infinitely. The effect fires, triggers a state change in the parent, the parent re-renders with a new callback reference, and the effect fires again. Storing the latest callback in a ref breaks the cycle while always calling the most recent version.
Incorrect (callback prop in dependency array — infinite loop):
function useInterval(callback: () => void, delayMs: number) {
useEffect(() => {
const id = setInterval(callback, delayMs);
return () => clearInterval(id);
}, [callback, delayMs]); // callback changes every render — effect restarts infinitely
}
function PollDashboard({ refreshRate }: { refreshRate: number }) {
const [metrics, setMetrics] = useState<Metrics | null>(null);
// New function reference every render
useInterval(() => {
fetchMetrics().then(setMetrics); // setMetrics triggers re-render, new callback, repeat
}, refreshRate);
return <MetricsDisplay metrics={metrics} />;
}Correct (ref stabilizes the callback — effect runs once):
function useInterval(callback: () => void, delayMs: number) {
const savedCallback = useRef(callback);
// Update ref on every render — always points to latest closure
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
const id = setInterval(() => savedCallback.current(), delayMs);
return () => clearInterval(id);
}, [delayMs]); // Only re-runs when delay changes
}
function PollDashboard({ refreshRate }: { refreshRate: number }) {
const [metrics, setMetrics] = useState<Metrics | null>(null);
useInterval(() => {
fetchMetrics().then(setMetrics);
}, refreshRate);
return <MetricsDisplay metrics={metrics} />;
}Modern alternative (useEffectEvent — React 19.2+):
useEffectEvent wraps a callback so it can be used inside effects without declaring it as a dependency, achieving the same stability with less boilerplate:
function useInterval(callback: () => void, delayMs: number) {
const onTick = useEffectEvent(callback);
useEffect(() => {
const id = setInterval(onTick, delayMs);
return () => clearInterval(id);
}, [delayMs]);
}For codebases on React 19.2+, prefer useEffectEvent over the ref pattern. See the react skill for detailed API usage.
Reference: React Docs - useEffectEvent
Extract Logic into Custom Hooks When Behavior Is Nameable
Inline timer setup, event subscriptions, and cleanup logic obscure the component's intent behind implementation mechanics. When the behavior has a clear name — "countdown", "window resize", "online status" — extracting it into a custom hook replaces 10-15 lines of mechanism with a single descriptive call.
Incorrect (inline timer + cleanup — behavior buried in mechanics):
function AuctionBidPanel({ endsAt }: { endsAt: Date }) {
const [timeRemaining, setTimeRemaining] = useState<number>(0);
const [isExpired, setIsExpired] = useState(false);
useEffect(() => {
function tick() {
const remaining = endsAt.getTime() - Date.now();
if (remaining <= 0) {
setTimeRemaining(0);
setIsExpired(true);
return;
}
setTimeRemaining(remaining);
}
tick();
const intervalId = setInterval(tick, 1000);
return () => clearInterval(intervalId);
}, [endsAt]);
const hours = Math.floor(timeRemaining / 3_600_000);
const minutes = Math.floor((timeRemaining % 3_600_000) / 60_000);
const seconds = Math.floor((timeRemaining % 60_000) / 1_000);
// 20 lines of timer plumbing before the actual UI begins
return (
<div>
{isExpired ? <p>Auction ended</p> : <p>{hours}h {minutes}m {seconds}s</p>}
<BidForm disabled={isExpired} />
</div>
);
}Correct (named hook — intent replaces mechanism):
function useCountdown(targetDate: Date) {
const [timeRemaining, setTimeRemaining] = useState(targetDate.getTime() - Date.now());
useEffect(() => {
function tick() {
const remaining = targetDate.getTime() - Date.now();
setTimeRemaining(Math.max(0, remaining));
}
tick();
const intervalId = setInterval(tick, 1000);
return () => clearInterval(intervalId);
}, [targetDate]);
return {
isExpired: timeRemaining <= 0,
hours: Math.floor(timeRemaining / 3_600_000),
minutes: Math.floor((timeRemaining % 3_600_000) / 60_000),
seconds: Math.floor((timeRemaining % 60_000) / 1_000),
};
}
function AuctionBidPanel({ endsAt }: { endsAt: Date }) {
const { isExpired, hours, minutes, seconds } = useCountdown(endsAt);
return (
<div>
{isExpired ? <p>Auction ended</p> : <p>{hours}h {minutes}m {seconds}s</p>}
<BidForm disabled={isExpired} />
</div>
);
}Reference: React Docs - Reusing Logic with Custom Hooks
Follow Hook Naming Conventions for Discoverability
Inconsistent hook names force developers to open each file to understand what a hook does. The use prefix is not just a React requirement — it is a discovery mechanism. Pairing it with a noun or verb phrase that describes the return value makes the hook self-documenting.
Incorrect (inconsistent names — unclear what each returns):
// What does getData return? A function? The data itself? Loading state?
function getData(endpoint: string) {
const [result, setResult] = useState(null);
useEffect(() => {
fetch(endpoint).then((r) => r.json()).then(setResult);
}, [endpoint]);
return result;
}
// "fetch" implies imperative call, but this is declarative
function fetchUserPermissions(userId: string) {
const [permissions, setPermissions] = useState<string[]>([]);
useEffect(() => {
loadPermissions(userId).then(setPermissions);
}, [userId]);
return permissions;
}
// "do" prefix reads like a command, not a hook
function doAuth() {
const [session, setSession] = useState<Session | null>(null);
return { session, login: (creds: Credentials) => authenticate(creds).then(setSession) };
}*Correct (use convention with return-type hints):**
// "useResource" pattern — returns { data, isLoading, error }
function useResource<T>(endpoint: string) {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
fetch(endpoint).then((r) => r.json()).then(setData).finally(() => setIsLoading(false));
}, [endpoint]);
return { data, isLoading };
}
// "usePermissions" — noun tells you it returns permissions
function usePermissions(userId: string) {
const [permissions, setPermissions] = useState<string[]>([]);
useEffect(() => {
loadPermissions(userId).then(setPermissions);
}, [userId]);
return permissions;
}
// "useAuth" — returns auth state and actions
function useAuth() {
const [session, setSession] = useState<Session | null>(null);
return { session, login: (creds: Credentials) => authenticate(creds).then(setSession) };
}Reference: React Docs - Custom Hooks: Sharing Logic Between Components
Keep Custom Hooks to a Single Responsibility
Hooks that fetch, transform, subscribe, and cache in one function become untestable monoliths — mocking one concern requires stubbing all of them. Breaking a monolithic hook into focused single-responsibility hooks makes each independently testable and reusable in different contexts.
Incorrect (monolithic hook — fetch + transform + subscribe + cache):
function useUserDashboard(userId: string) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [preferences, setPreferences] = useState<Preferences | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
Promise.all([fetchProfile(userId), fetchPreferences(userId)])
.then(([profileData, prefsData]) => {
setProfile(profileData);
setPreferences(prefsData);
})
.finally(() => setIsLoading(false));
}, [userId]);
useEffect(() => {
const ws = connectWebSocket(`/notifications/${userId}`, (msg) => {
setNotifications((prev) => [msg, ...prev]);
});
return () => ws.close();
}, [userId]);
// Testing notification logic requires mocking fetch + WebSocket
const unreadCount = notifications.filter((n) => !n.read).length;
const displayName = profile ? `${profile.firstName} ${profile.lastName}` : "";
return { profile, preferences, notifications, unreadCount, displayName, isLoading };
}Correct (focused hooks — each testable in isolation):
function useUserProfile(userId: string) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
fetchProfile(userId).then(setProfile).finally(() => setIsLoading(false));
}, [userId]);
const displayName = profile ? `${profile.firstName} ${profile.lastName}` : "";
return { profile, displayName, isLoading };
}
function useUserPreferences(userId: string) {
const [preferences, setPreferences] = useState<Preferences | null>(null);
useEffect(() => {
fetchPreferences(userId).then(setPreferences);
}, [userId]);
return { preferences };
}
function useNotificationStream(userId: string) {
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
const ws = connectWebSocket(`/notifications/${userId}`, (msg) => {
setNotifications((prev) => [msg, ...prev]);
});
return () => ws.close();
}, [userId]);
const unreadCount = notifications.filter((n) => !n.read).length;
return { notifications, unreadCount };
}
// Compose in the component — each hook testable with a single mock
function UserDashboard({ userId }: { userId: string }) {
const { profile, displayName, isLoading } = useUserProfile(userId);
const { preferences } = useUserPreferences(userId);
const { notifications, unreadCount } = useNotificationStream(userId);
}Reference: React Docs - Reusing Logic with Custom Hooks
Write Characterization Tests Before Refactoring
Refactoring without tests means changing behavior without a safety net. Bugs introduced during refactoring are invisible until production. Characterization tests lock in the current behavior first, so any unintended change triggers a failure immediately, making the refactoring reversible.
Incorrect (refactor first — no way to detect regressions):
// Step 1: Immediately refactor the monolith with no tests
// ShippingCalculator had complex conditional logic
// Developer "simplifies" it, unknowingly changing edge case behavior
export function calculateShippingCost(
weight: number,
destination: string,
isExpedited: boolean,
): number {
// Refactored version — looks cleaner but subtly broke the weight >= 50 case
const baseRate = destination === "international" ? 25 : 10;
const weightSurcharge = weight * 0.5;
const expeditedMultiplier = isExpedited ? 1.5 : 1;
return (baseRate + weightSurcharge) * expeditedMultiplier;
// Original had: weight >= 50 ? flatRate : perPoundRate
// Bug ships to production undetected
}Correct (characterization tests first — behavior locked before touching code):
// Step 1: Write tests that capture CURRENT behavior before any changes
import { calculateShippingCost } from "./ShippingCalculator";
describe("calculateShippingCost — characterization", () => {
test("domestic standard under 50lbs", () => {
expect(calculateShippingCost(10, "domestic", false)).toBe(15);
});
test("domestic standard at 50lbs threshold", () => {
// Discovered: 50lb+ uses flat rate $45, not per-pound
expect(calculateShippingCost(50, "domestic", false)).toBe(45);
});
test("domestic expedited under 50lbs", () => {
expect(calculateShippingCost(10, "domestic", true)).toBe(22.5);
});
test("international standard", () => {
expect(calculateShippingCost(10, "international", false)).toBe(30);
});
test("international expedited at threshold", () => {
expect(calculateShippingCost(50, "international", true)).toBe(90);
});
});
// Step 2: Now refactor — tests catch the 50lb edge case immediately
// Step 3: All 5 tests pass = safe to shipReference: Michael Feathers - Working Effectively with Legacy Code
Extract Pure Functions to Increase Testability
Business logic embedded inside components requires rendering, interacting, and querying the DOM to verify correctness. Each test needs React test infrastructure, increasing execution time and test complexity. Extracting logic into pure functions allows direct input/output testing with zero framework overhead.
Incorrect (logic inside component — needs full render to test):
import { render, screen } from "@testing-library/react";
function InvoiceSummary({ lineItems }: { lineItems: LineItem[] }) {
// Formatting and calculation logic trapped inside the component
const subtotal = lineItems.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
const discount = subtotal > 1000 ? subtotal * 0.1 : subtotal > 500 ? subtotal * 0.05 : 0;
const taxableAmount = subtotal - discount;
const tax = taxableAmount * 0.2;
const total = taxableAmount + tax;
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total);
return <div data-testid="invoice-total">{formatted}</div>;
}
// Testing requires rendering the entire component
test("invoice applies 10% discount over $1000", () => {
const lineItems = [{ unitPrice: 600, quantity: 2, description: "Widget" }];
render(<InvoiceSummary lineItems={lineItems} />);
expect(screen.getByTestId("invoice-total")).toHaveTextContent("$1,296.00");
});Correct (extracted pure functions — test directly):
// invoiceCalculations.ts — pure functions, no React dependency
export function calculateInvoiceTotals(lineItems: LineItem[]) {
const subtotal = lineItems.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
const discount = subtotal > 1000 ? subtotal * 0.1 : subtotal > 500 ? subtotal * 0.05 : 0;
const taxableAmount = subtotal - discount;
const tax = taxableAmount * 0.2;
return { subtotal, discount, tax, total: taxableAmount + tax };
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
}
// Pure function tests — no render, no DOM, 10x faster
test("applies 10% discount over $1000", () => {
const lineItems = [{ unitPrice: 600, quantity: 2, description: "Widget" }];
const { total, discount } = calculateInvoiceTotals(lineItems);
expect(discount).toBe(120);
expect(total).toBe(1296);
});
test("formats currency with USD symbol", () => {
expect(formatCurrency(1296)).toBe("$1,296.00");
});
// Component becomes a thin rendering layer
function InvoiceSummary({ lineItems }: { lineItems: LineItem[] }) {
const { total } = calculateInvoiceTotals(lineItems);
return <div>{formatCurrency(total)}</div>;
}Reference: Kent C. Dodds - AHA Testing
Prefer Integration Tests for Component Verification
Unit testing each component in isolation with mocked children and dependencies proves that individual pieces work, but misses the bugs that occur when pieces connect. Integration tests render the feature as the user experiences it, catching prop mismatches, context misconfiguration, and event propagation failures that isolated tests never see.
Incorrect (isolated unit tests — each passes, feature broken):
// Each test passes independently but misses integration bugs
test("SearchInput calls onSearch with query", async () => {
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
await userEvent.type(screen.getByRole("searchbox"), "laptop");
await userEvent.click(screen.getByRole("button", { name: "Search" }));
expect(onSearch).toHaveBeenCalledWith("laptop");
});
test("ProductGrid renders products", () => {
const products = [{ id: "1", name: "Laptop", price: 999 }];
render(<ProductGrid products={products} />);
expect(screen.getByText("Laptop")).toBeInTheDocument();
});
test("FilterPanel calls onFilterChange", async () => {
const onFilterChange = vi.fn();
render(<FilterPanel onFilterChange={onFilterChange} />);
await userEvent.click(screen.getByLabelText("In Stock"));
expect(onFilterChange).toHaveBeenCalledWith({ inStock: true });
});
// Bug: SearchInput passes { query: "laptop" } but ProductGrid expects string
// Bug: FilterPanel resets search results — not caught by isolated testsCorrect (integration test — feature tested as user experiences it):
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "@/test/mswServer";
test("search and filter products end to end", async () => {
server.use(
http.get("/api/products", ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const inStock = url.searchParams.get("inStock");
const products = [
{ id: "1", name: "Laptop Pro", price: 999, inStock: true },
{ id: "2", name: "Laptop Air", price: 799, inStock: false },
].filter((p) =>
(!query || p.name.toLowerCase().includes(query)) &&
(!inStock || p.inStock)
);
return HttpResponse.json(products);
}),
);
const user = userEvent.setup();
render(<ProductSearchPage />);
await user.type(screen.getByRole("searchbox"), "laptop");
await user.click(screen.getByRole("button", { name: "Search" }));
expect(await screen.findAllByRole("article")).toHaveLength(2);
await user.click(screen.getByLabelText("In Stock"));
expect(await screen.findAllByRole("article")).toHaveLength(1);
expect(screen.getByText("Laptop Pro")).toBeInTheDocument();
});Reference: Kent C. Dodds - Write tests. Not too many. Mostly integration.
Avoid Snapshot Tests for Refactored Components
Snapshot tests serialize the entire rendered output, so any change to class names, wrapper elements, whitespace, or attribute order causes a failure. During refactoring, every structural improvement triggers a snapshot diff that developers blindly update with --updateSnapshot. The test provides zero confidence — it only proves the output changed, not whether the change was correct.
Incorrect (snapshot test — fails on every structural change):
import { render } from "@testing-library/react";
test("PaymentStatus renders correctly", () => {
const { container } = render(
<PaymentStatus
status="completed"
amount={149.99}
transactionId="txn_abc123"
/>
);
// Breaks when: class name changes, wrapper div added, attribute order shifts
expect(container).toMatchSnapshot();
});
// Snapshot file contains 25 lines of serialized HTML
// Developer refactors CSS module names: snapshot fails
// Developer wraps in <section>: snapshot fails
// Developer reorders aria attributes: snapshot fails
// Every time: developer runs --updateSnapshot without reviewing diffCorrect (explicit assertions — only fail when behavior regresses):
import { render, screen } from "@testing-library/react";
test("PaymentStatus displays completed transaction details", () => {
render(
<PaymentStatus
status="completed"
amount={149.99}
transactionId="txn_abc123"
/>
);
expect(screen.getByText("Payment Complete")).toBeInTheDocument();
expect(screen.getByText("$149.99")).toBeInTheDocument();
expect(screen.getByText("txn_abc123")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
});
test("PaymentStatus displays failed state with retry", () => {
render(<PaymentStatus status="failed" amount={149.99} transactionId="txn_abc123" />);
expect(screen.getByText("Payment Failed")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry Payment" })).toBeEnabled();
});
// Renaming CSS classes, restructuring markup, adding wrappers — tests still passReference: Kent C. Dodds - Effective Snapshot Testing
Test Component Behavior Not Implementation Details
Tests that assert on internal state values, hook return values, or component instance methods break every time the implementation changes, even when the user-visible behavior stays identical. Testing what the user sees and interacts with creates tests that survive refactoring and only fail when actual behavior regresses.
Incorrect (testing implementation — breaks on internal refactor):
import { renderHook, act } from "@testing-library/react";
// Testing internal hook state directly
test("newsletter form manages subscription state", () => {
const { result } = renderHook(() => useNewsletterForm());
// Coupled to internal state shape — breaks if renamed or restructured
expect(result.current.email).toBe("");
expect(result.current.isSubmitting).toBe(false);
expect(result.current.isSubscribed).toBe(false);
act(() => {
result.current.setEmail("dev@example.com");
});
expect(result.current.email).toBe("dev@example.com");
// Renaming isSubmitting to isPending breaks this test
// Moving from useState to useReducer breaks this test
// Neither change affects what the user experiences
});Correct (testing behavior — survives internal rewrites):
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("newsletter form subscribes user and shows confirmation", async () => {
const user = userEvent.setup();
render(<NewsletterForm />);
const emailInput = screen.getByLabelText("Email address");
const subscribeButton = screen.getByRole("button", { name: "Subscribe" });
await user.type(emailInput, "dev@example.com");
await user.click(subscribeButton);
// Asserts on what the user sees — survives any internal refactor
expect(await screen.findByText("Subscribed successfully")).toBeInTheDocument();
expect(emailInput).toHaveValue("");
expect(subscribeButton).toBeEnabled();
});
// Rewriting from useState to useReducer, renaming internal variables,
// or extracting a custom hook — this test still passes unchangedReference: Testing Library - Guiding Principles
Colocate State with Components That Use It
State lifted too high forces intermediate components to forward props they do not use. Every parent in the chain re-renders when the state changes, even though only the leaf component reads it. Moving state to the component that consumes it eliminates the forwarding chain and confines re-renders.
Incorrect (state in parent, only child uses it):
function ProductPage() {
// searchQuery is only used by SearchResults, but ProductPage re-renders on every keystroke
const [searchQuery, setSearchQuery] = useState("");
return (
<div>
<ProductHeader />
<ProductCategories />
<SearchBar query={searchQuery} onQueryChange={setSearchQuery} />
<SearchResults query={searchQuery} />
<ProductFooter />
</div>
);
}
function SearchBar({ query, onQueryChange }: { query: string; onQueryChange: (q: string) => void }) {
return <input value={query} onChange={(e) => onQueryChange(e.target.value)} />;
}
function SearchResults({ query }: { query: string }) {
const results = useProductSearch(query);
return <ul>{results.map((r) => <li key={r.id}>{r.name}</li>)}</ul>;
}Correct (state colocated in the consuming subtree):
function ProductPage() {
return (
<div>
<ProductHeader />
<ProductCategories />
<ProductSearch /> {/* State lives here — ProductPage never re-renders for keystrokes */}
<ProductFooter />
</div>
);
}
function ProductSearch() {
const [searchQuery, setSearchQuery] = useState("");
const results = useProductSearch(searchQuery);
return (
<div>
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
<ul>{results.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
</div>
);
}Reference: Kent C. Dodds - State Colocation
Use Context for Rarely-Changing Values Only
Every context value change re-renders the entire consumer subtree, regardless of which part of the value changed. Putting rapidly-changing data (cursor position, scroll offset, real-time counters) in context forces O(n) re-renders across all consumers on every tick. Reserve context for values that change infrequently — theme, locale, auth — and use dedicated state management for dynamic data.
Incorrect (real-time data in context — all consumers re-render per tick):
interface DashboardContextValue {
theme: "light" | "dark";
locale: string;
currentUser: User;
liveVisitorCount: number; // Updates every second
realtimeRevenue: number; // Updates every second
activeAlerts: Alert[]; // Updates every few seconds
}
const DashboardContext = createContext<DashboardContextValue>(null!);
function DashboardProvider({ children }: { children: React.ReactNode }) {
const [liveVisitorCount, setLiveVisitorCount] = useState(0);
const [realtimeRevenue, setRealtimeRevenue] = useState(0);
const [activeAlerts, setActiveAlerts] = useState<Alert[]>([]);
useEffect(() => {
// Every tick re-renders Navbar, Sidebar, Footer — everything consuming this context
const ws = connectWebSocket("/metrics", (data) => {
setLiveVisitorCount(data.visitors);
setRealtimeRevenue(data.revenue);
setActiveAlerts(data.alerts);
});
return () => ws.close();
}, []);
const value = { theme: "light", locale: "en", currentUser: user, liveVisitorCount, realtimeRevenue, activeAlerts };
return <DashboardContext value={value}>{children}</DashboardContext>;
}Correct (context for config, dedicated state for dynamic data):
// Static context — changes on login or settings update only
interface AppConfigContextValue {
theme: "light" | "dark";
locale: string;
currentUser: User;
}
const AppConfigContext = createContext<AppConfigContextValue>(null!);
// Real-time data stays in the components that display it
function LiveMetricsPanel() {
const [visitorCount, setVisitorCount] = useState(0);
const [revenue, setRevenue] = useState(0);
useEffect(() => {
const ws = connectWebSocket("/metrics", (data) => {
setVisitorCount(data.visitors);
setRevenue(data.revenue);
});
return () => ws.close();
}, []);
// Only this component re-renders per tick — not the entire app
return (
<div>
<MetricCard label="Visitors" value={visitorCount} />
<MetricCard label="Revenue" value={formatCurrency(revenue)} />
</div>
);
}Derive Values Instead of Syncing State
Using useEffect to compute a derived value from other state causes a double-render: first the source state updates and renders, then the effect fires, updates the derived state, and renders again. Computing the value during render eliminates the extra cycle and makes it impossible for source and derived values to drift out of sync.
Incorrect (useEffect sync — double render + sync drift risk):
function ProductCatalog({ products, categoryFilter, priceRange }: ProductCatalogProps) {
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
const [totalPrice, setTotalPrice] = useState(0);
// First render: stale filteredProducts. Second render: correct filteredProducts.
useEffect(() => {
const filtered = products.filter(
(p) => p.category === categoryFilter && p.price >= priceRange.min && p.price <= priceRange.max
);
setFilteredProducts(filtered);
}, [products, categoryFilter, priceRange]);
// Third render: correct totalPrice. User sees flicker.
useEffect(() => {
const total = filteredProducts.reduce((sum, p) => sum + p.price, 0);
setTotalPrice(total);
}, [filteredProducts]);
return (
<div>
<p>Total: {formatCurrency(totalPrice)}</p>
{filteredProducts.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
);
}Correct (derive during render — single pass, always in sync):
function ProductCatalog({ products, categoryFilter, priceRange }: ProductCatalogProps) {
// Computed every render — always consistent with source data
const filteredProducts = products.filter(
(p) => p.category === categoryFilter && p.price >= priceRange.min && p.price <= priceRange.max
);
const totalPrice = filteredProducts.reduce((sum, p) => sum + p.price, 0);
return (
<div>
<p>Total: {formatCurrency(totalPrice)}</p>
{filteredProducts.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
);
}Reference: React Docs - You Might Not Need an Effect
Lift State Only When Multiple Components Read It
Premature lifting places state in a component that does not use it directly, making ownership unclear and forcing unnecessary re-renders on every state change. Lift state only when a second component needs to read the same value — not before.
Incorrect (state lifted to App for a single consumer):
function App() {
// Only ShippingForm reads shippingAddress, but App re-renders on every field change
const [shippingAddress, setShippingAddress] = useState<Address | null>(null);
return (
<div>
<Navbar />
<Sidebar />
<ShippingForm address={shippingAddress} onAddressChange={setShippingAddress} />
<RecommendedProducts />
</div>
);
}Correct (colocated first, lifted only when shared):
// Step 1: state lives in the only consumer
function ShippingForm() {
const [shippingAddress, setShippingAddress] = useState<Address | null>(null);
return <AddressFields address={shippingAddress} onChange={setShippingAddress} />;
}
// Step 2: when OrderSummary also needs the address, lift to shared parent
function CheckoutPage() {
const [shippingAddress, setShippingAddress] = useState<Address | null>(null);
// Both children read shippingAddress — lifting is justified
return (
<div>
<ShippingForm address={shippingAddress} onAddressChange={setShippingAddress} />
<OrderSummary shippingAddress={shippingAddress} />
</div>
);
}
function App() {
return (
<div>
<Navbar />
<Sidebar />
<CheckoutPage /> {/* App never re-renders for address changes */}
<RecommendedProducts />
</div>
);
}Related skills
FAQ
How many rules does react-refactor include?
react-refactor version 1.1.0 bundles 40 rules organized into 7 categories spanning component architecture, state, hooks, decomposition, coupling, data effects, and refactoring safety. Each rule includes smells and before/after examples.
What React problems does react-refactor target?
react-refactor addresses prop drilling, unnecessary re-renders, tangled component trees, and unsafe refactors. Developers apply interface segregation, hook patterns, and decomposition steps from the categorized rule set.
Is React Refactor safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.