Why your React app re-renders too much (and how to find out)
Sprinkling memo and useCallback across a codebase is not optimisation, it is superstition. Measure first, then apply one of four structural fixes.
Classify state before choosing a tool: local UI state belongs in useState, shareable view state belongs in the URL, server data belongs in a caching layer such as React Query or server components, cross-cutting app state belongs in a small store like Zustand, and context should carry stable configuration rather than frequently-changing values.
| Kind | Examples | Right home |
|---|---|---|
| Local UI | Open/closed, hover, input draft | useState / useReducer |
| URL | Filters, tab, page, search query | searchParams |
| Server data | Products, users, orders | Server components or React Query |
| Global client | Auth session, theme, feature flags | Small store or context |
| Form | Field values, validation, submission | Form library or server action state |
| Ephemeral | Toasts, modals, animation state | Local state or a tiny event bus |
Almost every codebase we audit has server data in a global store, which is where the pain comes from. It gets stale, two components disagree about it, and someone writes a manual refetch effect.
Because it is a cached copy of data owned elsewhere, so it needs staleness, revalidation, deduplication and retry — concerns a plain store does not model.
React Query, SWR and server components all exist to answer the same questions: is this data stale, who else is asking for it, what happens on focus, what happens on failure. Rebuilding that on top of useState and useEffect is a well-trodden path to subtle bugs.
// Server component: the cache lives on the server
export default async function Orders() {
const orders = await getOrders(); // deduped, cacheable, no client state
return <OrderTable orders={orders} />;
}
// Client component: the cache lives in the query client
const { data, isPending, error } = useQuery({
queryKey: ["orders", status],
queryFn: () => fetchOrders(status),
staleTime: 30_000,
});Whenever a user might reasonably want to share, bookmark or reload the current view and get the same thing back. Filters, sort order, pagination, the active tab, the open detail panel — all of these are URL state, and putting them in useState quietly breaks the back button.
Use context to pass stable things down without prop drilling: the current user, a theme, a configured client instance. Use a store when many distant components read and write the same frequently-changing value and you need subscriptions rather than re-renders.
| Context | Zustand / Jotai | Redux Toolkit | |
|---|---|---|---|
| Best for | Stable, rarely-changing values | Cross-cutting mutable state | Large apps needing strict conventions |
| Re-render behaviour | All consumers on value change | Only components reading that slice | Only selected slices |
| Boilerplate | Minimal | Minimal | Moderate |
| Devtools / time travel | No | Basic | Excellent |
For most product work we ship, the answer is: server components or React Query for data, URL for view state, useState for the rest, and a 30-line Zustand store for the two or three genuinely global things. Redux Toolkit still earns its place in large applications with many teams touching the same state and a real need for its tooling.
No, but its default position is gone. Redux Toolkit remains a good fit for large applications with many contributors and complex state transitions; smaller apps rarely need it now that server-data caching is handled elsewhere.
Yes. Fetch on the server for the initial render and hydrate the query cache, then let React Query handle client-side refetching and mutations. It is a common and effective combination.
Context itself is fast; the problem is that every consumer re-renders when the value identity changes. Split contexts and memoise values and it performs fine for the job it is meant to do.
In the form. Use a form library for complex client-side validation, or a server action with returned error state for simpler forms. Lifting every field into global state is a common and painful mistake.
Harshal Patel
Founder & Lead Engineer, ROVQIX
Harshal leads engineering at ROVQIX, where he has shipped production Next.js, Node.js and PostgreSQL systems for startups, SaaS teams and ecommerce brands. He writes about the trade-offs behind architecture decisions rather than the framework of the week.
ROVQIXdesigns and builds production web platforms — Next.js front ends, Node.js APIs and the infrastructure behind them. Tell us what you're building and we'll scope it with you.
Sprinkling memo and useCallback across a codebase is not optimisation, it is superstition. Measure first, then apply one of four structural fixes.
Most slow React pages are not slow because of rendering. They are slow because six requests are queued behind each other for no reason.
Server components are not 'SSR with extra steps'. They change what code ships to the browser at all — and that changes how you structure a codebase.
No spam. Just the occasional case study and craft breakdown.