useMemo, useCallback and useReducer

React & Next.js: The Complete Developer HandbookLesson 10 of 54

Part 1: React Foundations · 10 of 16

Goal: model state with several related actions as a reducer, and use useMemo and useCallback only where a measurement or a memoised child shows they help.

The tool library’s basket has grown. Members add and remove tools, clear the whole basket, choose loan dates, and the “three tools at most” rule must hold whatever they do. Spread across several useState calls and handlers, those rules become hard to follow. This lesson moves the basket into a reducer, where every change is described in one place. It then looks at the two hooks people reach for too early, useMemo and useCallback, and shows the specific situations where they earn their place.

Reducers: state changes as events

With useState, each handler works out the next state itself: the Add button builds a new array, the Remove button filters one, the Clear button resets everything. The logic for “what can happen to a basket” ends up scattered across the JSX. A reducer gathers it into one function:

  • Handlers no longer calculate state. They dispatch an action: a plain object describing what happened, such as { type: 'added', toolId: 't1' }.
  • The reducer takes the current state and an action, and returns the next state: (state, action) => nextState.

Name actions after what the user did, in the past tense (added, removed, cleared), rather than after the state change you want (setToolIds). The handler reports the event; the reducer decides what it means. Because a reducer is a plain function, you can run it without React at all:

const initialBasket = { toolIds: [], startDate: '', endDate: '' };

function basketReducer(basket, action) {
  switch (action.type) {
    case 'added':
      return { ...basket, toolIds: [...basket.toolIds, action.toolId] };
    case 'removed':
      return { ...basket, toolIds: basket.toolIds.filter((id) => id !== action.toolId) };
    case 'cleared':
      return initialBasket;
    default:
      throw new Error(`Unknown basket action: ${action.type}`);
  }
}

const actions = [
  { type: 'added', toolId: 't1' },
  { type: 'added', toolId: 't6' },
  { type: 'removed', toolId: 't1' },
];

const finalBasket = actions.reduce(basketReducer, initialBasket);
console.log(finalBasket);
console.log('initialBasket is unchanged:', initialBasket.toolIds.length === 0);

The console shows a basket holding only t6, and confirms the starting object was never modified. The example also explains the name: a reducer has the same shape as the callback you pass to an array’s reduce, which folds a list of actions into one final value. React does the same thing over time, one action per dispatch.

Two rules make reducers work. First, a reducer must be pure: no requests, timers or random ids inside it, and it must never change the state it receives. Return a new object instead, with the same spread patterns as in Lesson 5. React calls reducers during rendering (and twice in development under Strict Mode, to catch impure ones), so the purity rules of Lesson 1 apply. Second, an unknown action should fail loudly. Throwing in the default case turns a typo such as 'add' into an error you see straight away, not a button that silently does nothing.

useReducer in a component

useReducer(reducer, initialState) returns the current state and a dispatch function. Calling dispatch(action) asks React to run the reducer with the current state and that action, and to re-render with the result:

import { useReducer } from 'react';

const TOOLS = [
  { id: 't1', slug: 'cordless-drill', name: 'Cordless drill', category: 'Power tools', status: 'available', maxLoanDays: 7 },
  { id: 't3', slug: 'orbital-sander', name: 'Orbital sander', category: 'Power tools', status: 'available', maxLoanDays: 7 },
  { id: 't6', slug: 'tile-cutter', name: 'Tile cutter', category: 'Hand tools', status: 'available', maxLoanDays: 7 },
];
const nameOf = (id) => TOOLS.find((t) => t.id === id).name;
const initialBasket = { toolIds: [], startDate: '', endDate: '' };

function basketReducer(basket, action) {
  switch (action.type) {
    case 'added':
      return { ...basket, toolIds: [...basket.toolIds, action.toolId] };
    case 'removed':
      return { ...basket, toolIds: basket.toolIds.filter((id) => id !== action.toolId) };
    case 'cleared':
      return initialBasket;
    default:
      throw new Error(`Unknown basket action: ${action.type}`);
  }
}

function BasketDemo() {
  const [basket, dispatch] = useReducer(basketReducer, initialBasket);

  return (
    <div>
      {TOOLS.map((t) => (
        <button key={t.id} disabled={basket.toolIds.includes(t.id)}
          onClick={() => dispatch({ type: 'added', toolId: t.id })}>Add {t.name}</button>
      ))}
      <h3>Basket ({basket.toolIds.length})</h3>
      <ul>
        {basket.toolIds.map((id) => (
          <li key={id}>{nameOf(id)}{' '}
            <button onClick={() => dispatch({ type: 'removed', toolId: id })}>Remove</button>
          </li>
        ))}
      </ul>
      <button disabled={basket.toolIds.length === 0} onClick={() => dispatch({ type: 'cleared' })}>Clear basket</button>
    </div>
  );
}

Press Add Cordless drill and the basket shows one tool; its Add button is disabled because the tool is already in. Every handler is now one line that says what happened. To learn what a basket can do, you read one function, not the whole component.

Two more details. dispatch is the same function on every render, like a useState setter, so you can pass it down to children or use it in an effect without it ever being a changing dependency. And if the reducer returns the same state object it received, React compares it with Object.is, sees nothing changed and skips re-rendering the children. That is useful for “ignore this action” cases, such as adding a fourth tool, which you will write in the exercise. It is also why mutating the state and returning it breaks the screen: React sees the same object and assumes nothing happened.

Reducer or useState?

useState is still the default. A search box, a toggle or a single counter gains nothing from a reducer except extra code. A reducer starts to pay off when:

  • several pieces of state change together (adding a tool might also reset the dates, and clearing resets everything);
  • the same rule must hold whichever handler runs, like the three-tool limit;
  • many handlers update the same state, and you want one list of everything that can happen to it;
  • you want to unit-test the state logic as a plain function, without rendering anything.

Converting is mechanical and reversible, so you don’t have to guess right on day one. When a component’s handlers start repeating the same checks, that is your cue. A reducer is also easy to share: in Lesson 11 the same basketReducer moves into a BasketContext so the header and the catalogue can both use the basket.

useMemo: skip expensive recalculations

Lessons 7 to 9 kept insisting: calculate derived data during render. Filtering eight tools takes a tiny fraction of a millisecond, so doing it on every render is the right call. But a render can be triggered by anything, including state unrelated to the calculation, and occasionally a calculation is slow enough to matter. useMemo(calculate, dependencies) caches its result between renders and only runs calculate again when a dependency has changed.

To make the cost visible, this example builds a made-up catalogue of 40,000 rows (a real tool library is much smaller; the size is only there so the work can be measured). The search function logs how long it takes:

import { useMemo, useState } from 'react';

const NAMES = ['Cordless drill', 'Extension ladder', 'Orbital sander', 'Hedge trimmer',
  'Pressure washer', 'Tile cutter', 'Stud finder', 'Lawn aerator'];
// 40,000 made-up rows, only so the work is big enough to measure.
const BIG_CATALOGUE = Array.from({ length: 40000 }, (_, i) => ({
  id: `demo-${i}`,
  name: `${NAMES[i % NAMES.length]} ${i + 1}`,
}));

function searchCatalogue(tools, query) {
  const start = performance.now();
  const q = query.trim().toLowerCase();
  const found = tools.filter((t) => t.name.toLowerCase().includes(q))
    .sort((a, b) => a.name.localeCompare(b.name));
  console.log(`searchCatalogue: ${found.length} results in ${(performance.now() - start).toFixed(1)} ms`);
  return found;
}

function Catalogue() {
  const [query, setQuery] = useState('sander');
  const [compact, setCompact] = useState(false);
  const results = useMemo(() => searchCatalogue(BIG_CATALOGUE, query), [query]);

  return (
    <div>
      <button onClick={() => setCompact(!compact)}>{compact ? 'Roomy view' : 'Compact view'}</button>
      <p>
        <label htmlFor="big-search">Search</label>{' '}
        <input id="big-search" value={query} onChange={(e) => setQuery(e.target.value)} />
      </p>
      <p>{results.length} tools match.</p>
      <ul style={{ lineHeight: compact ? 1.2 : 2 }}>
        {results.slice(0, 3).map((t) => <li key={t.id}>{t.name}</li>)}
      </ul>
    </div>
  );
}

The console logs one search on load: 5,000 results, and a time that depends on your machine. Type in the box and it logs again for each keystroke, because query changed. Now press Compact view a few times: the list changes spacing, the component re-renders, but nothing is logged. query did not change, so React handed back the cached results. Replace the useMemo line with const results = searchCatalogue(BIG_CATALOGUE, query); and every press of the button logs a full search again.

That is the whole job of useMemo, and also its limit: it only helps when the component re-renders without the dependencies changing. While typing, the search still runs on every keystroke. Before adding it anywhere:

  • Measure first. Wrap the calculation in console.time('search') and console.timeEnd('search'), or use performance.now() as above. A rough rule of thumb: if it consistently costs around a millisecond or more, caching may be worth it; well below that, the hook’s own bookkeeping is not worth the extra code. Measure in a production build when you can (development builds are slower), and use the CPU throttling option in Chrome DevTools to see how a slower phone would cope. Lesson 13 shows the React DevTools Profiler.
  • Treat it as an optimisation only. Your component must still work if you delete the useMemo. React may discard a cached value, for example during development when you edit the file, so never rely on it to keep something alive.
  • Check the dependencies are stable. If a dependency is an object or array created during render, it is new every time and the cache never hits.

If your project uses the React Compiler, it adds this kind of memoisation automatically when it builds your code, and hand-written useMemo and useCallback become mostly unnecessary. It is still worth understanding what they do, because you will meet them in almost every existing codebase.

useCallback: keep a function the same

Every render creates new functions. A handler declared inside a component is a fresh function object each time, even if its code is identical. Most of the time that costs nothing. It matters in one situation: when the function is passed to a child wrapped in memo.

memo(Component) tells React to skip re-rendering that child when its props are the same as last time, compared one by one with Object.is. A new function is never the same as the old one, so passing a fresh handler on every render defeats memo completely. useCallback(fn, dependencies) returns the same function object until a dependency changes:

import { memo, useCallback, useState } from 'react';

const TOOLS = [
  { id: 't1', slug: 'cordless-drill', name: 'Cordless drill', category: 'Power tools', status: 'available', maxLoanDays: 7 },
  { id: 't3', slug: 'orbital-sander', name: 'Orbital sander', category: 'Power tools', status: 'available', maxLoanDays: 7 },
  { id: 't6', slug: 'tile-cutter', name: 'Tile cutter', category: 'Hand tools', status: 'available', maxLoanDays: 7 },
];

const ToolRow = memo(function ToolRow({ tool, onAdd }) {
  console.log('ToolRow rendered:', tool.name);
  return (
    <li>{tool.name}{' '}
      <button aria-label={`Add ${tool.name} to basket`} onClick={() => onAdd(tool.id)}>Add</button>
    </li>
  );
});

function Catalogue() {
  const [showHours, setShowHours] = useState(false);
  const [basket, setBasket] = useState([]);

  const handleAdd = useCallback((toolId) => {
    setBasket((prev) => (prev.includes(toolId) ? prev : [...prev, toolId]));
  }, []);

  return (
    <div>
      <button onClick={() => setShowHours(!showHours)}>{showHours ? 'Hide' : 'Show'} opening hours</button>
      {showHours && <p>Open Saturdays, 10:00 to 13:00.</p>}
      <ul>
        {TOOLS.map((t) => <ToolRow key={t.id} tool={t} onAdd={handleAdd} />)}
      </ul>
      <p>In basket: {basket.length}</p>
    </div>
  );
}

On load the console logs three “ToolRow rendered” lines. Press Show opening hours: Catalogue re-renders, but no rows do, because each row’s tool and onAdd props are the same objects as before. Pressing Add on a row doesn’t re-render the rows either; only the count changes. Now remove the useCallback wrapper (keep the arrow function, delete the useCallback( and the , [])), run it, and press the button again: three more lines on every press.

Notice the empty dependency array. The handler uses the updater form, setBasket((prev) => …), so it never reads basket directly and doesn’t need to change when the basket does. If it read basket, you would have to list it, and the function would change on every add. With a reducer, you can often skip useCallback entirely and pass dispatch down, since it never changes.

Without memo on the child, useCallback does nothing useful: the child re-renders with its parent anyway. The other legitimate use is a function listed in an effect’s dependencies, although moving the function inside the effect is usually simpler (Lesson 9). Wrapping every handler “just in case” adds noise, and dependency lists you now have to keep correct, for no gain.

Common mistakes

  • Mutating state in a reducer. basket.toolIds.push(id); return basket; returns the same object, so React skips the update. Look for push, splice, sort or property assignment on the incoming state.
  • Side effects in a reducer. Saving to a server, logging analytics or generating ids with Math.random() inside the reducer. Do them in the event handler and put the result in the action.
  • Memoising by habit. useMemo around a filter of eight items, or useCallback on handlers passed to plain DOM elements or to children without memo. Ask for the measurement or the memoised child; without one, remove it.
  • Dependencies that change every render. useMemo(() => search(tools, filters), [filters]) where filters is an object literal built in the same render. The cache never hits. Depend on the primitive values, such as [query, category], instead.

Build it: the basket reducer

The starter shows the whole catalogue, a basket and loan date fields, run by a reducer that only knows added, removed and cleared. Change a date and the result disappears, with “Unknown basket action: datesChanged” in the console. That is the default case doing its job: an error thrown while React renders removes the component tree (Lesson 14 shows how error boundaries contain that). Finish the reducer:

  • Handle datesChanged, which carries startDate and endDate.
  • Make added ignore a tool that is already in the basket, a tool that is not available, and any tool once the basket holds three. Return the same state object in those cases.
  • In the component, derive full from the state, disable the Add buttons accordingly and show “You can borrow up to 3 tools at once.” when the basket is full.
import { useReducer } from 'react';

const TOOLS = [
  { id: 't1', slug: 'cordless-drill', name: 'Cordless drill', category: 'Power tools', status: 'available', maxLoanDays: 7 },
  { id: 't2', slug: 'extension-ladder', name: 'Extension ladder', category: 'Ladders & access', status: 'on-loan', maxLoanDays: 3 },
  { id: 't3', slug: 'orbital-sander', name: 'Orbital sander', category: 'Power tools', status: 'available', maxLoanDays: 7 },
  { id: 't6', slug: 'tile-cutter', name: 'Tile cutter', category: 'Hand tools', status: 'available', maxLoanDays: 7 },
  { id: 't7', slug: 'stud-finder', name: 'Stud finder', category: 'Hand tools', status: 'available', maxLoanDays: 7 },
];
const initialBasket = { toolIds: [], startDate: '', endDate: '' };

function basketReducer(basket, action) {
  switch (action.type) {
    case 'added':
      return { ...basket, toolIds: [...basket.toolIds, action.toolId] };
    case 'removed':
      return { ...basket, toolIds: basket.toolIds.filter((id) => id !== action.toolId) };
    case 'cleared':
      return initialBasket;
    default:
      throw new Error(`Unknown basket action: ${action.type}`);
  }
}

function Basket() {
  const [basket, dispatch] = useReducer(basketReducer, initialBasket);
  const { startDate, endDate } = basket;

  return (
    <div>
      <ul>
        {TOOLS.map((t) => (
          <li key={t.id}>{t.name}{' '}
            <button aria-label={`Add ${t.name} to basket`} onClick={() => dispatch({ type: 'added', toolId: t.id })}>Add</button>
          </li>
        ))}
      </ul>
      <p>In basket: {basket.toolIds.length} of 3</p>
      <label htmlFor="b-start">From</label>{' '}
      <input id="b-start" type="date" value={startDate}
        onChange={(e) => dispatch({ type: 'datesChanged', startDate: e.target.value, endDate })} />{' '}
      <label htmlFor="b-end">To</label>{' '}
      <input id="b-end" type="date" value={endDate}
        onChange={(e) => dispatch({ type: 'datesChanged', startDate, endDate: e.target.value })} />
    </div>
  );
}

Hint: the rules for added are a guard at the top of the case: if (…) return basket;. The reducer only receives the tool’s id, so look the tool up in TOOLS to check its status. In the component, an Add button’s disabled can combine three derived conditions with ||.

Show a sample solution
import { useReducer } from 'react';

const TOOLS = [
  { id: 't1', slug: 'cordless-drill', name: 'Cordless drill', category: 'Power tools', status: 'available', maxLoanDays: 7 },
  { id: 't2', slug: 'extension-ladder', name: 'Extension ladder', category: 'Ladders & access', status: 'on-loan', maxLoanDays: 3 },
  { id: 't3', slug: 'orbital-sander', name: 'Orbital sander', category: 'Power tools', status: 'available', maxLoanDays: 7 },
  { id: 't6', slug: 'tile-cutter', name: 'Tile cutter', category: 'Hand tools', status: 'available', maxLoanDays: 7 },
  { id: 't7', slug: 'stud-finder', name: 'Stud finder', category: 'Hand tools', status: 'available', maxLoanDays: 7 },
];
const MAX_TOOLS = 3;
const initialBasket = { toolIds: [], startDate: '', endDate: '' };

function basketReducer(basket, action) {
  switch (action.type) {
    case 'added': {
      const tool = TOOLS.find((t) => t.id === action.toolId);
      if (!tool || tool.status !== 'available') return basket;
      if (basket.toolIds.includes(tool.id) || basket.toolIds.length >= MAX_TOOLS) return basket;
      return { ...basket, toolIds: [...basket.toolIds, tool.id] };
    }
    case 'removed':
      return { ...basket, toolIds: basket.toolIds.filter((id) => id !== action.toolId) };
    case 'cleared':
      return initialBasket;
    case 'datesChanged':
      return { ...basket, startDate: action.startDate, endDate: action.endDate };
    default:
      throw new Error(`Unknown basket action: ${action.type}`);
  }
}

function Basket() {
  const [basket, dispatch] = useReducer(basketReducer, initialBasket);
  const { toolIds, startDate, endDate } = basket;
  const full = toolIds.length >= MAX_TOOLS;

  return (
    <div>
      <ul>
        {TOOLS.map((t) => (
          <li key={t.id}>{t.name}{' '}
            <button aria-label={`Add ${t.name} to basket`}
              disabled={t.status !== 'available' || toolIds.includes(t.id) || full}
              onClick={() => dispatch({ type: 'added', toolId: t.id })}>Add</button>
          </li>
        ))}
      </ul>
      <p>In basket: {toolIds.length} of {MAX_TOOLS}</p>
      {full && <p>You can borrow up to 3 tools at once.</p>}
      <label htmlFor="basket-start">From</label>{' '}
      <input id="basket-start" type="date" value={startDate}
        onChange={(e) => dispatch({ type: 'datesChanged', startDate: e.target.value, endDate })} />{' '}
      <label htmlFor="basket-end">To</label>{' '}
      <input id="basket-end" type="date" value={endDate}
        onChange={(e) => dispatch({ type: 'datesChanged', startDate, endDate: e.target.value })} />
      <p><button disabled={toolIds.length === 0} onClick={() => dispatch({ type: 'cleared' })}>Clear basket</button></p>
    </div>
  );
}

Add three tools and the remaining Add buttons are disabled, with the message below the count. The rule lives in two places on purpose: the reducer guarantees it whatever dispatches added (a keyboard shortcut, a “borrow again” link, a future feature), and the derived full tells the member before they click. Returning basket unchanged for an ignored action means React doesn’t re-render the children for it. The dates are now part of the basket, ready to be checked with the Lesson 8 rules before checkout.

Check your understanding

Answer each question in a sentence or two before opening the answer.

1. A teammate’s added case does basket.toolIds.push(action.toolId) and then returns basket. Clicking Add does nothing on screen. Why, and how would a test catch it?

The reducer changes the existing object and returns it, so React compares the old and new state with Object.is, finds the same object and skips the re-render. A unit test that calls the reducer and checks that the original state still has an empty toolIds while the returned state has one item fails straight away. Pure functions make that test a few lines long.

2. Every handler in a component is wrapped in useCallback, but none of the children use memo. What does that buy?

Nothing. Children that are not memoised re-render with their parent whatever props they get, so keeping the functions identical saves no work. It adds code, dependency lists to maintain and a small cost on every render. Remove them, or add memo to a child only after measuring that its re-renders are actually slow.

3. A component has useMemo(() => searchCatalogue(tools, filters), [tools, filters]), and the search still runs on every render. What would you check?

Whether one of the dependencies is a new object each render. If filters is built in the component as { query, category }, it is a new object every time, so the cache never hits. A console.log inside the calculation shows how often it runs. Depend on query and category directly, and make sure tools comes from state or a constant rather than being rebuilt during render.

Key takeaways

  • A reducer is a pure function (state, action) => nextState. Handlers dispatch actions that describe what happened; the reducer decides the new state.
  • Use useReducer when related state changes together or one rule must hold for every update. Stay with useState for simple values.
  • useMemo caches a calculation until its dependencies change. Measure first, and make sure the code still works without it.
  • useCallback keeps a function identical between renders. It only helps when the function goes to a memo child or into an effect’s dependencies.

Priority: should know. You should be able to turn tangled state into a reducer and to judge, with a measurement, whether a useMemo or useCallback in a review is doing any good. For the exact APIs, see useReducer and useMemo in the React documentation.

Code examples in this course are free to use under the MIT licence. Lesson text © 2026 Farooq Bin Munir.