You shipped a feature. It works. But the moment your list hits 1,000 items, the app stutters. Typing in the search box causes a visible lag. The initial load takes 4 seconds. Sound familiar?
React is fast by default, but it doesn't stay fast as your app grows. The good news: most performance problems in React follow a small set of patterns, and there's a fixed toolkit for solving them.
This guide covers the techniques that actually matter in production — the same ones that come up in senior frontend interviews at companies like Stripe, Airbnb, and Shopify.
Why React Apps Get Slow
Before fixing anything, you need to know what's actually slow. React performance problems usually fall into one of these buckets:
- Unnecessary re-renders — components re-render when their props or state haven't meaningfully changed
- Expensive renders — a component does heavy computation on every render
- Large bundle size — the browser downloads and parses more JavaScript than needed
- Unoptimized lists — rendering thousands of DOM nodes without virtualization
- Layout thrashing — frequent DOM reads and writes that trigger recalculations
Let's tackle each one.
1. React.memo: Prevent Unnecessary Re-renders
When a parent component re-renders, React re-renders all of its children by default — even if their props haven't changed. React.memo tells React to skip re-rendering a component if its props are referentially equal.
// Without memo — re-renders every time the parent re-renders
function UserProfile({ user }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// With memo — only re-renders when `user` actually changes
const UserProfile = React.memo(function UserProfile({ user }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
});The Catch: Prop Equality
React.memo does a shallow comparison of props. If you pass a new object, array, or function reference on every render, the memo is useless.
function Dashboard({ data }) {
// ❌ This creates a new object every render — defeats React.memo
return <UserProfile user={{ name: 'Alice', email: 'alice@example.com' }} />;
// ✅ Pass the same reference
const user = useMemo(() => ({ name: 'Alice', email: 'alice@example.com' }), []);
return <UserProfile user={user} />;
}When to Use React.memo
- The component renders frequently (e.g., it's deep in a tree that updates often)
- It receives primitive props or stable object references
- Profiling shows it's actually re-rendering unnecessarily
Don't memo everything. Memoization has a cost — React has to store the previous props and compare them. If a component is cheap to render, memoizing it can make things slower.
2. useMemo and useCallback: Stabilize Values and Functions
These hooks exist for one reason: to give you stable references between renders.
useMemo — Cache Expensive Computations
function ProductGrid({ products, filterText }) {
// ❌ Filters on every render, even when products hasn't changed
const filtered = products.filter(p =>
p.name.toLowerCase().includes(filterText.toLowerCase())
);
// ✅ Only re-filters when products or filterText changes
const filtered = useMemo(
() => products.filter(p =>
p.name.toLowerCase().includes(filterText.toLowerCase())
),
[products, filterText]
);
return <Grid items={filtered} />;
}useCallback — Stable Function References
function Parent() {
const [count, setCount] = useState(0);
// ❌ New function every render — breaks React.memo on children
const handleClick = (id) => {
console.log('Clicked', id);
};
// ✅ Same function reference across renders
const handleClick = useCallback((id) => {
console.log('Clicked', id);
}, []);
return <ExpensiveChild onClick={handleClick} />;
}The Golden Rule
Don't wrap everything in useMemo/useCallback. Use them when:
- You're passing the value/function to a memoized child component
- The computation is genuinely expensive (filtering/sorting large arrays, complex calculations)
- You need a stable reference as a dependency for another hook
For simple values and functions that are cheap to create, let React do its thing.
3. Code Splitting with React.lazy and Suspense
Sending one massive JavaScript bundle means the user waits longer before seeing anything. Code splitting lets you load components only when they're needed.
import { lazy, Suspense } from 'react';
// Load the Dashboard only when navigated to
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}Route-Level vs Component-Level Splitting
- Route-level (most impactful): Split by page/route. Users only download code for the page they're on.
- Component-level: Split heavy components like charts, editors, or modals that are below the fold.
// Split a heavy chart component
const RevenueChart = lazy(() => import('./RevenueChart'));
function Report({ showChart }) {
return (
<div>
<h1>Monthly Report</h1>
{showChart && (
<Suspense fallback={<Skeleton />}>
<RevenueChart />
</Suspense>
)}
</div>
);
}Measure the Impact
Use your bundler's analyzer to see what's in your bundle:
# Webpack
npx webpack-bundle-analyzer dist/stats.json
# Vite
npx vite-bundle-visualizerLook for chunks over 50KB that could be lazy-loaded.
4. List Virtualization: Render Only What's Visible
Rendering 10,000 rows in the DOM will crash performance — no amount of memoization fixes that. Virtualization renders only the items currently visible in the viewport.
Using react-window
npm install react-windowimport { FixedSizeList as List } from 'react-window';
function BigList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].name} — {items[index].email}
</div>
);
return (
<List
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</List>
);
}This renders 10,000 items as smoothly as 20. Only ~12 rows exist in the DOM at any time.
When to Virtualize
- Lists with 100+ items where each item has similar height
- Tables with large datasets
- Chat/messaging interfaces with long histories
For shorter lists (under 100 items), regular rendering with proper keys is fine.
5. The React Profiler: Measure Before You Optimize
Guessing what's slow is how you waste time memoizing components that don't need it. The React Profiler shows you exactly which components are slow and why.
Using the Profiler Tab in React DevTools
- Install the React Developer Tools browser extension
- Open DevTools → Profiler tab
- Click the record button
- Interact with your app (type in a search box, scroll, click)
- Stop recording
You'll see a flame chart showing render times. Look for:
- Wide bars — components that take long to render
- Repeated bars — components rendering more than expected
- Why did this render? — click a component to see what triggered the re-render
Programmatic Profiling
import { Profiler } from 'react';
function onRenderCallback(id, phase, actualDuration) {
console.log(`${id} ${phase} took ${actualDuration}ms`);
}
function App() {
return (
<Profiler id="Dashboard" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
);
}This is useful for tracking render performance in production with real user data.
6. State Colocation: Keep State Where It Belongs
One of the most common performance mistakes is lifting state too high. When state lives at the top of the tree, every update re-renders everything below it.
// ❌ Search state in the parent re-renders the entire list
function ProductPage({ products }) {
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
return (
<div>
<SearchBar value={search} onChange={setSearch} />
<CategoryFilter value={selectedCategory} onChange={setSelectedCategory} />
<ProductList products={filterProducts(products, search, selectedCategory)} />
<Recommendations /> {/* Re-renders when search changes, even though it doesn't use search */}
<Footer /> {/* Same */}
</div>
);
}
// ✅ Colocate search state in a sub-tree
function ProductPage({ products }) {
return (
<div>
<ProductSearchSection products={products} />
<Recommendations />
<Footer />
</div>
);
}
function ProductSearchSection({ products }) {
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
return (
<>
<SearchBar value={search} onChange={setSearch} />
<CategoryFilter value={selectedCategory} onChange={setSelectedCategory} />
<ProductList products={filterProducts(products, search, selectedCategory)} />
</>
);
}Now typing in the search bar only re-renders ProductSearchSection, not the entire page.
7. Debouncing and Throttling User Input
Search inputs and scroll handlers that trigger state updates on every keystroke or pixel will hammer your re-render cycle.
import { useState, useEffect } from 'react';
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
function SearchWithResults({ allItems }) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
// This expensive filter only runs 300ms after the user stops typing
const results = useMemo(
() => allItems.filter(item =>
item.name.toLowerCase().includes(debouncedQuery.toLowerCase())
),
[allItems, debouncedQuery]
);
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<ResultList items={results} />
</>
);
}8. useTransition for Non-Urgent Updates
React 18 introduced useTransition to let you mark certain state updates as low priority. This keeps the UI responsive even when a heavy update is running.
import { useState, useTransition } from 'react';
function SearchableList({ items }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const [filtered, setFiltered] = useState(items);
function handleChange(e) {
const value = e.target.value;
// Urgent: update the input immediately so typing feels responsive
setQuery(value);
// Non-urgent: filter can take its time
startTransition(() => {
setFiltered(items.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
));
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <span>Filtering...</span>}
<List items={filtered} />
</>
);
}The input stays responsive while React processes the list filtering in the background.
Quick Reference: Which Technique When?
| Problem | Technique | Effort |
|---|---|---|
| Child re-renders unnecessarily | React.memo + useCallback | Low |
| Expensive computation on render | useMemo | Low |
| Slow initial load (large bundle) | React.lazy code splitting | Medium |
| Long list lagging | Virtualization (react-window) | Medium |
| Don't know what's slow | React Profiler | Low |
| Too much re-rendering on update | State colocation | Low |
| Input lag on search/filter | Debounce + useMemo | Low |
| UI freezes during heavy update | useTransition | Low |
Interview Cheatsheet
If you're asked "How would you optimize a slow React component?" in an interview, walk through this checklist:
- Profile first — Don't guess. Use React DevTools Profiler to find the actual bottleneck.
- Check re-renders — Are components re-rendering when they shouldn't? Use
React.memofor expensive leaf components. - Stabilize references — Are you creating new objects/functions on every render? Wrap them in
useMemo/useCallback. - Reduce the DOM — Large lists? Virtualize them.
- Split the bundle — Lazy-load routes and heavy components.
- Colocate state — Move state down to prevent unnecessary re-renders.
- Debounce input — Don't filter/search on every keystroke.
- Consider concurrent features —
useTransitionfor non-urgent updates.
Summary
- Profile before optimizing. The React Profiler tells you what's actually slow.
- Memoization isn't free. Use
React.memo,useMemo, anduseCallbackonly when profiling shows they help. - Code splitting gives the biggest bang for buck for initial load performance.
- Virtualization is non-negotiable for large lists.
- State colocation is the simplest fix that's often overlooked.
useTransitionis React 18's answer to keeping the UI responsive during heavy updates.
The best optimization strategy is to measure, fix the biggest bottleneck, then measure again. Don't optimize blindly — every optimization adds complexity, and you want the maximum performance gain for the minimum code complexity.