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.
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.
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.
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.
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.
Look at what changed:
Apphas no state at all. It only arranges components.BookListreads the books itself withuseBooks(), and has no props.AddBookFormandBookCarddispatch actions directly withuseBooksDispatch(). NoonAddordispatchprops travel through the tree.BookCardstill receivesbookas 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
dispatchfunction fromuseReducerstays 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.
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.
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.
Show solution
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>
);
}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.
Why does the provider supply books and dispatch through two separate contexts?
App renders <BooksProvider> and also calls useBooks() in its own body. What happens?
Day 5 progress