Your React app works. But is it fast?
In my experience building React applications for enterprise clients, I've seen apps that feel snappy with 100 users suddenly crawl to a halt with 10,000. The difference isn't the server — it's how the frontend handles rendering.
Here's the comprehensive guide to making your React apps blazingly fast, using techniques I apply on every production project.
Understanding the Cost of Rendering
Before optimizing, you need to understand why React re-renders.
React re-renders a component when:
- Its state changes
- Its props change
- Its parent re-renders (even if props didn't change!)
That third point is the silent killer. A single state change at the top of your component tree can cascade into hundreds of unnecessary re-renders.
How to Measure
Before optimizing blindly, profile first:
# Install React DevTools browser extension
# Then in your app:
# 1. Open DevTools → Profiler tab
# 2. Click Record
# 3. Interact with your app
# 4. Stop Recording
# 5. Look for components with high "render time"
You can also add this to spot unnecessary re-renders during development:
// hooks/useWhyDidYouRender.ts
import { useRef, useEffect } from 'react';
export function useWhyDidYouRender(name: string, props: Record<string, any>) {
const prev = useRef(props);
useEffect(() => {
const changes: Record<string, { from: any; to: any }> = {};
Object.entries(props).forEach(([key, value]) => {
if (prev.current[key] !== value) {
changes[key] = { from: prev.current[key], to: value };
}
});
if (Object.keys(changes).length > 0) {
console.log(`[WhyRender] ${name}:`, changes);
}
prev.current = props;
});
}
1. Memoization — useMemo and useCallback
These hooks prevent expensive calculations or function recreations on every render.
useMemo — Memoize Computed Values
// ❌ BAD — recalculates on every render
const FilteredProducts = ({ products, filter }) => {
const filtered = products.filter(p => p.category === filter);
const sorted = filtered.sort((a, b) => b.price - a.price);
return <ProductList items={sorted} />;
};
// ✅ GOOD — only recalculates when dependencies change
const FilteredProducts = ({ products, filter }) => {
const sorted = useMemo(() => {
const filtered = products.filter(p => p.category === filter);
return filtered.sort((a, b) => b.price - a.price);
}, [products, filter]);
return <ProductList items={sorted} />;
};
useCallback — Memoize Functions
// ❌ BAD — new function reference every render → child always re-renders
const Parent = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
console.log('clicked');
};
return <ExpensiveChild onClick={handleClick} />;
};
// ✅ GOOD — stable function reference
const Parent = () => {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <ExpensiveChild onClick={handleClick} />;
};
When NOT to Use Them
Don't wrap everything in useMemo/useCallback. Memoization itself has a cost (memory + comparison). Only use them when:
- The computation is genuinely expensive (filtering/sorting large arrays)
- The result is passed to a memoized child (
React.memo) - You're preventing expensive child re-renders
2. React.memo — Prevent Unnecessary Re-renders
Wrap components that receive stable props but re-render because their parent did:
// This component only re-renders when `items` or `onSelect` actually change
const ProductList = React.memo(({ items, onSelect }) => {
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name} — ${item.price}
</li>
))}
</ul>
);
});
Custom Comparison
For complex props, provide a custom comparator:
const Chart = React.memo(({ data, config }) => {
// Expensive chart rendering
return <canvas ref={chartRef} />;
}, (prevProps, nextProps) => {
// Only re-render if data length changes or config updates
return (
prevProps.data.length === nextProps.data.length &&
prevProps.config.type === nextProps.config.type
);
});
3. Virtualization — Render Only What's Visible
If you're rendering a list of 1,000+ items, the browser is creating 1,000+ DOM nodes. Virtualization renders only the ~20 visible items and recycles DOM nodes as the user scrolls.
Using react-window
npm install react-window
import { FixedSizeList } from 'react-window';
const BigList = ({ items }) => {
const Row = ({ index, style }) => (
<div style={style} className="flex items-center p-4 border-b">
<span>{items[index].name}</span>
<span className="ml-auto">${items[index].price}</span>
</div>
);
return (
<FixedSizeList
height={600} // Container height
itemCount={items.length}
itemSize={60} // Row height
width="100%"
>
{Row}
</FixedSizeList>
);
};
Result: Instead of 10,000 DOM nodes, you have ~15. Scroll performance goes from choppy to silky smooth.
For variable-height rows, use VariableSizeList. For grids, use FixedSizeGrid.
4. Code Splitting — Load Only What's Needed
Don't make users download your entire app upfront. Split your bundle by routes and heavy components:
import { lazy, Suspense } from 'react';
// These components are loaded only when the route is visited
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
const App = () => (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
Lazy Load Heavy Libraries
// Don't import chart libraries at the top level
const ChartComponent = lazy(() => import('./components/Chart'));
// The chart library JS only loads when <ChartComponent> is rendered
const Dashboard = () => (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading chart...</div>}>
<ChartComponent data={chartData} />
</Suspense>
</div>
);
5. State Management — Keep State Close
One of the biggest performance mistakes is putting everything in global state (Redux/Context). When global state updates, every connected component re-renders.
Rules of Thumb
| State Type | Where to Put It |
|---|---|
| Form input values | Local useState
|
| UI toggles (modals, dropdowns) | Local useState
|
| Server data (API responses) | React Query / SWR |
| Auth / Theme | Context (rarely changes) |
| Complex cross-component state | Zustand / Jotai |
// ❌ BAD — every modal open triggers global re-render
const globalState = {
user: { ... },
products: [...],
isModalOpen: false, // This doesn't belong here
modalContent: null, // Neither does this
};
// ✅ GOOD — modal state lives locally
const ProductPage = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const { data: products } = useQuery(['products'], fetchProducts);
return (
<>
<ProductList products={products} onEdit={() => setIsModalOpen(true)} />
{isModalOpen && <EditModal onClose={() => setIsModalOpen(false)} />}
</>
);
};
6. Image Optimization
Images are often the heaviest assets on a page:
// Lazy load images that are below the fold
<img
src={product.image}
alt={product.name}
loading="lazy"
decoding="async"
width={400}
height={300}
/>
// Use modern formats with fallbacks
<picture>
<source srcSet="/hero.avif" type="image/avif" />
<source srcSet="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="Hero" />
</picture>
7. Debounce Expensive Operations
Search inputs, resize handlers, and scroll listeners should be debounced:
import { useState, useMemo } from 'react';
import debounce from 'lodash.debounce';
const SearchBar = ({ onSearch }) => {
const [query, setQuery] = useState('');
const debouncedSearch = useMemo(
() => debounce((q: string) => onSearch(q), 300),
[onSearch]
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
debouncedSearch(e.target.value);
};
return <input value={query} onChange={handleChange} placeholder="Search..." />;
};
Quick Performance Checklist
Before shipping to production, run through this:
- [ ] Profile with React DevTools — identify slow components
- [ ] Bundle analyze with
webpack-bundle-analyzerorvite-plugin-visualizer - [ ] Lighthouse audit — target 90+ performance score
- [ ] Virtualize lists with 50+ items
- [ ] Lazy load routes and heavy components
- [ ] Memoize expensive computations and stable callbacks
- [ ] Optimize images — WebP/AVIF, lazy loading, proper sizing
- [ ] Debounce user input that triggers expensive operations
Conclusion
Performance is a feature, not an afterthought. By applying these techniques systematically — profiling first, then optimizing where it matters — you ensure your React applications remain snappy and responsive as they grow.
The key insight? Don't optimize everything. Measure first, then optimize the bottlenecks.
What performance wins have you had in your React apps? Share in the comments! 👇
For more technical deep dives, check out my blog at muhammadarslan.codes/blog or connect with me on LinkedIn and GitHub.
Top comments (0)