Skip to content

Advanced React: Hooks, Composition, Testing, and Performance

Step 27 of 45

React Reducers, Refs, and Custom Hooks

Introduction

This is lesson 3 of chapter 6, aligned with course 6 of the Meta Front-End Developer pathway ("Advanced React", domain advanced-react). So far you have managed state with useState and synchronised with the outside world with useEffect. Three more tools complete the everyday toolbox:

  • useReducer keeps complex state transitions in one predictable function.
  • useRef stores a value that survives renders without causing a re-render, and gives access to DOM elements.
  • Custom hooks let you extract and reuse stateful logic between components.

The scenario is original: the Harbor Workshops booking cart grows rules (quantities, a seat limit, a promo code, undo of the last removal), the search box must receive focus when the page opens, and two screens need the same "load a list" logic. By the end of the lesson you will know which tool fits each situation and how to combine them.

Learning objectives

  1. Convert scattered useState calls into a reducer with descriptive action types.
  2. Write a pure reducer and dispatch actions from event handlers.
  3. Choose between useState and useReducer.
  4. Use useRef for DOM access (focus, scroll, measurement) and for mutable values that must not trigger renders.
  5. Explain why changing ref.current does not re-render and why it must not be read during rendering.
  6. Extract logic into a custom hook, following the naming and rules of hooks.
  7. Return values from a custom hook in a convenient shape (object or array).
  8. Combine a reducer with context for shared state.

1. useReducer: many transitions, one place

useState is perfect for independent values. When the next state depends on a complicated rule, or when several fields change together, handlers scatter that logic around the component. A reducer concentrates it.

A reducer is a pure function (state, action) => newState:

JSX
// src/cartReducer.js
export const initialCart = { items: [], promo: null, lastRemoved: null };

export function cartReducer(state, action) {
  switch (action.type) {
    case 'added': {
      const existing = state.items.find(i => i.id === action.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map(i =>
            i.id === action.id ? { ...i, seats: Math.min(i.seats + 1, 6) } : i
          ),
        };
      }
      return { ...state, items: [...state.items, { id: action.id, title: action.title, seats: 1 }] };
    }
    case 'removed':
      return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case 'promo-applied':
      return { ...state, promo: action.code };
    case 'cleared':
      return initialCart;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

Rules for a reducer:

  • It must be pure: no fetching, no random numbers, no mutation. Return a new object or array; never state.items.push(...).
  • It receives the current state and an action and returns the next state.
  • Actions describe what happened ('added'), not how to change the state ('set-items'). The reducer decides the consequences.
  • Throw on unknown actions to expose typos. Returning unchanged state can be an intentional ignore policy, but then it does not reveal the typo.

Using it in a component

JSX
import { useReducer } from 'react';

function Cart() {
  const [cart, dispatch] = useReducer(cartReducer, initialCart);

  return (
    <>
      <button type="button" onClick={() => dispatch({ type: 'added', id: 'w1', title: 'Sourdough basics' })}>
        Add sourdough
      </button>
      <ul>
        {cart.items.map(i => (
          <li key={i.id}>
            {i.title} × {i.seats}
            <button type="button" onClick={() => dispatch({ type: 'removed', id: i.id })}>Remove</button>
          </li>
        ))}
      </ul>
    </>
  );
}

dispatch is stable between renders, so it can be passed to children or listed in dependency arrays without causing extra effect runs. React processes queued actions through the reducer. Use fresh payloads when dispatching: a stale value captured in an event handler can still produce a stale action.

useState or useReducer?

Criterion useState useReducer
Amount of code for simple state Less More
Related fields that change together Easy to forget one One transition updates all
Complex rules for the next state Spread across handlers Centralised and testable
Debugging Search every setX call Log or inspect actions
Testing Test through the component Test the reducer as a plain function

A good rule: start with useState; move to useReducer when you notice many handlers editing the same state in similar ways, or when the logic deserves unit tests.

Worked example: undoable removal

Statement. Extend the cart so that removing an item can be undone once.

Step 1: decide what to store. Add lastRemoved: null to the state. Removing an item stores it there; undo puts it back.

Step 2: replace the removed case and add undo-removed.

JSX
case 'removed': {
  const item = state.items.find(i => i.id === action.id);
  if (!item) return state;
  return {
    ...state,
    items: state.items.filter(i => i.id !== action.id),
    lastRemoved: item ?? null,
  };
}
case 'undo-removed':
  if (!state.lastRemoved) return state;
  return {
    ...state,
    items: state.items.some(i => i.id === state.lastRemoved.id)
      ? state.items.map(i => i.id === state.lastRemoved.id
        ? { ...i, seats: Math.min(6, i.seats + state.lastRemoved.seats) } : i)
      : [...state.items, state.lastRemoved],
    lastRemoved: null,
  };

If the workshop was added again before undo, merge quantities with the six-seat cap instead of creating a duplicate ID. Otherwise restore it at the end; restoring the original position would require storing its index too.

Step 3: render the button conditionally.

JSX
{cart.lastRemoved && (
  <button type="button" onClick={() => dispatch({ type: 'undo-removed' })}>
    Undo removal of {cart.lastRemoved.title}
  </button>
)}

Step 4: why the reducer is the better home. With separate useState calls, the removal handler must update items and lastRemoved as two separate calls, and any new code path that changes items could forget lastRemoved. In the reducer, one action updates both fields atomically. Returning state unchanged for an impossible undo avoids a needless re-render because React skips updates when the returned state is the same object.

Testing a reducer as a plain function

Use Jest and React Testing Library tests for different boundaries: a pure reducer can also be checked without rendering. This is one of the strongest arguments for useReducer.

JavaScript
import { cartReducer, initialCart } from './cartReducer';

test('adding the same workshop twice increases seats, capped at 6', () => {
  let state = initialCart;
  for (let i = 0; i < 8; i += 1) {
    state = cartReducer(state, { type: 'added', id: 'w1', title: 'Sourdough basics' });
  }
  expect(state.items).toHaveLength(1);
  expect(state.items[0].seats).toBe(6);
});

test('the initial state object is not mutated', () => {
  const frozen = Object.freeze({ items: Object.freeze([]), promo: null });
  const next = cartReducer(frozen, { type: 'added', id: 'w2', title: 'Wheel pottery' });
  expect(next).not.toBe(frozen);
});

The first test checks a business rule (the six-seat limit); the second checks this addition path against a frozen object and array. It does not prove that every reducer branch, or nested item, is immutable; freeze representative nested fixtures when testing those paths. Both run in milliseconds, with no DOM. Chapter 6 lesson 5 shows how to test the same cart through the user interface.

Worked example: choosing between state tools

Statement. A signup form has a name, an email, a "newsletter" checkbox, and a submit status (idle, sending, sent, error). A teammate proposes useReducer "because there are four pieces of state". Decide.

Step 1: list the transitions. The user types in a field (one value changes), toggles the checkbox (one value changes), submits (status becomes sending), and the server answers (status becomes sent or error).

Step 2: look for coupled changes. Only the status changes in response to the server, and no field depends on another. There is no rule such as "when X changes, Y must also change".

Step 3: decide. Two useState calls (one object for the fields, one string for the status) are clear and short. A reducer would add a switch statement without removing complexity.

Step 4: when the answer would change. If submitting also had to clear errors, lock all fields, store a retry count, and restore the previous values on failure, the coupled updates would justify a reducer. The number of variables is not the criterion; the complexity of their transitions is.

2. useRef: values outside rendering

A ref is an object { current: initialValue } kept for the mounted component. Avoid reading or writing current during render except for the documented predictable-initialization pattern. Changing ref.current does not trigger a re-render.

Use 1: access a DOM element

JSX
import { useRef, useEffect } from 'react';

function SearchBox() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return (
    <label>
      Search workshops
      <input ref={inputRef} type="search" />
    </label>
  );
}

React sets inputRef.current to the DOM node after the element is rendered, and back to null when it is removed. Typical uses: focusing, scrolling into view, measuring size, and controlling media playback. Prefer declarative props over refs when a prop exists; do not use refs to set text or classes that React manages.

Use 2: remember a mutable value across renders

JSX
function AutoSave({ draft }) {
  const timerId = useRef(null);

  useEffect(() => {
    const id = setTimeout(() => saveDraft(draft), 800);
    timerId.current = id;
    return () => clearTimeout(id);
  }, [draft]);
  return null;
}

Good candidates include timer IDs and previous values read by handlers or effects. Do not use a ref flag merely to suppress the Strict Mode setup/cleanup check. Values that affect what is displayed belong in state, not in refs.

useState versus useRef

useState useRef
Re-renders on change Yes No
Value read during render Yes (it is the render input) Avoid; reading current during render makes output unpredictable
Mutated directly Never Yes, via ref.current
Typical content Anything shown on screen DOM nodes, timer ids, previous values

Passing a ref to your own component

In React 19, a function component can receive ref as an ordinary prop:

JSX
function TextField({ label, ref, ...props }) {
  return (
    <label>
      {label}
      <input ref={ref} {...props} />
    </label>
  );
}

function Parent() {
  const emailRef = useRef(null);
  return <TextField label="Email" type="email" ref={emailRef} />;
}

In React 18 and earlier (and in much existing code) you must wrap the component in forwardRef. Recognise both forms; forwardRef continues to work.

3. Custom hooks

A custom hook is a JavaScript function whose name starts with use and which calls other hooks. It extracts logic, not markup. Each component that calls the hook gets its own independent state; hooks share code, not data.

Rules of hooks

  1. Call hooks only at the top level of a component or custom hook: never inside loops, conditions, or nested functions.
  2. Call hooks only from React function components or other hooks.

These rules apply to Hooks such as useState and useEffect, whose calls must retain their order. The React use API has special rules allowing loops and conditions; it is not an exception for useState.

Extracting a data-loading hook

Package the previous lesson’s effect cleanup and safe API requests into a reusable hook:

JSX
// src/useWorkshops.js
import { useState, useEffect } from 'react';
import { fetchWorkshopList } from './workshopApi.js';

export function useWorkshops(category) {
  const [result, setResult] = useState({ category: null, status: 'loading', workshops: [] });
  useEffect(() => {
    let active = true;
    const controller = new AbortController();
    setResult({ category, status: 'loading', workshops: [] });
    fetchWorkshopList(`/api/workshops?category=${encodeURIComponent(category)}`, {
      signal: controller.signal,
    }).then(workshops => {
      if (active) setResult({ category, status: 'success', workshops });
    }).catch(error => {
      if (active && error.name !== 'AbortError') setResult({ category, status: 'error', workshops: [] });
    });
    return () => { active = false; controller.abort(); };
  }, [category]);
  return result.category === category
    ? { workshops: result.workshops, status: result.status }
    : { workshops: [], status: 'loading' };
}

function WorkshopList({ category }) {
  const { workshops, status } = useWorkshops(category);
  if (status === 'loading') return <p role="status">Loading...</p>;
  if (status === 'error') return <p role="alert">Something went wrong.</p>;
  if (workshops.length === 0) return <p>No workshops in this category.</p>;
  return <ul>{workshops.map(w => <li key={w.id}>{w.title}</li>)}</ul>;
}

The component now describes what to show; the hook knows how to get the data.

Another hook: persistent state

JSX
// src/useLocalStorage.js — client-only, fixed key per mounted owner
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored !== null ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      /* storage may be full or blocked */
    }
  }, [key, value]);

  return [value, setValue];
}

function ThemeSetting() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  return <button type="button" onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>Theme: {theme}</button>;
}

This small hook is for a client-only component with a key fixed for its mounted lifetime. If the key changes, remount its owner with a React key; otherwise the old state would be written under the new storage key. It is not SSR/hydration-safe: an SSR version should first render a consistent fallback and read storage after hydration. Only JSON-serializable values are supported, and parsed data needs application-specific shape validation. Separate callers are not synchronized, and this code does not subscribe to storage events.

Three details: state uses lazy initialization (development Strict Mode may invoke the initializer twice), storage access is wrapped in try/catch because browsers can block it, and the hook returns an array so callers can name the pair freely, exactly like useState.

Return shape

Return When
Array [value, setter] Two or three values with obvious roles; callers rename them
Object { workshops, status } Several values; callers pick what they need and order does not matter

Hooks and context together

A reducer plus context gives you shared, structured state:

JSX
// src/CartContext.jsx
import { createContext, useContext, useMemo, useReducer } from 'react';
import { cartReducer, initialCart } from './cartReducer.js';

const CartContext = createContext(null);

export function CartProvider({ children }) {
  const [cart, dispatch] = useReducer(cartReducer, initialCart);
  const value = useMemo(() => ({ cart, dispatch }), [cart]);
  return <CartContext value={value}>{children}</CartContext>;
}

export function useCart() {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error('useCart must be used inside <CartProvider>');
  return ctx;
}

Any component can now call const { cart, dispatch } = useCart(). This combines the course's reducer, context, and custom-hook concepts in the original Harbor Workshops exercise.

Common pitfalls

  • Mutating state inside the reducer (state.items.push). React may skip the update or show stale UI.
  • Side effects in a reducer (fetch, localStorage, Math.random). Reducers must be pure; in Strict Mode they are called twice.
  • Reading or writing ref.current during rendering to display data. Use state.
  • Expecting a ref change to update the screen.
  • Calling a hook conditionally (if (open) useState(...)).
  • Naming a hook without the use prefix, which prevents lint rules from checking it.
  • Thinking a custom hook shares state between components. Two components calling useLocalStorage('theme') have separate states; share by lifting state or context.
  • Forgetting default or a throw in the reducer, which silently returns undefined and wipes the state.
  • Unclear action contracts. React allows any action value; this reducer expects plain objects with type and payload fields. Do not pass an event or function to this particular reducer.

Study tips

  • "Pure function that receives the current state and an action" is the definition of a reducer.
  • The second value is dispatch. This example dispatches action objects; React passes whatever action you supply to your reducer, which decides what it means.
  • If a question asks how to focus an input on load, expect useRef and useEffect.
  • If a question says a value must persist but changing it should not repaint, choose useRef.
  • A function named useSomething that is not called at the top level is a rules-of-hooks violation.
  • Custom hooks reuse logic, not state.

Mini case study: the shared counter that is not shared

Situation. A developer writes useSeatCount() that stores a number with useState. The header shows the count from useSeatCount() and the cart page calls useSeatCount() too. When the user adds a seat in the cart page, the header number stays at zero. The developer concludes that custom hooks "do not work".

Analysis. Every call to a hook creates a separate state slot belonging to the component that calls it. The header and the cart each own a different useState. The hook shared the code, not the data.

Solution. Keep the state in one place and share it:

  1. Move the state into a provider using useReducer (or useState).
  2. Expose it through context.
  3. Make useSeatCount() read the context: const { cart } = useCart(); return cart.items.reduce((n, i) => n + i.seats, 0);.

Now the hook is a convenient way to derive the number, and both components read the same source of truth.

Why other fixes are worse. Using a module-level variable would update the value but not re-render components. Copying the count into both components with useEffect creates two sources that can disagree.

Key terms

  • Reducer: a pure function that computes the next state from the current state and an action.
  • Action: a plain object describing what happened, usually with a type.
  • dispatch: the function returned by useReducer that sends an action.
  • Ref: a mutable container { current } that persists across renders without causing renders.
  • Lazy initialisation: passing a function to useState so the initial value is computed once.
  • Custom hook: a use-prefixed function that reuses stateful logic.
  • Rules of hooks: call hooks at the top level, only from components or hooks.
  • forwardRef: the pre-19 wrapper for passing refs to function components.
  • Pure function: a function whose result depends only on its inputs and has no side effects.

Summary checklist

  • I can write a pure reducer with descriptive action types.
  • I use dispatch with plain action objects.
  • I choose useReducer when transitions are complex or need unit tests.
  • I use useRef for DOM access and non-visual mutable values.
  • I never read ref.current to render output.
  • I extract logic into use-prefixed custom hooks and follow the rules of hooks.
  • I understand that each hook call has its own state.
  • I can combine a reducer and context into a provider with a guarded hook.

Official sources