Hermes-native port of Vercel's official react-best-practices agent skill (vercel-labs/agent-skills, MIT). React/Next.js performance guidance from Vercel Engineering: waterfall elimination, bundle optimization, server-side patterns, re-render hygiene, and more. Core SKILL.md plus 8 on-demand reference files; upstream rule IDs preserved for traceability. Credit: Vercel (vercel-labs).
20 KiB
Re-render Optimization (rerender-*)
Impact: MEDIUM. Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.
Rules below are from Vercel's react-best-practices skill (MIT, vercel-labs/agent-skills). Rule IDs match upstream filenames for traceability.
rerender-defer-reads — Defer State Reads to Usage Point
Impact: MEDIUM (avoids unnecessary subscriptions)
Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
Incorrect (subscribes to all searchParams changes):
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):
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>
}
rerender-dependencies — Narrow Effect Dependencies
Impact: LOW (minimizes effect re-runs)
Specify primitive dependencies instead of objects to minimize effect re-runs.
Incorrect (re-runs on any user field change):
useEffect(() => {
console.log(user.id)
}, [user])
Correct (re-runs only when id changes):
useEffect(() => {
console.log(user.id)
}, [user.id])
For derived state, compute outside effect:
// Incorrect: runs on width=767, 766, 765...
useEffect(() => {
if (width < 768) {
enableMobileMode()
}
}, [width])
// Correct: runs only on boolean transition
const isMobile = width < 768
useEffect(() => {
if (isMobile) {
enableMobileMode()
}
}, [isMobile])
rerender-derived-state-no-effect — Calculate Derived State During Rendering
Impact: MEDIUM (avoids redundant renders and state drift)
If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead.
Incorrect (redundant state and effect):
function Form() {
const [firstName, setFirstName] = useState('First')
const [lastName, setLastName] = useState('Last')
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(firstName + ' ' + lastName)
}, [firstName, lastName])
return <p>{fullName}</p>
}
Correct (derive during render):
function Form() {
const [firstName, setFirstName] = useState('First')
const [lastName, setLastName] = useState('Last')
const fullName = firstName + ' ' + lastName
return <p>{fullName}</p>
}
References: You Might Not Need an Effect
rerender-derived-state — Subscribe to Derived State
Impact: MEDIUM (reduces re-render frequency)
Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
Incorrect (re-renders on every pixel change):
function Sidebar() {
const width = useWindowWidth() // updates continuously
const isMobile = width < 768
return <nav className={isMobile ? 'mobile' : 'desktop'} />
}
Correct (re-renders only when boolean changes):
function Sidebar() {
const isMobile = useMediaQuery('(max-width: 767px)')
return <nav className={isMobile ? 'mobile' : 'desktop'} />
}
rerender-functional-setstate — Use Functional setState Updates
Impact: MEDIUM (prevents stale closures and unnecessary callback recreations)
When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.
Incorrect (requires state as dependency):
function TodoList() {
const [items, setItems] = useState(initialItems)
// Callback must depend on items, recreated on every items change
const addItems = useCallback((newItems: Item[]) => {
setItems([...items, ...newItems])
}, [items]) // ❌ items dependency causes recreations
// Risk of stale closure if dependency is forgotten
const removeItem = useCallback((id: string) => {
setItems(items.filter(item => item.id !== id))
}, []) // ❌ Missing items dependency - will use stale items!
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}
The first callback is recreated every time items changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial items value.
Correct (stable callbacks, no stale closures):
function TodoList() {
const [items, setItems] = useState(initialItems)
// Stable callback, never recreated
const addItems = useCallback((newItems: Item[]) => {
setItems(curr => [...curr, ...newItems])
}, []) // ✅ No dependencies needed
// Always uses latest state, no stale closure risk
const removeItem = useCallback((id: string) => {
setItems(curr => curr.filter(item => item.id !== id))
}, []) // ✅ Safe and stable
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}
Benefits:
- Stable callback references - Callbacks don't need to be recreated when state changes
- No stale closures - Always operates on the latest state value
- Fewer dependencies - Simplifies dependency arrays and reduces memory leaks
- Prevents bugs - Eliminates the most common source of React closure bugs
When to use functional updates:
- Any setState that depends on the current state value
- Inside useCallback/useMemo when state is needed
- Event handlers that reference state
- Async operations that update state
When direct updates are fine:
- Setting state to a static value:
setCount(0) - Setting state from props/arguments only:
setName(newName) - State doesn't depend on previous value
Note: If your project has React Compiler enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.
rerender-lazy-state-init — Use Lazy State Initialization
Impact: MEDIUM (wasted computation on every render)
Pass a function to useState for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
Incorrect (runs on every render):
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs on EVERY render, even after initialization
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
const [query, setQuery] = useState('')
// When query changes, buildSearchIndex runs again unnecessarily
return <SearchResults index={searchIndex} query={query} />
}
function UserProfile() {
// JSON.parse runs on every render
const [settings, setSettings] = useState(
JSON.parse(localStorage.getItem('settings') || '{}')
)
return <SettingsForm settings={settings} onChange={setSettings} />
}
Correct (runs only once):
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs ONLY on initial render
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
const [query, setQuery] = useState('')
return <SearchResults index={searchIndex} query={query} />
}
function UserProfile() {
// JSON.parse runs only on initial render
const [settings, setSettings] = useState(() => {
const stored = localStorage.getItem('settings')
return stored ? JSON.parse(stored) : {}
})
return <SettingsForm settings={settings} onChange={setSettings} />
}
Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
For simple primitives (useState(0)), direct references (useState(props.value)), or cheap literals (useState({})), the function form is unnecessary.
rerender-memo-with-default-value — Extract Default Non-primitive Parameter Value from Memoized Component to Constant
Impact: MEDIUM (restores memoization by using a constant for default value)
When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in memo().
To address this issue, extract the default value into a constant.
Incorrect (onClick has different values on every rerender):
const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
// ...
})
// Used without optional onClick
<UserAvatar />
Correct (stable default value):
const NOOP = () => {};
const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
// ...
})
// Used without optional onClick
<UserAvatar />
rerender-memo — Extract to Memoized Components
Impact: MEDIUM (enables early returns)
Extract expensive work into memoized components to enable early returns before computation.
Incorrect (computes avatar even when loading):
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):
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>
)
}
Note: If your project has React Compiler enabled, manual memoization with memo() and useMemo() is not necessary. The compiler automatically optimizes re-renders.
rerender-move-effect-to-event — Put Interaction Logic in Event Handlers
Impact: MEDIUM (avoids effect re-runs and duplicate side effects)
If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action.
Incorrect (event modeled as state + effect):
function Form() {
const [submitted, setSubmitted] = useState(false)
const theme = useContext(ThemeContext)
useEffect(() => {
if (submitted) {
post('/api/register')
showToast('Registered', theme)
}
}, [submitted, theme])
return <button onClick={() => setSubmitted(true)}>Submit</button>
}
Correct (do it in the handler):
function Form() {
const theme = useContext(ThemeContext)
function handleSubmit() {
post('/api/register')
showToast('Registered', theme)
}
return <button onClick={handleSubmit}>Submit</button>
}
Reference: Should this code move to an event handler?
rerender-no-inline-components — Don't Define Components Inside Components
Impact: HIGH (prevents remount on every render)
Impact: HIGH (prevents remount on every render)
Defining a component inside another component creates a new component type on every render. React sees a different component each time and fully remounts it, destroying all state and DOM.
A common reason developers do this is to access parent variables without passing props. Always pass props instead.
Incorrect (remounts on every render):
function UserProfile({ user, theme }) {
// Defined inside to access `theme` - BAD
const Avatar = () => (
<img
src={user.avatarUrl}
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
/>
)
// Defined inside to access `user` - BAD
const Stats = () => (
<div>
<span>{user.followers} followers</span>
<span>{user.posts} posts</span>
</div>
)
return (
<div>
<Avatar />
<Stats />
</div>
)
}
Every time UserProfile renders, Avatar and Stats are new component types. React unmounts the old instances and mounts new ones, losing any internal state, running effects again, and recreating DOM nodes.
Correct (pass props instead):
function Avatar({ src, theme }: { src: string; theme: string }) {
return (
<img
src={src}
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
/>
)
}
function Stats({ followers, posts }: { followers: number; posts: number }) {
return (
<div>
<span>{followers} followers</span>
<span>{posts} posts</span>
</div>
)
}
function UserProfile({ user, theme }) {
return (
<div>
<Avatar src={user.avatarUrl} theme={theme} />
<Stats followers={user.followers} posts={user.posts} />
</div>
)
}
Symptoms of this bug:
- Input fields lose focus on every keystroke
- Animations restart unexpectedly
useEffectcleanup/setup runs on every parent render- Scroll position resets inside the component
rerender-simple-expression-in-memo — Do not wrap a simple expression with a primitive result type in useMemo
Impact: LOW-MEDIUM (wasted computation on every render)
When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in useMemo.
Calling useMemo and comparing hook dependencies may consume more resources than the expression itself.
Incorrect:
function Header({ user, notifications }: Props) {
const isLoading = useMemo(() => {
return user.isLoading || notifications.isLoading
}, [user.isLoading, notifications.isLoading])
if (isLoading) return <Skeleton />
// return some markup
}
Correct:
function Header({ user, notifications }: Props) {
const isLoading = user.isLoading || notifications.isLoading
if (isLoading) return <Skeleton />
// return some markup
}
rerender-split-combined-hooks — Split Combined Hook Computations
Impact: MEDIUM (avoids recomputing independent steps)
When a hook contains multiple independent tasks with different dependencies, split them into separate hooks. A combined hook reruns all tasks when any dependency changes, even if some tasks don't use the changed value.
Incorrect (changing sortOrder recomputes filtering):
const sortedProducts = useMemo(() => {
const filtered = products.filter((p) => p.category === category)
const sorted = filtered.toSorted((a, b) =>
sortOrder === "asc" ? a.price - b.price : b.price - a.price
)
return sorted
}, [products, category, sortOrder])
Correct (filtering only recomputes when products or category change):
const filteredProducts = useMemo(
() => products.filter((p) => p.category === category),
[products, category]
)
const sortedProducts = useMemo(
() =>
filteredProducts.toSorted((a, b) =>
sortOrder === "asc" ? a.price - b.price : b.price - a.price
),
[filteredProducts, sortOrder]
)
This pattern also applies to useEffect when combining unrelated side effects:
Incorrect (both effects run when either dependency changes):
useEffect(() => {
analytics.trackPageView(pathname)
document.title = `${pageTitle} | My App`
}, [pathname, pageTitle])
Correct (effects run independently):
useEffect(() => {
analytics.trackPageView(pathname)
}, [pathname])
useEffect(() => {
document.title = `${pageTitle} | My App`
}, [pageTitle])
Note: If your project has React Compiler enabled, it automatically optimizes dependency tracking and may handle some of these cases for you.
rerender-transitions — Use Transitions for Non-Urgent Updates
Impact: MEDIUM (maintains UI responsiveness)
Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
Incorrect (blocks UI on every scroll):
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):
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)
}, [])
}
rerender-use-deferred-value — Use useDeferredValue for Expensive Derived Renders
Impact: MEDIUM (keeps input responsive during heavy computation)
When user input triggers expensive computations or renders, use useDeferredValue to keep the input responsive. The deferred value lags behind, allowing React to prioritize the input update and render the expensive result when idle.
Incorrect (input feels laggy while filtering):
function Search({ items }: { items: Item[] }) {
const [query, setQuery] = useState('')
const filtered = items.filter(item => fuzzyMatch(item, query))
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultsList results={filtered} />
</>
)
}
Correct (input stays snappy, results render when ready):
function Search({ items }: { items: Item[] }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const filtered = useMemo(
() => items.filter(item => fuzzyMatch(item, deferredQuery)),
[items, deferredQuery]
)
const isStale = query !== deferredQuery
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ResultsList results={filtered} />
</div>
</>
)
}
When to use:
- Filtering/searching large lists
- Expensive visualizations (charts, graphs) reacting to input
- Any derived state that causes noticeable render delays
Note: Wrap the expensive computation in useMemo with the deferred value as a dependency, otherwise it still runs on every render.
Reference: React useDeferredValue
rerender-use-ref-transient-values — Use useRef for Transient Values
Impact: MEDIUM (avoids unnecessary re-renders on frequent updates)
When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in useRef instead of useState. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.
Incorrect (renders every update):
function Tracker() {
const [lastX, setLastX] = useState(0)
useEffect(() => {
const onMove = (e: MouseEvent) => setLastX(e.clientX)
window.addEventListener('mousemove', onMove)
return () => window.removeEventListener('mousemove', onMove)
}, [])
return (
<div
style={{
position: 'fixed',
top: 0,
left: lastX,
width: 8,
height: 8,
background: 'black',
}}
/>
)
}
Correct (no re-render for tracking):
function Tracker() {
const lastXRef = useRef(0)
const dotRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const onMove = (e: MouseEvent) => {
lastXRef.current = e.clientX
const node = dotRef.current
if (node) {
node.style.transform = `translateX(${e.clientX}px)`
}
}
window.addEventListener('mousemove', onMove)
return () => window.removeEventListener('mousemove', onMove)
}, [])
return (
<div
ref={dotRef}
style={{
position: 'fixed',
top: 0,
left: 0,
width: 8,
height: 8,
background: 'black',
transform: 'translateX(0px)',
}}
/>
)
}