Skip to Content
React in a Week
Day 5 · Scaling up (TypeScript)

Scaling up with reducer and context

Combine useReducer with context so any component can read the books and dispatch actions, with no prop drilling.

On Day 4 you moved all your book logic into booksReducer. That made updates predictable, but the state still lives in App. Every component that shows books or changes them needs books or dispatch passed down as props, through BookList, into BookCard, and soon into pages that do not exist yet.

In the previous lesson you saw that context solves exactly this. Put the two ideas together and you get a pattern React's own docs recommend for growing apps: a reducer holds the state, and context delivers it.

The starting point

Here is a trimmed-down Reading List before the change. Follow dispatch with your eyes: it goes from App to BookList to BookCard, and BookList never uses it.

App.tsx (before)
export default function App() {
  const [books, dispatch] = useReducer(booksReducer, initialBooks);
  return (
    <>
      <AddBookForm onAdd={(book) => dispatch({ type: 'added', book })} />
      <BookList books={books} dispatch={dispatch} />
    </>
  );
}

function BookList({ books, dispatch }: { books: Book[]; dispatch: Dispatch<Action> }) {
  return books.map((book) => <BookCard key={book.id} book={book} dispatch={dispatch} />);
}

You will fix this in three steps.

Step 1: create two contexts

You create one context for the state (the books) and a separate one for the dispatch function.

BooksContext.tsx
import { createContext } from 'react';
import type { Dispatch } from 'react';
import type { Book } from './types';
import type { Action } from './booksReducer';

const BooksContext = createContext<Book[] | null>(null);
const BooksDispatchContext = createContext<Dispatch<Action> | null>(null);

Dispatch<Action> is React's type for "a function that takes an Action and returns nothing", which is exactly what useReducer gives you. Both start as null, because there are no sensible default books.

Step 2: provide them from a BooksProvider

Next, write a component that calls useReducer and puts both values into context. It takes children, so you can wrap any part of the app with it.

BooksContext.tsx (continued)
export function BooksProvider({ children }: { children: ReactNode }) {
  const [books, dispatch] = useReducer(booksReducer, initialBooks);

  return (
    <BooksContext value={books}>
      <BooksDispatchContext value={dispatch}>{children}</BooksDispatchContext>
    </BooksContext>
  );
}

Step 3: read them anywhere with custom hooks

Finally, export two hooks that read the contexts and throw a helpful error if the provider is missing. This is the same safety pattern you saw with useUser.

BooksContext.tsx (continued)
export function useBooks() {
  const books = useContext(BooksContext);
  if (books === null) throw new Error('useBooks must be used inside <BooksProvider>');
  return books;
}

export function useBooksDispatch() {
  const dispatch = useContext(BooksDispatchContext);
  if (dispatch === null) throw new Error('useBooksDispatch must be used inside <BooksProvider>');
  return dispatch;
}

Here it all is, running. Open each file and notice how little the components need to know.

Live code· Reading List with BooksProvider

Look at what changed:

  • App has no state at all. It only arranges components.
  • BookList reads the books itself with useBooks(), and has no props.
  • AddBookForm and BookCard dispatch actions directly with useBooksDispatch(). No onAdd or dispatch props travel through the tree.
  • BookCard still receives book as a prop. That is correct: it is a direct, specific piece of data from its parent, which is what props are for.

Why two contexts instead of one?

You could put { books, dispatch } in a single context. Splitting them has two benefits:

  • Clear intent. A component that only changes books, like AddBookForm, asks for only the dispatch function. Reading the code tells you it never displays the list.
  • Fewer re-renders. React guarantees that the dispatch function from useReducer stays the same across renders. So the dispatch context's value never changes, and a component that reads only that context is not re-rendered by context when books change. Bundling both into a new { books, dispatch } object on every render would make every reader re-render whenever anything changed.

The second point rarely matters in a small app, but the habit costs nothing.

Deep diveKeeping persistence from Day 4

On Day 4 your list survived page reloads thanks to localStorage. You can keep that inside the provider. useReducer accepts a third argument, an initializer function, which runs once to compute the starting state. An effect then saves the books whenever they change.

BooksContext.tsx
const STORAGE_KEY = 'reading-list:books'; // same key as Day 4

function loadBooks(fallback: Book[]): Book[] {
  const saved = localStorage.getItem(STORAGE_KEY);
  return saved ? (JSON.parse(saved) as Book[]) : fallback;
}

export function BooksProvider({ children }: { children: ReactNode }) {
  const [books, dispatch] = useReducer(booksReducer, initialBooks, loadBooks);

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

  // ...providers as before
}

as Book[] is a type assertion: you tell TypeScript what JSON.parse returns, because it cannot know. Only assert when you are sure, since TypeScript will believe you.

Try it

A stats bar from context

Add a Stats component that shows a line like 5 books: 2 want to read, 1 reading, 2 done. Render it under the heading in App. It must get the books from context, with no props. Count each status by deriving from the list, not by storing counts in state.

Live code· Challenge: Stats
Show solution
Stats.tsx
import { useBooks } from './BooksContext';
import type { Status } from './types';

export function Stats() {
  const books = useBooks();
  const count = (status: Status) => books.filter((b) => b.status === status).length;

  return (
    <p>
      {books.length} books: {count('want')} want to read, {count('reading')} reading, {count('done')} done
    </p>
  );
}
App.tsx
import { BooksProvider } from './BooksContext';
import { BookList } from './BookList';
import { Stats } from './Stats';

export default function App() {
  return (
    <BooksProvider>
      <h1>My Reading List</h1>
      <Stats />
      <BookList />
    </BooksProvider>
  );
}

Click "Next status" on a book and the stats update instantly. Both Stats and BookList read the same context, so they always agree. Typing the helper's parameter as Status means count('finished') would be a type error.

Quick check

Why does the provider supply books and dispatch through two separate contexts?

Quick check

App renders <BooksProvider> and also calls useBooks() in its own body. What happens?

Day 5 progress

0/7

On this page