React Best Practices
Comprehensive performance optimization guide for React and Next.js applications. Contains 45+ rules across 8 categories, prioritized by impact.
When to Use
Reference these guidelines when:
-
Writing new React components or Next.js pages
-
Implementing data fetching (client or server-side)
-
Reviewing code for performance issues
-
Refactoring existing React/Next.js code
-
Optimizing bundle size or load times
Relevant Agents: Blaze (React/Next.js), Spark (Electron), Tap (Expo/React Native)
Rule Categories by Priority
Priority Category Impact
1 Eliminating Waterfalls CRITICAL
2 Bundle Size Optimization CRITICAL
3 Server-Side Performance HIGH
4 Client-Side Data Fetching MEDIUM-HIGH
5 Re-render Optimization MEDIUM
6 Rendering Performance MEDIUM
7 JavaScript Performance LOW-MEDIUM
- Eliminating Waterfalls (CRITICAL)
Waterfalls are the #1 performance killer. Fix these first.
Move await into branches
Bad:
async function handleRequest(userId: string, skipProcessing: boolean) { const userData = await fetchUserData(userId); // Always waits
if (skipProcessing) { return { skipped: true }; // Waited for nothing }
return processUserData(userData); }
Good:
async function handleRequest(userId: string, skipProcessing: boolean) { if (skipProcessing) { return { skipped: true }; // Returns immediately }
const userData = await fetchUserData(userId); return processUserData(userData); }
Parallelize independent operations
Bad:
const user = await fetchUser(id); const posts = await fetchPosts(id); const comments = await fetchComments(id); // Total time: user + posts + comments
Good:
const [user, posts, comments] = await Promise.all([ fetchUser(id), fetchPosts(id), fetchComments(id), ]); // Total time: max(user, posts, comments)
Start promises early, await late
Bad:
export async function GET() { const data = await fetchData(); // Blocks immediately const transformed = transform(data); return Response.json(transformed); }
Good:
export async function GET() { const dataPromise = fetchData(); // Start immediately // ... do other setup work ... const data = await dataPromise; // Await when needed return Response.json(transform(data)); }
Use Suspense boundaries for streaming
<Suspense fallback={<Loading />}> <SlowComponent /> </Suspense>
- Bundle Size Optimization (CRITICAL)
Every KB matters for initial load.
Import directly, avoid barrel files
Bad:
import { Button } from '@/components'; // Pulls entire barrel
Good:
import { Button } from '@/components/Button'; // Only Button
Use dynamic imports for heavy components
Bad:
import { HeavyChart } from './HeavyChart';
function Dashboard() { return showChart ? <HeavyChart /> : null; }
Good:
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), { loading: () => <ChartSkeleton />, });
function Dashboard() { return showChart ? <HeavyChart /> : null; }
Defer third-party scripts
Bad:
import { Analytics } from '@analytics/lib';
function App() { useEffect(() => { Analytics.init(); // Blocks hydration }, []); }
Good:
function App() { useEffect(() => { // Load after hydration import('@analytics/lib').then(({ Analytics }) => { Analytics.init(); }); }, []); }
Preload on hover/focus
function Link({ href, children }) { const preload = () => { const link = document.createElement('link'); link.rel = 'prefetch'; link.href = href; document.head.appendChild(link); };
return ( <a href={href} onMouseEnter={preload} onFocus={preload}> {children} </a> ); }
- Server-Side Performance (HIGH)
Use React.cache() for per-request deduplication
import { cache } from 'react';
const getUser = cache(async (id: string) => { return await db.user.findUnique({ where: { id } }); });
// Multiple components can call getUser(id) - only one DB query
Minimize data passed to client components
Bad:
// Server Component async function UserPage({ id }) { const user = await getFullUser(id); // 50 fields return <ClientProfile user={user} />; }
Good:
// Server Component async function UserPage({ id }) { const user = await getFullUser(id); return ( <ClientProfile name={user.name} avatar={user.avatar} // Only what client needs /> ); }
Use after() for non-blocking operations
import { after } from 'next/server';
export async function POST(request: Request) { const data = await request.json(); const result = await saveToDb(data);
after(async () => { await sendAnalytics(result); await notifyWebhooks(result); });
return Response.json(result); // Returns immediately }
- Client-Side Data Fetching (MEDIUM-HIGH)
Use SWR for automatic deduplication
import useSWR from 'swr';
function useUser(id: string) {
return useSWR(/api/users/${id}, fetcher, {
dedupingInterval: 2000, // Dedup requests within 2s
});
}
// Multiple components using useUser(same-id) = one request
Deduplicate global event listeners
Bad:
function Component() { useEffect(() => { window.addEventListener('resize', handler); // Each instance adds one return () => window.removeEventListener('resize', handler); }, []); }
Good:
// Shared hook with ref counting const listeners = new Set();
function useWindowResize(handler: () => void) { useEffect(() => { listeners.add(handler); if (listeners.size === 1) { window.addEventListener('resize', notifyAll); } return () => { listeners.delete(handler); if (listeners.size === 0) { window.removeEventListener('resize', notifyAll); } }; }, [handler]); }
- Re-render Optimization (MEDIUM)
Don't subscribe to state only used in callbacks
Bad:
function Form() { const [value, setValue] = useState(''); // Re-renders on every keystroke
const handleSubmit = () => { submitForm(value); };
return <input onChange={e => setValue(e.target.value)} />; }
Good:
function Form() { const valueRef = useRef('');
const handleSubmit = () => { submitForm(valueRef.current); };
return <input onChange={e => { valueRef.current = e.target.value }} />; }
Extract expensive work into memoized components
Bad:
function Parent({ data, filter }) { return ( <div> <ExpensiveList data={data} /> {/* Re-renders when filter changes */} <Filter value={filter} /> </div> ); }
Good:
const MemoizedList = memo(ExpensiveList);
function Parent({ data, filter }) { return ( <div> <MemoizedList data={data} /> {/* Only re-renders when data changes */} <Filter value={filter} /> </div> ); }
Use functional setState for stable callbacks
Bad:
const increment = useCallback(() => { setCount(count + 1); // Dependency on count }, [count]);
Good:
const increment = useCallback(() => { setCount(c => c + 1); // No dependencies }, []);
Use startTransition for non-urgent updates
import { startTransition } from 'react';
function SearchBox() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]);
const handleChange = (e) => { setQuery(e.target.value); // Urgent: update input
startTransition(() => {
setResults(search(e.target.value)); // Non-urgent: can be interrupted
});
}; }
- Rendering Performance (MEDIUM)
Animate wrapper divs, not SVG elements
Bad:
<motion.svg animate={{ scale: 1.2 }}> {/* Triggers SVG recalc */} <path d="..." /> </motion.svg>
Good:
<motion.div animate={{ scale: 1.2 }}> {/* GPU accelerated */} <svg><path d="..." /></svg> </motion.div>
Use content-visibility for long lists
.list-item { content-visibility: auto; contain-intrinsic-size: 0 50px; }
Extract static JSX outside components
Bad:
function Component() { return ( <div> <header>Static Header</header> {/* Recreated every render */} <DynamicContent /> </div> ); }
Good:
const StaticHeader = <header>Static Header</header>;
function Component() { return ( <div> {StaticHeader} {/* Same reference */} <DynamicContent /> </div> ); }
Use ternary, not && for conditionals
Bad:
{items.length && <List items={items} />} // Renders "0" when empty
Good:
{items.length > 0 ? <List items={items} /> : null}
- JavaScript Performance (LOW-MEDIUM)
Build Map for repeated lookups
Bad:
users.forEach(user => { const role = roles.find(r => r.userId === user.id); // O(n) each time });
Good:
const roleMap = new Map(roles.map(r => [r.userId, r])); users.forEach(user => { const role = roleMap.get(user.id); // O(1) });
Combine multiple iterations
Bad:
const active = users.filter(u => u.active); const names = active.map(u => u.name); const sorted = names.sort(); // 3 iterations
Good:
const names = []; for (const u of users) { if (u.active) names.push(u.name); } names.sort(); // 1 iteration + sort
Check length before expensive operations
Bad:
if (items.some(item => expensiveCheck(item))) { ... }
Good:
if (items.length > 0 && items.some(item => expensiveCheck(item))) { ... }
Use Set/Map for O(1) lookups
Bad:
const isSelected = selectedIds.includes(id); // O(n)
Good:
const selectedSet = new Set(selectedIds); const isSelected = selectedSet.has(id); // O(1)
Quick Reference Checklist
Before submitting React/Next.js code:
Critical (Fix First)
-
No sequential awaits for independent operations
-
Dynamic imports for components >50KB
-
No barrel file imports in hot paths
-
Third-party scripts loaded after hydration
High Priority
-
Server components minimize client-passed data
-
React.cache() for repeated data fetches
-
Proper Suspense boundaries
Medium Priority
-
Memoized expensive components
-
Stable callback references (functional setState)
-
No unnecessary re-renders from state subscriptions
Related Skills
-
test-driven-development - TDD for React components
-
verification-before-completion - Verify bundle size, performance metrics