Expand the Reducer to Manage All Boards

In the previous sections, we moved from useState to useReducer to manage a Kudo[] for a single board page. That was useful for learning the pattern, but there is a problem with the design: the board page keeps its own separate copy of one board’s kudos, while the rest of the app still reads from INITIAL_BOARDS.

That means the implementation is only partially correct. You can add and delete kudos on the board page, but the home page does not reflect those updates, board counts get out of sync, and the state disappears when you leave the page or refresh the browser. In other words, we have organized the update logic better, but the state is still stored at the wrong level.

Expand the reducer to manage the entire Board[] collection. Instead of dispatching { type: "added", kudo }, we will dispatch { type: "kudo_added", boardId, kudo } — the reducer finds the right board and updates it.

Create the boards reducer

Create src/reducers/boards.ts:

import type { Board, Kudo } from "@/data/types";

export type BoardsAction =
  | { type: "kudo_added"; boardId: string; kudo: Kudo }
  | { type: "kudo_deleted"; boardId: string; kudoId: string };

function assertNever(value: never): never {
  throw new Error(`Unexpected action: ${JSON.stringify(value)}`);
}

export function boardsReducer(boards: Board[], action: BoardsAction): Board[] {
  switch (action.type) {
    case "kudo_added":
      return boards.map((board) =>
        board.id === action.boardId
          ? { ...board, kudos: [...board.kudos, action.kudo] }
          : board,
      );
    case "kudo_deleted":
      return boards.map((board) =>
        board.id === action.boardId
          ? {
              ...board,
              kudos: board.kudos.filter((k) => k.id !== action.kudoId),
            }
          : board,
      );
  }

  return assertNever(action);
}

This is the same idea as kudosReducer, just one level up. Each action now includes a boardId so the reducer can locate the right board with .map() and only modify that one. The assertNever helper does the same job here: it makes sure the reducer stays in sync if you add a new action type later.

Immutability with nested state

In an earlier section, we saw that React requires a new array reference to detect state changes. That is why we spread into a new array instead of using .push(). The same principle applies to objects: you must create a new object rather than mutating the existing one.

Look at the "kudo_added" case:

case "kudo_added":
  return boards.map((board) =>
    board.id === action.boardId
      ? { ...board, kudos: [...board.kudos, action.kudo] }
      : board,
  );

Immutability applies at three levels here:

  • .map() creates a new array. We never mutate the boards array itself.
  • { ...board, kudos: ... } creates a new board object. We never mutate the matching board. The spread copies all existing properties, and the kudos key overrides just that one field.
  • [...board.kudos, action.kudo] creates a new kudos array. We never push onto the existing kudos array.

Boards that do not match the boardId are returned unchanged. They are still the same object reference, so React does not do extra work on them.

A common mistake is to mutate the board directly:

// ❌ Wrong — mutates the existing board object
case "kudo_added":
  const target = boards.find((b) => b.id === action.boardId);
  target.kudos.push(action.kudo);
  return boards;

This fails for two reasons: boards is the same array reference (React skips re-render), and the board object is mutated in place (breaking React’s assumption that state is immutable). Always create new copies at every level of nesting that changes.

Since boardsReducer replaces kudosReducer, delete the old file:

rm src/reducers/kudos.ts

Create storage helpers

The reducer handles state transitions, but we also need to persist state across page refreshes. For that, we need two helpers: one to load boards from localStorage, and one to save them back.

Create src/data/board-storage.ts:

import type { Board } from "@/data/types";
import { INITIAL_BOARDS } from "./boards";

const STORAGE_KEY = "kudoboard-boards";

export function loadBoards(_: null = null): Board[] {
  const stored = localStorage.getItem(STORAGE_KEY);

  if (!stored) {
    return INITIAL_BOARDS;
  }

  try {
    return JSON.parse(stored) as Board[];
  } catch {
    return INITIAL_BOARDS;
  }
}

export function saveBoards(boards: Board[]) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(boards));
}

loadBoards() returns whatever is in localStorage, falling back to INITIAL_BOARDS on the first load. The optional _ parameter exists so this function can be passed directly as a useReducer lazy initializer in the next section. For simplicity, this tutorial assumes that any stored JSON has the correct Board[] shape; as Board[] is a TypeScript assertion, not runtime validation. saveBoards() writes the current state.

Checkpoint: Commit your progress.

git add .
git commit -m "kudoboard-10: Create boards reducer and storage helpers"
git push