React performance optimization is the process of reducing wasted rendering work, keeping user interactions responsive, and loading only the code that is needed at the right time. Many React applications feel fast in the beginning, but as components grow, lists get larger, dashboards add charts, and more routes are bundled together, users may begin to notice typing lag, slow navigation, or delayed updates.
The goal is not to avoid re-rendering completely. React is built around re-rendering. The real goal is to avoid unnecessary work. If a component re-renders because something the user can see actually changed, that is normal. But if a heavy component re-renders even though its input did not change, or if the browser is forced to paint thousands of rows that are not visible, that is wasted work and a good place to optimize.
A smart performance strategy in React usually follows three steps: measure the problem, identify the real bottleneck, and apply the smallest optimization that solves that specific issue.
| Technique | What it does | Best use case |
|---|---|---|
React.memo() | Skips child re-render when props are unchanged | Expensive child components with stable props |
useMemo() | Caches expensive computed values | Sorting, filtering, derived totals, grouped data |
useCallback() | Keeps function references stable | Callbacks passed to memoized children |
lazy() + Suspense | Code-splits and loads features later | Routes, dashboards, modals, heavy widgets |
useTransition() | Marks updates as non-urgent | Slow filters, large list updates, heavy screen changes |
useDeferredValue() | Lets a derived value update later | Search inputs with expensive result rendering |
| Virtualization | Renders only visible list rows | Very large lists and tables |
| State colocation | Keeps state near where it is used | Reducing broad unnecessary tree updates |
A common React performance problem is that a parent component changes one small piece of state and accidentally causes a heavy child to render again. If the child is pure and receives the same data as before, wrapping it in React.memo() can help React skip that work. If the child also receives functions or calculated arrays, we often combine React.memo with useCallback and useMemo.
import { useCallback, useMemo, useState } from 'react'
import ProductTable from './ProductTable'
const initialProducts = [
{ id: 1, name: 'Laptop', price: 65000, category: 'Electronics' },
{ id: 2, name: 'Keyboard', price: 1800, category: 'Accessories' },
{ id: 3, name: 'Mouse', price: 950, category: 'Accessories' },
]
function ProductPage() {
const [search, setSearch] = useState('')
const [counter, setCounter] = useState(0)
const [products, setProducts] = useState(initialProducts)
const filteredProducts = useMemo(() => {
console.log('Filtering products')
return products.filter(product =>
product.name.toLowerCase().includes(search.toLowerCase())
)
}, [products, search])
const totalPrice = useMemo(() => {
return filteredProducts.reduce((sum, product) => sum + product.price, 0)
}, [filteredProducts])
const handleDelete = useCallback((id) => {
setProducts(current => current.filter(product => product.id !== id))
}, [])
return (
<div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search product"
/>
<button onClick={() => setCounter(c => c + 1)}>
Counter: {counter}
</button>
<p>Visible products: {filteredProducts.length}</p>
<p>Visible total: Rs. {totalPrice}</p>
<ProductTable products={filteredProducts} onDelete={handleDelete} />
</div>
)
}
export default ProductPage
import { memo } from 'react'
import ProductRow from './ProductRow'
const ProductTable = memo(function ProductTable({ products, onDelete }) {
console.log('Rendering ProductTable')
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Price</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{products.map(product => (
<ProductRow key={product.id} product={product} onDelete={onDelete} />
))}
</tbody>
</table>
)
})
export default ProductTable
import { memo } from 'react'
const ProductRow = memo(function ProductRow({ product, onDelete }) {
console.log('Rendering row:', product.id)
return (
<tr>
<td>{product.name}</td>
<td>{product.category}</td>
<td>Rs. {product.price}</td>
<td>
<button onClick={() => onDelete(product.id)}>Delete</button>
</td>
</tr>
)
})
export default ProductRow
If the user clicks the counter button, the parent component re-renders. But the tl-table and rows can be skipped when their props stay the same. This is a good example of performance optimization that preserves behavior while reducing unnecessary rendering.
Sometimes the app is not slow because of re-rendering. Sometimes it is slow because too much JavaScript is downloaded before the first page becomes interactive. Route-based code splitting is one of the easiest and most valuable optimizations in React applications.
import { lazy, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import Home from './pages/Home'
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Reports = lazy(() => import('./pages/Reports'))
const Admin = lazy(() => import('./pages/Admin'))
function Loader() {
return <p>Loading page...</p>
}
function App() {
return (
<Suspense fallback={<Loader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/reports" element={<Reports />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
)
}
export default App
import { lazy, Suspense, useState } from 'react'
const RevenueChart = lazy(() => import('./RevenueChart'))
function Reports() {
const [showChart, setShowChart] = useState(false)
return (
<section>
<h2>Reports</h2>
<button onClick={() => setShowChart(true)}>
Open Chart
</button>
{showChart && (
<Suspense fallback={<p>Loading chart...</p>}>
<RevenueChart />
</Suspense>
)}
</section>
)
}
export default Reports
This pattern loads the code only when the user actually opens that page or feature. It reduces the initial bundle size and helps the application become usable sooner.
When a user types into a search field, the input should feel immediate. If a large list is filtered on every keystroke and the filter is expensive, the UI may feel delayed. useTransition() lets React treat the input update as urgent and the results update as non-urgent.
import { useMemo, useState, useTransition } from 'react'
const items = Array.from({ length: 10000 }, (_, index) => ({
id: index + 1,
name: `Item ${index + 1}`
}))
function SearchPage() {
const [input, setInput] = useState('')
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
const results = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
)
}, [query])
function handleChange(event) {
const value = event.target.value
setInput(value)
startTransition(() => {
setQuery(value)
})
}
return (
<div>
<input value={input} onChange={handleChange} placeholder="Search..." />
{isPending && <p>Updating results...</p>}
<ul style={{ opacity: isPending ? 0.6 : 1 }}>
{results.slice(0, 20).map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
)
}
export default SearchPage
import { useDeferredValue, useMemo } from 'react'
function FilteredList({ query, items }) {
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery
const filtered = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase())
)
}, [items, deferredQuery])
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<p>Matches: {filtered.length}</p>
<ul>
{filtered.slice(0, 10).map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
)
}
If thousands of DOM nodes are rendered at once, the browser itself becomes the bottleneck. In that case, memoization alone is not enough. List virtualization renders only the rows visible in the viewport.
import { FixedSizeList } from 'react-window'
const rows = Array.from({ length: 5000 }, (_, index) => ({
id: index,
label: `Row ${index + 1}`
}))
function Row({ index, style }) {
return <div style={style}>{rows[index].label}</div>
}
function BigList() {
return (
<FixedSizeList
height={400}
width={500}
itemCount={rows.length}
itemSize={40}
>
{Row}
</FixedSizeList>
)
}
export default BigList
| Mistake | Problem | Better approach |
|---|---|---|
Using useMemo for every expression | Adds complexity without real benefit | Memoize only expensive work |
Wrapping tiny components in React.memo automatically | May not improve anything | Memoize expensive or frequently repeated children |
| Passing new objects or callbacks each render | Breaks memoization opportunities | Use stable values where needed |
| Rendering huge lists directly | Creates too many DOM nodes | Use virtualization |
| Optimizing before measuring | May solve the wrong problem | Profile first |
React.memo only when prop stability makes it meaningfulReact performance optimization is about reducing unnecessary work, not avoiding rendering completely. A fast React application usually comes from a mix of better state placement, selective memoization, stable callbacks, code splitting, and virtualization for large lists. The strongest improvements usually come from solving the real bottleneck rather than applying every optimization tool everywhere.
If you remember one principle, let it be this: measure first, then optimize the part that is actually slow. That keeps your code simpler, your optimization choices more effective, and your React application easier to maintain over time.
Explore 500+ free tutorials across 20+ languages and frameworks.