| name | react-best-practices |
| description | Use when sequential user actions hit multiple endpoints needing the same data within seconds. |
| metadata | {"author":"davila7"} |
React Best Practices
Version 0.1.0
Vercel Engineering
January 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring React and Next.js codebases at Vercel. Humans
may also find it useful, but guidance here is optimized for automation
and consistency by AI-assisted workflows.
Abstract
Comprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
Table of Contents
- Eliminating Waterfalls — CRITICAL
- Bundle Size Optimization — CRITICAL
- Server-Side Performance — HIGH
- Client-Side Data Fetching — MEDIUM-HIGH
- Re-render Optimization — MEDIUM
- Rendering Performance — MEDIUM
- JavaScript Performance — LOW-MEDIUM
- Advanced Patterns — LOW
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 Defer Await Until Needed
Impact: HIGH (avoids blocking unused code paths)
Move await operations into the branches where they're actually used to avoid blocking code paths that don't need them.
Incorrect: blocks both branches
async function handleRequest(userId: string, skipProcessing: boolean) {
const userData = await fetchUserData(userId)
if (skipProcessing) {
return { skipped: true }
}
return processUserData(userData)
}
Correct: only blocks when needed
async function handleRequest(userId: string, skipProcessing: boolean) {
if (skipProcessing) {
return { skipped: true }
}
const userData = await fetchUserData(userId)
return processUserData(userData)
}
Another example: early return optimization
async function updateResource(resourceId: string, userId: string) {
const permissions = await fetchPermissions(userId)
const resource = await getResource(resourceId)
if (!resource) {
return { error: 'Not found' }
}
if (!permissions.canEdit) {
return { error: 'Forbidden' }
}
return await updateResourceData(resource, permissions)
}
async function updateResource(resourceId: string, userId: string) {
const resource = await getResource(resourceId)
if (!resource) {
return { error: 'Not found' }
}
const permissions = await fetchPermissions(userId)
if (!permissions.canEdit) {
return { error: 'Forbidden' }
}
return await updateResourceData(resource, permissions)
}
This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
1.2 Dependency-Based Parallelization
Impact: CRITICAL (2-10× improvement)
For operations with partial dependencies, use better-all to maximize parallelism. It automatically starts each task at the earliest possible moment.
Incorrect: profile waits for config unnecessarily
const [user, config] = await Promise.all([
fetchUser(),
fetchConfig()
])
const profile = await fetchProfile(user.id)
Correct: config and profile run in parallel
import { all } from 'better-all'
const { user, config, profile } = await all({
async user() { return fetchUser() },
async config() { return fetchConfig() },
async profile() {
return fetchProfile((await this.$.user).id)
}
})
Reference: https://github.com/shuding/better-all
1.3 Prevent Waterfall Chains in API Routes
Impact: CRITICAL (2-10× improvement)
In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
Incorrect: config waits for auth, data waits for both
export async function GET(request: Request) {
const session = await auth()
const config = await fetchConfig()
const data = await fetchData(session.user.id)
return Response.json({ data, config })
}
Correct: auth and config start immediately
export async function GET(request: Request) {
const sessionPromise = auth()
const configPromise = fetchConfig()
const session = await sessionPromise
const [config, data] = await Promise.all([
configPromise,
fetchData(session.user.id)
])
return Response.json({ data, config })
}
For operations with more complex dependency chains, use better-all to automatically maximize parallelism (see Dependency-Based Parallelization).
1.4 Promise.all() for Independent Operations
Impact: CRITICAL (2-10× improvement)
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()
])
1.5 Strategic Suspense Boundaries
Impact: HIGH (faster initial paint)
Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
Incorrect: wrapper blocked by data fetching
async function Page() {
const data = await fetchData()
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div>
<DataDisplay data={data} />
</div>
<div>Footer</div>
</div>
)
}
The entire layout waits for data even though only the middle section needs it.
Correct: wrapper shows immediately, data streams in
function Page() {
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div>
<Suspense fallback={<Skeleton />}>
<DataDisplay />
</Suspense>
</div>
<div>Footer</div>
</div>
)
}
async function DataDisplay() {
const data = await fetchData()
return <div>{data.content}</div>
}
Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
Alternative: share promise across components
function Page() {
const dataPromise = fetchData()
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<Suspense fallback={<Skeleton />}>
<DataDisplay dataPromise={dataPromise} />
<DataSummary dataPromise={dataPromise} />
</Suspense>
<div>Footer</div>
</div>
)
}
function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise)
return <div>{data.content}</div>
}
function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise)
return <div>{data.summary}</div>
}
Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.
When NOT to use this pattern:
-
Critical data needed for layout decisions (affects positioning)
-
SEO-critical content above the fold
-
Small, fast queries where suspense overhead isn't worth it
-
When you want to avoid layout shift (loading → content jump)
Trade-off: Faster initial paint vs potential layout shift. Choose based on your UX priorities.
2. Bundle Size Optimization
Impact: CRITICAL
Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
2.1 Avoid Barrel File Imports
Impact: CRITICAL (200-800ms import cost, slow builds)
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'
import { Button, TextField } from '@mui/material'
Correct: imports only what you need
import Check from 'lucide-react/dist/esm/icons/check'