AgentSkillsCN

React Performance

React性能优化指南。当您需要优化React/Next.js应用、修复性能问题、缩减打包体积、消除瀑布效应,或提升渲染速度时,可使用此指南。

SKILL.md
--- frontmatter
description: React performance optimization guidelines. Use when optimizing React/Next.js apps, fixing performance issues, reducing bundle size, eliminating waterfalls, or improving rendering speed.

React Performance Guidelines

Version 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

Performance optimization guide for React and Next.js applications, ordered by impact. Sections 1-2 yield the highest gains (2-10x improvements), sections 3-5 provide medium gains (20-50%), and sections 6-8 offer incremental improvements (5-20%) in hot paths.


Table of Contents

  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
  8. Advanced Patterns - LOW

1. Eliminating Waterfalls

Impact: CRITICAL (2-10x improvement)

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):

typescript
const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()

Correct (parallel execution, 1 round trip):

typescript
const [user, posts, comments] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchComments()
])

1.2 Dependency-Based Parallelization

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):

typescript
const [user, config] = await Promise.all([
  fetchUser(),
  fetchConfig()
])
const profile = await fetchProfile(user.id)

Correct (config and profile run in parallel):

typescript
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: better-all

1.3 Prevent Waterfall Chains in API Routes

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):

typescript
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):

typescript
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 })
}

2. Bundle Size Optimization

Impact: CRITICAL (directly affects TTI and LCP)

Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.

2.1 Dynamic Imports for Heavy Components

Use next/dynamic to lazy-load large components not needed on initial render.

Incorrect (Monaco bundles with main chunk ~300KB):

tsx
import { MonacoEditor } from './monaco-editor'

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />
}

Correct (Monaco loads on demand):

tsx
import dynamic from 'next/dynamic'

const MonacoEditor = dynamic(
  () => import('./monaco-editor').then(m => m.MonacoEditor),
  { ssr: false }
)

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />
}

2.2 Preload Based on User Intent

Preload heavy bundles before they're needed to reduce perceived latency.

Example (preload on hover/focus):

tsx
function EditorButton({ onClick }: { onClick: () => void }) {
  const preload = () => {
    void import('./monaco-editor')
  }

  return (
    <button
      onMouseEnter={preload}
      onFocus={preload}
      onClick={onClick}
    >
      Open Editor
    </button>
  )
}

2.3 Conditional Module Loading

Load large data or modules only when a feature is activated.

Example (lazy-load animation frames):

tsx
function AnimationPlayer({ enabled }: { enabled: boolean }) {
  const [frames, setFrames] = useState<Frame[] | null>(null)

  useEffect(() => {
    if (enabled && !frames) {
      import('./animation-frames.js')
        .then(mod => setFrames(mod.frames))
        .catch(() => setEnabled(false))
    }
  }, [enabled, frames])

  if (!frames) return <Skeleton />
  return <Canvas frames={frames} />
}

2.4 Defer Non-Critical Third-Party Libraries

Analytics, logging, and error tracking don't block user interaction. Load them after hydration.

Incorrect (blocks initial bundle):

tsx
import { Analytics } from '@vercel/analytics/react'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

Correct (loads after hydration):

tsx
import dynamic from 'next/dynamic'

const Analytics = dynamic(
  () => import('@vercel/analytics/react').then(m => m.Analytics),
  { ssr: false }
)

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

3. Server-Side Performance

Impact: HIGH (eliminates server-side waterfalls)

3.1 Parallel Data Fetching with Component Composition

React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.

Incorrect (Sidebar waits for Page's fetch to complete):

tsx
export default async function Page() {
  const header = await fetchHeader()
  return (
    <div>
      <div>{header}</div>
      <Sidebar />
    </div>
  )
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

Correct (both fetch simultaneously):

tsx
async function Header() {
  const data = await fetchHeader()
  return <div>{data}</div>
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

export default function Page() {
  return (
    <div>
      <Header />
      <Sidebar />
    </div>
  )
}

3.2 Minimize Serialization at RSC Boundaries

The React Server/Client boundary serializes all object properties. Only pass fields that the client actually uses.

Incorrect (serializes all 50 fields):

tsx
async function Page() {
  const user = await fetchUser()  // 50 fields
  return <Profile user={user} />
}

'use client'
function Profile({ user }: { user: User }) {
  return <div>{user.name}</div>  // uses 1 field
}

Correct (serializes only 1 field):

tsx
async function Page() {
  const user = await fetchUser()
  return <Profile name={user.name} />
}

'use client'
function Profile({ name }: { name: string }) {
  return <div>{name}</div>
}

3.3 Per-Request Deduplication with React.cache()

Use React.cache() for server-side request deduplication. Authentication and database queries benefit most.

Usage:

typescript
import { cache } from 'react'

export const getCurrentUser = cache(async () => {
  const session = await auth()
  if (!session?.user?.id) return null
  return await db.user.findUnique({
    where: { id: session.user.id }
  })
})

Within a single request, multiple calls to getCurrentUser() execute the query only once.

3.4 Cross-Request LRU Caching

React.cache() only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.

Implementation:

typescript
import { LRUCache } from 'lru-cache'

const cache = new LRUCache<string, any>({
  max: 1000,
  ttl: 5 * 60 * 1000  // 5 minutes
})

export async function getUser(id: string) {
  const cached = cache.get(id)
  if (cached) return cached

  const user = await db.user.findUnique({ where: { id } })
  cache.set(id, user)
  return user
}

Use when sequential user actions hit multiple endpoints needing the same data within seconds. In serverless, consider Redis for cross-process caching.


4. Client-Side Data Fetching

Impact: MEDIUM-HIGH (automatic deduplication)

4.1 Use SWR for Automatic Deduplication

SWR enables request deduplication, caching, and revalidation across component instances.

Incorrect (no deduplication, each instance fetches):

tsx
function UserList() {
  const [users, setUsers] = useState([])
  useEffect(() => {
    fetch('/api/users')
      .then(r => r.json())
      .then(setUsers)
  }, [])
}

Correct (multiple instances share one request):

tsx
import useSWR from 'swr'

function UserList() {
  const { data: users } = useSWR('/api/users', fetcher)
}

4.2 Deduplicate Global Event Listeners

Use useSWRSubscription() to share global event listeners across component instances.

Incorrect (N instances = N listeners):

tsx
function KeyboardShortcut({ onTrigger }: Props) {
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && e.key === 'k') {
        onTrigger()
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  }, [onTrigger])
}

Correct (N instances = 1 listener):

tsx
import useSWRSubscription from 'swr/subscription'

function useKeyboardShortcut(key: string, callback: () => void) {
  useSWRSubscription(['keydown', key], (_, { next }) => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && e.key === key) {
        next(null, e)
        callback()
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  })
}

5. Re-render Optimization

Impact: MEDIUM (reduces unnecessary work)

5.1 Subscribe to Derived State

Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.

Incorrect (re-renders on every pixel change):

tsx
function Sidebar() {
  const width = useWindowWidth()  // updates continuously
  const isMobile = width < 768
  return <nav className={isMobile ? 'mobile' : 'desktop'}>
}

Correct (re-renders only when boolean changes):

tsx
function Sidebar() {
  const isMobile = useMediaQuery('(max-width: 767px)')
  return <nav className={isMobile ? 'mobile' : 'desktop'}>
}

5.2 Narrow Effect Dependencies

Specify primitive dependencies instead of objects to minimize effect re-runs.

Incorrect (re-runs on any user field change):

tsx
useEffect(() => {
  console.log(user.id)
}, [user])

Correct (re-runs only when id changes):

tsx
useEffect(() => {
  console.log(user.id)
}, [user.id])

5.3 Use Transitions for Non-Urgent Updates

Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.

Incorrect (blocks UI on every scroll):

tsx
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):

tsx
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)
  }, [])
}

5.4 Extract to Memoized Components

Extract expensive work into memoized components to enable early returns before computation.

Incorrect (computes avatar even when loading):

tsx
function Profile({ user, loading }: Props) {
  const avatar = useMemo(() => {
    const id = computeAvatarId(user)
    return <Avatar id={id} />
  }, [user])

  if (loading) return <Skeleton />
  return <div>{avatar}</div>
}

Correct (skips computation when loading):

tsx
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
  const id = useMemo(() => computeAvatarId(user), [user])
  return <Avatar id={id} />
})

function Profile({ user, loading }: Props) {
  if (loading) return <Skeleton />
  return (
    <div>
      <UserAvatar user={user} />
    </div>
  )
}

5.5 Defer State Reads to Usage Point

Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.

Incorrect (subscribes to all searchParams changes):

tsx
function ShareButton({ chatId }: { chatId: string }) {
  const searchParams = useSearchParams()

  const handleShare = () => {
    const ref = searchParams.get('ref')
    shareChat(chatId, { ref })
  }

  return <button onClick={handleShare}>Share</button>
}

Correct (reads on demand, no subscription):

tsx
function ShareButton({ chatId }: { chatId: string }) {
  const handleShare = () => {
    const params = new URLSearchParams(window.location.search)
    const ref = params.get('ref')
    shareChat(chatId, { ref })
  }

  return <button onClick={handleShare}>Share</button>
}

6. Rendering Performance

Impact: MEDIUM (reduces rendering work)

6.1 Hoist Static JSX Elements

Extract static JSX outside components to avoid re-creation.

Incorrect (recreates element every render):

tsx
function LoadingSkeleton() {
  return <div className="animate-pulse h-20 bg-gray-200" />
}

function Container() {
  return (
    <div>
      {loading && <LoadingSkeleton />}
    </div>
  )
}

Correct (reuses same element):

tsx
const loadingSkeleton = (
  <div className="animate-pulse h-20 bg-gray-200" />
)

function Container() {
  return (
    <div>
      {loading && loadingSkeleton}
    </div>
  )
}

6.2 Use Activity Component for Show/Hide

Use React's <Activity> to preserve state/DOM for expensive components that frequently toggle visibility.

Usage:

tsx
import { Activity } from 'react'

function Dropdown({ isOpen }: Props) {
  return (
    <Activity mode={isOpen ? 'visible' : 'hidden'}>
      <ExpensiveMenu />
    </Activity>
  )
}

6.3 CSS content-visibility for Long Lists

Apply content-visibility: auto to defer off-screen rendering.

CSS:

css
.message-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 80px;
}

Example:

tsx
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 (10x faster initial render).

6.4 Optimize SVG Precision

Reduce SVG coordinate precision to decrease file size.

Incorrect (excessive precision):

svg
<path d="M 10.293847 20.847362 L 30.938472 40.192837" />

Correct (1 decimal place):

svg
<path d="M 10.3 20.8 L 30.9 40.2" />

Automate with SVGO:

bash
npx svgo --precision=1 --multipass icon.svg

7. JavaScript Performance

Impact: LOW-MEDIUM (micro-optimizations for hot paths)

7.1 Combine Multiple Array Iterations

Multiple .filter() or .map() calls iterate the array multiple times. Combine into one loop.

Incorrect (3 iterations):

typescript
const admins = users.filter(u => u.isAdmin)
const testers = users.filter(u => u.isTester)
const inactive = users.filter(u => !u.isActive)

Correct (1 iteration):

typescript
const admins: User[] = []
const testers: User[] = []
const inactive: User[] = []

for (const user of users) {
  if (user.isAdmin) admins.push(user)
  if (user.isTester) testers.push(user)
  if (!user.isActive) inactive.push(user)
}

7.2 Cache Property Access in Loops

Cache object property lookups in hot paths.

Incorrect (3 lookups x N iterations):

typescript
for (let i = 0; i < arr.length; i++) {
  process(obj.config.settings.value)
}

Correct (1 lookup total):

typescript
const value = obj.config.settings.value
const len = arr.length
for (let i = 0; i < len; i++) {
  process(value)
}

7.3 Use Set/Map for O(1) Lookups

Convert arrays to Set/Map for repeated membership checks.

Incorrect (O(n) per check):

typescript
const allowedIds = ['a', 'b', 'c', ...]
items.filter(item => allowedIds.includes(item.id))

Correct (O(1) per check):

typescript
const allowedIds = new Set(['a', 'b', 'c', ...])
items.filter(item => allowedIds.has(item.id))

7.4 Early Exit from Loops

Exit as soon as result is determined.

Incorrect (always iterates all):

typescript
const hasAdmin = users.some(u => u.role === 'admin')
const admin = users.find(u => u.role === 'admin')
// Two iterations

Correct (single pass with early exit):

typescript
let admin: User | undefined
for (const user of users) {
  if (user.role === 'admin') {
    admin = user
    break
  }
}
const hasAdmin = admin !== undefined

7.5 Hoist RegExp Creation

Don't create RegExp inside render. Hoist to module scope or memoize with useMemo().

Incorrect (new RegExp every render):

tsx
function Highlighter({ text, query }: Props) {
  const regex = new RegExp(`(${query})`, 'gi')
  const parts = text.split(regex)
  return <>{parts.map((part, i) => ...)}</>
}

Correct (memoize or hoist):

tsx
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

function Highlighter({ text, query }: Props) {
  const regex = useMemo(
    () => new RegExp(`(${escapeRegex(query)})`, 'gi'),
    [query]
  )
  const parts = text.split(regex)
  return <>{parts.map((part, i) => ...)}</>
}

7.6 Cache Storage API Calls

localStorage, sessionStorage, and document.cookie are synchronous and expensive. Cache reads in memory.

Incorrect (reads storage on every call):

typescript
function getTheme() {
  return localStorage.getItem('theme') ?? 'light'
}
// Called 10 times = 10 storage reads

Correct (Map cache):

typescript
const storageCache = new Map<string, string | null>()

function getLocalStorage(key: string) {
  if (!storageCache.has(key)) {
    storageCache.set(key, localStorage.getItem(key))
  }
  return storageCache.get(key)
}

function setLocalStorage(key: string, value: string) {
  localStorage.setItem(key, value)
  storageCache.set(key, value)  // keep cache in sync
}

7.7 Build Index Maps for Repeated Lookups

Multiple .find() calls by the same key should use a Map.

Incorrect (O(n) per lookup):

typescript
function processOrders(orders: Order[], users: User[]) {
  return orders.map(order => ({
    ...order,
    user: users.find(u => u.id === order.userId)
  }))
}

Correct (O(1) per lookup):

typescript
function processOrders(orders: Order[], users: User[]) {
  const userById = new Map(users.map(u => [u.id, u]))

  return orders.map(order => ({
    ...order,
    user: userById.get(order.userId)
  }))
}

Build map once (O(n)), then all lookups are O(1). For 1000 orders x 1000 users: 1M ops -> 2K ops.


8. Advanced Patterns

Impact: LOW (advanced patterns for specific cases)

8.1 useLatest for Stable Callback Refs

Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.

Implementation:

typescript
function useLatest<T>(value: T) {
  const ref = useRef(value)
  useEffect(() => {
    ref.current = value
  }, [value])
  return ref
}

Incorrect (effect re-runs on every callback change):

tsx
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')

  useEffect(() => {
    const timeout = setTimeout(() => onSearch(query), 300)
    return () => clearTimeout(timeout)
  }, [query, onSearch])
}

Correct (stable effect, fresh callback):

tsx
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')
  const onSearchRef = useLatest(onSearch)

  useEffect(() => {
    const timeout = setTimeout(() => onSearchRef.current(query), 300)
    return () => clearTimeout(timeout)
  }, [query])
}

8.2 Store Event Handlers in Refs

Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.

Incorrect (re-subscribes on every render):

tsx
function useWindowEvent(event: string, handler: () => void) {
  useEffect(() => {
    window.addEventListener(event, handler)
    return () => window.removeEventListener(event, handler)
  }, [event, handler])
}

Correct (stable subscription):

tsx
function useWindowEvent(event: string, handler: () => void) {
  const handlerRef = useRef(handler)
  useEffect(() => {
    handlerRef.current = handler
  }, [handler])

  useEffect(() => {
    const listener = () => handlerRef.current()
    window.addEventListener(event, listener)
    return () => window.removeEventListener(event, listener)
  }, [event])
}

References

  1. React Documentation
  2. Next.js Documentation
  3. SWR Documentation
  4. better-all
  5. LRU Cache