
React Best Practices
- 1.9k installs
- 26.9k repo stars
- Updated August 5, 2026
- mastra-ai/mastra
A set of 14 performance optimization rules for React applications, organized by impact priority and applicable category.
About
Priority-ordered React performance guidelines covering 14 rules across 7 categories: waterfall elimination, bundle size optimization, client-side data fetching, re-render optimization, rendering performance, JavaScript micro-optimizations, and component structure. Developers apply these when writing new components, implementing data fetching, reviewing code for performance issues, or refactoring existing React code. Critical patterns include Promise.all() for parallel async operations, direct imports to avoid barrel files, Tanstack Query for request deduplication, lazy state initialization, startTransition for non-urgent updates, and single-responsibility component design. Rules prioritized by impact from CRITICAL to MEDIUM-HIGH to guide automated refactoring.
- 14 rules prioritized by impact across 7 categories (waterfall elimination, bundle optimization, data fetching, re-render
- Eliminate waterfalls with Promise.all() for independent async operations to reduce CRITICAL-impact bottlenecks
- Reduce bundle size by avoiding barrel file imports and deferring non-critical third-party libraries
- Apply Tanstack Query for automatic request deduplication and lazy state initialization for expensive computed values
- Use startTransition for non-urgent updates, content-visibility: auto for long lists, and split bloated components into s
React Best Practices by the numbers
- 1,874 all-time installs (skills.sh)
- +90 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #257 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
react-best-practices capabilities & compatibility
- Capabilities
- identify async waterfall patterns · optimize bundle size via import strategies · detect unnecessary re renders · recommend tanstack query for data fetching · guide component splitting and single responsibil · advise on rendering performance (svg animation,
- Use cases
- code review · refactoring · frontend
What react-best-practices says it does
Comprehensive performance optimization guide for React applications, containing 14 rules across 7 categories. Rules are prioritized by impact to guide automated refactoring and code generation.
npx skills add https://github.com/mastra-ai/mastra --skill react-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.9k |
|---|---|
| repo stars | ★ 26.9k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 5, 2026 |
| Repository | mastra-ai/mastra ↗ |
What it does
Optimize React component performance, bundle size, and data fetching patterns during development and code review.
Who is it for?
Writing new React components, implementing data fetching, reviewing code for performance issues, refactoring existing React code, optimizing bundle size or load times
Skip if: Non-React frontend frameworks, backend optimization, design system creation, non-performance-critical prototypes
When should I use this skill?
Writing React components, implementing data fetching, reviewing React code for performance, refactoring React code, optimizing bundle size or load times
What you get
Developers apply priority-ordered guidelines to eliminate performance bottlenecks, reduce bundle size, optimize data fetching, and improve component maintainability.
- Performance-compliant React component code
- Refactored components without waterfall patterns
By the numbers
- 14 total optimization rules across 7 categories
- 7 priority levels from CRITICAL to LOW-MEDIUM impact
- 3 critical pattern categories: waterfall elimination, bundle optimization, component structure
Files
React Best Practices
Overview
Comprehensive performance optimization guide for React applications, containing 14 rules across 7 categories. Rules are prioritized by impact to guide automated refactoring and code generation.
When to Apply
Reference these guidelines when:
- Writing new React components
- Implementing data fetching
- Reviewing code for performance issues
- Refactoring existing React code
- Optimizing bundle size or load times
Priority-Ordered Guidelines
Rules are prioritized by impact:
| Priority | Category | Impact |
|---|---|---|
| 1 | Eliminating Waterfalls | CRITICAL |
| 2 | Bundle Size Optimization | CRITICAL |
| 3 | Client-Side Data Fetching | MEDIUM-HIGH |
| 4 | Re-render Optimization | MEDIUM |
| 5 | Rendering Performance | MEDIUM |
| 6 | JavaScript Performance | LOW-MEDIUM |
| 7 | Component Structure | MEDIUM-HIGH (maintainability) |
Quick Reference
Critical Patterns (Apply First)
Eliminate Waterfalls:
- Use
Promise.all()for independent async operations (async-parallel)
Reduce Bundle Size:
- Avoid barrel file imports, import directly from source (
bundle-barrel-imports) - Defer non-critical third-party libraries (
bundle-defer-third-party)
Medium-Impact Patterns
Client-Side Data Fetching:
- Use Tanstack Query for automatic request deduplication (
client-request-dedupe)
Re-render Optimization:
- Use lazy state initialization for expensive values (
rerender-lazy-state-init) - Apply
startTransitionfor non-urgent updates (rerender-transitions) - Minimize
useEffectfunction calls (rerender-useeffect-function-calls) - Never reset state with
useEffect; lift the discriminant and remount the branch (rerender-no-useeffect-state-reset)
Component Structure:
- One domain component/hook per file, one responsibility each — split bloated components (
structure-single-responsibility)
Rendering Patterns
- Animate SVG wrappers, not SVG elements directly (
rendering-animate-svg-wrapper) - Use
content-visibility: autofor long lists (rendering-content-visibility)
JavaScript Patterns
- Use Set/Map for repeated lookups (
js-set-map-lookups) - Use
toSorted()instead ofsort()for immutability (js-tosorted-immutable) - Early length check for array comparisons (
js-length-check-first)
References
Full documentation with code examples is available in:
references/react-best-practices-reference.md- Complete guide with all patternsreferences/rules/- Individual rule files organized by category
To look up a specific pattern, grep the rules directory:
grep -l "Promise.all" references/rules/
grep -l "barrel" references/rules/
grep -l "Tanstack" references/rules/Rule Categories in references/rules/
async-*- Waterfall elimination (1 rule)bundle-*- Bundle size optimization (2 rules)client-*- Client-side data fetching (1 rule)rerender-*- Re-render optimization (4 rules)rendering-*- DOM rendering performance (2 rules)js-*- JavaScript micro-optimizations (3 rules)structure-*- Component/hook structure (1 rule)
React Best Practices
Version 0.2.0 Mastra Engineering January 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring React codebases. Humans
may also find it useful, but guidance here is optimized for automation
and consistency by AI-assisted workflows.
---
Abstract
Performance optimization guide for React applications, designed for AI agents and LLMs. Contains 14 rules across 7 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (JavaScript micro-optimizations). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
---
Table of Contents
1. Eliminating Waterfalls — CRITICAL
2. Bundle Size Optimization — CRITICAL
3. Client-Side Data Fetching — MEDIUM-HIGH
4. Re-render Optimization — MEDIUM
- 4.1 Use Lazy State Initialization
- 4.2 Use Transitions for Non-Urgent Updates
- 4.3 useEffectEvent for Functions in useEffect
- 4.4 Never Reset State with useEffect
5. Rendering Performance — MEDIUM
6. JavaScript Performance — LOW-MEDIUM
- 6.1 Early Length Check for Array Comparisons
- 6.2 Use Set/Map for O(1) Lookups
- 6.3 Use toSorted() Instead of sort() for Immutability
7. Component Structure — MEDIUM-HIGH
---
1. Eliminating Waterfalls
Impact: CRITICAL
Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
1.1 Promise.all() for Independent Operations
When async operations have no interdependencies, execute them concurrently using Promise.all().
Incorrect (sequential execution, 3 round trips):
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();Correct (parallel execution, 1 round trip):
const [user, posts, comments] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()]);---
2. Bundle Size Optimization
Impact: CRITICAL
Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
2.1 Avoid Barrel File Imports
Import directly from source files instead of barrel files to avoid loading thousands of unused modules. Barrel files are entry points that re-export multiple modules (e.g., index.js that does export * from './module').
Popular icon and component libraries can have up to 10,000 re-exports in their entry file. For many React packages, it takes 200-800ms just to import them, affecting both development speed and production cold starts.
Why tree-shaking doesn't help: When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.
Incorrect (imports entire library):
import { Check, X, Menu } from 'lucide-react';
// Loads 1,583 modules, takes ~2.8s extra in dev
// Runtime cost: 200-800ms on every cold start
import { Button, TextField } from '@mui/material';
// Loads 2,225 modules, takes ~4.2s extra in devCorrect (imports only what you need):
import Check from 'lucide-react/dist/esm/icons/check';
import X from 'lucide-react/dist/esm/icons/x';
import Menu from 'lucide-react/dist/esm/icons/menu';
// Loads only 3 modules (~2KB vs ~1MB)
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
// Loads only what you use2.2 Defer Non-Critical Third-Party Libraries
Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
Incorrect (blocks initial bundle):
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}Correct (loads after hydration):
import { lazy, Suspense } from 'react';
const Analytics = lazy(() => import('@vercel/analytics/react').then(m => ({ default: m.Analytics })));
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Suspense fallback={null}>
<Analytics />
</Suspense>
</body>
</html>
);
}---
3. Client-Side Data Fetching
Impact: MEDIUM-HIGH
Automatic deduplication and efficient data fetching patterns reduce redundant network requests.
3.1 Use TanStack Query for Automatic Deduplication
TanStack Query enables request deduplication, caching, and revalidation across component instances.
Incorrect (no deduplication, each instance fetches):
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(setUsers);
}, []);
}Correct (multiple instances share one request):
import { useQuery } from '@tanstack/react-query';
function UserList() {
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
});
}For immutable data:
import { useQuery } from '@tanstack/react-query';
function StaticContent() {
const { data } = useQuery({
queryKey: ['config'],
queryFn: () => fetch('/api/config').then(r => r.json()),
staleTime: Infinity,
});
}For mutations:
import { useMutation } from '@tanstack/react-query';
function UpdateButton() {
const { mutate } = useMutation({
mutationFn: updateUser,
});
return <button onClick={() => mutate()}>Update</button>;
}Reference: https://tanstack.com/query
---
4. Re-render Optimization
Impact: MEDIUM
Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.
4.1 Use Lazy State Initialization
Pass a function to useState for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
Incorrect (runs on every render):
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs on EVERY render, even after initialization
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items));
const [query, setQuery] = useState('');
// When query changes, buildSearchIndex runs again unnecessarily
return <SearchResults index={searchIndex} query={query} />;
}
function UserProfile() {
// JSON.parse runs on every render
const [settings, setSettings] = useState(JSON.parse(localStorage.getItem('settings') || '{}'));
return <SettingsForm settings={settings} onChange={setSettings} />;
}Correct (runs only once):
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs ONLY on initial render
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items));
const [query, setQuery] = useState('');
return <SearchResults index={searchIndex} query={query} />;
}
function UserProfile() {
// JSON.parse runs only on initial render
const [settings, setSettings] = useState(() => {
const stored = localStorage.getItem('settings');
return stored ? JSON.parse(stored) : {};
});
return <SettingsForm settings={settings} onChange={setSettings} />;
}Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
For simple primitives (useState(0)), direct references (useState(props.value)), or cheap literals (useState({})), the function form is unnecessary.
4.2 Use Transitions for Non-Urgent Updates
Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
Incorrect (blocks UI on every scroll):
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handler = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handler, { passive: true });
return () => window.removeEventListener('scroll', handler);
}, []);
}Correct (non-blocking updates):
import { startTransition } from 'react';
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handler = () => {
startTransition(() => setScrollY(window.scrollY));
};
window.addEventListener('scroll', handler, { passive: true });
return () => window.removeEventListener('scroll', handler);
}, []);
}4.3 useEffectEvent for Functions in useEffect
Using useCallback for event handlers adds unnecessary complexity and dependency array management. React 19's useEffectEvent provides a cleaner solution that automatically captures the latest values without requiring dependency tracking.
Incorrect (unnecessary memoization with dependency management):
import { useCallback } from 'react';
export function App() {
// useCallback adds boilerplate and requires managing dependencies
const onSubmit = useCallback((data: FormData) => {
// handle submission
}, []);
return <Form onSubmit={onSubmit} />;
}Correct (useEffectEvent for event handlers):
import { useEffectEvent } from 'react';
export function App() {
// useEffectEvent always sees latest values, no dependency array needed
const onSubmit = useEffectEvent((data: FormData) => {
// handle submission
});
return <Form onSubmit={onSubmit} />;
}Reserve useCallback and useMemo for expensive computations (e.g., processing 200+ entries) where memoization provides measurable performance benefits. For typical event handlers, useEffectEvent is the preferred approach.
4.4 Never Reset State with useEffect
Never use useEffect to reset or re-sync local state when an upstream identity changes (e.g., a form's initial values when the selected product changes). Instead, restructure the component hierarchy: lift the discriminant (the product id) to the top of the tree, fetch the new entity there, and render a skeleton while loading. The skeleton unmounts the branch containing the form, so when the data arrives the form remounts and its useState initializers naturally pick up the fresh initialValues.
Incorrect (useEffect syncs state when product changes):
function ProductForm({ product }: { product: Product }) {
const [name, setName] = useState(product.name);
const [price, setPrice] = useState(product.price);
// Anti-pattern: re-syncing state with an effect.
// Renders once with stale values, then again after the effect runs.
// Every new field must be added here too — easy to forget, causes drift.
useEffect(() => {
setName(product.name);
setPrice(product.price);
}, [product.id]);
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={price} onChange={e => setPrice(e.target.value)} />
</form>
);
}Correct (lift the discriminant; skeleton unmounts the branch, form remounts fresh):
// Top of the tree: the product id is the discriminant
function ProductPage({ productId }: { productId: string }) {
const { data: product, isLoading } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
});
// While the new product loads, the skeleton replaces the form branch.
// The old ProductForm unmounts, so its state is discarded.
if (isLoading) return <ProductFormSkeleton />;
// Fresh mount: useState initializers run again with the new values.
return <ProductForm initialValues={product} />;
}
function ProductForm({ initialValues }: { initialValues: Product }) {
// Initialized once per mount — no sync effect needed, ever.
const [name, setName] = useState(initialValues.name);
const [price, setPrice] = useState(initialValues.price);
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={price} onChange={e => setPrice(e.target.value)} />
</form>
);
}Fallback when data is already local (no fetch boundary):
// key forces a remount when the product changes
<ProductForm key={product.id} initialValues={product} />Prefer the hierarchy restructure (fetch high + skeleton) over key: it also removes the stale-data problem because the form can never render with the previous product's values. Use key as the minimal fix when no async boundary exists.
---
5. Rendering Performance
Impact: MEDIUM
Optimizing the rendering process reduces the work the browser needs to do.
5.1 Animate SVG Wrapper Instead of SVG Element
Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a <div> and animate the wrapper instead.
Incorrect (animating SVG directly - no hardware acceleration):
function LoadingSpinner() {
return (
<svg className="animate-spin" width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
);
}Correct (animating wrapper div - hardware accelerated):
function LoadingSpinner() {
return (
<div className="animate-spin">
<svg width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
</div>
);
}This applies to all CSS transforms and transitions (transform, opacity, translate, scale, rotate). The wrapper div allows browsers to use GPU acceleration for smoother animations.
5.2 CSS content-visibility for Long Lists
Apply content-visibility: auto to defer off-screen rendering.
CSS:
.message-item {
content-visibility: auto;
contain-intrinsic-size: 0 80px;
}Example:
function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="overflow-y-auto h-screen">
{messages.map(msg => (
<div key={msg.id} className="message-item">
<Avatar user={msg.author} />
<div>{msg.content}</div>
</div>
))}
</div>
);
}For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
---
6. JavaScript Performance
Impact: LOW-MEDIUM
Micro-optimizations for hot paths can add up to meaningful improvements.
6.1 Early Length Check for Array Comparisons
When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
Incorrect (always runs expensive comparison):
function hasChanges(current: string[], original: string[]) {
// Always sorts and joins, even when lengths differ
return current.sort().join() !== original.sort().join();
}Two O(n log n) sorts run even when current.length is 5 and original.length is 100. There is also overhead of joining the arrays and comparing the strings.
Correct (O(1) length check first):
function hasChanges(current: string[], original: string[]) {
// Early return if lengths differ
if (current.length !== original.length) {
return true;
}
// Only sort/join when lengths match
const currentSorted = current.toSorted();
const originalSorted = original.toSorted();
for (let i = 0; i < currentSorted.length; i++) {
if (currentSorted[i] !== originalSorted[i]) {
return true;
}
}
return false;
}This new approach is more efficient because:
- It avoids the overhead of sorting and joining the arrays when lengths differ
- It avoids consuming memory for the joined strings (especially important for large arrays)
- It avoids mutating the original arrays
- It returns early when a difference is found
6.2 Use Set/Map for O(1) Lookups
Convert arrays to Set/Map for repeated membership checks.
Incorrect (O(n) per check):
const allowedIds = ['a', 'b', 'c', ...]
items.filter(item => allowedIds.includes(item.id))Correct (O(1) per check):
const allowedIds = new Set(['a', 'b', 'c', ...])
items.filter(item => allowedIds.has(item.id))6.3 Use toSorted() Instead of sort() for Immutability
.sort() mutates the array in place, which can cause bugs with React state and props. Use .toSorted() to create a new sorted array without mutation.
Incorrect (mutates original array):
function UserList({ users }: { users: User[] }) {
// Mutates the users prop array!
const sorted = useMemo(
() => users.sort((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}Correct (creates new array):
function UserList({ users }: { users: User[] }) {
// Creates new sorted array, original unchanged
const sorted = useMemo(
() => users.toSorted((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}Why this matters in React:
1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only 2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
Browser support:
.toSorted() is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
// Fallback for older browsers
const sorted = [...items].sort((a, b) => a.value - b.value);Other immutable array methods:
.toSorted()- immutable sort.toReversed()- immutable reverse.toSpliced()- immutable splice.with()- immutable element replacement
---
7. Component Structure
Impact: MEDIUM-HIGH (maintainability)
Bloated components are hard to test, review, and reuse, and unrelated state changes re-render everything.
7.1 One Component or Hook = One Responsibility = One File
Domain components and hooks must each own exactly one responsibility. If one part of a component fetches data, another filters it, another manages form state — split it. Don't fear refactoring: extracting hooks and components is cheap, while bloated components are hard to test, review, and reuse. Enforce 1 file = 1 component (or hook).
Exemption: UI/design-system components (Button, Card, layout primitives) are composition-focused and out of scope for this rule. It targets domain components.
Incorrect (one file, one component, four responsibilities):
// products-page.tsx — fetching + filtering + selection + form, all entangled
function ProductsPage() {
// Responsibility 1: data fetching
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
// Responsibility 2: filtering logic
const [query, setQuery] = useState('');
const [category, setCategory] = useState<Category | null>(null);
const filtered = useMemo(
() =>
(products ?? []).filter(
p => p.name.includes(query) && (!category || p.category === category),
),
[products, query, category],
);
// Responsibility 3: selection
const [selectedId, setSelectedId] = useState<string | null>(null);
// Responsibility 4: edit-form state for the selected product
const selected = filtered.find(p => p.id === selectedId);
const [name, setName] = useState('');
const [price, setPrice] = useState(0);
// ...100 more lines of form handlers, validation, submit logic
return (/* one giant JSX tree mixing filters, list, and form */);
}Correct (each responsibility in its own file):
// use-product-search.ts — hook: fetching + filtering
export function useProductSearch() {
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
const [query, setQuery] = useState('');
const [category, setCategory] = useState<Category | null>(null);
const filtered = useMemo(
() => (products ?? []).filter(p => p.name.includes(query) && (!category || p.category === category)),
[products, query, category],
);
return { filtered, query, setQuery, category, setCategory };
}// product-list.tsx — component: render the list, report selection
export function ProductList({ products, onSelect }: ProductListProps) {
return (
<ul>
{products.map(p => (
<li key={p.id} onClick={() => onSelect(p.id)}>
{p.name}
</li>
))}
</ul>
);
}// product-edit-form.tsx — component: edit one product
export function ProductEditForm({ initialValues }: { initialValues: Product }) {
const [name, setName] = useState(initialValues.name);
const [price, setPrice] = useState(initialValues.price);
// form handlers live here, and only here
return (/* form JSX */);
}// products-page.tsx — component: composition only
export function ProductsPage() {
const { filtered, query, setQuery, category, setCategory } = useProductSearch();
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = filtered.find(p => p.id === selectedId);
return (
<>
<ProductFilters query={query} onQueryChange={setQuery} category={category} onCategoryChange={setCategory} />
<ProductList products={filtered} onSelect={setSelectedId} />
{selected && <ProductEditForm key={selected.id} initialValues={selected} />}
</>
);
}Splitting also narrows re-render scope: typing in the filter no longer re-renders the form.
Smells that trigger a split: multiple unrelated useState/useQuery clusters, comment headers separating "sections", a component you can't name without "And".
The page/container component's single responsibility is composition — wiring hooks and children together is fine.
---
References
Promise.all() for Independent Operations
When async operations have no interdependencies, execute them concurrently using Promise.all().
Incorrect (sequential execution, 3 round trips):
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();Correct (parallel execution, 1 round trip):
const [user, posts, comments] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()]);Avoid Barrel File Imports
Import directly from source files instead of barrel files to avoid loading thousands of unused modules. Barrel files are entry points that re-export multiple modules (e.g., index.js that does export * from './module').
Popular icon and component libraries can have up to 10,000 re-exports in their entry file. For many React packages, it takes 200-800ms just to import them, affecting both development speed and production cold starts.
Why tree-shaking doesn't help: When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.
Incorrect (imports entire library):
import { Check, X, Menu } from 'lucide-react';
// Loads 1,583 modules, takes ~2.8s extra in dev
// Runtime cost: 200-800ms on every cold start
import { Button, TextField } from '@mui/material';
// Loads 2,225 modules, takes ~4.2s extra in devCorrect (imports only what you need):
import Check from 'lucide-react/dist/esm/icons/check';
import X from 'lucide-react/dist/esm/icons/x';
import Menu from 'lucide-react/dist/esm/icons/menu';
// Loads only 3 modules (~2KB vs ~1MB)
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
// Loads only what you useDefer Non-Critical Third-Party Libraries
Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
Incorrect (blocks initial bundle):
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}Correct (loads after hydration):
import { lazy, Suspense } from 'react';
const Analytics = lazy(() => import('@vercel/analytics/react').then(m => ({ default: m.Analytics })));
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Suspense fallback={null}>
<Analytics />
</Suspense>
</body>
</html>
);
}Use TanStack Query for Automatic Deduplication
TanStack Query enables request deduplication, caching, and revalidation across component instances.
Incorrect (no deduplication, each instance fetches):
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(setUsers);
}, []);
}Correct (multiple instances share one request):
import { useQuery } from '@tanstack/react-query';
function UserList() {
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
});
}For immutable data:
import { useQuery } from '@tanstack/react-query';
function StaticContent() {
const { data } = useQuery({
queryKey: ['config'],
queryFn: () => fetch('/api/config').then(r => r.json()),
staleTime: Infinity,
});
}For mutations:
import { useMutation } from '@tanstack/react-query';
function UpdateButton() {
const { mutate } = useMutation({
mutationFn: updateUser,
});
return <button onClick={() => mutate()}>Update</button>;
}Reference: https://tanstack.com/query
Early Length Check for Array Comparisons
When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
Incorrect (always runs expensive comparison):
function hasChanges(current: string[], original: string[]) {
// Always sorts and joins, even when lengths differ
return current.sort().join() !== original.sort().join();
}Two O(n log n) sorts run even when current.length is 5 and original.length is 100. There is also overhead of joining the arrays and comparing the strings.
Correct (O(1) length check first):
function hasChanges(current: string[], original: string[]) {
// Early return if lengths differ
if (current.length !== original.length) {
return true;
}
// Only sort/join when lengths match
const currentSorted = current.toSorted();
const originalSorted = original.toSorted();
for (let i = 0; i < currentSorted.length; i++) {
if (currentSorted[i] !== originalSorted[i]) {
return true;
}
}
return false;
}This new approach is more efficient because:
- It avoids the overhead of sorting and joining the arrays when lengths differ
- It avoids consuming memory for the joined strings (especially important for large arrays)
- It avoids mutating the original arrays
- It returns early when a difference is found
Use Set/Map for O(1) Lookups
Convert arrays to Set/Map for repeated membership checks.
Incorrect (O(n) per check):
const allowedIds = ['a', 'b', 'c', ...]
items.filter(item => allowedIds.includes(item.id))Correct (O(1) per check):
const allowedIds = new Set(['a', 'b', 'c', ...])
items.filter(item => allowedIds.has(item.id))Use toSorted() Instead of sort() for Immutability
.sort() mutates the array in place, which can cause bugs with React state and props. Use .toSorted() to create a new sorted array without mutation.
Incorrect (mutates original array):
function UserList({ users }: { users: User[] }) {
// Mutates the users prop array!
const sorted = useMemo(
() => users.sort((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}Correct (creates new array):
function UserList({ users }: { users: User[] }) {
// Creates new sorted array, original unchanged
const sorted = useMemo(
() => users.toSorted((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}Why this matters in React:
1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only 2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
Browser support:
.toSorted() is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
// Fallback for older browsers
const sorted = [...items].sort((a, b) => a.value - b.value);Other immutable array methods:
.toSorted()- immutable sort.toReversed()- immutable reverse.toSpliced()- immutable splice.with()- immutable element replacement
Animate SVG Wrapper Instead of SVG Element
Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a <div> and animate the wrapper instead.
Incorrect (animating SVG directly - no hardware acceleration):
function LoadingSpinner() {
return (
<svg className="animate-spin" width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
);
}Correct (animating wrapper div - hardware accelerated):
function LoadingSpinner() {
return (
<div className="animate-spin">
<svg width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
</div>
);
}This applies to all CSS transforms and transitions (transform, opacity, translate, scale, rotate). The wrapper div allows browsers to use GPU acceleration for smoother animations.
CSS content-visibility for Long Lists
Apply content-visibility: auto to defer off-screen rendering.
CSS:
.message-item {
content-visibility: auto;
contain-intrinsic-size: 0 80px;
}Example:
function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="overflow-y-auto h-screen">
{messages.map(msg => (
<div key={msg.id} className="message-item">
<Avatar user={msg.author} />
<div>{msg.content}</div>
</div>
))}
</div>
);
}For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
Use Lazy State Initialization
Pass a function to useState for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
Incorrect (runs on every render):
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs on EVERY render, even after initialization
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items));
const [query, setQuery] = useState('');
// When query changes, buildSearchIndex runs again unnecessarily
return <SearchResults index={searchIndex} query={query} />;
}
function UserProfile() {
// JSON.parse runs on every render
const [settings, setSettings] = useState(JSON.parse(localStorage.getItem('settings') || '{}'));
return <SettingsForm settings={settings} onChange={setSettings} />;
}Correct (runs only once):
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs ONLY on initial render
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items));
const [query, setQuery] = useState('');
return <SearchResults index={searchIndex} query={query} />;
}
function UserProfile() {
// JSON.parse runs only on initial render
const [settings, setSettings] = useState(() => {
const stored = localStorage.getItem('settings');
return stored ? JSON.parse(stored) : {};
});
return <SettingsForm settings={settings} onChange={setSettings} />;
}Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
For simple primitives (useState(0)), direct references (useState(props.value)), or cheap literals (useState({})), the function form is unnecessary.
Never Reset State with useEffect — Remount via Component Hierarchy
Never use useEffect to reset or re-sync local state when an upstream identity changes (e.g., a form's initial values when the selected product changes). Instead, restructure the component hierarchy: lift the discriminant (the product id) to the top of the tree, fetch the new entity there, and render a skeleton while loading. The skeleton unmounts the branch containing the form, so when the data arrives the form remounts and its useState initializers naturally pick up the fresh initialValues.
Incorrect (useEffect syncs state when product changes):
function ProductForm({ product }: { product: Product }) {
const [name, setName] = useState(product.name);
const [price, setPrice] = useState(product.price);
// Anti-pattern: re-syncing state with an effect.
// Renders once with stale values, then again after the effect runs.
// Every new field must be added here too — easy to forget, causes drift.
useEffect(() => {
setName(product.name);
setPrice(product.price);
}, [product.id]);
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={price} onChange={e => setPrice(e.target.value)} />
</form>
);
}Correct (lift the discriminant; skeleton unmounts the branch, form remounts fresh):
// Top of the tree: the product id is the discriminant
function ProductPage({ productId }: { productId: string }) {
const { data: product, isLoading } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
});
// While the new product loads, the skeleton replaces the form branch.
// The old ProductForm unmounts, so its state is discarded.
if (isLoading) return <ProductFormSkeleton />;
// Fresh mount: useState initializers run again with the new values.
return <ProductForm initialValues={product} />;
}
function ProductForm({ initialValues }: { initialValues: Product }) {
// Initialized once per mount — no sync effect needed, ever.
const [name, setName] = useState(initialValues.name);
const [price, setPrice] = useState(initialValues.price);
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={price} onChange={e => setPrice(e.target.value)} />
</form>
);
}Fallback when data is already local (no fetch boundary):
// key forces a remount when the product changes
<ProductForm key={product.id} initialValues={product} />Prefer the hierarchy restructure (fetch high + skeleton) over key: it also removes the stale-data problem because the form can never render with the previous product's values. Use key as the minimal fix when no async boundary exists.
Use Transitions for Non-Urgent Updates
Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
Incorrect (blocks UI on every scroll):
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handler = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handler, { passive: true });
return () => window.removeEventListener('scroll', handler);
}, []);
}Correct (non-blocking updates):
import { startTransition } from 'react';
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handler = () => {
startTransition(() => setScrollY(window.scrollY));
};
window.addEventListener('scroll', handler, { passive: true });
return () => window.removeEventListener('scroll', handler);
}, []);
}useEffectEvent when using functions in useEffect
Using useCallback for event handlers adds unnecessary complexity and dependency array management. React 19's useEffectEvent provides a cleaner solution that automatically captures the latest values without requiring dependency tracking.
Incorrect (unnecessary memoization with dependency management):
import { useCallback } from 'react';
export function App() {
// useCallback adds boilerplate and requires managing dependencies
const onSubmit = useCallback((data: FormData) => {
// handle submission
}, []);
return <Form onSubmit={onSubmit} />;
}Correct (useEffectEvent for event handlers):
import { useEffectEvent } from 'react';
export function App() {
// useEffectEvent always sees latest values, no dependency array needed
const onSubmit = useEffectEvent((data: FormData) => {
// handle submission
});
return <Form onSubmit={onSubmit} />;
}Reserve useCallback and useMemo for expensive computations (e.g., processing 200+ entries) where memoization provides measurable performance benefits. For typical event handlers, useEffectEvent is the preferred approach.
One Component or Hook = One Responsibility = One File
Domain components and hooks must each own exactly one responsibility. If one part of a component fetches data, another filters it, another manages form state — split it. Don't fear refactoring: extracting hooks and components is cheap, while bloated components are hard to test, review, and reuse. Enforce 1 file = 1 component (or hook).
Exemption: UI/design-system components (Button, Card, layout primitives) are composition-focused and out of scope for this rule. It targets domain components.
Incorrect (one file, one component, four responsibilities):
// products-page.tsx — fetching + filtering + selection + form, all entangled
function ProductsPage() {
// Responsibility 1: data fetching
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
// Responsibility 2: filtering logic
const [query, setQuery] = useState('');
const [category, setCategory] = useState<Category | null>(null);
const filtered = useMemo(
() =>
(products ?? []).filter(
p => p.name.includes(query) && (!category || p.category === category),
),
[products, query, category],
);
// Responsibility 3: selection
const [selectedId, setSelectedId] = useState<string | null>(null);
// Responsibility 4: edit-form state for the selected product
const selected = filtered.find(p => p.id === selectedId);
const [name, setName] = useState('');
const [price, setPrice] = useState(0);
// ...100 more lines of form handlers, validation, submit logic
return (/* one giant JSX tree mixing filters, list, and form */);
}Correct (each responsibility in its own file):
// use-product-search.ts — hook: fetching + filtering
export function useProductSearch() {
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
const [query, setQuery] = useState('');
const [category, setCategory] = useState<Category | null>(null);
const filtered = useMemo(
() => (products ?? []).filter(p => p.name.includes(query) && (!category || p.category === category)),
[products, query, category],
);
return { filtered, query, setQuery, category, setCategory };
}// product-list.tsx — component: render the list, report selection
export function ProductList({ products, onSelect }: ProductListProps) {
return (
<ul>
{products.map(p => (
<li key={p.id} onClick={() => onSelect(p.id)}>
{p.name}
</li>
))}
</ul>
);
}// product-edit-form.tsx — component: edit one product
export function ProductEditForm({ initialValues }: { initialValues: Product }) {
const [name, setName] = useState(initialValues.name);
const [price, setPrice] = useState(initialValues.price);
// form handlers live here, and only here
return (/* form JSX */);
}// products-page.tsx — component: composition only
export function ProductsPage() {
const { filtered, query, setQuery, category, setCategory } = useProductSearch();
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = filtered.find(p => p.id === selectedId);
return (
<>
<ProductFilters query={query} onQueryChange={setQuery} category={category} onCategoryChange={setCategory} />
<ProductList products={filtered} onSelect={setSelectedId} />
{selected && <ProductEditForm key={selected.id} initialValues={selected} />}
</>
);
}Splitting also narrows re-render scope: typing in the filter no longer re-renders the form.
Smells that trigger a split: multiple unrelated useState/useQuery clusters, comment headers separating "sections", a component you can't name without "And".
The page/container component's single responsibility is composition — wiring hooks and children together is fine.
Related skills
How it compares
Pick react-best-practices over generic React lint skills when AI agents need impact-ranked performance rules with concrete anti-pattern examples, not just style enforcement.
FAQ
What is the highest-priority React performance issue to fix?
Eliminating async waterfalls (Promise.all for independent operations) and reducing bundle size via direct imports instead of barrel files are CRITICAL priority.
When should I apply these guidelines?
Apply when writing new React components, reviewing code for performance issues, refactoring existing React code, or optimizing bundle size and load times.
How are these rules organized?
14 rules across 7 categories prioritized by impact: waterfalls (CRITICAL), bundle optimization (CRITICAL), client-side data fetching (MEDIUM-HIGH), re-renders (MEDIUM), rendering (MEDIUM), JavaScript performance (LOW-MEDIUM), component structure (MEDIUM-HIGH for maintainability).
Is React Best Practices safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.