Avoiding Unnecessary Renders
React is fast by default for most apps. But as applications grow, some components re-render far more often than they need to. Each unnecessary render wastes CPU time, can cause UI flicker, and compounds in large trees. The good news: a handful of concrete techniques eliminate the vast majority of unnecessary renders without making code hard to follow.
1. Hoist Stable Objects Outside the Component
Every time a component function runs, any object or array literals inside it are recreated — they are new references even if the values are identical. If those values are passed as props to memoized children or listed in dependency arrays, they cause re-renders or effect re-runs even when nothing logically changed:
// ✗ New object on every render — defeats any memo on <Chart>
function Dashboard() {
const chartOptions = { responsive: true, animation: false } // recreated!
return <Chart options={chartOptions} />
}
// ✓ Define outside the component — same reference forever
const CHART_OPTIONS = { responsive: true, animation: false }
function Dashboard() {
return <Chart options={CHART_OPTIONS} />
}2. useCallback for Event Handlers
Functions defined inside a component are recreated on every render. If you pass them to React.memo-wrapped children, the memo check always fails. useCallback memoizes the function reference:
import { memo, useState, useCallback } from 'react'
const Button = memo(({ onClick, label }) => {
console.log('Button rendered:', label)
return <button onClick={onClick}>{label}</button>
})
function Form() {
const [name, setName] = useState('')
// ✗ New function every render — Button always re-renders
const handleSubmit = () => console.log('Submit:', name)
// ✓ Same function reference when dependencies haven't changed
const handleSubmit = useCallback(() => {
console.log('Submit:', name)
}, [name])
return (
<>
<input value={name} onChange={e => setName(e.target.value)} />
<Button onClick={handleSubmit} label="Submit" />
</>
)
}3. useMemo for Computed Values Passed as Props
When a derived value (sorted list, filtered array, calculated object) is passed to a child, useMemo ensures the reference only changes when the source data changes:
import { memo, useMemo } from 'react'
const SortedTable = memo(({ rows }) => {
return <table>{/* render rows */}</table>
})
function ReportPage({ rawData, sortKey }) {
// ✗ New array on every render — SortedTable always re-renders
const sorted = [...rawData].sort((a, b) => a[sortKey] > b[sortKey] ? 1 : -1)
// ✓ New array only when rawData or sortKey changes
const sorted = useMemo(
() => [...rawData].sort((a, b) => a[sortKey] > b[sortKey] ? 1 : -1),
[rawData, sortKey]
)
return <SortedTable rows={sorted} />
}4. React.memo on Leaf / Expensive Components
Wrap components that are expensive to render and receive stable props in React.memo. This is especially effective for repeated items in large lists:
import { memo } from 'react'
// Without memo: re-renders every time the parent list re-renders,
// even for items whose data didn't change
const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
return (
<div>
<img src={product.imageUrl} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => onAddToCart(product.id)}>Add to cart</button>
</div>
)
})
// In the parent, stabilize the callback so memo is effective:
const handleAdd = useCallback((id) => {
setCart(prev => [...prev, id])
}, [])5. Split Large Components
A component that mixes fast-changing state (like a text input) with slow-changing UI (like a sidebar) causes the slow parts to re-render every keystroke. Extract the fast-changing part into its own component:
// ✗ Every keystroke re-renders the expensive Sidebar
function Page() {
const [query, setQuery] = useState('')
return (
<div style={{ display: 'flex' }}>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ExpensiveSidebar /> {/* re-renders on every keystroke */}
</div>
)
}
// ✓ Extract the input — only SearchBar re-renders on keystrokes
function SearchBar() {
const [query, setQuery] = useState('')
return <input value={query} onChange={e => setQuery(e.target.value)} />
}
function Page() {
return (
<div style={{ display: 'flex' }}>
<SearchBar />
<ExpensiveSidebar /> {/* never re-renders due to typing */}
</div>
)
}6. The Children Composition Pattern
When a parent component has its own state, its children are normally re-rendered too. But if children are passed via props.children (or any render prop), React does not re-create them — the parent component created the element, and re-rendering the wrapper doesn't change it:
// ✗ <ExpensiveTree> re-renders whenever ColorPicker re-renders
function App() {
const [color, setColor] = useState('blue')
return (
<div style={{ color }}>
<input value={color} onChange={e => setColor(e.target.value)} />
<ExpensiveTree /> {/* re-renders on every color change */}
</div>
)
}
// ✓ Move state into a wrapper; ExpensiveTree is passed as children
function ColoredContainer({ children }) {
const [color, setColor] = useState('blue')
return (
<div style={{ color }}>
<input value={color} onChange={e => setColor(e.target.value)} />
{children} {/* React does not re-render children here */}
</div>
)
}
function App() {
return (
<ColoredContainer>
<ExpensiveTree /> {/* only re-renders if App itself re-renders */}
</ColoredContainer>
)
}7. Granular State Subscriptions
React Context re-renders every consumer whenever any part of the context value changes. For high-frequency updates (store, theme with many values), prefer Zustand or Jotai which support selector-based subscriptions — only the components that care about a specific slice re-render:
// Context problem: updating cartCount re-renders ALL consumers,
// including components that only use 'user'
const AppContext = createContext()
const { user, cartCount } = useContext(AppContext) // re-renders on any change
// ──────────────────────────────────────────────────────────────────────
// Zustand solution: component only re-renders when its selected slice changes
import { create } from 'zustand'
const useStore = create(set => ({
user: null,
cartCount: 0,
setUser: user => set({ user }),
addToCart: () => set(state => ({ cartCount: state.cartCount + 1 })),
}))
// Only re-renders when 'user' changes — cartCount updates do NOT trigger this
function UserGreeting() {
const user = useStore(state => state.user)
return <span>Hello, {user?.name}</span>
}
// Only re-renders when 'cartCount' changes — user updates do NOT trigger this
function CartBadge() {
const cartCount = useStore(state => state.cartCount)
return <span>{cartCount}</span>
}8. key to Control Component Identity
React uses key to track component identity across renders. Setting a different key forces React to unmount the old instance and mount a fresh one — which resets all state. This can replace complex reset logic and also prevent stale state bugs:
// ✗ When userId changes, UserProfile keeps its old state (stale cache)
function App({ userId }) {
return <UserProfile userId={userId} />
}
// ✓ Different key → React unmounts old instance, mounts fresh one
// All useState/useEffect in UserProfile start clean
function App({ userId }) {
return <UserProfile key={userId} userId={userId} />
}
// Also useful for forcing a form to reset when a dialog closes:
{isOpen && <EditModal key={editTarget.id} target={editTarget} />}Hoist static objects and arrays outside the component — same reference, zero cost.
useCallbackstabilizes function references for memoized children.useMemostabilizes computed values passed as props.React.memoskips re-renders when props are shallowly equal.Split components to contain fast-changing state near where it is used.
The
childrenpattern lets state changes in a wrapper skip expensive subtrees.Zustand/Jotai selectors give component-level granularity without Context re-render storms.
keycontrols component identity — use it to explicitly reset state.