
You know what hurts more than a bug in prod? An app that works... but that nobody wants to use because it's too slow.
That's exactly what happened to me on my first real project.
The code looked fine. It worked in development. But in production, with real data and real users, everything felt sluggish. Users were complaining.
It took me weeks to understand what was happening. Here are the mistakes I made and what I learned fixing them.
Mistake 1: re-renders everywhere
I didn't understand how React re-renders work.
Every time a parent component re-renders, all its children re-render too. Even if their props didn't change. I had a dashboard with 50+ components, and clicking a single button would trigger hundreds of re-renders.
function Dashboard() {
const [selectedTab, setSelectedTab] = useState('overview');
return (
<div>
<Tabs selected={selectedTab} onChange={setSelectedTab} />
<Header /> {/* Re-renders on every tab change */}
<Sidebar /> {/* Re-renders on every tab change */}
<Chart data={data} /> {/* Re-renders on every tab change */}
<Table data={data} /> {/* Re-renders on every tab change */}
<Footer /> {/* Re-renders on every tab change */}
</div>
);
}
Every tab click re-rendered everything. The Chart component was expensive. The Table had 500 rows. My users felt every single re-render.
The fix: React.memo
const Chart = memo(function Chart({ data }) {
// Only re-renders if data changes
return <ExpensiveChart data={data} />;
});
const Table = memo(function Table({ data }) {
// Only re-renders if data changes
return <ExpensiveTable data={data} />;
});
Now clicking tabs doesn't re-render Chart or Table because their data prop doesn't change.
But there's a catch. I learned it the hard way too.
Mistake 2: breaking memo with new object references
I wrapped everything in memo. Problem solved, right?
Wrong.
function Dashboard() {
const [selectedTab, setSelectedTab] = useState('overview');
// New object created on every render!
const chartConfig = {
showLegend: true,
animate: false
};
return (
<Chart data={data} config={chartConfig} /> {/* Still re-renders! */}
);
}
memo does shallow comparison. Every render creates a new chartConfig object. New object = new reference = memo thinks props changed = re-render.
Same problem with functions:
function Dashboard() {
// New function created on every render!
const handleClick = () => {
console.log('clicked');
};
return <Button onClick={handleClick} />; {/* Still re-renders! */}
}
The fix: useMemo and useCallback
function Dashboard() {
const [selectedTab, setSelectedTab] = useState('overview');
// Same reference between renders
const chartConfig = useMemo(() => ({
showLegend: true,
animate: false
}), []);
// Same reference between renders
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return (
<>
<Chart data={data} config={chartConfig} />
<Button onClick={handleClick} />
</>
);
}
Now memo actually works because the references stay stable.
Mistake 3: importing everything upfront
My bundle was 2.5MB.
Every user downloaded 2.5MB of JavaScript before seeing anything. The admin panel? Downloaded. The settings page? Downloaded. The rarely-used export feature? Downloaded.
import { AdminPanel } from './AdminPanel';
import { Settings } from './Settings';
import { ExportFeature } from './ExportFeature';
import { Dashboard } from './Dashboard';
function App() {
return (
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/admin" element={<AdminPanel />} />
<Route path="/settings" element={<Settings />} />
<Route path="/export" element={<ExportFeature />} />
</Routes>
);
}
The fix: lazy loading
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
const AdminPanel = lazy(() => import('./AdminPanel'));
const Settings = lazy(() => import('./Settings'));
const ExportFeature = lazy(() => import('./ExportFeature'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/admin" element={<AdminPanel />} />
<Route path="/settings" element={<Settings />} />
<Route path="/export" element={<ExportFeature />} />
</Routes>
</Suspense>
);
}
Now users only download what they need. Dashboard loads first. Admin panel loads when they navigate there.
My initial bundle went from 2.5MB to 400KB. First paint went from 4 seconds to under 1 second.
Mistake 4: rendering huge lists
I had a table with 5000 rows. I rendered all 5000.
function UserTable({ users }) {
return (
<table>
<tbody>
{users.map(user => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.role}</td>
</tr>
))}
</tbody>
</table>
);
}
5000 DOM nodes. Scrolling was a slideshow.
The fix: virtualization
import { FixedSizeList } from 'react-window';
function UserTable({ users }) {
const Row = ({ index, style }) => (
<div style={style}>
{users[index].name} - {users[index].email}
</div>
);
return (
<FixedSizeList
height={500}
itemCount={users.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
Now only visible rows are in the DOM. 5000 items? Still only ~20 DOM nodes at a time. Scrolling is butter smooth.
Libraries that do this: react-window, react-virtualized, @tanstack/react-virtual.
Mistake 5: not using the React DevTools Profiler
I was guessing where the performance problems were. Sometimes I guessed right. Mostly I didn't.
Then I discovered the React DevTools Profiler.
It shows you:
- Which components rendered
- How long each render took
- What triggered the render
- "Wasted" renders (renders that produced no visible change)
How to use it
- Install React DevTools browser extension
- Open DevTools → Profiler tab
- Click "Record"
- Do the slow action in your app
- Click "Stop"
- See exactly which components are slow
The Profiler showed me that my Table component was taking 800ms to render. Not because it was complex, but because it was re-rendering on every keystroke in a search input.
I would have never found that by reading code.
Mistake 6: expensive calculations in render
I was filtering and sorting data inside the render:
function UserList({ users, searchTerm, sortBy }) {
// Runs on EVERY render
const filteredUsers = users
.filter(u => u.name.includes(searchTerm))
.sort((a, b) => a[sortBy].localeCompare(b[sortBy]));
return (
<ul>
{filteredUsers.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
With 5000 users, filtering and sorting on every render was killing performance.
The fix: useMemo
function UserList({ users, searchTerm, sortBy }) {
const filteredUsers = useMemo(() => {
return users
.filter(u => u.name.includes(searchTerm))
.sort((a, b) => a[sortBy].localeCompare(b[sortBy]));
}, [users, searchTerm, sortBy]);
return (
<ul>
{filteredUsers.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
Now the calculation only runs when users, searchTerm, or sortBy actually change. Not on every parent re-render.
Mistake 7: using index as key
This one bit me hard.
{items.map((item, index) => (
<Item key={index} data={item} />
))}
Looks fine. Works fine... until you add, remove, or reorder items.
React uses keys to track which items changed. If you use index as key:
- Delete item 0 → React thinks item 0 became item 1, item 1 became item 2...
- Every item re-renders with wrong data
- State gets mixed up between items
The fix: use unique IDs
{items.map(item => (
<Item key={item.id} data={item} />
))}
Always use a stable, unique identifier. If your data doesn't have IDs, create them when the data is created, not during render.
The mental model I wish I had
The cheat sheet
| Problem | Symptom | Fix |
|---|---|---|
| Unnecessary re-renders | Clicking triggers lag | React.memo |
| New object/function refs | memo doesn't help | useMemo / useCallback |
| Large bundle | Slow initial load | React.lazy + code splitting |
| Long lists | Scroll lag | react-window virtualization |
| Expensive calculations | Lag on interactions | useMemo |
| Wrong keys | List items mix up | Use unique IDs, not index |
The lesson
Performance isn't magic. It's measurement + understanding.
I spent weeks guessing. I could have spent hours measuring.
The React DevTools Profiler would have shown me exactly where the problems were. The docs would have taught me about memo, useMemo, and lazy loading. But I thought I could figure it out myself.
The hard way taught me: measure first, optimize second, guess never.
This is part 2 of my "What I learned the hard way" series. Next up: how I structure a Next.js project after 3 years.
Continue to part 3: How I structure a Next.js project after 3 years →
Got questions? Hit me up on LinkedIn or check out more on my blog.