Files
aris/.claude/skills/vercel-react-best-practices/AGENTS.md
kenneth 331a2596fa Add Claude skills for React best practices and web design
- react-best-practices: Performance optimization patterns (client-side only)
- web-design-guidelines: UI review against Web Interface Guidelines

Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 11:53:08 +00:00

16 KiB
Raw Permalink Blame History

React Best Practices

Version 1.0.0

Note: This document is for agents and LLMs to follow when maintaining, generating, or refactoring React codebases. Humans may also find it useful.


Abstract

Performance optimization guide for React applications, designed for AI agents and LLMs. Contains rules across 7 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns).


Table of Contents

  1. Eliminating WaterfallsCRITICAL
  2. Bundle Size OptimizationCRITICAL
  3. Client-Side Data FetchingMEDIUM-HIGH
  4. Re-render OptimizationMEDIUM
  5. Rendering PerformanceMEDIUM
  6. JavaScript PerformanceLOW-MEDIUM
  7. Advanced PatternsLOW

1. Eliminating Waterfalls

Impact: CRITICAL

Waterfalls are the #1 performance killer. Each sequential await adds full network latency.

1.1 Defer Await Until Needed

Impact: HIGH (avoids blocking unused code paths)

Move await operations into the branches where they're actually used.

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

1.2 Dependency-Based Parallelization

Impact: CRITICAL (2-10× improvement)

For operations with partial dependencies, use better-all to maximize parallelism.

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

1.3 Promise.all() for Independent Operations

Impact: CRITICAL (2-10× improvement)

When async operations have no interdependencies, execute them concurrently.

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

Impact: CRITICAL (200-800ms import cost, slow builds)

Import directly from source files instead of barrel files.

Incorrect: imports entire library

import { Check, X, Menu } from 'lucide-react'
// Loads 1,583 modules

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

Libraries commonly affected: lucide-react, @mui/material, @mui/icons-material, @tabler/icons-react, react-icons, @headlessui/react, @radix-ui/react-*, lodash, ramda, date-fns, rxjs, react-use.

2.2 Conditional Module Loading

Impact: HIGH (loads large data only when needed)

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

function AnimationPlayer({ enabled, setEnabled }) {
  const [frames, setFrames] = useState(null)

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

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

2.3 Preload Based on User Intent

Impact: MEDIUM (reduces perceived latency)

Preload heavy bundles before they're needed.

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

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

3. Client-Side Data Fetching

Impact: MEDIUM-HIGH

3.1 Deduplicate Global Event Listeners

Impact: MEDIUM (prevents memory leaks and duplicate handlers)

Use a singleton pattern for global event listeners.

Incorrect: multiple listeners

function useWindowResize(callback) {
  useEffect(() => {
    window.addEventListener('resize', callback)
    return () => window.removeEventListener('resize', callback)
  }, [callback])
}

Correct: shared listener

const listeners = new Set()
let isListening = false

function useWindowResize(callback) {
  useEffect(() => {
    listeners.add(callback)
    
    if (!isListening) {
      isListening = true
      window.addEventListener('resize', (e) => {
        listeners.forEach(fn => fn(e))
      })
    }
    
    return () => listeners.delete(callback)
  }, [callback])
}

3.2 Use Passive Event Listeners

Impact: MEDIUM (improves scroll performance)

Use passive listeners for scroll and touch events.

useEffect(() => {
  const handler = (e) => { /* handle scroll */ }
  window.addEventListener('scroll', handler, { passive: true })
  return () => window.removeEventListener('scroll', handler)
}, [])

3.3 Use SWR for Automatic Deduplication

Impact: HIGH (eliminates duplicate requests)

SWR automatically deduplicates requests to the same key.

import useSWR from 'swr'

function UserProfile({ userId }) {
  const { data } = useSWR(`/api/users/${userId}`, fetcher)
  return <div>{data?.name}</div>
}

// Multiple components using the same key = single request

3.4 Version and Minimize localStorage Data

Impact: MEDIUM (prevents data corruption)

Use schema versioning for localStorage.

const STORAGE_VERSION = 2

interface StoredData {
  version: number
  data: UserPreferences
}

function loadPreferences(): UserPreferences {
  const raw = localStorage.getItem('prefs')
  if (!raw) return defaultPreferences
  
  const stored: StoredData = JSON.parse(raw)
  if (stored.version !== STORAGE_VERSION) {
    return migrate(stored)
  }
  return stored.data
}

4. Re-render Optimization

Impact: MEDIUM

4.1 Defer State Reads to Usage Point

Impact: MEDIUM (avoids unnecessary re-renders)

Don't subscribe to state that's only used in callbacks.

Incorrect: re-renders on every count change

function Counter() {
  const count = useStore(state => state.count)
  const increment = useStore(state => state.increment)
  
  return <button onClick={() => increment()}>+</button>
}

Correct: no re-renders from count changes

function Counter() {
  const increment = useStore(state => state.increment)
  
  return <button onClick={() => increment()}>+</button>
}

4.2 Extract to Memoized Components

Impact: MEDIUM (isolates expensive renders)

Extract expensive computations into memoized child components.

const ExpensiveList = memo(function ExpensiveList({ items }) {
  return items.map(item => <ExpensiveItem key={item.id} item={item} />)
})

function Parent() {
  const [filter, setFilter] = useState('')
  const items = useItems()
  
  return (
    <>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      <ExpensiveList items={items} />
    </>
  )
}

4.3 Narrow Effect Dependencies

Impact: MEDIUM (reduces effect runs)

Use primitive dependencies instead of objects.

Incorrect: runs on every render

useEffect(() => {
  fetchData(options)
}, [options]) // Object reference changes every render

Correct: runs only when values change

useEffect(() => {
  fetchData({ page, limit })
}, [page, limit])

4.4 Subscribe to Derived State

Impact: MEDIUM (reduces re-renders)

Subscribe to derived booleans instead of raw values.

Incorrect: re-renders on every count change

function Badge() {
  const count = useStore(state => state.notifications.length)
  return count > 0 ? <span>New</span> : null
}

Correct: re-renders only when visibility changes

function Badge() {
  const hasNotifications = useStore(state => state.notifications.length > 0)
  return hasNotifications ? <span>New</span> : null
}

4.5 Use Functional setState Updates

Impact: MEDIUM (stable callback references)

Use functional updates to avoid dependency on current state.

// Incorrect: callback changes when count changes
const increment = useCallback(() => {
  setCount(count + 1)
}, [count])

// Correct: stable callback reference
const increment = useCallback(() => {
  setCount(c => c + 1)
}, [])

4.6 Use Lazy State Initialization

Impact: LOW-MEDIUM (avoids expensive initial computation)

Pass a function to useState for expensive initial values.

// Incorrect: parses on every render
const [data, setData] = useState(JSON.parse(localStorage.getItem('data')))

// Correct: parses only once
const [data, setData] = useState(() => JSON.parse(localStorage.getItem('data')))

4.7 Use Transitions for Non-Urgent Updates

Impact: MEDIUM (keeps UI responsive)

Use startTransition for updates that can be deferred.

import { startTransition } from 'react'

function SearchResults() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  
  const handleChange = (e) => {
    setQuery(e.target.value) // Urgent: update input immediately
    
    startTransition(() => {
      setResults(search(e.target.value)) // Non-urgent: can be interrupted
    })
  }
  
  return (
    <>
      <input value={query} onChange={handleChange} />
      <ResultsList results={results} />
    </>
  )
}

5. Rendering Performance

Impact: MEDIUM

5.1 Animate SVG Wrapper Instead of SVG Element

Impact: MEDIUM (avoids SVG re-parsing)

Wrap SVGs in a div and animate the wrapper.

// Incorrect: triggers SVG re-parsing
<motion.svg animate={{ scale: 1.2 }}>...</motion.svg>

// Correct: animates wrapper only
<motion.div animate={{ scale: 1.2 }}>
  <svg>...</svg>
</motion.div>

5.2 CSS content-visibility for Long Lists

Impact: HIGH (skips off-screen rendering)

Use content-visibility to skip rendering off-screen items.

.list-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 50px;
}

5.3 Hoist Static JSX Elements

Impact: LOW-MEDIUM (avoids recreation)

Extract static JSX outside components.

const staticIcon = <Icon name="check" />

function ListItem({ label }) {
  return (
    <div>
      {staticIcon}
      <span>{label}</span>
    </div>
  )
}

5.4 Optimize SVG Precision

Impact: LOW (reduces SVG size)

Reduce coordinate precision in SVGs.

// Before: 847 bytes
<path d="M12.7071067811865 5.29289321881345..." />

// After: 324 bytes
<path d="M12.71 5.29..." />

5.5 Use Explicit Conditional Rendering

Impact: LOW (prevents rendering bugs)

Use ternary instead of && for conditionals.

// Incorrect: renders "0" when count is 0
{count && <Badge count={count} />}

// Correct: renders nothing when count is 0
{count > 0 ? <Badge count={count} /> : null}

6. JavaScript Performance

Impact: LOW-MEDIUM

6.1 Batch DOM CSS Changes

Impact: MEDIUM (reduces reflows)

Group CSS changes via classes or cssText.

// Incorrect: 3 reflows
element.style.width = '100px'
element.style.height = '100px'
element.style.margin = '10px'

// Correct: 1 reflow
element.style.cssText = 'width: 100px; height: 100px; margin: 10px;'

6.2 Build Index Maps for Repeated Lookups

Impact: HIGH for large datasets

Build a Map for O(1) lookups instead of O(n) array searches.

// Incorrect: O(n) for each lookup
users.find(u => u.id === targetId)

// Correct: O(1) lookup
const userMap = new Map(users.map(u => [u.id, u]))
userMap.get(targetId)

6.3 Cache Property Access in Loops

Impact: LOW-MEDIUM

Cache object properties accessed in loops.

// Incorrect
for (let i = 0; i < items.length; i++) {
  process(items[i])
}

// Correct
const len = items.length
for (let i = 0; i < len; i++) {
  process(items[i])
}

6.4 Cache Repeated Function Calls

Impact: MEDIUM

Cache expensive function results.

const cache = new Map()

function expensiveComputation(input) {
  if (cache.has(input)) return cache.get(input)
  const result = /* expensive work */
  cache.set(input, result)
  return result
}

6.5 Cache Storage API Calls

Impact: MEDIUM

Cache localStorage/sessionStorage reads.

let cachedTheme = null

function getTheme() {
  if (cachedTheme === null) {
    cachedTheme = localStorage.getItem('theme') || 'light'
  }
  return cachedTheme
}

6.6 Combine Multiple Array Iterations

Impact: LOW-MEDIUM

Combine filter/map into a single loop.

// Incorrect: 2 iterations
const result = items
  .filter(item => item.active)
  .map(item => item.value)

// Correct: 1 iteration
const result = []
for (const item of items) {
  if (item.active) result.push(item.value)
}

6.7 Early Length Check for Array Comparisons

Impact: LOW

Check array length before expensive comparisons.

function arraysEqual(a, b) {
  if (a.length !== b.length) return false
  return a.every((val, i) => val === b[i])
}

6.8 Early Return from Functions

Impact: LOW

Return early to avoid unnecessary work.

function processUser(user) {
  if (!user) return null
  if (!user.active) return null
  
  // Process active user
}

6.9 Hoist RegExp Creation

Impact: LOW-MEDIUM

Create RegExp outside loops.

// Incorrect: creates regex on each iteration
items.forEach(item => {
  if (/pattern/.test(item)) { /* ... */ }
})

// Correct: reuses regex
const pattern = /pattern/
items.forEach(item => {
  if (pattern.test(item)) { /* ... */ }
})

6.10 Use Loop for Min/Max Instead of Sort

Impact: MEDIUM for large arrays

Use a loop instead of sorting to find min/max.

// Incorrect: O(n log n)
const max = items.sort((a, b) => b - a)[0]

// Correct: O(n)
const max = Math.max(...items)
// Or for very large arrays:
let max = items[0]
for (let i = 1; i < items.length; i++) {
  if (items[i] > max) max = items[i]
}

6.11 Use Set/Map for O(1) Lookups

Impact: HIGH for repeated lookups

Use Set for membership checks, Map for key-value lookups.

// Incorrect: O(n)
const isValid = validIds.includes(id)

// Correct: O(1)
const validIdSet = new Set(validIds)
const isValid = validIdSet.has(id)

6.12 Use toSorted() for Immutability

Impact: LOW

Use toSorted() instead of sort() to avoid mutation.

// Mutates original
const sorted = items.sort((a, b) => a - b)

// Returns new array
const sorted = items.toSorted((a, b) => a - b)

7. Advanced Patterns

Impact: LOW

7.1 Store Event Handlers in Refs

Impact: LOW (avoids effect re-runs)

Store handlers in refs to avoid effect dependencies.

function useEventListener(event, handler) {
  const handlerRef = useRef(handler)
  handlerRef.current = handler
  
  useEffect(() => {
    const listener = (e) => handlerRef.current(e)
    window.addEventListener(event, listener)
    return () => window.removeEventListener(event, listener)
  }, [event]) // handler not in deps
}

7.2 useLatest for Stable Callback Refs

Impact: LOW

Create a useLatest hook for stable references.

function useLatest(value) {
  const ref = useRef(value)
  ref.current = value
  return ref
}

function useInterval(callback, delay) {
  const callbackRef = useLatest(callback)
  
  useEffect(() => {
    const id = setInterval(() => callbackRef.current(), delay)
    return () => clearInterval(id)
  }, [delay])
}

References

  1. https://github.com/shuding/better-all
  2. https://vercel.com/blog/how-we-optimized-package-imports-in-next-js