Extract a Custom Hook for Boards

Look at the board detail page from the previous section. It has three pieces of setup:

const [boards, dispatch] = useReducer(boardsReducer, null, loadBoards);

useEffect(() => {
  saveBoards(boards);
}, [boards]);

const board = boards.find((b) => b.id === boardId);

The first two lines, the reducer and the persistence effect, have nothing to do with rendering a board page. They are generic “load boards, keep them in sync” logic. If we wanted the home page to also modify boards (say, to create a new board), we would have to copy that same setup there.

This is exactly what custom hooks solve. A custom hook is a function whose name starts with use that calls other hooks. It lets you extract reusable stateful logic out of components.

Create the useBoards hook

Create src/hooks/use-boards.ts:

import { useEffect, useReducer } from "react";
import { boardsReducer } from "@/reducers/boards";
import { loadBoards, saveBoards } from "@/data/board-storage";
import type { Kudo } from "@/data/types";

export function useBoards() {
  const [boards, dispatch] = useReducer(boardsReducer, null, loadBoards);

  useEffect(() => {
    saveBoards(boards);
  }, [boards]);

  function addKudo(boardId: string, kudo: Kudo) {
    dispatch({ type: "kudo_added", boardId, kudo });
  }

  function deleteKudo(boardId: string, kudoId: string) {
    dispatch({ type: "kudo_deleted", boardId, kudoId });
  }

  return { boards, addKudo, deleteKudo };
}

This hook combines three things we have learned:

  • useReducer for state management, which centralizes state transitions in a pure function.
  • useEffect for persistence, which syncs state to localStorage on every change.
  • A clean API, so consumers do not need to know about dispatch or action types; they call addKudo and deleteKudo.

Notice that useReducer(boardsReducer, null, loadBoards) uses null only as the initializer input to loadBoards. The actual reducer state is still the Board[] returned by that function.

Update the board detail page

Update src/routes/boards.$boardId.tsx:

import { createFileRoute } from "@tanstack/react-router";
import AddKudoForm from "@/components/add-kudo-form";
import KudoCard from "@/components/kudo-card";
import { useBoards } from "@/hooks/use-boards"; // 👀
import type { Kudo } from "@/data/types";

export const Route = createFileRoute("/boards/$boardId")({
  component: BoardPage,
});

function BoardPage() {
  const { boardId } = Route.useParams();
  const { boards, addKudo, deleteKudo } = useBoards(); // 👀

  const board = boards.find((b) => b.id === boardId);

  if (!board) {
    return (
      <div className="text-center">
        <h1 className="mb-2 text-2xl font-bold">Board not found</h1>
        <p className="text-muted-foreground">
          The board you're looking for doesn't exist.
        </p>
      </div>
    );
  }

  const handleAddKudo = (kudo: Kudo) => {
    addKudo(board.id, kudo); // 👀
  };

  const handleDeleteKudo = (kudoId: string) => {
    deleteKudo(board.id, kudoId); // 👀
  };

  return (
    <div>
      <h1 className="mb-2 text-3xl font-bold">{board.title}</h1>
      <p className="mb-6 text-muted-foreground">{board.description}</p>
      <AddKudoForm onAdd={handleAddKudo} />

      {board.kudos.length === 0 ? (
        <p className="text-muted-foreground">
          No kudos yet. Be the first to add one!
        </p>
      ) : (
        <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {board.kudos.map((kudo) => (
            <KudoCard
              key={kudo.id}
              kudo={kudo}
              onDelete={() => handleDeleteKudo(kudo.id)}
            />
          ))}
        </div>
      )}
    </div>
  );
}

The component no longer imports useReducer, useEffect, the reducer, or the storage helpers. It just calls useBoards() and uses the returned API.

Update the home page

Update src/routes/index.tsx:

import { createFileRoute, Link } from "@tanstack/react-router";
import { useBoards } from "@/hooks/use-boards"; // 👀
import {
  Card,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

export const Route = createFileRoute("/")({
  component: HomePage,
});

function HomePage() {
  const { boards } = useBoards(); // 👀

  return (
    <div>
      <h1 className="mb-6 text-3xl font-bold">Boards</h1>
      <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
        {boards.map((board) => (
          <Link
            key={board.id}
            to="/boards/$boardId"
            params={{ boardId: board.id }}
          >
            <Card className="transition-shadow hover:shadow-md">
              <CardHeader>
                <CardTitle>{board.title}</CardTitle>
                <CardDescription>{board.description}</CardDescription>
                <p className="text-sm text-muted-foreground">
                  {board.kudos.length}{" "}
                  {board.kudos.length === 1 ? "kudo" : "kudos"}
                </p>
              </CardHeader>
            </Card>
          </Link>
        ))}
      </div>
    </div>
  );
}

The home page only needs boards for display, so it ignores addKudo and deleteKudo. If we later wanted to add board creation on this page, this hook would be a good place to put that logic. We would add a new action to the reducer and a new function to the hook.

From here, you could expand the app by adding new action types to the reducer and new functions to the hook. That is how you would add new boards, archive old ones, or edit kudo messages. The pattern is the same in each case. We will leave those as exercises.

Checkpoint: Commit your progress.

git add .
git commit -m "kudoboard-12: Extract a reusable useBoards custom hook"
git push