Week 4: useReducer & Custom Hooks

Some state doesn't fit cleanly into a handful of independent useState calls — several fields need to change together, in response to a small set of well-defined actions. This week covers useReducer for exactly that case, then moves to the single highest-leverage skill in the hooks system: extracting your own reusable logic into a custom hook.

Module 3 of 13 Week 4 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Model multi-field state transitions with useReducer
  • Extract stateful logic into your own custom hooks
  • Apply the Rules of Hooks correctly, and explain why they exist

1. useReducer: Modeling Complex State Transitions

Consider a shopping cart: adding an item, removing one, and changing a quantity all touch the same array, and each needs slightly different logic. Doing this with several separate useState calls tends to scatter that logic across every event handler that touches the cart. useReducer centralizes it into one place: a single function that takes the current state and an "action," and returns the next state.

cart-reducer.ts
interface CartItem {
  id: number;
  name: string;
  quantity: number;
}

type CartAction =
  | { type: 'add'; item: Omit<CartItem, 'quantity'> }
  | { type: 'remove'; id: number }
  | { type: 'setQuantity'; id: number; quantity: number };

function cartReducer(state: CartItem[], action: CartAction): CartItem[] {
  switch (action.type) {
    case 'add': {
      const existing = state.find(item => item.id === action.item.id);
      if (existing) {
        return state.map(item =>
          item.id === action.item.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      }
      return [...state, { ...action.item, quantity: 1 }];
    }
    case 'remove':
      return state.filter(item => item.id !== action.id);
    case 'setQuantity':
      return state.map(item =>
        item.id === action.id ? { ...item, quantity: action.quantity } : item
      );
  }
}
Cart.tsx
import { useReducer } from 'react';

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

  return (
    <div>
      <button onClick={() => dispatch({ type: 'add', item: { id: 1, name: 'Keyboard' } })}>
        Add Keyboard
      </button>
      {items.map(item => (
        <div key={item.id}>
          {item.name} × {item.quantity}
          <button onClick={() => dispatch({ type: 'remove', id: item.id })}>Remove</button>
        </div>
      ))}
    </div>
  );
}

useReducer(reducer, initialState) returns the current state and a dispatch function. Every state transition goes through dispatch(action), which calls cartReducer(currentState, action) and re-renders with whatever it returns. Notice the component itself never computes cart logic directly — it just describes what happened ("add", "remove") and the reducer decides what that means for state.

2. Actions & the Reducer Pattern

This structure — a typed action describing an event, and a pure function computing the next state from it — is the exact pattern behind Redux (Week 10), just without a library. Two properties make it worth learning here first, by hand:

  • The reducer is a pure function. Same state + same action always produces the same result, with no side effects — no API calls, no Math.random(), no mutating the input state directly. That purity is what makes reducer logic easy to test in isolation, without rendering any component at all.
  • Actions describe intent, not implementation. {'{ type: 'remove', id: 1 }'} reads like something that happened in the UI, not "splice index 2 out of the array." This separation is what lets the same event handler logic stay stable even if you later change exactly how removal is implemented internally.
useState or useReducer?

If a piece of state is a single, independent value (a boolean toggle, a text input), useState is simpler and correct. Reach for useReducer when several related values update together in response to the same set of events, or when the "next state" logic itself is complex enough to be worth testing on its own.

3. Your First Custom Hook

A custom hook is nothing magical — it's a regular function whose name starts with use, that calls other hooks inside it. That naming convention is what lets React's linter (and other hooks) verify the Rules of Hooks are being followed. Its entire purpose is extracting logic you'd otherwise duplicate across components.

before — duplicated in two components
function ProfileForm() {
  const [isOpen, setIsOpen] = useState(false);
  const toggle = () => setIsOpen(prev => !prev);
  // ...
}

function Sidebar() {
  const [isOpen, setIsOpen] = useState(false);
  const toggle = () => setIsOpen(prev => !prev);
  // ...
}
useToggle.ts
import { useState, useCallback } from 'react';

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(prev => !prev), []);
  return [value, toggle] as const;
}
after
function ProfileForm() {
  const [isOpen, toggleOpen] = useToggle();
  // ...
}

function Sidebar() {
  const [isOpen, toggleOpen] = useToggle();
  // ...
}

A crucial thing to understand: useToggle doesn't share state between the two components. Each call creates its own independent useState — you've extracted and reused the logic, not a shared value. Sharing an actual value across components is Week 10's job (Context and global state), not a custom hook's.

4. Custom Hooks That Wrap Effects

The most common real-world custom hooks wrap a useEffect (plus often some state) behind a clean, reusable name — hiding setup/cleanup details a consuming component shouldn't need to think about.

useLocalStorage.ts
import { useState, useEffect } from 'react';

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

// Usage -- looks exactly like useState, but persists automatically
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
useDebouncedValue.ts
function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timeout = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timeout); // cancel if value changes before delay elapses
  }, [value, delayMs]);

  return debounced;
}

// Usage -- delays reacting to fast typing until it pauses
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebouncedValue(query, 300);

  // An effect depending on debouncedQuery only fires 300ms after typing stops
  useEffect(() => {
    if (debouncedQuery) console.log('searching for', debouncedQuery);
  }, [debouncedQuery]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

Notice how much Week 2 knowledge is packed into these eight lines: state, an effect with a dependency array, and a cleanup function that cancels a stale timeout. This is exactly the pattern you'll reach for constantly once real network requests enter the picture in Week 8 — debouncing a search input before firing a request on every keystroke.

5. The Rules of Hooks

Hooks work by relying on being called in the exact same order on every render — React matches each useState/useEffect call to its stored data by call order, not by name. Break that order, and hooks silently read the wrong stored data. Two rules keep that order stable:

  1. Only call hooks at the top level. Never inside a condition, loop, or nested function.
  2. Only call hooks from React function components or other custom hooks. Never from a regular function or a class method.
breaks the rules
function Profile({ showBio }: { showBio: boolean }) {
  if (showBio) {
    const [bio, setBio] = useState(''); // conditional hook call -- illegal
  }
  // If showBio flips between renders, every hook AFTER this one
  // shifts position and reads the wrong stored state.
}
follows the rules
function Profile({ showBio }: { showBio: boolean }) {
  const [bio, setBio] = useState(''); // always called, unconditionally

  if (showBio) {
    return <p>{bio}</p>; // conditional RENDERING is fine -- just not conditional hooks
  }
  return null;
}

The fix is always the same shape: call the hook unconditionally at the top, and put the conditional logic in what you do with its value afterward. React's ESLint plugin (eslint-plugin-react-hooks, included by default in a Vite React template) catches almost every violation of this automatically — treat its warnings as errors, not suggestions.

6. Hands-on Exercise

Hands-on

Build a reducer-powered todo list, then extract a reusable hook from it

Combine this week's two ideas: centralized state transitions, and reusable logic extraction.

Part 1 — Reducer:

  1. Define a Todo type ({'{ id, text, done }'}) and a TodoAction union with add, toggle, remove, and clearCompleted variants.
  2. Write todoReducer(state, action) handling all four actions, and use it via useReducer in a TodoApp component with a text input, an add button, a list of toggleable items, and a "Clear completed" button.

Part 2 — Extract a custom hook:

  1. Move the useReducer call and all four dispatch(...) calls out of TodoApp into a useTodos() custom hook that returns {'{ todos, addTodo, toggleTodo, removeTodo, clearCompleted }'} — named functions instead of raw dispatch calls.
  2. TodoApp should now call const { todos, addTodo, ... } = useTodos(); and never reference dispatch or the reducer directly.
  3. Persist the todos with useLocalStorage (from Section 4) inside useTodos, so the list survives a page refresh.
Hint

You can't directly combine useReducer and useLocalStorage as written this week — instead, initialize the reducer's state from localStorage (like useLocalStorage's lazy initializer) and add a useEffect inside useTodos that writes todos back to localStorage whenever it changes.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What two things does useReducer return, and what does dispatch actually do?

The current state and a dispatch function. Calling dispatch(action) runs your reducer with the current state and that action, and schedules a re-render with whatever the reducer returns — it never mutates state directly, it just triggers the same "compute next state" flow every action goes through.

Q2

Why must a reducer function be pure — no API calls, no Math.random(), no mutating its input?

React may call a reducer more than once per action in some cases (like Strict Mode's development double-invoking, used to surface exactly these kinds of bugs), and relies on being able to predict its output from its inputs. A side effect or non-deterministic result inside the reducer would run at unpredictable times or produce inconsistent state, and mutating the input directly breaks React's reference-equality change detection entirely.

Q3

When two components both call useToggle(), do they share the same boolean value?

No. A custom hook is just a function that calls other hooks — each call site gets its own completely independent useState internally. A custom hook reuses logic, not state; making two components share an actual value requires lifting that state up or using Context (Week 10), not a custom hook on its own.

Q4

Why does calling useState inside an if block break a component?

React identifies each hook's stored data by the order hooks are called in, not by name or variable — it assumes call #3 this render is the same hook as call #3 last render. If a condition makes that hook call happen sometimes and not others, every hook after it shifts position between renders, and React ends up reading the wrong stored state for each one.