Performance advice often starts with the wrong lesson: “optimise everything”. The right order is to measure first, fix what is slow, and leave the rest alone. Most small apps are fast without any tricks. But three techniques are cheap, safe and worth knowing, because they solve problems you will meet: unnecessary redraws, too many requests, and a big first download.
1. Debounce: wait until typing stops
import { useEffect, useState } from 'react';
// Returns `value`, but only after it has stopped changing for `delay` ms.
// Typing "bank" would otherwise send four requests. With a debounce it sends one.
export function useDebounce(value, delay = 350) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // typing again cancels the earlier timer
}, [value, delay]);
return debounced;
}
useDebounce(value, delay) returns a copy of value that only changes after the original has stopped changing for delay milliseconds. Each change starts a timer, and the cleanup function cancels the previous one, so only the last one fires. Typing “bank” sends one request instead of four.
import { useEffect, useState } from 'react';
import { STATUSES } from './JobCard.jsx';
import { useDebounce } from '../hooks/useDebounce.js';
export default function Filters({ q, status, sort, onChange }) {
// The box shows what the person types at once; the URL (and the server request) follow 350 ms after they stop.
const [text, setText] = useState(q);
const debounced = useDebounce(text, 350);
useEffect(() => { if (debounced !== q) onChange({ q: debounced }); }, [debounced]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { if (q !== debounced) setText(q); }, [q]); // eslint-disable-line react-hooks/exhaustive-deps -- back button or "clear"
return (
<form className="filters" role="search" onSubmit={(e) => e.preventDefault()}>
<div className="field">
<label htmlFor="q">Search</label>
<input id="q" type="search" placeholder="Company, role or place" value={text} onChange={(e) => setText(e.target.value)} />
</div>
<div className="field">
<label htmlFor="filter-status">Status</label>
<select id="filter-status" value={status} onChange={(e) => onChange({ status: e.target.value })}>
<option value="">All</option>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="field">
<label htmlFor="sort">Sort by</label>
<select id="sort" value={sort} onChange={(e) => onChange({ sort: e.target.value })}>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="company">Company A to Z</option>
</select>
</div>
</form>
);
}
The search box now shows what you type at once from local text state, so typing never feels laggy, and only the debounced value is passed up, which updates the URL and triggers the request. The second effect handles the other direction: if the address changes by itself (the back button, “clear filters”), the box follows it. Read the two effects carefully: they are written so they cannot overwrite what you are typing.
2. memo: skip redraws that would change nothing
import { memo } from 'react';
import StatusBadge from './StatusBadge.jsx';
export const STATUSES = ['saved', 'applied', 'interview', 'offer', 'rejected'];
// memo: skip drawing this card again when its props did not change. With 8 to 50 cards on the page,
// changing one job's status then redraws one card instead of all of them.
// (It works because `job` objects are replaced only when they change, and onStatusChange is a stable function.)
function JobCard({ job, onStatusChange }) {
return (
<article className="card">
<h2>{job.role}</h2>
<p>{job.company}{job.location ? ` · ${job.location}` : ''}</p>
<StatusBadge status={job.status} />
<div className="row">
<label className="muted" htmlFor={`status-${job.id}`}>Move to</label>
<select id={`status-${job.id}`} value={job.status} onChange={(e) => onStatusChange(job.id, e.target.value)}>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</article>
);
}
export default memo(JobCard);
When a parent component draws again, all its children draw again too, even if their props are the same. For a handful of cards that is nothing. For fifty cards that each contain a <select>, it adds up. memo(JobCard) tells React to skip drawing a card when its props are identical to last time.
This only works if the props really are the same. That is why earlier lessons took care of two things. Jobs are replaced only when they change (the spread in changeStatus, never mutation), so an unchanged job object is the same reference. And changeStatus is wrapped in useCallback, so its identity is stable. If the page passed a new inline function such as onStatusChange={(id, s) => ...} on each draw, memo would do nothing. Performance work is often just consistency.
3. Code splitting: download less at the start
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout.jsx';
import ProtectedRoute from './components/ProtectedRoute.jsx';
// Code splitting: each page becomes its own small file, downloaded only when someone opens it.
const JobsPage = lazy(() => import('./pages/JobsPage.jsx'));
const JobDetailPage = lazy(() => import('./pages/JobDetailPage.jsx'));
const LoginPage = lazy(() => import('./pages/LoginPage.jsx'));
const RegisterPage = lazy(() => import('./pages/RegisterPage.jsx'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage.jsx'));
export default function App() {
return (
<Suspense fallback={<p className="muted container">Loading…</p>}>
<Routes>
<Route element={<Layout />}>
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
<Route element={<ProtectedRoute />}>
<Route index element={<JobsPage />} />
<Route path="jobs/:id" element={<JobDetailPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</Suspense>
);
}
A single bundle contains the code of every page, so the person downloads the registration form even if they only wanted to sign in. lazy(() => import('./pages/X.jsx')) tells the bundler to put that page in its own file, which is fetched the first time the route is needed. <Suspense fallback=...> shows something while it loads. The build output shows the result: instead of one large file you will see a small file per page.
Measure the result
- Run
npm run buildinclientand compare the list of files and their sizes before and after. - In the browser's Network tab, throttle to “Slow 3G” and reload the login page. Only the login code should be fetched.
- In the Performance and Lighthouse panels you will see numbers such as first paint. Treat them as a guide, not a score to chase.
Your task
- Run the app and type quickly in the search box. In the Network tab, count the requests before and after the debounce (undo it to compare).
- Open React Developer Tools, enable “Highlight updates when components render”, and change one status. With
memo, only one card flashes. - Build the client and note how many JavaScript files appear. Open the login page and see which are loaded.
How to check it worked
- Fast typing sends one request, not one per letter.
- Changing a status redraws one card.
- The build shows separate files for the pages.
Summary
- Measure first. Fix only real problems.
- Debounce user input; memoise list items when props are stable; split code by page.
- memo only helps when props keep the same identity: keep objects and functions stable.