
React Optimise
- 308 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
react-optimise is a Claude Code skill from pproenca/dot-skills aimed at improving React application performance through component-level optimisation patterns for developers shipping interactive UIs.
About
react-optimise is a skill entry in the pproenca/dot-skills repository intended for React performance work, though its published description is minimal. From the name and repository context, it guides agents through React UI optimisation tasks such as reducing unnecessary re-renders, tightening component structure, and improving perceived load performance. Developers reach for react-optimise when a React codebase feels slow or bloated and they want agent-assisted refactors rather than manual profiling alone. Because the bundled readme is empty, treat triggers as React optimisation requests and validate recommendations against your profiler metrics.
- react-optimise
React Optimise by the numbers
- 308 all-time installs (skills.sh)
- +14 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,327 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill react-optimiseAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 308 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you optimize React component render performance?
Use react-optimise for development tasks
Who is it for?
Frontend engineers tuning an existing React app where interaction latency or bundle weight blocks release readiness.
Skip if: Greenfield UI design, non-React frameworks, or backend-only performance work unrelated to the component tree.
When should I use this skill?
The user asks to optimise, speed up, or reduce re-renders in a React application or names react-optimise directly.
What you get
Refactored React components with fewer re-renders, leaner bundles, and documented performance changes.
- Optimised React components
Files
React Optimise Best Practices
Application-level performance optimization guide for React applications. Contains 43 rules across 8 categories, prioritized by impact from critical (React Compiler, bundle optimization) to incremental (memory management).
When to Apply
- Optimizing React application performance or bundle size
- Adopting or troubleshooting React Compiler v1.0
- Splitting bundles and configuring code splitting
- Improving Core Web Vitals (INP, LCP, CLS)
- Profiling render performance and identifying bottlenecks
- Fixing memory leaks in long-lived single-page applications
- Optimizing data fetching patterns and eliminating waterfalls
Rule Categories
| Category | Impact | Rules | Key Topics |
|---|---|---|---|
| React Compiler Mastery | CRITICAL | 6 | Compiler-friendly code, bailout detection, incremental adoption |
| Bundle & Loading | CRITICAL | 6 | Route splitting, barrel elimination, dynamic imports, prefetching |
| Rendering Optimization | HIGH | 6 | Virtualization, children pattern, debouncing, CSS containment |
| Data Fetching Performance | HIGH | 5 | Waterfall elimination, route preloading, SWR, deduplication |
| Core Web Vitals | MEDIUM-HIGH | 5 | INP yielding, LCP priority, CLS prevention, image optimization |
| State & Subscription Performance | MEDIUM-HIGH | 5 | Context splitting, selectors, atomic state, derived state |
| Profiling & Measurement | MEDIUM | 5 | DevTools profiling, flame charts, CI budgets, production builds |
| Memory Management | LOW-MEDIUM | 5 | Effect cleanup, async cancellation, closure leaks, heap analysis |
Quick Reference
Critical patterns — get these right first:
- Write compiler-friendly components to unlock automatic 2-10x optimization
- Split code at route boundaries to reduce initial bundle by 40-70%
- Eliminate barrel files to enable tree shaking
- Detect and fix silent compiler bailouts
Common mistakes — avoid these anti-patterns:
- Reading refs during render (breaks compiler optimization)
- Importing entire libraries when only using one function
- Not profiling before optimizing (targeting the wrong bottleneck)
- Missing effect cleanup (subscription memory leaks)
Table of Contents
1. React Compiler Mastery — CRITICAL
- 1.1 Detect and Fix Silent Compiler Bailouts — CRITICAL (prevents losing automatic memoization)
- 1.2 Isolate Side Effects from Render for Compiler Correctness — CRITICAL (prevents compiler from producing incorrect cached output)
- 1.3 Remove Manual Memoization After Compiler Adoption — CRITICAL (20-40% code reduction in component files)
- 1.4 Use Incremental Compiler Adoption with Directives — CRITICAL (enables safe rollout without full codebase migration)
- 1.5 Use Ref Access Patterns That Enable Compilation — CRITICAL (maintains compiler optimization for ref-using components)
- 1.6 Write Compiler-Friendly Component Patterns — CRITICAL (2-10x automatic render optimization)
2. Bundle & Loading — CRITICAL
- 2.1 Configure Dependencies for Effective Tree Shaking — CRITICAL (50-90% dead code elimination in dependencies)
- 2.2 Eliminate Barrel Files to Enable Tree Shaking — CRITICAL (200-800ms import cost eliminated)
- 2.3 Enforce Bundle Size Budgets with Analysis Tools — CRITICAL (prevents gradual bundle size regression)
- 2.4 Prefetch Likely Next Routes on Interaction — CRITICAL (200-1000ms faster perceived navigation)
- 2.5 Split Code at Route Boundaries with React.lazy — CRITICAL (40-70% reduction in initial bundle size)
- 2.6 Use Dynamic Imports for Heavy Libraries — CRITICAL (100-500KB removed from critical path)
3. Rendering Optimization — HIGH
- 3.1 Avoid Inline Object Creation in JSX Props — HIGH (prevents unnecessary child re-renders)
- 3.2 Debounce Expensive Derived Computations — HIGH (50-200ms saved per keystroke)
- 3.3 Use CSS Containment to Isolate Layout Recalculation — HIGH (60-90% layout recalc reduction)
- 3.4 Use Children Pattern to Prevent Parent Re-Renders — HIGH (eliminates re-renders of static subtrees)
- 3.5 Use Stable Keys for List Rendering Performance — HIGH (O(n) DOM mutations to O(1) moves)
- 3.6 Virtualize Long Lists with TanStack Virtual — HIGH (O(n) to O(1) DOM nodes)
4. Data Fetching Performance — HIGH
- 4.1 Abort Stale Requests on Navigation or Re-fetch — HIGH (prevents stale data display)
- 4.2 Deduplicate Identical In-Flight Requests — HIGH (50-80% fewer network requests)
- 4.3 Eliminate Sequential Data Fetch Waterfalls — HIGH (2-5x faster page loads)
- 4.4 Preload Data at Route Level Before Component Mounts — HIGH (200-1000ms eliminated)
- 4.5 Use Stale-While-Revalidate for Cache Freshness — HIGH (0ms perceived load for returning visitors)
5. Core Web Vitals — MEDIUM-HIGH
- 5.1 Instrument Real User Monitoring with web-vitals — MEDIUM-HIGH (enables data-driven optimization)
- 5.2 Optimize Images with Responsive Sizing and Lazy Loading — MEDIUM-HIGH (40-70% bandwidth reduction)
- 5.3 Optimize Interaction to Next Paint with Yielding — MEDIUM-HIGH (INP from 500ms+ to under 200ms)
- 5.4 Optimize Largest Contentful Paint with Priority Loading — MEDIUM-HIGH (200-1000ms LCP improvement)
- 5.5 Prevent Cumulative Layout Shift with Size Reservations — MEDIUM-HIGH (CLS from 0.25+ to under 0.1)
6. State & Subscription Performance — MEDIUM-HIGH
- 6.1 Derive State Instead of Syncing for Zero Extra Renders — MEDIUM-HIGH (eliminates double-render cycle)
- 6.2 Separate Server State from Client State Management — MEDIUM-HIGH (reduces state management code by 40%)
- 6.3 Split Contexts to Isolate High-Frequency Updates — MEDIUM-HIGH (5-50x fewer re-renders)
- 6.4 Use Atomic State for Independent Reactive Values — MEDIUM-HIGH (3-10x fewer unnecessary re-renders)
- 6.5 Use Selector-Based Subscriptions for Granular Updates — MEDIUM-HIGH (reduces re-renders to only affected components)
7. Profiling & Measurement — MEDIUM
- 7.1 Benchmark with Production Builds Only — MEDIUM (prevents false positives from dev-mode overhead)
- 7.2 Enforce Performance Budgets in CI — MEDIUM (catches 90% of perf issues before merge)
- 7.3 Profile Before Optimizing to Target Real Bottlenecks — MEDIUM (10x more effective optimization effort)
- 7.4 Read Flame Charts to Identify Hot Render Paths — MEDIUM (identifies exact function causing 80% of render time)
- 7.5 Use React Performance Tracks for Render Analysis — MEDIUM (pinpoints render bottlenecks in minutes)
8. Memory Management — LOW-MEDIUM
- 8.1 Avoid Closure-Based Memory Leaks in Event Handlers — LOW-MEDIUM (prevents MB-scale memory retention)
- 8.2 Cancel Async Operations on Unmount — LOW-MEDIUM (prevents stale updates and memory retention)
- 8.3 Clean Up Effects to Prevent Subscription Memory Leaks — LOW-MEDIUM (prevents linear memory growth)
- 8.4 Dispose Heavy Resources in Cleanup Functions — LOW-MEDIUM (prevents 5-50MB per resource retention)
- 8.5 Use Heap Snapshots to Detect Component Retention — LOW-MEDIUM (identifies 10-100MB memory growth)
References
1. https://react.dev 2. https://react.dev/blog/2025/10/07/react-compiler-1 3. https://web.dev/articles/vitals 4. https://tanstack.com/virtual 5. https://developer.chrome.com/docs/devtools/performance
Related Skills
- For React 19 API best practices, see
reactskill - For Next.js App Router optimization, see
nextjs-16-app-routerskill - For client-side form handling, see
react-hook-formskill
React
Version 0.1.0 React Optimise Best Practices February 2026
Note: React performance optimization guide for agents and LLMs.
Use when maintaining, generating, or refactoring React codebases.
Humans may also find it useful, but guidance here is optimized for AI-assisted workflows.
---
Abstract
Application-level performance optimization guide for React applications. Contains 43 rules across 8 categories covering React Compiler mastery, bundle optimization, rendering performance, data fetching, Core Web Vitals, state subscriptions, profiling, and memory management. Complements the react skill (API-level patterns) with holistic performance strategies.
---
Table of Contents
1. React Compiler Mastery — CRITICAL
- 1.1 Detect and Fix Silent Compiler Bailouts — CRITICAL (prevents losing automatic memoization on affected components)
- 1.2 Isolate Side Effects from Render for Compiler Correctness — CRITICAL (prevents compiler from producing incorrect cached output)
- 1.3 Remove Manual Memoization After Compiler Adoption — CRITICAL (20-40% code reduction in component files)
- 1.4 Use Incremental Compiler Adoption with Directives — CRITICAL (enables safe rollout without full codebase migration)
- 1.5 Use Ref Access Patterns That Enable Compilation — CRITICAL (maintains compiler optimization for ref-using components)
- 1.6 Write Compiler-Friendly Component Patterns — CRITICAL (2-10× automatic render optimization)
2. Bundle & Loading — CRITICAL
- 2.1 Configure Dependencies for Effective Tree Shaking — CRITICAL (50-90% dead code elimination in dependencies)
- 2.2 Eliminate Barrel Files to Enable Tree Shaking — CRITICAL (200-800ms import cost eliminated)
- 2.3 Enforce Bundle Size Budgets with Analysis Tools — CRITICAL (prevents gradual bundle size regression)
- 2.4 Prefetch Likely Next Routes on Interaction — CRITICAL (200-1000ms faster perceived navigation)
- 2.5 Split Code at Route Boundaries with React.lazy — CRITICAL (40-70% reduction in initial bundle size)
- 2.6 Use Dynamic Imports for Heavy Libraries — CRITICAL (reduces critical-path JS by 100-500KB)
3. Rendering Optimization — HIGH
- 3.1 Avoid Inline Object Creation in JSX Props — HIGH (prevents unnecessary child re-renders, improves memo effectiveness)
- 3.2 Debounce Expensive Derived Computations — HIGH (50-200ms saved per keystroke in search/filter UIs)
- 3.3 Use Children Pattern to Prevent Parent Re-Renders — HIGH (eliminates re-renders of static subtrees during parent state changes)
- 3.4 Use CSS Containment to Isolate Layout Recalculation — HIGH (reduces layout recalculation scope by 60-90%)
- 3.5 Use Stable Keys for List Rendering Performance — HIGH (O(n) DOM mutations reduced to O(1) moves)
- 3.6 Virtualize Long Lists with TanStack Virtual — HIGH (O(n) to O(1) DOM nodes, 10-100x improvement for large lists)
4. Data Fetching Performance — HIGH
- 4.1 Abort Stale Requests on Navigation or Re-fetch — HIGH (prevents stale data display, eliminates race conditions)
- 4.2 Deduplicate Identical In-Flight Requests — HIGH (reduces network requests by 50-80% in component-heavy pages)
- 4.3 Eliminate Sequential Data Fetch Waterfalls — HIGH (2-5x faster page loads by parallelizing requests)
- 4.4 Preload Data at Route Level Before Component Mounts — HIGH (200-1000ms eliminated by starting fetch before render)
- 4.5 Use Stale-While-Revalidate for Cache Freshness — HIGH (0ms perceived load time for returning visitors)
5. Core Web Vitals — MEDIUM-HIGH
- 5.1 Instrument Real User Monitoring with web-vitals — HIGH (enables data-driven optimization targeting real bottlenecks)
- 5.2 Optimize Images with Responsive Sizing and Lazy Loading — HIGH (40-70% image bandwidth reduction)
- 5.3 Optimize Interaction to Next Paint with Yielding — HIGH (reduces INP from 500ms+ to under 200ms)
- 5.4 Optimize Largest Contentful Paint with Priority Loading — HIGH (200-1000ms LCP improvement)
- 5.5 Prevent Cumulative Layout Shift with Size Reservations — HIGH (reduces CLS from 0.25+ to under 0.1)
6. State & Subscription Performance — MEDIUM-HIGH
- 6.1 Derive State Instead of Syncing for Zero Extra Renders — MEDIUM-HIGH (eliminates double-render cycle, 1 render instead of 2 per update)
- 6.2 Separate Server State from Client State Management — MEDIUM-HIGH (eliminates manual cache invalidation, reduces state management code by 40%)
- 6.3 Split Contexts to Isolate High-Frequency Updates — MEDIUM-HIGH (5-50× fewer re-renders for low-frequency consumers)
- 6.4 Use Atomic State for Independent Reactive Values — MEDIUM-HIGH (3-10× fewer unnecessary re-renders in complex dashboards)
- 6.5 Use Selector-Based Subscriptions for Granular Updates — MEDIUM-HIGH (reduces re-renders to only affected components)
7. Profiling & Measurement — MEDIUM
- 7.1 Benchmark with Production Builds Only — MEDIUM (prevents false positives from dev-mode overhead)
- 7.2 Enforce Performance Budgets in CI — MEDIUM (prevents regressions, catches 90% of perf issues before merge)
- 7.3 Profile Before Optimizing to Target Real Bottlenecks — MEDIUM (10× faster bottleneck identification)
- 7.4 Read Flame Charts to Identify Hot Render Paths — MEDIUM (identifies exact function causing 80% of render time)
- 7.5 Use React Performance Tracks for Render Analysis — MEDIUM (reduces render bottleneck diagnosis from hours to minutes)
8. Memory Management — LOW-MEDIUM
- 8.1 Avoid Closure-Based Memory Leaks in Event Handlers — LOW-MEDIUM (prevents MB-scale memory retention in event-heavy UIs)
- 8.2 Cancel Async Operations on Unmount — LOW-MEDIUM (prevents stale updates and memory retention)
- 8.3 Clean Up Effects to Prevent Subscription Memory Leaks — LOW-MEDIUM (prevents linear memory growth in long-lived SPAs)
- 8.4 Dispose Heavy Resources in Cleanup Functions — LOW-MEDIUM (prevents 5-50MB per resource retention)
- 8.5 Use Heap Snapshots to Detect Component Retention — LOW-MEDIUM (eliminates 10-100MB retained memory from component leaks)
---
References
1. https://react.dev 2. https://react.dev/blog/2025/10/07/react-compiler-1 3. https://web.dev/articles/vitals 4. https://tanstack.com/virtual 5. https://developer.chrome.com/docs/devtools/performance
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact 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.0.5",
"organization": "React Optimise Best Practices",
"technology": "React",
"date": "February 2026",
"abstract": "Application-level performance optimization guide for React applications. Contains 43 rules across 8 categories covering React Compiler mastery, bundle optimization, rendering performance, data fetching, Core Web Vitals, state subscriptions, profiling, and memory management. Complements the react skill (API-level patterns) with holistic performance strategies.",
"references": [
"https://react.dev",
"https://react.dev/blog/2025/10/07/react-compiler-1",
"https://web.dev/articles/vitals",
"https://tanstack.com/virtual",
"https://developer.chrome.com/docs/devtools/performance"
],
"category": "Framework"
}
React Optimise Best Practices
Application-level performance optimization guide for React applications, designed for AI agents and LLMs.
Overview
This skill provides 43 rules across 8 categories to optimize React application performance. Complements the react skill (API-level patterns) with holistic optimization strategies covering bundling, rendering, data fetching, Core Web Vitals, profiling, and memory management.
Structure
react-optimise/
├── 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
│ ├── compiler-*.md # React Compiler mastery rules (6)
│ ├── bundle-*.md # Bundle & loading rules (6)
│ ├── render-*.md # Rendering optimization rules (6)
│ ├── fetch-*.md # Data fetching performance rules (5)
│ ├── cwv-*.md # Core Web Vitals rules (5)
│ ├── sub-*.md # State & subscription rules (5)
│ ├── profile-*.md # Profiling & measurement rules (5)
│ └── mem-*.md # Memory management 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 |
|---|---|---|
compiler- | React Compiler Mastery | CRITICAL |
bundle- | Bundle & Loading | CRITICAL |
render- | Rendering Optimization | HIGH |
fetch- | Data Fetching Performance | HIGH |
cwv- | Core Web Vitals | MEDIUM-HIGH |
sub- | State & Subscription Performance | MEDIUM-HIGH |
profile- | Profiling & Measurement | MEDIUM |
mem- | Memory Management | LOW-MEDIUM |
Rule File Structure
Each rule follows this template:
---
title: Rule Title Here
impact: HIGH
impactDescription: Quantified impact (e.g., "2-10x improvement")
tags: prefix, technique, related-concepts
---
## Rule Title Here
1-3 sentences explaining WHY this matters for React performance.
**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:
compiler-friendly-code.md— React Compiler, about compiler-friendly patternsbundle-route-splitting.md— Bundle optimization, about route-level code splittingcwv-inp-optimization.md— Core Web Vitals, about Interaction to Next Paint
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Must-do optimization; directly affects load time, TTI, or compiler adoption |
| HIGH | Strong impact on rendering performance and user experience |
| MEDIUM-HIGH | Measurable improvement for medium-to-large applications |
| MEDIUM | Important for ongoing performance maintenance and debugging |
| LOW-MEDIUM | Relevant for long-lived SPAs and complex resource management |
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
- React Compiler Blog Post
- Web.dev Core Web Vitals
- Chrome DevTools Performance
- TanStack Virtual
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. React Compiler Mastery (compiler)
Impact: CRITICAL Description: React Compiler v1.0 auto-memoizes components but silently bails out on non-idiomatic code. Writing compiler-friendly patterns unlocks automatic 2-10x render optimization without manual useMemo/useCallback.
2. Bundle & Loading (bundle)
Impact: CRITICAL Description: JavaScript bundle size is the #1 controllable factor for Time to Interactive. Route splitting, barrel elimination, and dynamic imports reduce initial load by 40-70%.
3. Rendering Optimization (render)
Impact: HIGH Description: Unnecessary re-renders and layout recalculations cause jank in data-heavy UIs. Virtualization, stable keys, and CSS containment keep frame times under 16ms.
4. Data Fetching Performance (fetch)
Impact: HIGH Description: Sequential data waterfalls add 200-2000ms to page loads. Parallel fetching, route preloading, and request deduplication eliminate round-trip waste.
5. Core Web Vitals (cwv)
Impact: MEDIUM-HIGH Description: INP, LCP, and CLS directly affect user experience and search ranking. Yielding to the main thread, priority loading, and size reservations target each vital.
6. State & Subscription Performance (sub)
Impact: MEDIUM-HIGH Description: Overly broad context and global subscriptions cause cascade re-renders across unrelated components. Context splitting and selector patterns isolate updates to affected subtrees.
7. Profiling & Measurement (profile)
Impact: MEDIUM Description: Optimizing without profiling wastes effort on non-bottlenecks. React Performance Tracks, flame charts, and CI budgets ensure optimization targets real user impact.
8. Memory Management (mem)
Impact: LOW-MEDIUM Description: Leaked subscriptions, uncancelled async operations, and retained closures cause gradual performance degradation in long-lived SPAs. Cleanup patterns prevent memory growth.
Enforce Bundle Size Budgets with Analysis Tools
Without explicit size budgets, bundles grow 5-15% per quarter as developers add dependencies and features. By the time slowness is noticeable, the bundle is 2-3x larger than necessary. Per-route budgets caught in CI prevent this drift and make every size increase a conscious, reviewed decision.
Incorrect (no size tracking, regressions go unnoticed):
// package.json — no size analysis, no budgets
{
"scripts": {
"build": "vite build"
}
}
// vite.config.ts — no chunk analysis
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
export default defineConfig({
plugins: [react()],
})Correct (size-limit enforces budgets in CI):
// package.json — size-limit checks on every PR
{
"scripts": {
"build": "vite build",
"size": "size-limit",
"analyze": "ANALYZE=true vite build"
},
"size-limit": [
{ "path": "dist/assets/index-*.js", "limit": "80 KB", "gzip": true },
{ "path": "dist/assets/vendor-*.js", "limit": "120 KB", "gzip": true },
{ "path": "dist/assets/admin-*.js", "limit": "60 KB", "gzip": true }
]
}
// vite.config.ts — visualizer for manual analysis
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import { visualizer } from "rollup-plugin-visualizer"
export default defineConfig({
plugins: [
react(),
process.env.ANALYZE === "true" &&
visualizer({ open: true, gzipSize: true, template: "treemap" }),
],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ["react", "react-dom", "react-router-dom"],
},
},
},
},
})Reference: size-limit — Performance Budget Tool
Eliminate Barrel Files to Enable Tree Shaking
Barrel files (index.ts that re-export everything from a directory) force bundlers to parse and include every exported module, even when the consuming file uses a single export. Tree shaking cannot remove these unused modules because the barrel creates a dependency chain that links all exports together. Direct imports let the bundler include only what is used.
Incorrect (barrel re-exports pull in every module):
// components/index.ts — barrel file
export { UserAvatar } from "./UserAvatar"
export { UserBadge } from "./UserBadge"
export { UserCard } from "./UserCard"
export { UserProfileHeader } from "./UserProfileHeader"
export { UserActivityFeed } from "./UserActivityFeed"
export { UserSettingsForm } from "./UserSettingsForm" // 45KB, only used in settings page
// pages/Dashboard.tsx — only needs UserAvatar
import { UserAvatar } from "@/components" // loads all 6 components into the chunkCorrect (direct imports enable precise tree shaking):
// pages/Dashboard.tsx — imports only what it uses
import { UserAvatar } from "@/components/UserAvatar"
// tsconfig.json — optional: enforce direct imports via path restrictions
{
"compilerOptions": {
"paths": {
"@/components/*": ["./src/components/*"]
}
}
}Reference: Webpack — Tree Shaking
Use Dynamic Imports for Heavy Libraries
Large visualization, PDF, rich-text, and charting libraries add hundreds of kilobytes to the main bundle even when they render in a single feature. Dynamic import() defers these libraries to the moment the user actually needs them, keeping the critical rendering path lean.
Incorrect (static imports load heavy libraries upfront):
import { Chart } from "chart.js/auto" // 180KB
import { jsPDF } from "jspdf" // 280KB
import MarkdownIt from "markdown-it" // 95KB
function OrderReport({ orders }: { orders: Order[] }) {
const [showChart, setShowChart] = useState(false)
const handleExportPDF = () => {
const pdf = new jsPDF()
pdf.text("Order Report", 10, 10)
pdf.save("report.pdf")
}
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
<button onClick={handleExportPDF}>Export PDF</button>
{showChart && <Chart type="bar" data={formatChartData(orders)} />}
</div>
)
}Correct (dynamic imports load libraries on demand):
import { lazy, Suspense, useState } from "react"
const OrderChart = lazy(() => import("./OrderChart"))
function OrderReport({ orders }: { orders: Order[] }) {
const [showChart, setShowChart] = useState(false)
const handleExportPDF = async () => {
const { jsPDF } = await import("jspdf") // 280KB loaded only when user clicks
const pdf = new jsPDF()
pdf.text("Order Report", 10, 10)
pdf.save("report.pdf")
}
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
<button onClick={handleExportPDF}>Export PDF</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<OrderChart orders={orders} />
</Suspense>
)}
</div>
)
}Reference: MDN — Dynamic import()
Prefetch Likely Next Routes on Interaction
Code-split routes introduce a network round-trip when the user navigates. Prefetching the chunk on hover or focus eliminates this delay by loading the code before the click fires. The browser's idle time between hover and click (200-400ms average) is enough to fetch most route chunks over a fast connection.
Incorrect (chunk loads after click, user sees loading state):
import { lazy, Suspense } from "react"
import { Link, Routes, Route } from "react-router-dom"
const ProductCatalog = lazy(() => import("./pages/ProductCatalog"))
const OrderHistory = lazy(() => import("./pages/OrderHistory"))
function Navigation() {
return (
<nav>
<Link to="/products">Products</Link>
<Link to="/orders">Orders</Link>
</nav>
)
}
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Navigation />
<Routes>
<Route path="/products" element={<ProductCatalog />} />
<Route path="/orders" element={<OrderHistory />} />
</Routes>
</Suspense>
)
}Correct (prefetch on hover loads chunk before navigation):
import { lazy, Suspense } from "react"
import { Link, Routes, Route } from "react-router-dom"
const productCatalogImport = () => import("./pages/ProductCatalog")
const orderHistoryImport = () => import("./pages/OrderHistory")
const ProductCatalog = lazy(productCatalogImport)
const OrderHistory = lazy(orderHistoryImport)
function PrefetchLink({
to,
prefetch,
children,
}: {
to: string
prefetch: () => Promise<unknown>
children: React.ReactNode
}) {
const handlePrefetch = () => { prefetch() } // fires on hover, loads chunk
return (
<Link to={to} onMouseEnter={handlePrefetch} onFocus={handlePrefetch}>
{children}
</Link>
)
}
function Navigation() {
return (
<nav>
<PrefetchLink to="/products" prefetch={productCatalogImport}>
Products
</PrefetchLink>
<PrefetchLink to="/orders" prefetch={orderHistoryImport}>
Orders
</PrefetchLink>
</nav>
)
}
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Navigation />
<Routes>
<Route path="/products" element={<ProductCatalog />} />
<Route path="/orders" element={<OrderHistory />} />
</Routes>
</Suspense>
)
}Reference: web.dev — Prefetching Resources
Split Code at Route Boundaries with React.lazy
Every route a user never visits is wasted bytes in the initial bundle. Wrapping route components with React.lazy creates a separate chunk per route, so the browser downloads only the code for the current page. Combined with a Suspense boundary, the user sees a loading state instead of a blank screen while the chunk loads.
Incorrect (all routes in a single bundle):
import { BrowserRouter, Routes, Route } from "react-router-dom"
import Dashboard from "./pages/Dashboard"
import OrderHistory from "./pages/OrderHistory"
import ProductCatalog from "./pages/ProductCatalog"
import UserSettings from "./pages/UserSettings"
import AdminPanel from "./pages/AdminPanel" // 120KB — most users never see this
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/orders" element={<OrderHistory />} />
<Route path="/products" element={<ProductCatalog />} />
<Route path="/settings" element={<UserSettings />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</BrowserRouter>
)
}Correct (each route loads its own chunk):
import { BrowserRouter, Routes, Route } from "react-router-dom"
import { lazy, Suspense } from "react"
import { PageSkeleton } from "./components/PageSkeleton"
const Dashboard = lazy(() => import("./pages/Dashboard"))
const OrderHistory = lazy(() => import("./pages/OrderHistory"))
const ProductCatalog = lazy(() => import("./pages/ProductCatalog"))
const UserSettings = lazy(() => import("./pages/UserSettings"))
const AdminPanel = lazy(() => import("./pages/AdminPanel"))
function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/orders" element={<OrderHistory />} />
<Route path="/products" element={<ProductCatalog />} />
<Route path="/settings" element={<UserSettings />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}Configure Dependencies for Effective Tree Shaking
Tree shaking removes unused exports at build time, but only works when dependencies ship ESM (ES Modules) and declare sideEffects: false. CommonJS modules, barrel re-exports, and libraries without side-effect annotations force the bundler to include the entire package. Choosing ESM-native alternatives and configuring imports correctly recovers 50-90% of dead dependency code.
Incorrect (CJS imports defeat tree shaking):
import _ from "lodash" // CJS — entire 72KB library included
import moment from "moment" // CJS — 67KB with all locales
import * as Icons from "react-icons/fa" // barrel import — all 1600 icons
function OrderConfirmation({ order }: { order: Order }) {
const formattedDate = moment(order.createdAt).format("DD MMM YYYY")
const total = _.sumBy(order.items, "price")
return (
<div>
<Icons.FaCheckCircle />
<p>Order placed on {formattedDate}</p>
<p>Total: ${total.toFixed(2)}</p>
</div>
)
}Correct (ESM imports with tree-shakeable alternatives):
import { sumBy } from "lodash-es" // ESM — only sumBy is bundled
import { format } from "date-fns" // ESM with sideEffects: false
import { FaCheckCircle } from "react-icons/fa" // direct named import
function OrderConfirmation({ order }: { order: Order }) {
const formattedDate = format(order.createdAt, "dd MMM yyyy")
const total = sumBy(order.items, (orderItem) => orderItem.price)
return (
<div>
<FaCheckCircle />
<p>Order placed on {formattedDate}</p>
<p>Total: ${total.toFixed(2)}</p>
</div>
)
}
// package.json — mark your own package as tree-shakeable
{
"type": "module",
"sideEffects": ["*.css"],
"exports": {
".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" }
}
}Reference: Webpack — Tree Shaking
Write Compiler-Friendly Component Patterns
React Compiler auto-memoizes components and hooks, but bails out when it encounters mutation, non-idiomatic control flow, or impure render logic. Writing idiomatic React unlocks automatic optimization that replaces all manual useMemo/useCallback/memo calls.
Incorrect (mutations and impurity cause compiler bailout):
function OrderSummary({ orders }: { orders: Order[] }) {
const sorted = orders.sort((a, b) => b.total - a.total) // mutates input array
const totals: Record<string, number> = {}
for (const order of sorted) {
totals[order.status] = (totals[order.status] ?? 0) + order.total // builds object via mutation
}
let discountLabel = ""
if (totals["completed"] > 500) {
discountLabel = "VIP Discount Applied" // reassignment in conditional
}
return (
<div>
<h2>{discountLabel}</h2>
{sorted.map((order) => (
<OrderRow key={order.id} order={order} />
))}
</div>
)
}Correct (pure transforms enable full compiler memoization):
function OrderSummary({ orders }: { orders: Order[] }) {
const sorted = [...orders].sort((a, b) => b.total - a.total)
const totals = Object.groupBy(sorted, (order) => order.status)
const completedTotal = (totals["completed"] ?? []).reduce(
(sum, order) => sum + order.total,
0
)
const discountLabel = completedTotal > 500 ? "VIP Discount Applied" : ""
return (
<div>
<h2>{discountLabel}</h2>
{sorted.map((order) => (
<OrderRow key={order.id} order={order} />
))}
</div>
)
}Reference: React Compiler — How It Works
Use Incremental Compiler Adoption with Directives
Enabling the React Compiler across an entire codebase at once risks regressions in components that rely on mutation or impure patterns. The "use memo" and "use no memo" directives give per-file and per-function control, enabling a measured rollout that validates optimization correctness one module at a time.
Incorrect (all-or-nothing compiler config):
// babel.config.js — compiler applies to every file at once
module.exports = {
plugins: [
["babel-plugin-react-compiler", {}],
],
}
// components/LegacyOrderForm.tsx — relies on mutation patterns
function LegacyOrderForm({ order }: { order: Order }) {
const fields = order.fields
fields.push({ name: "total", value: order.computeTotal() }) // mutation breaks compiler
return <FormRenderer fields={fields} />
}
// components/Dashboard.tsx — safe, but no way to enable separately
function Dashboard({ metrics }: { metrics: Metric[] }) {
const sorted = [...metrics].sort((a, b) => b.value - a.value)
return <MetricGrid metrics={sorted} />
}Correct (incremental adoption via directives):
// babel.config.js — compiler runs but respects directives
module.exports = {
plugins: [
["babel-plugin-react-compiler", {
compilationMode: "annotation", // only compile files that opt in
}],
],
}
// components/Dashboard.tsx — opted in, fully compiler-safe
"use memo"
function Dashboard({ metrics }: { metrics: Metric[] }) {
const sorted = [...metrics].sort((a, b) => b.value - a.value)
return <MetricGrid metrics={sorted} />
}
// components/LegacyOrderForm.tsx — explicitly opted out until refactored
"use no memo"
function LegacyOrderForm({ order }: { order: Order }) {
const fields = order.fields
fields.push({ name: "total", value: order.computeTotal() })
return <FormRenderer fields={fields} />
}Reference: React Compiler — Opting Out
Use Ref Access Patterns That Enable Compilation
Reading ref.current during the render phase causes a compiler bailout because refs are mutable containers that break the purity assumption. The compiler cannot cache a render whose output depends on a value that changes outside React's control. Move all ref reads to effects, event handlers, or layout callbacks where the DOM is stable.
Incorrect (ref reads in render cause compiler bailout):
function ChatMessages({ messages }: { messages: Message[] }) {
const scrollRef = useRef<HTMLDivElement>(null)
const prevCountRef = useRef(messages.length)
// Bailout: reading ref.current during render
const isAtBottom =
scrollRef.current !== null &&
scrollRef.current.scrollHeight - scrollRef.current.scrollTop <=
scrollRef.current.clientHeight + 50
// Bailout: reading and writing ref in render
const hasNewMessages = messages.length > prevCountRef.current
prevCountRef.current = messages.length
return (
<div ref={scrollRef}>
{messages.map((message) => (
<ChatBubble key={message.id} message={message} />
))}
{hasNewMessages && isAtBottom && <ScrollAnchor />}
</div>
)
}Correct (ref reads moved to effects and callbacks):
function ChatMessages({ messages }: { messages: Message[] }) {
const scrollRef = useRef<HTMLDivElement>(null)
const [isAtBottom, setIsAtBottom] = useState(true)
const [hasNewMessages, setHasNewMessages] = useState(false)
const prevCountRef = useRef(messages.length)
useEffect(() => {
setHasNewMessages(messages.length > prevCountRef.current)
prevCountRef.current = messages.length
}, [messages.length])
useEffect(() => {
const element = scrollRef.current
if (!element) return
const checkScroll = () => {
setIsAtBottom(
element.scrollHeight - element.scrollTop <= element.clientHeight + 50
)
}
checkScroll()
element.addEventListener("scroll", checkScroll, { passive: true })
return () => element.removeEventListener("scroll", checkScroll)
}, [])
return (
<div ref={scrollRef}>
{messages.map((message) => (
<ChatBubble key={message.id} message={message} />
))}
{hasNewMessages && isAtBottom && <ScrollAnchor />}
</div>
)
}Reference: React — Referencing Values with Refs
Remove Manual Memoization After Compiler Adoption
Once React Compiler is enabled and validated, manual useMemo, useCallback, and React.memo wrappers become redundant. The compiler inserts equivalent (or better) memoization automatically. Leaving manual memo in place adds maintenance burden, obscures intent, and produces double-memoization that the compiler must work around.
Incorrect (manual memoization with compiler already enabled):
"use memo"
const UserProfile = memo(function UserProfile({
user,
onFollow,
}: UserProfileProps) {
const fullName = useMemo(
() => `${user.firstName} ${user.lastName}`,
[user.firstName, user.lastName]
)
const handleFollow = useCallback(
() => onFollow(user.id),
[user.id, onFollow]
)
const joinDate = useMemo(
() => new Intl.DateTimeFormat("en-GB").format(user.createdAt),
[user.createdAt]
)
return (
<div>
<h1>{fullName}</h1>
<span>Joined {joinDate}</span>
<button onClick={handleFollow}>Follow</button>
</div>
)
})Correct (clean code, compiler handles memoization):
"use memo"
function UserProfile({ user, onFollow }: UserProfileProps) {
const fullName = `${user.firstName} ${user.lastName}`
const handleFollow = () => onFollow(user.id)
const joinDate = new Intl.DateTimeFormat("en-GB").format(user.createdAt)
return (
<div>
<h1>{fullName}</h1>
<span>Joined {joinDate}</span>
<button onClick={handleFollow}>Follow</button>
</div>
)
}Reference: React Compiler — Removing Manual Memoization
Isolate Side Effects from Render for Compiler Correctness
React Compiler assumes the render phase is pure and caches its output. Side effects executed during render -- analytics calls, logging, DOM measurements, or external store writes -- run fewer times than expected when the compiler skips re-renders. This produces stale analytics, missing log entries, and inconsistent state.
Incorrect (side effects in render are skipped when compiler caches):
function ProductPage({ product }: { product: Product }) {
analytics.track("product_viewed", { productId: product.id }) // skipped on cached renders
console.log(`Rendering product: ${product.name}`) // fires unpredictably
document.title = `${product.name} | Store` // DOM mutation in render
const [quantity, setQuantity] = useState(1)
const subtotal = product.price * quantity
return (
<div>
<h1>{product.name}</h1>
<span>${subtotal.toFixed(2)}</span>
<button onClick={() => setQuantity((q) => q + 1)}>Add</button>
</div>
)
}Correct (side effects in effects and handlers, render stays pure):
function ProductPage({ product }: { product: Product }) {
const [quantity, setQuantity] = useState(1)
const subtotal = product.price * quantity
useEffect(() => {
analytics.track("product_viewed", { productId: product.id })
document.title = `${product.name} | Store`
}, [product.id, product.name])
return (
<div>
<h1>{product.name}</h1>
<span>${subtotal.toFixed(2)}</span>
<button onClick={() => setQuantity((q) => q + 1)}>Add</button>
</div>
)
}Reference: React — Keeping Components Pure
Detect and Fix Silent Compiler Bailouts
React Compiler silently skips optimization when it encounters patterns it cannot prove safe: try/catch wrapping render expressions, optional chaining on refs during render, mutating values during render, and class component patterns. These bailouts produce no warnings — the component just runs without memoization.
Incorrect (three silent bailout patterns):
function ProductDetail({ productId }: { productId: string }) {
const containerRef = useRef<HTMLDivElement>(null)
// Bailout: try/catch in render path
let product: Product
try {
product = parseProductData(productId)
} catch {
product = FALLBACK_PRODUCT
}
// Bailout: optional chaining on ref during render
const containerWidth = containerRef.current?.offsetWidth ?? 0
// Bailout: mutating an object during render
const config = { theme: "light" }
config.theme = getUserTheme(productId) // mutation breaks compiler tracking
return (
<div ref={containerRef}>
<ProductCard
name={product.name}
price={product.price}
width={containerWidth}
theme={config.theme}
/>
</div>
)
}Correct (compiler-safe alternatives):
function ProductDetail({ productId }: { productId: string }) {
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
useEffect(() => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth)
}
}, [])
const product = parseProductData(productId) ?? FALLBACK_PRODUCT
const theme = getUserTheme(productId) // compute directly, no mutation
return (
<div ref={containerRef}>
<ProductCard
name={product.name}
price={product.price}
width={containerWidth}
theme={theme}
/>
</div>
)
}Reference: React Compiler — Troubleshooting
Prevent Cumulative Layout Shift with Size Reservations
Dynamic content that loads without reserved space pushes existing elements around the page. Users clicking a button find their target has moved, and reading text gets interrupted. Reserving exact dimensions for images, ads, and dynamic embeds eliminates layout shifts.
Incorrect (images load without dimensions, shifting content below):
function ArticlePage({ article }: { article: Article }) {
return (
<article>
<h1>{article.headline}</h1>
<img
src={article.coverImageUrl}
alt={article.coverAlt}
/>
{/* content below shifts 300px down when image loads */}
<p>{article.body}</p>
<div className="ad-slot">
<AdBanner slotId="article-mid" />
{/* ad loads 250px tall, pushes footer down */}
</div>
<CommentSection articleId={article.id} />
</article>
)
}
// article.css
// .ad-slot {
// /* no height reserved */
// }Correct (dimensions reserved, zero layout shift):
function ArticlePage({ article }: { article: Article }) {
return (
<article>
<h1>{article.headline}</h1>
<img
src={article.coverImageUrl}
alt={article.coverAlt}
width={1200}
height={630}
style={{ aspectRatio: "1200 / 630", width: "100%", height: "auto" }}
/>
<p>{article.body}</p>
<div className="ad-slot">
<AdBanner slotId="article-mid" />
</div>
<CommentSection articleId={article.id} />
</article>
)
}
// article.css
// .ad-slot {
// min-height: 250px; /* reserves space before ad loads */
// contain: layout;
// }Reference: web.dev — Optimize CLS
Optimize Images with Responsive Sizing and Lazy Loading
Serving a single full-resolution image to all devices wastes bandwidth on mobile and delays Time to Interactive. Responsive srcset delivers appropriately sized images per viewport, and loading="lazy" defers offscreen images until the user scrolls near them.
Incorrect (single oversized image loaded eagerly for all devices):
function PropertyGallery({ images }: { images: PropertyImage[] }) {
return (
<div className="gallery-grid">
{images.map((image) => (
<img
key={image.id}
src={image.originalUrl} // 4000x3000 image served to 375px mobile viewport
alt={image.caption}
className="gallery-image"
/>
))}
</div>
)
}Correct (responsive srcset with lazy loading for offscreen images):
function PropertyGallery({ images }: { images: PropertyImage[] }) {
return (
<div className="gallery-grid">
{images.map((image, index) => (
<img
key={image.id}
src={image.sizes.medium}
srcSet={`
${image.sizes.small} 400w,
${image.sizes.medium} 800w,
${image.sizes.large} 1200w,
${image.sizes.xlarge} 2000w
`}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
alt={image.caption}
loading={index < 4 ? "eager" : "lazy"} // first 4 images load immediately
decoding="async"
className="gallery-image"
/>
))}
</div>
)
}
interface PropertyImage {
id: string
caption: string
originalUrl: string
sizes: {
small: string // 400px wide
medium: string // 800px wide
large: string // 1200px wide
xlarge: string // 2000px wide
}
}Reference: web.dev — Responsive Images
Optimize Interaction to Next Paint with Yielding
Long tasks that exceed 50ms block the main thread, preventing the browser from processing user input and painting visual feedback. High INP scores indicate users experience sluggish interactions. Yielding breaks expensive work into smaller chunks, letting the browser paint between them.
Incorrect (synchronous processing blocks paint for 500ms+):
function OrderConfirmation({ orderId }: { orderId: string }) {
const handleConfirm = () => {
validateInventory(orderId) // 150ms
calculateShipping(orderId) // 100ms
applyDiscountRules(orderId) // 80ms
generateInvoice(orderId) // 120ms
updateAnalytics(orderId) // 70ms
// total: 520ms blocking — browser cannot paint "Processing..." until done
navigateToReceipt(orderId)
}
return (
<button onClick={handleConfirm}>
Confirm Order
</button>
)
}Correct (yielding lets browser paint between chunks):
function OrderConfirmation({ orderId }: { orderId: string }) {
const [processing, setProcessing] = useState(false)
const handleConfirm = async () => {
setProcessing(true) // browser paints "Processing..." immediately
validateInventory(orderId)
await yieldToMain()
calculateShipping(orderId)
await yieldToMain()
applyDiscountRules(orderId)
await yieldToMain()
generateInvoice(orderId)
await yieldToMain()
updateAnalytics(orderId)
navigateToReceipt(orderId)
}
return (
<button onClick={handleConfirm} disabled={processing}>
{processing ? "Processing..." : "Confirm Order"}
</button>
)
}
function yieldToMain(): Promise<void> {
if ("scheduler" in globalThis && "yield" in scheduler) {
return scheduler.yield() // preferred: preserves task priority
}
return new Promise((resolve) => setTimeout(resolve, 0))
}Reference: web.dev — Optimize INP
Instrument Real User Monitoring with web-vitals
Lab metrics from Lighthouse measure synthetic conditions that miss device diversity, network variance, and real user interaction patterns. Real User Monitoring (RUM) captures actual CWV scores from production users, revealing bottlenecks that lab tools cannot reproduce.
Incorrect (no production metrics, relying only on lab tests):
// No performance monitoring in production
// Developers run Lighthouse locally and assume scores reflect real users
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/listings/:id" element={<ListingPage />} />
<Route path="/account" element={<AccountPage />} />
</Routes>
</Router>
)
}
// "Lighthouse says 95 — ship it"
// Real users on 3G Android devices experience 4s LCP and 800ms INPCorrect (RUM captures real CWV scores per route):
import { onCLS, onINP, onLCP, onFCP, onTTFB, type Metric } from "web-vitals"
function reportWebVital(metric: Metric) {
const payload = {
name: metric.name,
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
route: window.location.pathname,
}
navigator.sendBeacon("/api/analytics/vitals", JSON.stringify(payload))
}
function initWebVitals() {
onCLS(reportWebVital)
onINP(reportWebVital)
onLCP(reportWebVital)
onFCP(reportWebVital)
onTTFB(reportWebVital)
}
function App() {
useEffect(() => {
initWebVitals()
}, [])
return (
<Router>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/listings/:id" element={<ListingPage />} />
<Route path="/account" element={<AccountPage />} />
</Routes>
</Router>
)
}Reference: web-vitals Library
Optimize Largest Contentful Paint with Priority Loading
The LCP element (typically a hero image or heading) renders late when images use default lazy loading, fonts block rendering, or resources lack priority hints. Explicitly prioritizing the LCP resource tells the browser to fetch it before other assets.
Incorrect (hero image loads with default priority, delayed by other resources):
function PropertyHero({ property }: { property: Property }) {
return (
<section className="hero">
<img
src={property.heroImageUrl}
alt={property.title}
loading="lazy" // defers the most important image on the page
/>
<h1>{property.title}</h1>
<p>{property.location}</p>
</section>
)
}
function PropertyPage({ property }: { property: Property }) {
return (
<>
<head>
<link rel="stylesheet" href="/fonts/custom-font.css" />
{/* no preload hint — browser discovers hero image late */}
</head>
<PropertyHero property={property} />
<PropertyDetails property={property} />
<PropertyGallery images={property.galleryImages} />
</>
)
}Correct (LCP resources loaded with highest priority):
function PropertyHero({ property }: { property: Property }) {
return (
<section className="hero">
<img
src={property.heroImageUrl}
alt={property.title}
fetchPriority="high" // browser fetches this before lower-priority resources
loading="eager"
decoding="async"
/>
<h1>{property.title}</h1>
<p>{property.location}</p>
</section>
)
}
function PropertyPage({ property }: { property: Property }) {
return (
<>
<head>
<link
rel="preload"
href={property.heroImageUrl}
as="image"
fetchPriority="high"
/>
<link
rel="preload"
href="/fonts/brand-heading.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
</head>
<PropertyHero property={property} />
<PropertyDetails property={property} />
<PropertyGallery images={property.galleryImages} />
</>
)
}Reference: web.dev — Optimize LCP
Abort Stale Requests on Navigation or Re-fetch
When a user types quickly in a search field or navigates between pages, earlier requests may resolve after later ones. Without aborting stale requests, the UI displays outdated results that overwrite fresh data. AbortController cancels superseded requests and prevents race conditions.
Incorrect (stale response overwrites fresh data):
import { useEffect, useState } from "react"
interface SearchResult {
id: string
title: string
relevanceScore: number
}
function PropertySearch({ query }: { query: string }) {
const [results, setResults] = useState<SearchResult[]>([])
useEffect(() => {
if (!query) return
// slow "apartment" request (500ms) resolves AFTER fast "apartment london" (200ms)
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then((response) => response.json())
.then((data) => setResults(data.results))
}, [query])
return (
<ul>
{results.map((result) => (
<li key={result.id}>{result.title} ({result.relevanceScore})</li>
))}
</ul>
)
}Correct (stale requests aborted on re-fetch):
import { useEffect, useState } from "react"
interface SearchResult {
id: string
title: string
relevanceScore: number
}
function PropertySearch({ query }: { query: string }) {
const [results, setResults] = useState<SearchResult[]>([])
useEffect(() => {
if (!query) return
const controller = new AbortController()
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((response) => response.json())
.then((data) => setResults(data.results))
.catch((error) => {
if (error.name !== "AbortError") throw error // ignore aborted requests
})
return () => controller.abort() // cancels previous request when query changes
}, [query])
return (
<ul>
{results.map((result) => (
<li key={result.id}>{result.title} ({result.relevanceScore})</li>
))}
</ul>
)
}Reference: MDN — AbortController
Eliminate Sequential Data Fetch Waterfalls
When a parent component fetches data and then renders children that start their own fetches, each request waits for the previous one to complete. Three sequential 200ms requests take 600ms total. Parallelizing them reduces total latency to the duration of the slowest single request.
Incorrect (sequential fetch waterfall — 3 round trips):
function ProjectDashboard({ projectId }: { projectId: string }) {
const { data: project } = useQuery({
queryKey: ["project", projectId],
queryFn: () => fetchProject(projectId),
})
if (!project) return <Skeleton />
// waits for project to load before starting
return (
<div>
<ProjectHeader project={project} />
<ProjectMembers projectId={projectId} />
<ProjectActivity projectId={projectId} />
</div>
)
}
function ProjectMembers({ projectId }: { projectId: string }) {
const { data: members } = useQuery({
queryKey: ["members", projectId],
queryFn: () => fetchMembers(projectId), // starts after parent renders
})
if (!members) return <Skeleton />
return <MemberList members={members} />
}
function ProjectActivity({ projectId }: { projectId: string }) {
const { data: activity } = useQuery({
queryKey: ["activity", projectId],
queryFn: () => fetchActivity(projectId), // starts after parent renders
})
if (!activity) return <Skeleton />
return <ActivityFeed activity={activity} />
}Correct (parallel fetching — all requests start simultaneously):
function ProjectDashboard({ projectId }: { projectId: string }) {
const projectQuery = useQuery({
queryKey: ["project", projectId],
queryFn: () => fetchProject(projectId),
})
const membersQuery = useQuery({
queryKey: ["members", projectId],
queryFn: () => fetchMembers(projectId), // starts immediately, no dependency
})
const activityQuery = useQuery({
queryKey: ["activity", projectId],
queryFn: () => fetchActivity(projectId), // starts immediately, no dependency
})
if (projectQuery.isPending) return <Skeleton />
return (
<div>
<ProjectHeader project={projectQuery.data} />
{membersQuery.data ? <MemberList members={membersQuery.data} /> : <Skeleton />}
{activityQuery.data ? <ActivityFeed activity={activityQuery.data} /> : <Skeleton />}
</div>
)
}Reference: TanStack Query — Parallel Queries
Deduplicate Identical In-Flight Requests
When multiple components independently fetch the same endpoint, the browser sends duplicate network requests that waste bandwidth and increase server load. Request deduplication ensures that concurrent requests for the same resource share a single in-flight promise.
Incorrect (three components fire three identical requests):
import { useEffect, useState } from "react"
interface UserProfile {
id: string
displayName: string
avatarUrl: string
plan: string
}
function NavigationBar() {
const [user, setUser] = useState<UserProfile | null>(null)
useEffect(() => {
fetch("/api/user/me").then((r) => r.json()).then(setUser)
}, [])
return <nav>{user?.displayName}</nav>
}
function ProfileBadge() {
const [user, setUser] = useState<UserProfile | null>(null)
useEffect(() => {
fetch("/api/user/me").then((r) => r.json()).then(setUser) // duplicate request
}, [])
return <img src={user?.avatarUrl} alt="Profile" />
}
function BillingBanner() {
const [user, setUser] = useState<UserProfile | null>(null)
useEffect(() => {
fetch("/api/user/me").then((r) => r.json()).then(setUser) // third duplicate
}, [])
return user?.plan === "free" ? <UpgradeBanner /> : null
}Correct (single request shared across all consumers):
import { useQuery } from "@tanstack/react-query"
interface UserProfile {
id: string
displayName: string
avatarUrl: string
plan: string
}
function useCurrentUser() {
return useQuery({
queryKey: ["currentUser"],
queryFn: () => fetch("/api/user/me").then((r) => r.json() as Promise<UserProfile>),
staleTime: 30_000, // all consumers share one cached result
})
}
function NavigationBar() {
const { data: user } = useCurrentUser() // shares in-flight request
return <nav>{user?.displayName}</nav>
}
function ProfileBadge() {
const { data: user } = useCurrentUser() // reuses same promise
return <img src={user?.avatarUrl} alt="Profile" />
}
function BillingBanner() {
const { data: user } = useCurrentUser() // no additional network call
return user?.plan === "free" ? <UpgradeBanner /> : null
}Reference: TanStack Query — Query Deduplication
Preload Data at Route Level Before Component Mounts
When data fetching starts inside a component's useEffect, the browser must download JavaScript, parse it, render the component, and only then begin the network request. Router-level loaders start fetching data as soon as the route matches, overlapping network time with component loading.
Incorrect (fetch starts after component mounts — wasted render cycle):
import { useEffect, useState } from "react"
import { useParams } from "react-router-dom"
function PropertyListing() {
const { propertyId } = useParams<{ propertyId: string }>()
const [property, setProperty] = useState<Property | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// fetch starts AFTER mount — user sees spinner during network round trip
fetchProperty(propertyId!).then((data) => {
setProperty(data)
setLoading(false)
})
}, [propertyId])
if (loading) return <ListingSkeleton />
return (
<div>
<PropertyHeader property={property!} />
<PropertyGallery images={property!.images} />
<PropertyDetails property={property!} />
</div>
)
}Correct (fetch starts at route match — overlaps with code loading):
import { useLoaderData, type LoaderFunctionArgs } from "react-router-dom"
export async function propertyLoader({ params }: LoaderFunctionArgs) {
return fetchProperty(params.propertyId!) // starts immediately on navigation
}
function PropertyListing() {
const property = useLoaderData() as Property
return (
<div>
<PropertyHeader property={property} />
<PropertyGallery images={property.images} />
<PropertyDetails property={property} />
</div>
)
}
// Route configuration
const routes = [
{
path: "/properties/:propertyId",
element: <PropertyListing />,
loader: propertyLoader,
},
]Reference: React Router — Data Loading
Use Stale-While-Revalidate for Cache Freshness
Showing a loading spinner on every page visit forces users to wait for the full network round trip. Stale-while-revalidate displays cached data instantly and refreshes it in the background, giving returning visitors a 0ms perceived load time while keeping data fresh.
Incorrect (loading spinner on every visit):
import { useEffect, useState } from "react"
interface TeamMember {
id: string
name: string
role: string
avatarUrl: string
}
function TeamDirectory() {
const [members, setMembers] = useState<TeamMember[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true) // spinner shown even when data hasn't changed
fetchTeamMembers().then((data) => {
setMembers(data)
setLoading(false)
})
}, [])
if (loading) return <DirectorySkeleton />
return (
<ul>
{members.map((member) => (
<li key={member.id}>
<img src={member.avatarUrl} alt={member.name} />
<span>{member.name}</span>
<span>{member.role}</span>
</li>
))}
</ul>
)
}Correct (instant cached data, background refresh):
import { useQuery } from "@tanstack/react-query"
interface TeamMember {
id: string
name: string
role: string
avatarUrl: string
}
function TeamDirectory() {
const { data: members, isLoading } = useQuery({
queryKey: ["teamMembers"],
queryFn: fetchTeamMembers,
staleTime: 60_000, // data considered fresh for 60 seconds
gcTime: 5 * 60_000, // cached data kept for 5 minutes
})
if (isLoading) return <DirectorySkeleton /> // only on first visit
return (
<ul>
{members!.map((member) => (
<li key={member.id}>
<img src={member.avatarUrl} alt={member.name} />
<span>{member.name}</span>
<span>{member.role}</span>
</li>
))}
</ul>
)
}Reference: TanStack Query — Caching
Cancel Async Operations on Unmount
Fetch requests and async operations that complete after a component unmounts attempt to update state on an unmounted component. The in-flight response holds a reference to the component's closure, preventing garbage collection until the request completes.
Incorrect (fetch continues after unmount, sets state on dead component):
function UserProfile({ userId }: { userId: string }) {
const [profile, setProfile] = useState<UserData | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
setIsLoading(true)
fetch(`/api/users/${userId}`)
.then((response) => response.json())
.then((userData: UserData) => {
setProfile(userData) // fires even if component already unmounted
setIsLoading(false)
})
.catch((error) => {
console.error("Failed to load profile:", error)
setIsLoading(false)
})
// No cancellation — navigating away mid-request keeps closure alive
}, [userId])
if (isLoading) return <ProfileSkeleton />
return <ProfileCard profile={profile!} />
}Correct (AbortController cancels request on unmount or userId change):
function UserProfile({ userId }: { userId: string }) {
const [profile, setProfile] = useState<UserData | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const controller = new AbortController()
setIsLoading(true)
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((response) => response.json())
.then((userData: UserData) => {
setProfile(userData)
setIsLoading(false)
})
.catch((error) => {
if (error.name !== "AbortError") {
console.error("Failed to load profile:", error)
setIsLoading(false)
}
})
return () => {
controller.abort() // cancels in-flight request, releases closure
}
}, [userId])
if (isLoading) return <ProfileSkeleton />
return <ProfileCard profile={profile!} />
}Reference: MDN — AbortController
Avoid Closure-Based Memory Leaks in Event Handlers
Event handlers that capture large arrays, datasets, or DOM references in their closure retain that data for the lifetime of the handler registration. When handlers are attached to long-lived elements like window or document without cleanup, the closed-over data is never garbage collected.
Incorrect (handler closure retains entire analytics dataset):
function AnalyticsOverlay({ events }: { events: AnalyticsEvent[] }) {
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null)
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
// Closure captures the entire events array (50,000+ objects retained)
const nearbyEvent = events.find(
(event) =>
Math.abs(event.x - e.clientX) < 10 &&
Math.abs(event.y - e.clientY) < 10
)
if (nearbyEvent) {
setHoverPosition({ x: e.clientX, y: e.clientY })
}
}
window.addEventListener("mousemove", handleMouseMove)
// Missing cleanup — handler retains events array after unmount
}, [events])
return hoverPosition ? (
<div style={{ position: "fixed", left: hoverPosition.x, top: hoverPosition.y }}>
<EventTooltip />
</div>
) : null
}Correct (minimal closure with spatial index, proper cleanup):
function AnalyticsOverlay({ events }: { events: AnalyticsEvent[] }) {
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null)
const spatialIndexRef = useRef<Map<string, AnalyticsEvent>>(new Map())
useEffect(() => {
// Build lightweight lookup — closure captures only the ref
const index = new Map<string, AnalyticsEvent>()
for (const event of events) {
const key = `${Math.round(event.x / 10)},${Math.round(event.y / 10)}`
index.set(key, event)
}
spatialIndexRef.current = index
}, [events])
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const key = `${Math.round(e.clientX / 10)},${Math.round(e.clientY / 10)}`
if (spatialIndexRef.current.has(key)) {
setHoverPosition({ x: e.clientX, y: e.clientY })
} else {
setHoverPosition(null)
}
}
window.addEventListener("mousemove", handleMouseMove)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
}
}, [])
return hoverPosition ? (
<div style={{ position: "fixed", left: hoverPosition.x, top: hoverPosition.y }}>
<EventTooltip />
</div>
) : null
}Reference: Chrome DevTools — Fix Memory Problems
Clean Up Effects to Prevent Subscription Memory Leaks
Subscriptions created in useEffect without a cleanup function accumulate listeners every time the component re-mounts. In SPAs with client-side routing, a user navigating back and forth creates duplicate subscriptions that consume memory and trigger stale callbacks.
Incorrect (new listener added on each mount, never removed):
function StockTicker({ symbol }: { symbol: string }) {
const [price, setPrice] = useState<number | null>(null)
useEffect(() => {
const socket = new WebSocket(`wss://market-feed.example.com/ws/${symbol}`)
socket.onmessage = (event) => {
const update = JSON.parse(event.data) as { price: number }
setPrice(update.price) // stale callback fires after unmount
}
// No cleanup — socket stays open after unmount
// Navigating away and back creates a second socket
// 10 navigations = 10 open sockets consuming memory and bandwidth
}, [symbol])
return <span className="ticker">{price ? `$${price.toFixed(2)}` : "Loading..."}</span>
}Correct (cleanup closes connection on unmount or symbol change):
function StockTicker({ symbol }: { symbol: string }) {
const [price, setPrice] = useState<number | null>(null)
useEffect(() => {
const socket = new WebSocket(`wss://market-feed.example.com/ws/${symbol}`)
socket.onmessage = (event) => {
const update = JSON.parse(event.data) as { price: number }
setPrice(update.price)
}
return () => {
socket.close() // closes connection on unmount or symbol change
}
}, [symbol])
return <span className="ticker">{price ? `$${price.toFixed(2)}` : "Loading..."}</span>
}Reference: React Docs — Synchronizing with Effects
Use Heap Snapshots to Detect Component Retention
Detached DOM nodes and retained component closures are invisible in code review. A component that appears to unmount correctly can still be held in memory by a stale event handler, timer, or external reference. Heap snapshot comparison between navigation states reveals exactly which objects are retained and what retains them.
Incorrect (no leak detection, memory grows silently):
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const connection = createChatConnection(roomId)
connection.on("message", (message: Message) => {
setMessages((prev) => [...prev, message])
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
})
connection.connect()
// Missing cleanup — connection holds reference to setMessages closure
// Navigating between rooms accumulates connections and message arrays
// No way to detect this without heap analysis
}, [roomId])
return (
<div className="chat-messages">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
<div ref={messagesEndRef} />
</div>
)
}Correct (heap snapshot workflow to detect and fix retention):
// Step 1: Chrome DevTools → Memory → Take heap snapshot (baseline)
// Step 2: Navigate to ChatRoom, send messages, navigate away
// Step 3: Force garbage collection (trash can icon)
// Step 4: Take second heap snapshot
// Step 5: Select "Comparison" view between snapshots
// Step 6: Filter by "Detached" — find retained ChatRoom DOM nodes
// Step 7: Follow retainer chain to find the leaking reference
// Fix: proper cleanup after identifying the leak via heap snapshot
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const connection = createChatConnection(roomId)
connection.on("message", (message: Message) => {
setMessages((prev) => [...prev, message])
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
})
connection.connect()
return () => {
connection.disconnect() // releases socket, listeners, and closure
}
}, [roomId])
return (
<div className="chat-messages">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
<div ref={messagesEndRef} />
</div>
)
}
// Verify fix: repeat snapshot comparison — no detached nodes remainReference: Chrome DevTools — Record Heap Snapshots
Dispose Heavy Resources in Cleanup Functions
Canvas contexts, Web Workers, object URLs, and media streams allocate significant memory outside the JavaScript heap. These resources are not automatically garbage collected when a component unmounts — they require explicit disposal calls or they persist for the lifetime of the page.
Incorrect (resources allocated without disposal):
function ImageEditor({ imageFile }: { imageFile: File }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const objectUrl = URL.createObjectURL(imageFile) // 5-20MB blob retained
const worker = new Worker("/image-processing-worker.js") // thread + memory allocated
const img = new Image()
img.onload = () => {
const ctx = canvasRef.current?.getContext("2d")
ctx?.drawImage(img, 0, 0)
worker.postMessage({ type: "analyze", imageData: objectUrl })
worker.onmessage = (e) => {
console.log("Analysis complete:", e.data)
}
}
img.src = objectUrl
// No cleanup — object URL, worker, and canvas context persist after unmount
// Each mount leaks ~25MB (object URL + worker heap + canvas buffer)
}, [imageFile])
return <canvas ref={canvasRef} width={1920} height={1080} />
}Correct (explicit disposal for each resource type):
function ImageEditor({ imageFile }: { imageFile: File }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const objectUrl = URL.createObjectURL(imageFile)
const worker = new Worker("/image-processing-worker.js")
const img = new Image()
img.onload = () => {
const ctx = canvasRef.current?.getContext("2d")
ctx?.drawImage(img, 0, 0)
worker.postMessage({ type: "analyze", imageData: objectUrl })
worker.onmessage = (e) => {
console.log("Analysis complete:", e.data)
}
}
img.src = objectUrl
return () => {
URL.revokeObjectURL(objectUrl) // frees blob memory
worker.terminate() // kills thread and releases worker heap
const ctx = canvasRef.current?.getContext("2d")
if (ctx && canvasRef.current) {
canvasRef.current.width = 0 // releases canvas buffer memory
canvasRef.current.height = 0
}
}
}, [imageFile])
return <canvas ref={canvasRef} width={1920} height={1080} />
}Reference: MDN — URL.revokeObjectURL()
Profile Before Optimizing to Target Real Bottlenecks
Most components render in under 1ms and gain nothing from memoization. Optimizing without profiling wastes engineering time on fast components while the actual bottleneck — often a single expensive subtree — remains untouched.
Incorrect (memoizing cheap components without measurement):
// Developer assumes ProductCard is slow and wraps everything in memo
const ProductCard = memo(function ProductCard({ product }: { product: Product }) {
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<span className={`badge-${product.availability}`}>
{product.availability}
</span>
</div>
)
})
// Meanwhile, the actual bottleneck is an unvirtualized search results list
// rendering 2000 items that takes 400ms per render — never profiled
function SearchResults({ results }: { results: Product[] }) {
return (
<div>
{results.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}Correct (profile first, then optimize the measured bottleneck):
// Step 1: Profile with React DevTools Profiler
// Found: SearchResults renders 2000 items in 400ms
// Found: ProductCard renders in 0.3ms each — not the issue
// Step 2: Fix the actual bottleneck — virtualize the long list
import { useVirtualizer } from "@tanstack/react-virtual"
function SearchResults({ results }: { results: Product[] }) {
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: results.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 96,
})
return (
<div ref={scrollRef} style={{ height: 600, overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={results[row.index].id}
style={{ position: "absolute", top: row.start, height: row.size, width: "100%" }}
>
<ProductCard product={results[row.index]} />
</div>
))}
</div>
</div>
)
}
// No memo needed — ProductCard was never the bottleneck
function ProductCard({ product }: { product: Product }) {
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<span className={`badge-${product.availability}`}>
{product.availability}
</span>
</div>
)
}Reference: React Docs — React DevTools Profiler
Read Flame Charts to Identify Hot Render Paths
Flame charts visualize the call stack over time, making it immediately visible which function consumes the most CPU during a render. The widest bar is the hot path — the single call responsible for most of the frame time. Without reading flame charts, developers optimize random functions instead of the dominant cost.
Incorrect (optimizing without flame chart data):
// Developer blindly memoizes the parent component
const OrderHistory = memo(function OrderHistory({ orders }: { orders: Order[] }) {
const sortedOrders = [...orders].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
)
return (
<div>
{sortedOrders.map((order) => (
<OrderRow key={order.id} order={order} />
))}
</div>
)
})
// The actual bottleneck is inside OrderRow's date formatting
// called 500 times per render — never investigated
function OrderRow({ order }: { order: Order }) {
const formattedDate = new Intl.DateTimeFormat("en-GB", {
dateStyle: "full",
timeStyle: "long",
timeZone: order.customerTimezone, // creates new formatter per row
}).format(order.createdAt)
return (
<tr>
<td>{order.id}</td>
<td>{formattedDate}</td>
<td>${order.total.toFixed(2)}</td>
</tr>
)
}Correct (flame chart reveals the hot path, fix targets it):
// Flame chart showed: Intl.DateTimeFormat constructor = 85% of render time
// Fix: cache formatters by timezone to avoid repeated construction
const formatterCache = new Map<string, Intl.DateTimeFormat>()
function getDateFormatter(timezone: string): Intl.DateTimeFormat {
let formatter = formatterCache.get(timezone)
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-GB", {
dateStyle: "full",
timeStyle: "long",
timeZone: timezone,
})
formatterCache.set(timezone, formatter)
}
return formatter
}
function OrderRow({ order }: { order: Order }) {
const formattedDate = getDateFormatter(order.customerTimezone).format(
order.createdAt
)
return (
<tr>
<td>{order.id}</td>
<td>{formattedDate}</td>
<td>${order.total.toFixed(2)}</td>
</tr>
)
}
// No memo needed on OrderHistory — the hot path was inside OrderRow
function OrderHistory({ orders }: { orders: Order[] }) {
const sortedOrders = [...orders].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
)
return (
<div>
{sortedOrders.map((order) => (
<OrderRow key={order.id} order={order} />
))}
</div>
)
}Enforce Performance Budgets in CI
Without automated enforcement, performance degrades incrementally — each PR adds a few KB or a slightly slower interaction until the app crosses unacceptable thresholds. CI performance budgets catch regressions at the PR level before they compound.
Incorrect (no automated performance checks):
// package.json — no size or performance checks
{
"scripts": {
"build": "next build",
"test": "vitest",
"lint": "eslint ."
}
}
// .github/workflows/ci.yml — only runs tests and lint
// name: CI
// jobs:
// check:
// steps:
// - run: npm test
// - run: npm run lint
// # No bundle size check — grew from 180KB to 420KB over 6 months
// # No Lighthouse check — LCP regressed from 1.2s to 3.8s unnoticedCorrect (bundle size limits and Lighthouse scores enforced per PR):
# Install size-limit for bundle analysis
npm install --save-dev size-limit @size-limit/preset-app// package.json — enforced size budgets
{
"scripts": {
"build": "next build",
"test": "vitest",
"lint": "eslint .",
"size": "size-limit"
},
"size-limit": [
{
"path": ".next/static/chunks/*.js",
"limit": "200 KB",
"gzip": true
},
{
"path": ".next/static/chunks/pages/index-*.js",
"limit": "80 KB",
"gzip": true
}
]
}# .github/workflows/ci.yml — performance gates
# jobs:
# performance:
# steps:
# - run: npm run build
# - run: npx size-limit --json | npx size-limit-action
# - run: npx lhci autorun
# # lighthouserc.json asserts:
# # performance >= 0.9
# # largest-contentful-paint <= 2500ms
# # cumulative-layout-shift <= 0.1Reference: Size Limit — Performance Budget Tool
Benchmark with Production Builds Only
React development mode includes StrictMode double-rendering, prop validation, and detailed warning checks that add 2-10x overhead. Profiling a dev build produces inflated numbers that do not reflect real user experience and leads to optimizing problems that do not exist in production.
Incorrect (profiling dev build with inflated timings):
// Developer runs "npm start" (development mode) and opens Chrome DevTools
// Profiler shows:
// ContactList render: 120ms ← inflated by StrictMode double render
// ContactCard render: 8ms each ← includes prop-type validation overhead
// Total frame: 340ms ← not representative of production
// Developer concludes ContactCard is slow and wraps every instance in memo
const ContactCard = memo(function ContactCard({ contact }: { contact: Contact }) {
return (
<div className="contact-card">
<img src={contact.avatarUrl} alt={contact.name} />
<h3>{contact.name}</h3>
<p>{contact.email}</p>
<span className="department">{contact.department}</span>
</div>
)
})
// In production, ContactCard renders in 0.4ms — memo overhead is wastedCorrect (profile production build with React profiling enabled):
# Build production bundle with profiling support
npx react-scripts build --profile
# or for Next.js:
# next build && next start// Production profiler shows actual timings:
// ContactList render: 18ms ← no StrictMode double render
// ContactCard render: 0.4ms each ← no dev-mode validation
// Total frame: 22ms ← within 16ms budget at scale
// Production data reveals the real bottleneck: ContactSearch filtering
// 10,000 contacts filtered with .includes() on every keystroke = 85ms
function ContactSearch({ contacts }: { contacts: Contact[] }) {
const [query, setQuery] = useState("")
const filteredContacts = useDeferredValue(
contacts.filter(
(contact) =>
contact.name.toLowerCase().includes(query.toLowerCase()) ||
contact.email.toLowerCase().includes(query.toLowerCase())
)
)
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ContactList contacts={filteredContacts} />
</div>
)
}Reference: React Docs — Profiler
Use React Performance Tracks for Render Analysis
React's Performance Tracks integration in Chrome DevTools shows exactly which components re-rendered, how long each render took, and whether renders were triggered by state, props, or context changes. Without this data, developers resort to guessing which components cause slowdowns.
Incorrect (guessing at re-render causes with console.log):
function InventoryDashboard({ warehouses }: { warehouses: Warehouse[] }) {
console.log("InventoryDashboard rendered") // clutters console, no timing data
return (
<div>
<WarehouseFilters />
<WarehouseMap warehouses={warehouses} />
<InventoryTable warehouses={warehouses} />
</div>
)
}
function WarehouseMap({ warehouses }: { warehouses: Warehouse[] }) {
console.log("WarehouseMap rendered") // no information about render duration
return <MapVisualization data={warehouses} />
}
function InventoryTable({ warehouses }: { warehouses: Warehouse[] }) {
console.log("InventoryTable rendered") // cannot compare relative costs
return <Table rows={warehouses.flatMap((w) => w.inventory)} />
}Correct (systematic profiling with React Performance Tracks):
// Step 1: Enable React Performance Tracks
// Chrome DevTools → Performance → enable "React" track
// Step 2: Record a trace while interacting with the slow UI
// Click record → perform the slow action → stop recording
// Step 3: Read the React track in the flame chart
// - Each component render appears as a bar with duration
// - Yellow/red bars indicate slow renders (>16ms)
// - Gray bars indicate components that did not re-render
// Step 4: Apply targeted fix based on profiling data
// Profiler showed: InventoryTable takes 280ms (renders 5000 rows)
// Profiler showed: WarehouseMap takes 2ms (not a bottleneck)
function InventoryDashboard({ warehouses }: { warehouses: Warehouse[] }) {
return (
<div>
<WarehouseFilters />
<WarehouseMap warehouses={warehouses} />
<Suspense fallback={<TableSkeleton />}>
<VirtualizedInventoryTable warehouses={warehouses} />
</Suspense>
</div>
)
}Reference: React Blog — React Performance Tracks
Avoid Inline Object Creation in JSX Props
Inline objects in JSX create a new reference on every render. When passed as props to memoized children, the new reference breaks referential equality checks, causing the child to re-render even though the values haven't changed.
Incorrect (new object reference created every render):
import { memo } from "react"
interface ChartConfig {
color: string
strokeWidth: number
animated: boolean
}
const RevenueChart = memo(function RevenueChart({
config,
revenues,
}: {
config: ChartConfig
revenues: number[]
}) {
return <canvas data-config={JSON.stringify(config)} />
})
function Dashboard({ revenues }: { revenues: number[] }) {
const [selectedTab, setSelectedTab] = useState("overview")
return (
<div>
<TabBar selected={selectedTab} onSelect={setSelectedTab} />
<RevenueChart
revenues={revenues}
config={{ color: "#4f46e5", strokeWidth: 2, animated: true }} // new object every render
/>
</div>
)
}Correct (stable reference preserved across renders):
import { memo } from "react"
interface ChartConfig {
color: string
strokeWidth: number
animated: boolean
}
const REVENUE_CHART_CONFIG: ChartConfig = {
color: "#4f46e5",
strokeWidth: 2,
animated: true,
}
const RevenueChart = memo(function RevenueChart({
config,
revenues,
}: {
config: ChartConfig
revenues: number[]
}) {
return <canvas data-config={JSON.stringify(config)} />
})
function Dashboard({ revenues }: { revenues: number[] }) {
const [selectedTab, setSelectedTab] = useState("overview")
return (
<div>
<TabBar selected={selectedTab} onSelect={setSelectedTab} />
<RevenueChart revenues={revenues} config={REVENUE_CHART_CONFIG} />
</div>
)
}With React Compiler: The compiler auto-memoizes components and values, making memo() unnecessary. Hoisting constant objects to module scope remains good practice for code clarity regardless of compiler usage. See compiler-remove-manual-memo for migration steps.
Reference: React — Optimizing Performance
Use Children Pattern to Prevent Parent Re-Renders
When a parent component owns state that changes frequently, all JSX declared inside that parent re-renders on every state change. Moving expensive children above the state-owning component and passing them as children props preserves their identity across renders, skipping reconciliation entirely.
Incorrect (expensive subtree re-renders on every mouse move):
function ProductPage() {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
return (
<div onMouseMove={(e) => setMousePosition({ x: e.clientX, y: e.clientY })}>
<Cursor position={mousePosition} />
<ProductGallery /> {/* re-renders on every mouse move */}
<ProductReviews /> {/* re-renders on every mouse move */}
<RelatedProducts /> {/* re-renders on every mouse move */}
</div>
)
}Correct (children identity preserved, no re-renders):
function MouseTracker({ children }: { children: ReactNode }) {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
return (
<div onMouseMove={(e) => setMousePosition({ x: e.clientX, y: e.clientY })}>
<Cursor position={mousePosition} />
{children} {/* same JSX reference — React skips reconciliation */}
</div>
)
}
function ProductPage() {
return (
<MouseTracker>
<ProductGallery />
<ProductReviews />
<RelatedProducts />
</MouseTracker>
)
}Reference: Before You memo() — Dan Abramov
Use CSS Containment to Isolate Layout Recalculation
By default, any DOM change triggers layout recalculation for the entire document. CSS contain tells the browser that an element's internals are independent from the rest of the page, allowing the engine to skip recalculating layout, paint, and style outside the contained subtree.
Incorrect (sidebar toggle triggers full-page layout recalculation):
function DashboardPanel({ widgets }: { widgets: Widget[] }) {
const [collapsed, setCollapsed] = useState(false)
return (
<div className="dashboard-panel">
<button onClick={() => setCollapsed(!collapsed)}>
{collapsed ? "Expand" : "Collapse"}
</button>
<div className="widget-grid">
{widgets.map((widget) => (
<div key={widget.id} className="widget-card">
{/* height changes here recalculate layout for entire page */}
<WidgetContent widget={widget} collapsed={collapsed} />
</div>
))}
</div>
</div>
)
}
// dashboard-panel.css
// .widget-card {
// padding: 16px;
// border: 1px solid #e5e7eb;
// }Correct (containment isolates layout recalculation to each card):
function DashboardPanel({ widgets }: { widgets: Widget[] }) {
const [collapsed, setCollapsed] = useState(false)
return (
<div className="dashboard-panel">
<button onClick={() => setCollapsed(!collapsed)}>
{collapsed ? "Expand" : "Collapse"}
</button>
<div className="widget-grid">
{widgets.map((widget) => (
<div key={widget.id} className="widget-card">
<WidgetContent widget={widget} collapsed={collapsed} />
</div>
))}
</div>
</div>
)
}
// dashboard-panel.css
// .widget-card {
// contain: layout style paint; /* browser skips recalc outside this subtree */
// padding: 16px;
// border: 1px solid #e5e7eb;
// }
//
// .widget-grid {
// content-visibility: auto; /* offscreen cards skip rendering entirely */
// contain-intrinsic-size: 0 300px;
// }Reference: CSS Containment — MDN
Debounce Expensive Derived Computations
Computing derived results on every keystroke forces the main thread to process expensive operations (filtering thousands of records, scoring matches) at 30-60 events per second. useDeferredValue tells React to defer the expensive re-render to a lower priority, keeping the input responsive while the filtered results update in the background.
Incorrect (filters 10,000 records on every keystroke):
import { useState } from "react"
interface Listing {
id: string
title: string
description: string
location: string
}
function ListingSearch({ listings }: { listings: Listing[] }) {
const [query, setQuery] = useState("")
// runs on every keystroke — blocks UI for 50-200ms per invocation
const matchedListings = listings.filter(
(listing) =>
listing.title.toLowerCase().includes(query.toLowerCase()) ||
listing.description.toLowerCase().includes(query.toLowerCase()) ||
listing.location.toLowerCase().includes(query.toLowerCase())
)
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search listings..."
/>
<ResultsList listings={matchedListings} />
</div>
)
}Correct (deferred computation keeps input responsive):
import { useState, useDeferredValue, useMemo } from "react"
interface Listing {
id: string
title: string
description: string
location: string
}
function ListingSearch({ listings }: { listings: Listing[] }) {
const [query, setQuery] = useState("")
const deferredQuery = useDeferredValue(query)
const matchedListings = useMemo(() => {
if (!deferredQuery) return listings
const lowerQuery = deferredQuery.toLowerCase()
return listings.filter(
(listing) =>
listing.title.toLowerCase().includes(lowerQuery) ||
listing.description.toLowerCase().includes(lowerQuery) ||
listing.location.toLowerCase().includes(lowerQuery)
)
}, [listings, deferredQuery])
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search listings..."
/>
<ResultsList listings={matchedListings} />
</div>
)
}Alternative (setTimeout debounce for fixed-delay requirements):
When you need a guaranteed minimum delay (e.g., rate-limiting API calls), use a timeout-based debounce instead of useDeferredValue:
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delayMs)
return () => clearTimeout(timer)
}, [value, delayMs])
return debouncedValue
}Reference: React — useDeferredValue
Use Stable Keys for List Rendering Performance
Array index keys cause React's reconciler to treat every element as changed when items are reordered, inserted, or removed. This triggers full DOM teardown and rebuild for every item below the change point. Stable unique IDs let React match elements across renders and perform minimal DOM moves.
Incorrect (index keys force full reconciliation on reorder):
interface Notification {
id: string
message: string
timestamp: number
priority: "low" | "medium" | "high"
}
function NotificationFeed({ notifications }: { notifications: Notification[] }) {
const sorted = [...notifications].sort((a, b) => b.timestamp - a.timestamp)
return (
<ul>
{sorted.map((notification, index) => (
<li key={index}> {/* reorder changes every key — full DOM rebuild */}
<NotificationCard notification={notification} />
</li>
))}
</ul>
)
}Correct (stable ID keys enable minimal DOM moves):
interface Notification {
id: string
message: string
timestamp: number
priority: "low" | "medium" | "high"
}
function NotificationFeed({ notifications }: { notifications: Notification[] }) {
const sorted = [...notifications].sort((a, b) => b.timestamp - a.timestamp)
return (
<ul>
{sorted.map((notification) => (
<li key={notification.id}>
<NotificationCard notification={notification} />
</li>
))}
</ul>
)
}Reference: React — Rendering Lists
Virtualize Long Lists with TanStack Virtual
Rendering 1000+ items creates 1000+ DOM nodes that consume memory, slow initial paint, and degrade scroll performance. Virtualization renders only the items visible in the viewport, keeping DOM node count constant regardless of list size.
Incorrect (renders all 5000 DOM nodes upfront):
interface Product {
id: string
name: string
price: number
}
function ProductCatalog({ products }: { products: Product[] }) {
return (
<div className="product-list" style={{ height: 600, overflow: "auto" }}>
{products.map((product) => (
<div key={product.id} className="product-row" style={{ height: 48 }}>
<span>{product.name}</span>
<span>${product.price.toFixed(2)}</span>
</div>
))}
{/* 5000 products = 5000 DOM nodes mounted simultaneously */}
</div>
)
}Correct (renders only visible DOM nodes):
import { useVirtualizer } from "@tanstack/react-virtual"
import { useRef } from "react"
interface Product {
id: string
name: string
price: number
}
function ProductCatalog({ products }: { products: Product[] }) {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => 48, // estimated row height in pixels
})
return (
<div ref={scrollContainerRef} style={{ height: 600, overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const product = products[virtualRow.index]
return (
<div
key={product.id}
className="product-row"
style={{
position: "absolute",
top: virtualRow.start,
height: virtualRow.size,
width: "100%",
}}
>
<span>{product.name}</span>
<span>${product.price.toFixed(2)}</span>
</div>
)
})}
</div>
</div>
)
}Reference: TanStack Virtual Documentation
Use Atomic State for Independent Reactive Values
A large monolithic store triggers re-renders in every subscriber when any single field changes. Atomic state splits each independent value into its own reactive unit, so components subscribe to exactly the atoms they read.
Incorrect (single store re-renders all consumers on any field change):
interface DashboardState {
sidebarCollapsed: boolean
activeChartRange: "1d" | "7d" | "30d"
selectedMetric: string
alertThreshold: number
refreshInterval: number
}
const DashboardContext = createContext<{
state: DashboardState
dispatch: Dispatch<DashboardAction>
}>(null!)
function MetricSelector() {
const { state, dispatch } = useContext(DashboardContext)
// Re-renders when sidebarCollapsed, alertThreshold, or refreshInterval change
return (
<select
value={state.selectedMetric}
onChange={(e) => dispatch({ type: "SET_METRIC", payload: e.target.value })}
>
<option value="revenue">Revenue</option>
<option value="signups">Signups</option>
</select>
)
}
function SidebarToggle() {
const { state, dispatch } = useContext(DashboardContext)
// Re-renders when selectedMetric, alertThreshold, or refreshInterval change
return (
<button onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}>
{state.sidebarCollapsed ? "Expand" : "Collapse"}
</button>
)
}Correct (each atom triggers re-renders only in its subscribers):
import { atom, useAtom } from "jotai"
const sidebarCollapsedAtom = atom(false)
const activeChartRangeAtom = atom<"1d" | "7d" | "30d">("7d")
const selectedMetricAtom = atom("revenue")
const alertThresholdAtom = atom(90)
const refreshIntervalAtom = atom(30_000)
function MetricSelector() {
const [selectedMetric, setSelectedMetric] = useAtom(selectedMetricAtom)
return (
<select
value={selectedMetric}
onChange={(e) => setSelectedMetric(e.target.value)}
>
<option value="revenue">Revenue</option>
<option value="signups">Signups</option>
</select>
)
}
function SidebarToggle() {
const [sidebarCollapsed, setSidebarCollapsed] = useAtom(sidebarCollapsedAtom)
return (
<button onClick={() => setSidebarCollapsed((prev) => !prev)}>
{sidebarCollapsed ? "Expand" : "Collapse"}
</button>
)
}Reference: Jotai — Introduction
Split Contexts to Isolate High-Frequency Updates
A single context holding mixed fast-changing and slow-changing values forces every consumer to re-render on any change. Splitting by update frequency ensures components only re-render when their specific data changes.
Incorrect (all consumers re-render on any context value change):
interface AppContextValue {
theme: "light" | "dark"
currentUser: User
notifications: Notification[]
unreadCount: number
}
const AppContext = createContext<AppContextValue>(null!)
function AppProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light")
const [currentUser, setCurrentUser] = useState<User>(initialUser)
const [notifications, setNotifications] = useState<Notification[]>([])
const value = {
theme,
currentUser,
notifications,
unreadCount: notifications.filter((n) => !n.read).length,
}
return <AppContext.Provider value={value}>{children}</AppContext.Provider>
}
// Re-renders every time notifications change, even though it only reads theme
function PageHeader() {
const { theme } = useContext(AppContext)
return <header className={theme}>My App</header>
}Correct (consumers only re-render when their specific context changes):
const ThemeContext = createContext<{ theme: "light" | "dark" }>(null!)
const UserContext = createContext<{ currentUser: User }>(null!)
const NotificationContext = createContext<{
notifications: Notification[]
unreadCount: number
}>(null!)
function AppProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light")
const [currentUser, setCurrentUser] = useState<User>(initialUser)
const [notifications, setNotifications] = useState<Notification[]>([])
return (
<ThemeContext.Provider value={{ theme }}>
<UserContext.Provider value={{ currentUser }}>
<NotificationContext.Provider
value={{
notifications,
unreadCount: notifications.filter((n) => !n.read).length,
}}
>
{children}
</NotificationContext.Provider>
</UserContext.Provider>
</ThemeContext.Provider>
)
}
// Only re-renders when theme changes
function PageHeader() {
const { theme } = useContext(ThemeContext)
return <header className={theme}>My App</header>
}Derive State Instead of Syncing for Zero Extra Renders
Using useEffect to synchronize derived state from props or other state causes a double-render cycle: the first render uses stale derived state, then the effect fires and sets state again, triggering a second render. Computing the derived value directly during render produces the correct result in a single pass.
Incorrect (useEffect sync causes double render per update):
interface CartItem {
id: string
name: string
price: number
quantity: number
}
function CartSummary({ cartItems }: { cartItems: CartItem[] }) {
const [totalPrice, setTotalPrice] = useState(0)
const [itemCount, setItemCount] = useState(0)
const [hasExpensiveItem, setHasExpensiveItem] = useState(false)
useEffect(() => {
// Fires after render, triggers a second render with updated values
setTotalPrice(cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0))
setItemCount(cartItems.reduce((sum, item) => sum + item.quantity, 0))
setHasExpensiveItem(cartItems.some((item) => item.price > 100))
}, [cartItems])
return (
<div>
<p>{itemCount} items — ${totalPrice.toFixed(2)}</p>
{hasExpensiveItem && <span className="badge">Premium items in cart</span>}
</div>
)
}Correct (derived during render, single render cycle):
interface CartItem {
id: string
name: string
price: number
quantity: number
}
function CartSummary({ cartItems }: { cartItems: CartItem[] }) {
const totalPrice = cartItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
const itemCount = cartItems.reduce((sum, item) => sum + item.quantity, 0)
const hasExpensiveItem = cartItems.some((item) => item.price > 100)
return (
<div>
<p>{itemCount} items — ${totalPrice.toFixed(2)}</p>
{hasExpensiveItem && <span className="badge">Premium items in cart</span>}
</div>
)
}Reference: React Docs — You Might Not Need an Effect
Use Selector-Based Subscriptions for Granular Updates
Subscribing to an entire store object re-renders the component whenever any field changes, even fields the component never reads. Selector functions narrow the subscription to specific slices, so the component only re-renders when its selected value changes.
Incorrect (re-renders on any store field change):
import { useSyncExternalStore } from "react"
import { dashboardStore } from "./store"
function OrderCount() {
const storeState = useSyncExternalStore(
dashboardStore.subscribe,
dashboardStore.getSnapshot // returns entire { orders, filters, selectedTab, notifications }
)
// Re-renders when filters, selectedTab, or notifications change
return <span className="badge">{storeState.orders.length} orders</span>
}
function FilterPanel() {
const storeState = useSyncExternalStore(
dashboardStore.subscribe,
dashboardStore.getSnapshot
)
// Re-renders when orders or notifications change
return <div>{storeState.filters.map((f) => <FilterChip key={f.id} filter={f} />)}</div>
}Correct (re-renders only when selected slice changes):
import { useSyncExternalStore } from "react"
import { dashboardStore } from "./store"
function useStoreSelector<T>(selector: (state: DashboardState) => T): T {
return useSyncExternalStore(
dashboardStore.subscribe,
() => selector(dashboardStore.getSnapshot())
)
}
function OrderCount() {
const orderCount = useStoreSelector((state) => state.orders.length)
return <span className="badge">{orderCount} orders</span>
}
function FilterPanel() {
const filters = useStoreSelector((state) => state.filters)
return <div>{filters.map((f) => <FilterChip key={f.id} filter={f} />)}</div>
}Reference: React Docs — useSyncExternalStore
Separate Server State from Client State Management
Server state is asynchronous, cached, and owned by a remote source. Client state is synchronous, ephemeral, and owned by the browser. Mixing both in a single store forces manual cache invalidation, loading state tracking, and stale-while-revalidate logic that a dedicated server-state library handles automatically.
Incorrect (manual cache management mixed with UI state):
interface AppState {
products: Product[]
isLoadingProducts: boolean
productsError: string | null
lastFetchedAt: number | null
searchQuery: string
selectedCategory: string
}
function useProductStore() {
const [state, setState] = useState<AppState>({
products: [],
isLoadingProducts: false,
productsError: null,
lastFetchedAt: null,
searchQuery: "",
selectedCategory: "all",
})
const fetchProducts = async () => {
setState((prev) => ({ ...prev, isLoadingProducts: true }))
try {
const products = await api.getProducts()
setState((prev) => ({
...prev,
products,
isLoadingProducts: false,
lastFetchedAt: Date.now(),
}))
} catch (error) {
setState((prev) => ({
...prev,
isLoadingProducts: false,
productsError: (error as Error).message,
}))
}
}
// Must manually refetch after mutations
const addProduct = async (product: NewProduct) => {
await api.createProduct(product)
await fetchProducts() // manual cache invalidation
}
return { ...state, fetchProducts, addProduct }
}Correct (dedicated server-state library + local UI state):
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
function useProductCatalog() {
const queryClient = useQueryClient()
const [searchQuery, setSearchQuery] = useState("")
const [selectedCategory, setSelectedCategory] = useState("all")
const productsQuery = useQuery({
queryKey: ["products"],
queryFn: api.getProducts,
staleTime: 30_000, // automatic background refetch after 30s
})
const addProductMutation = useMutation({
mutationFn: api.createProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
},
})
return {
products: productsQuery.data ?? [],
isLoading: productsQuery.isLoading,
error: productsQuery.error,
searchQuery,
setSearchQuery,
selectedCategory,
setSelectedCategory,
addProduct: addProductMutation.mutate,
}
}Reference: TanStack Query — Comparison
Related skills
FAQ
What does react-optimise help with?
react-optimise is a dot-skills entry for React performance work—reducing re-renders, improving load behavior, and refactoring components when a React UI feels slow.
Is react-optimise React-only?
Yes—react-optimise is named and scoped for React optimisation tasks in the pproenca/dot-skills repository, not Vue, Svelte, or backend tuning.