Everything seems fine… until it isn’t.
You can have a React app that feels instant with a small number of users, and then suddenly it starts struggling as load grows.
In most cases, the issue isn’t the server or network — it’s inefficient rendering on the client.
The real gap between an “okay” app and a fast one is how predictable and controlled your renders are.
What actually triggers re-renders
Before trying to optimize anything, it’s important to understand what causes updates in React.
A component will re-render if:
- its internal state updates
- it receives new props
- its parent updates and forces a new render cycle
That last case is where things usually go wrong.
A single update near the top of your component tree can ripple down and cause a lot of unnecessary work.
This is why performance issues often scale with your app — not because the logic changes, but because the rendering cost multiplies.
Stop guessing
Before touching any optimization, figure out where time is actually spent.
Start simple — use React DevTools Profiler:
- open DevTools → switch to Profiler
- hit record
- interact with your app like a real user
- stop recording
- check which components take the most time to render
Don’t optimize blindly. Find the bottleneck first.
Quick way to catch useless re-renders
During development, it’s useful to see why a component updates.
You can drop a small hook that compares previous and current props and logs the difference:
// hooks/useRenderDebug.ts
import { useEffect, useRef } from 'react';
export function useRenderDebug(
label: string,
props: Record<string, unknown>
) {
const prevProps = useRef(props);
useEffect(() => {
const diff: Record<string, { before: unknown; after: unknown }> = {};
Object.keys(props).forEach((key) => {
if (prevProps.current[key] !== props[key]) {
diff[key] = {
before: prevProps.current[key],
after: props[key],
};
}
});
if (Object.keys(diff).length) {
console.log(`[render-debug] ${label}`, diff);
}
prevProps.current = props;
});
}
1.Memoization is not a silver bullet (but it helps)
useMemo and useCallback exist to avoid unnecessary work — not to make your app magically faster.
Use them to prevent:
- repeated heavy calculations
- unstable function references that trigger re-renders
Avoid recomputing on every render
If you derive data inside render — it will run every time.
❌ naive approach:
const Products = ({ products, filter }) => {
const filtered = products.filter(p => p.category === filter);
const sorted = filtered.sort((a, b) => b.price - a.price);
return <ProductList items={sorted} />;
};
Every render = filter + sort again.
✅ better approach:
const Products = ({ 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} />;
};
Now it recalculates only when inputs change.
Function identity matters
Functions are recreated on every render.
If you pass them down — children re-render.
❌ unstable reference:
const Parent = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
console.log('clicked');
};
return <ExpensiveChild onClick={handleClick} />;
};
Child re-renders every time because the function is new.
✅ stable reference:
const Parent = () => {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <ExpensiveChild onClick={handleClick} />;
};
Now the reference is stable.
When NOT to use memoization
Blindly wrapping everything is a mistake.
Memoization also costs:
- memory
- dependency comparisons
Use it only when:
- you actually have heavy computations (filter/sort large data)
- the result is passed to a memoized component
- you're fixing real re-render issues, not guessing
2. React.memo is about control, not magic
React.memo doesn’t make components faster by itself.
It simply prevents re-renders when props didn’t change.
Use it when a component receives stable props but still re-renders because its parent updated.
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>
);
});
If items and onSelect are stable → this component won’t re-render.
Custom comparison (advanced case)
Sometimes shallow comparison isn’t enough.
You can control when React should skip re-rendering:
const Chart = React.memo(
({ data, config }) => {
return <canvas ref={chartRef} />;
},
(prev, next) => {
return (
prev.data.length === next.data.length &&
prev.config.type === next.config.type
);
}
);
Now the component updates only when you decide.
When it actually makes sense
Don’t wrap everything in React.memo.
Use it when:
- the component is expensive to render
- props are stable most of the time
- re-renders come from parent updates, not real changes
3. Render less, not faster
If you're rendering thousands of items, the problem isn’t React — it’s the DOM.
Rendering 1,000+ elements = 1,000+ DOM nodes.
That’s where performance dies.
Virtualization solves this by rendering only what’s visible on screen and reusing nodes while scrolling.
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}
itemCount={items.length}
itemSize={60}
width="100%"
>
{Row}
</FixedSizeList>
);
};
Instead of rendering 10,000 nodes, you render ~15–20.
Scroll becomes smooth because the browser has less work to do.
If your rows have dynamic height → use VariableSizeList
For grids → use FixedSizeGrid
4. Load code only when it’s needed
Shipping your entire bundle upfront is wasteful.
Split it by routes and heavy components.
import { lazy, Suspense } from 'react';
// loaded only when 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 stuff (like charts)
const ChartComponent = lazy(() => import('./components/Chart'));
const Dashboard = () => (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading chart...</div>}>
<ChartComponent data={chartData} />
</Suspense>
</div>
);
Don’t load what the user might never see.
5. Stop firing expensive logic on every keystroke
Search inputs, resize handlers, scroll listeners — all of these can kill performance if they run too often.
Don’t execute logic on every event. Delay it.
import { useState, useMemo } from 'react';
import debounce from 'lodash.debounce';
const SearchBar = ({ onSearch }) => {
const [query, setQuery] = useState('');
const debouncedSearch = useMemo(
() => debounce((value: string) => onSearch(value), 300),
[onSearch]
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
};
return (
<input
value={query}
onChange={handleChange}
placeholder="Search..."
/>
);
};
Instead of firing on every key press → you wait and execute once.
6. Images are often your biggest bottleneck
Images are usually heavier than your JS.
If you ignore them — no optimization will save you.
Lazy load everything below the fold
<img
src={product.image}
alt={product.name}
loading="lazy"
decoding="async"
width={400}
height={300}
/>
Let the browser load images only when needed.
Use modern formats (with fallback)
<picture>
<source srcSet="/hero.avif" type="image/avif" />
<source srcSet="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="Hero" />
</picture>
Smaller size → faster load → better UX.
Final thoughts
React performance is not about tricks. It’s about control.
Most slow apps have the same problems:
- too many unnecessary renders
- heavy work inside render
- no control over when code runs
- loading everything upfront
Fix those — and your app feels fast.
Not because React changed.
Because you stopped doing extra work.
Top comments (0)