Why React Apps Slow Down
React is fast by default, but several patterns cause unnecessary work:
- Unnecessary re-renders — components re-rendering when their output wouldn't change
- Expensive computations on every render — recalculating derived data unnecessarily
- Large bundle sizes — loading code the user doesn't need yet
- Long render lists — rendering thousands of DOM nodes at once
- Layout thrashing — JS reads/writes that force the browser to recalculate layout repeatedly
This guide is a practical checklist, not theory.
Step 1: Measure Before Optimizing
Never optimize blindly. Use these tools first:
// React DevTools Profiler
// 1. Install React DevTools browser extension
// 2. Open DevTools → Profiler tab
// 3. Record a user interaction
// 4. Look for components that render often or take long
// Detect unnecessary re-renders in development
// Add this to component to see when it renders:
const MyComponent = ({ value }) => {
console.log('MyComponent rendered:', value);
return <div>{value}</div>;
};
// Or use react-why-did-you-render
import whyDidYouRender from '@welldone-software/why-did-you-render';
whyDidYouRender(React, { trackAllPureComponents: true });
Step 2: Fix Unnecessary Re-renders
React.memo — Memoize Components
// Without memo: re-renders whenever parent re-renders
const UserCard = ({ user }) => (
<div>{user.name} - {user.email}</div>
);
// With memo: only re-renders when user prop changes (shallow comparison)
const UserCard = React.memo(({ user }) => (
<div>{user.name} - {user.email}</div>
));
// Custom comparison (when shallow check isn't enough)
const UserCard = React.memo(
({ user }) => <div>{user.name}</div>,
(prevProps, nextProps) => prevProps.user.id === nextProps.user.id
);
When to use: Components that render often but receive the same props frequently — list items, chart nodes, table rows.
When NOT to use: Simple components that render rarely. The memoization overhead can cost more than re-rendering.
Stable Callback References
// ❌ New function on every render → memo'd children always re-render
function ParentList() {
const [items, setItems] = useState([]);
return items.map(item => (
<MemoizedItem
key={item.id}
item={item}
onDelete={() => setItems(prev => prev.filter(i => i.id !== item.id))} // new fn each render
/>
));
}
// ✅ Stable callback with useCallback
function ParentList() {
const [items, setItems] = useState([]);
const handleDelete = useCallback((id) => {
setItems(prev => prev.filter(i => i.id !== id));
}, []); // stable reference
return items.map(item => (
<MemoizedItem key={item.id} item={item} onDelete={handleDelete} />
));
}
Stable Object References
// ❌ New object on every render
function Chart({ data }) {
const options = { color: 'blue', animated: true }; // new reference each render
return <MemoizedChart data={data} options={options} />; // always re-renders
}
// ✅ Memoize the options object
function Chart({ data }) {
const options = useMemo(
() => ({ color: 'blue', animated: true }),
[] // never changes — could also be module-level constant
);
return <MemoizedChart data={data} options={options} />;
}
Step 3: Memoize Expensive Computations
// ❌ Filters 10,000 items on every render
function ProductList({ products, search, category, maxPrice }) {
const filtered = products
.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
.filter(p => category ? p.category === category : true)
.filter(p => p.price <= maxPrice)
.sort((a, b) => a.price - b.price);
return filtered.map(p => <ProductCard key={p.id} product={p} />);
}
// ✅ Only recomputes when inputs change
function ProductList({ products, search, category, maxPrice }) {
const filtered = useMemo(
() => products
.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
.filter(p => category ? p.category === category : true)
.filter(p => p.price <= maxPrice)
.sort((a, b) => a.price - b.price),
[products, search, category, maxPrice]
);
return filtered.map(p => <ProductCard key={p.id} product={p} />);
}
Step 4: State Architecture
Colocate State
Move state as close to where it's used as possible. Global state causes widespread re-renders.
// ❌ Tooltip open state in global store → every component re-renders on hover
const { isTooltipOpen, setTooltipOpen } = useGlobalStore();
// ✅ Tooltip state local to the tooltip component
function TooltipWrapper({ children, content }) {
const [isOpen, setIsOpen] = useState(false); // only this component re-renders
return (
<div onMouseEnter={() => setIsOpen(true)} onMouseLeave={() => setIsOpen(false)}>
{children}
{isOpen && <Tooltip>{content}</Tooltip>}
</div>
);
}
Split Context
// ❌ One context for everything — any update re-renders all consumers
const AppContext = createContext({ user, theme, cart, notifications });
// ✅ Split into separate contexts
const UserContext = createContext(user);
const ThemeContext = createContext(theme);
const CartContext = createContext(cart);
// Components only subscribe to what they need
function PriceTag() {
const cart = useContext(CartContext); // only re-renders on cart changes
return <span>{cart.total}</span>;
}
Step 5: List Virtualization
Rendering 1,000+ rows creates 1,000+ DOM nodes. Virtualization only renders what's visible.
import { FixedSizeList as List } from 'react-window';
function VirtualizedList({ items }) {
const Row = ({ index, style }) => (
<div style={style} className="list-item">
{items[index].name}
</div>
);
return (
<List
height={600} // visible container height
itemCount={items.length}
itemSize={50} // each item height
width="100%"
>
{Row}
</List>
);
}
// For variable-height items
import { VariableSizeList } from 'react-window';
Use react-window for simple lists, react-virtual (TanStack Virtual) for more complex cases including grids and masonry layouts.
Step 6: Code Splitting
// ❌ Everything in one bundle
import HeavyEditor from './HeavyEditor';
import Chart from './Chart';
// ✅ Dynamic imports — load only when needed
import { lazy, Suspense } from 'react';
const HeavyEditor = lazy(() => import('./HeavyEditor'));
const Chart = lazy(() => import('./Chart'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/editor" element={<HeavyEditor />} />
<Route path="/analytics" element={<Chart />} />
</Routes>
</Suspense>
);
}
Prefetch on hover
// Preload the chunk before the user clicks
function NavLink({ to, children }) {
const prefetch = () => import('./HeavyPage'); // triggers chunk download
return (
<Link to={to} onMouseEnter={prefetch} onFocus={prefetch}>
{children}
</Link>
);
}
Step 7: Image and Asset Optimization
// Lazy load images below the fold
function ProductImage({ src, alt }) {
return (
<img
src={src}
alt={alt}
loading="lazy" // native lazy loading
decoding="async" // don't block rendering while decoding
width={300}
height={200} // prevents layout shift
/>
);
}
// For Next.js — use next/image
import Image from 'next/image';
<Image src={src} alt={alt} width={300} height={200} priority={false} />
Step 8: React 18 Concurrent Features
import { useTransition, useDeferredValue } from 'react';
// useTransition: mark expensive state updates as non-urgent
function SearchBox({ items }) {
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleSearch(e) {
setQuery(e.target.value); // urgent: update input immediately
startTransition(() => {
// non-urgent: can be interrupted if user types again
setFilteredItems(items.filter(i => i.includes(e.target.value)));
});
}
return (
<>
<input value={query} onChange={handleSearch} />
{isPending && <Spinner />}
<List items={filteredItems} />
</>
);
}
// useDeferredValue: debounce a derived value
function SearchResults({ query, items }) {
const deferredQuery = useDeferredValue(query); // lags behind during fast typing
const filtered = useMemo(
() => items.filter(i => i.includes(deferredQuery)),
[items, deferredQuery]
);
return <List items={filtered} />;
}
Performance Checklist
- Profile first — identify actual bottlenecks
- Memo components that receive stable props frequently
- Use
useCallbackfor callbacks passed to memoized children - Use
useMemofor expensive filtered/sorted lists - Colocate state — avoid global state for ephemeral UI
- Split contexts — separate concerns
- Virtualize long lists (1000+ items)
- Code-split routes and heavy components
- Lazy-load images with
loading="lazy" - Use
useTransitionfor slow state updates (React 18+)
→ Benchmark and measure performance metrics with the Benchmark Builder.