- 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>
16 KiB
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
- Eliminating Waterfalls — CRITICAL
- Bundle Size Optimization — CRITICAL
- 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.
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])
}