Home / Articles / Do Less Work First: A React Performance Checklist Before Memoization

This article is published in English.

Do Less Work First: A React Performance Checklist Before Memoization

Cut React app cost by avoiding work: debounce searches, paginate, colocate state, use stable keys, move processing server-side and lazy-load, then memoize if needed.

2118 words

When a React app feels slow, the reflex is to reach for useMemo, useCallback or React.memo. Those tools make existing work cheaper, but in many applications the real cost comes from work that never needed to happen: redundant requests, oversized payloads, state updates that ripple through half the tree, and code users have not asked for yet. This guide walks through seven places to remove work before you optimize what remains, with a checklist you can apply during code review.

The guiding question throughout is simple: can this work be avoided entirely?

Fire fewer requests: debounce search input

Search boxes are the classic source of wasted requests. A naive handler calls the API on every change:

const handleSearch = (value) => {
  fetchUsers(value);
};

Typing the word "React" into that input produces one request per keystroke:

R
Re
Rea
Reac
React

Five round trips for one search, four of which return results nobody will look at. A better approach is to wait until the user pauses briefly and only then send the query. That is what debouncing does: each new call resets a timer, and the wrapped function runs only once the timer expires without interruption.

const handleSearch = debounce((value) => {
  fetchUsers(value);
}, 300);

With a 300 ms window, fast typists trigger a single request at the end. You save network traffic, server load and client-side processing of discarded responses. Note that nothing here touches rendering; the gain comes purely from not doing the work.

One caveat when you use a helper like this inside a component: if debounce(...) is called directly in the component body, a new debounced function (and a new timer) is created on every render, which breaks the debounce. Create it once, for example with useMemo or useRef, or use the effect-based pattern below.

A self-contained debounced search component

The same idea can be expressed with React primitives alone, with no helper library. Start with the imports and two pieces of state: the current input text and the fetched users.

import { useEffect, useState } from "react";
function UserSearch() {
  const [search, setSearch] = useState("");
  const [users, setUsers] = useState([]);

An effect runs whenever search changes. Instead of fetching immediately, it schedules the work with setTimeout. Inside the callback, an empty or whitespace-only query clears the results and exits early without a request.

useEffect(() => {
    const timer = setTimeout(async () => {
      if (!search.trim()) {
        setUsers([]);
        return;
      }

For a real query, the callback requests matching users, encoding the search term so special characters cannot break the URL:

const response = await fetch(
        `/api/users?search=${encodeURIComponent(search)}`
      );

It then parses the JSON and stores the result, all still inside the 300 ms timer:

const data = await response.json();
      setUsers(data);
    }, 300);

The cleanup function is where the debouncing actually happens. React runs it before the effect re-runs, so every keystroke cancels the previous pending timer:

return () => clearTimeout(timer);
  }, [search]);

Finally, the component renders a controlled input bound to search:

return (
    <div>
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search users..."
      />

And it lists the users, keyed by their IDs:

{users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

Each character typed resets the timer, and the request fires only after 300 ms of silence. Keep in mind that debouncing reduces how many requests are sent but does not guarantee they finish in order; a slow earlier response can still overwrite a newer one. If that matters for your UI, pair this with request cancellation, as covered in fixing race conditions debouncing cannot solve in search UIs.

The broader lesson: preventing work usually beats making existing work faster.

Fetch only the data the screen needs

Another frequent drain is downloading far more data than is displayed. Suppose an endpoint returns 10,000 users while the view shows 20 at a time. The browser still has to download, parse and hold all of them in memory, and React has to work with much larger arrays than necessary.

Where the use case allows, paginate so each request carries just one page:

API
 ↓
20 users
 ↓
Browser
 ↓
Display

Each time the user advances, request the following slice. This reduces network usage, memory consumption, client-side processing and the volume of data flowing through your components. Infinite scroll and cursor-based APIs follow the same principle. Changing how data is fetched often has a far larger effect than tuning any single component.

Keep fast-changing state near its consumers

Where state lives determines how much of the tree re-renders when it changes. Consider a dashboard that owns the search text:

function Dashboard() {
  const [search, setSearch] = useState("");
return (
    <>
      <SearchBox value={search} onChange={setSearch} />
      <Analytics />
      <UserTable />
    </>
  );
}

Every keystroke updates Dashboard, so Analytics and UserTable re-render as well, even though neither uses the search value. On a large dashboard that adds up. If the state only matters to a small part of the UI, moving it into that part (here, into SearchBox itself) confines updates to where they are needed and makes the component structure easier to follow.

This is not a rule to always push state down. When several components genuinely need the same value, their closest common parent is the right owner. The aim is to keep state at the lowest level that is still shared by every component that actually reads it.

Give dynamic list items stable keys

Lists are where small details have outsized effects. A common pattern uses the array index as the key:

{users.map((user, index) => (
  <UserCard key={index} user={user} />
))}

Index keys are not always wrong. For a static list whose order never changes, they work. For a dynamic list, a stable ID from the data gives each item a durable identity:

{users.map((user) => (
  <UserCard key={user.id} user={user} />
))}

The difference comes down to what the key represents:

index → position
id    → identity

If items can be added, removed, reordered or filtered, positions shift and React matches the wrong items: it may re-render more than needed, and worse, it can attach one item's internal state to another. That becomes a visible bug when list items contain inputs, local state or other interactive elements, such as a text field keeping its typed value while the row beneath it changes. Prefer a stable ID whenever the list can change.

Question the processing, not just its speed

Heavy client-side transformations are another source of avoidable cost. Here, users are filtered, sorted and mapped into new objects:

const filteredUsers = users
  .filter((user) => user.isActive)
  .sort((a, b) => a.name.localeCompare(b.name))
  .map((user) => ({
    ...user,
    displayName: user.name.toUpperCase()
  }));

On a few thousand records, repeating this on every render gets expensive. Memoizing it is an option, but first ask whether the client needs to do this work at all. Often the endpoint could filter to active users itself, the database could handle ordering where indexes make it cheap, and pagination can shrink the dataset so the remaining processing is trivial. Reducing the input tends to beat speeding up the computation.

Load features on demand

Large apps ship features many users never open in a given session. A Reports page, for instance, may be entirely separate from the main dashboard. Rather than bundling it into the initial download, load it lazily:

const Reports = lazy(() => import("./Reports"));

With React.lazy, the module is fetched the first time the component is rendered, which must happen inside a Suspense boundary that shows a fallback while it loads. This pays off most in apps with many routes, big feature areas, weighty dependencies such as charting or editor libraries, or screens that few users visit.

Be clear about what it buys you: a smaller initial JavaScript payload and a faster first load. It does not make the code inside the lazy component run any faster once loaded, and it adds a short loading delay the first time the feature opens.

Memoize with a reason

A common mistake is sprinkling optimization APIs across a codebase by default, like wrapping every handler:

const handleClick = useCallback(() => {
  setSelectedUser(id);
}, [id]);

Or every derived value:

const data = useMemo(() => {
  return processData(users);
}, [users]);

These hooks have legitimate uses, but each one adds code, dependency arrays that must be kept correct, and its own small runtime cost. Before adding one, check:

  • Is the computation actually expensive?
  • Does it run often?
  • Is the result reused across renders?
  • Does a memoized child or an effect depend on a stable reference?
  • Will the change make a measurable difference?

If the answers are mostly no, the simpler code is the better code. Performance comes from the right optimization in the right place, not from the amount of optimization code. The React Compiler, where you have adopted it, automates much of this memoization, which is another reason not to hand-write it everywhere; see what React Compiler optimizes and what it leaves on your plate.

A review checklist

Run through these questions when auditing a React feature:

  1. Unnecessary requests? Look for calls per keystroke, duplicated requests, and fetches for data not currently shown.
  2. Too much data? Paginate, filter on the server, and defer fetches until the data is needed.
  3. State too high? Check whether one update makes a larger part of the tree re-render than it should.
  4. Unstable list keys? Use stable IDs wherever items can move or change membership.
  5. Excess processing? See whether work can move to the server or be skipped before you optimize it.
  6. Eager features? Lazy-load large or rarely used modules.
  7. Unjustified memoization? Do not add useMemo, useCallback or React.memo just because they exist.

Performance is a pipeline, not a render

React performance is not only about React. Cost accumulates along the whole chain of work:

API calls
   ↓
Amount of data
   ↓
State updates
   ↓
Component structure
   ↓
Data processing
   ↓
Rendering
   ↓
Bundle size

Focusing only on the rendering layer can hide a much bigger problem upstream. Shaving milliseconds off a render does little if the page still fires redundant requests, and memoizing a component does not help when you are downloading thousands of records you never display. Start at the top of the chain and work down.

Key takeaways

  • Removing work (requests, bytes, updates, code) usually yields more than making work faster.
  • Debounce input-driven requests, and pair debouncing with cancellation when ordering matters.
  • Let the server filter, sort and paginate; send the client only what it shows.
  • Place state at the lowest level that serves all its readers, and key dynamic lists by identity.
  • Lazy-load for a lighter first load, and reach for memoization only when a concrete cost justifies it.
  • Before asking how to optimize something, ask whether it needs to happen at all.