π¬ Weekend Project: Build a Multi-Page React App
This is the project where all of Week 5 clicks into one real application. You'll build a TV Show Explorer β a multi-page app you navigate with a URL, that fetches live data, remembers your favorites across the whole app, and loads each page only when you visit it. In one weekend you'll wire together every advanced-React idea from this week: React Router with a nested <Outlet> layout, a data-fetching custom hook, a global Context + useReducer store, lazy-loaded routes, and a React Testing Library test.
Week 5 · Weekend Project · Advanced React Capstone
π― Learning Objectives
By completing this project, you will be able to:
- Structure a real app with React Router v6 β nested routes sharing a layout via
<Outlet> - Read dynamic route params with
useParamsto power a detail page at/shows/:id - Extract data fetching into a reusable custom hook with loading, error, and cleanup handling
- Manage global state across pages with
Context+useReducer, updated immutably - Code-split each page with
React.lazyand<Suspense>to shrink the initial bundle - Write a React Testing Library test that renders a component and asserts on user-visible behavior
Estimated Time: 5β8 hours across the weekend
Project: A deployable multi-page TV Show Explorer with search, detail pages, and a persistent favorites list.
In This Project
The Goal
Build an app that feels like a small product, not a demo. You open it and land on a Home page. You click "Browse" and reach a Shows page where you can search a live TV database. You click a show and the URL changes to /shows/431 β a real, shareable detail page. On any card you hit β and it drops into a Favorites page that any part of the app can see, and it's still there after a refresh. Every page's code downloads only when you first visit it.
That names the five pillars you'll stand the app on. Keep this map in your head β each stage below builds one:
nav + Outlet"] Layout --> Home["/ (index)
Home"] Layout --> Shows["/shows
Shows + search"] Layout --> Detail["/shows/:id
ShowDetail"] Layout --> Fav["/favorites
Favorites"] Layout --> NF["*
NotFound"]
We'll use the TVMaze API (https://api.tvmaze.com). It's free, needs no API key, and returns rich JSON with images β perfect for a keyless weekend build you can deploy publicly without leaking a secret.
π A "multi-page" app that's still a single-page app
React apps are single-page applications β one HTML file, one page load. React Router fakes multiple pages by watching the URL and swapping which component renders, with no server round-trip. You get the feel of separate pages (real URLs, a working Back button, shareable links) with the speed of never reloading.
Prerequisites
This is the Week 5 capstone, so it assumes the whole "Advanced React" week plus the React fundamentals from Week 4. Before you start, be comfortable with:
- Function components, props &
useStateβ the Week 4 core (the to-do app) useEffectβ running and cleaning up side effects like data fetchesuseContextβ reading shared state without prop-drilling (this week)useReducerβ managing state transitions with a reducer function (this week)- Custom hooks β extracting stateful logic into a reusable
useSomething()(this week) - Immutable updates β
map,filter, and the spread operator, never mutating in place
You'll need Node.js 18+ (for npm and the built-in fetch), a code editor, and a terminal. Check with node --version. No prior React Router or testing experience is required β the stages introduce each.
Required Features Checklist
These are the non-negotiables. Each one maps to a Week 5 skill, and the whole app is buildable with React Router, hooks, and one small testing setup β no state-management libraries. Tick each off as you go.
β Must-have features
- β Multiple routes under a shared nested layout (nav + footer wrap every page via
<Outlet>) - β Data fetching from the live TVMaze API with loading and error states
- β A dynamic route
/shows/:idwhose detail page reads the id withuseParams - β Global state for favorites via
Context+useReducer, reachable from any page - β At least one custom hook (we build
useFetch) that other components reuse - β Lazy-loaded routes with
React.lazy+<Suspense>so pages code-split - β At least one React Testing Library test asserting on user-visible behavior
- β Function components + hooks only; state updated immutably; a 404 / NotFound catch-all route
Project Structure
Here's the layout you're building toward. Notice the folders map one-to-one onto the pillars: pages/ for routed screens, components/ for reusable UI, context/ for the global store, hooks/ for reusable logic, api/ for the data layer, and __tests__/ for tests. Keeping these separate is what stops a growing app from turning into one giant file.
show-explorer/
βββ index.html <-- Vite entry (has <div id="root">)
βββ package.json <-- scripts + dependencies
βββ vite.config.js <-- Vite + React plugin + test config
βββ src/
βββ main.jsx <-- mounts <App />, wraps app in providers
βββ App.jsx <-- the <Routes> tree + <Suspense>
βββ App.css
βββ api/
β βββ tvmaze.js <-- thin wrapper around the TVMaze endpoints
βββ context/
β βββ FavoritesContext.jsx <-- reducer + Provider (the global store)
βββ hooks/
β βββ useFetch.js <-- reusable data-fetching hook
β βββ useFavorites.js <-- convenience hook to read the store
βββ components/
β βββ Layout.jsx <-- nav + <Outlet /> + footer (the shell)
β βββ ShowCard.jsx <-- one show tile with a favorite toggle
β βββ SearchBar.jsx <-- controlled search input
βββ pages/
β βββ Home.jsx
β βββ Shows.jsx <-- search + results grid
β βββ ShowDetail.jsx <-- reads :id, shows one show
β βββ Favorites.jsx <-- reads the global store
β βββ NotFound.jsx <-- 404 catch-all
βββ __tests__/
βββ favoritesReducer.test.js <-- pure-logic test
βββ ShowCard.test.jsx <-- RTL component test
π‘ Two custom hooks, two jobs
useFetch encapsulates the "call an API and track loading/error" pattern so no page repeats it. useFavorites is a tiny wrapper over useContext that gives components a clean API (addFavorite, isFavoriteβ¦) instead of poking at the raw context. Both are the same idea: a custom hook is just a function that uses other hooks.
Stage 1 β Scaffold & Install
We'll scaffold with Vite (the modern default), then add the two things this project needs beyond React: the router and a test setup.
# 1. Scaffold a React project (pick "React" then "JavaScript" if prompted)
npm create vite@latest show-explorer -- --template react
cd show-explorer
npm install
# 2. Routing
npm install react-router-dom
# 3. Testing tools (dev dependencies only)
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
# 4. Start the dev server
npm run dev
Vite prints a local URL (usually http://localhost:5173). Next, tell Vite how to run tests by adding a test block to vite.config.js, and add a test script to package.json:
// vite.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true, // use describe/it/expect without importing them
environment: 'jsdom', // a fake DOM so components can render in Node
},
});
// package.json β add to "scripts"
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest"
}
π Why Vitest and not Jest?
Vitest is a test runner built for Vite: it reuses your Vite config, understands JSX and ES modules out of the box, and starts in milliseconds. Its API is a drop-in match for Jest (describe, it, expect), so everything you learn transfers. For a Vite project it's the path of least resistance.
Stage 2 β Routing & Nested Layout
The heart of a multi-page app is the route tree. In React Router v6 you describe routes as JSX, and a nested route lets several pages share one layout. The parent route renders the chrome (nav + footer) and drops an <Outlet /> where the current child page should appear.
First, wrap the app in the router. <BrowserRouter> goes at the very top, in main.jsx:
// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import './App.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
Now the route tree in App.jsx. The parent <Route element={<Layout />}> has no path of its own β it just wraps its children in the shared shell. The child with index is the default page at /, and the path="*" child catches anything unmatched:
// src/App.jsx (lazy loading added in Stage 6 β plain imports for now)
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import Home from './pages/Home';
import Shows from './pages/Shows';
import ShowDetail from './pages/ShowDetail';
import Favorites from './pages/Favorites';
import NotFound from './pages/NotFound';
function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<Home />} />
<Route path="shows" element={<Shows />} />
<Route path="shows/:id" element={<ShowDetail />} />
<Route path="favorites" element={<Favorites />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
}
export default App;
The Layout is the shared shell. <NavLink> is like <a> but it navigates without reloading and knows when it's the active route, and <Outlet /> is the hole the matched page renders into:
// src/components/Layout.jsx
import { NavLink, Outlet } from 'react-router-dom';
function Layout() {
return (
<div className="layout">
<header className="app-nav">
<strong>π¬ Show Explorer</strong>
<nav>
<NavLink to="/" end>Home</NavLink>
<NavLink to="/shows">Browse</NavLink>
<NavLink to="/favorites">Favorites</NavLink>
</nav>
</header>
<main className="app-main">
<Outlet /> {/* the current page renders here */}
</main>
<footer className="app-footer">
<p>Data from the TVMaze API</p>
</footer>
</div>
);
}
export default Layout;
β οΈ The end prop on the Home link
Without end, the to="/" NavLink would count as "active" on every route, because every path starts with /. The end prop says "only match this exactly." A classic first-router bug β set it on your home link and move on.
Stub each page (e.g. function Home() { return <h1>Home</h1>; }) so the app runs. Clicking the nav should now change the URL and the outlet with no reload. That's routing working; the rest is filling the pages in.
Stage 3 β Data Fetching Custom Hook
Every page that talks to the API needs the same three-state dance: loading β data or error. Rather than copy that into each page, extract it once into a custom hook. A custom hook is just a function whose name starts with use and that calls other hooks β here useState and useEffect.
loading and lands in exactly one of two terminal states. useFetch encodes this once for the whole app.First, a thin API layer so URLs live in one place:
// src/api/tvmaze.js
const BASE = 'https://api.tvmaze.com';
// Search returns [{ score, show }, ...] β we map down to just the shows.
export const searchUrl = (query) =>
`${BASE}/search/shows?q=${encodeURIComponent(query)}`;
export const showUrl = (id) => `${BASE}/shows/${id}`;
export const scheduleUrl = () => `${BASE}/shows?page=0`; // a page of shows for Home
Now the hook. Two details make it robust: an AbortController cancels an in-flight request if the URL changes or the component unmounts (no "set state on an unmounted component" warnings), and we check response.ok before trusting the body:
// src/hooks/useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!url) return; // nothing to fetch (e.g. empty search)
const controller = new AbortController();
async function run() {
setLoading(true);
setError(null);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
setData(await res.json());
} catch (err) {
if (err.name !== 'AbortError') setError(err.message);
} finally {
setLoading(false);
}
}
run();
return () => controller.abort(); // cleanup: cancel if url changes/unmounts
}, [url]);
return { data, loading, error };
}
With the hook in hand, the Shows page is almost entirely markup. It owns a search term, builds a URL from it, and hands that URL to useFetch. Change the search and the hook re-runs itself:
// src/pages/Shows.jsx
import { useState } from 'react';
import { useFetch } from '../hooks/useFetch';
import { searchUrl, scheduleUrl } from '../api/tvmaze';
import SearchBar from '../components/SearchBar';
import ShowCard from '../components/ShowCard';
function Shows() {
const [query, setQuery] = useState('');
// Empty search β show a default page; otherwise search.
const url = query.trim() ? searchUrl(query) : scheduleUrl();
const { data, loading, error } = useFetch(url);
// Search returns [{ show }]; the default endpoint returns [show]. Normalize.
const shows = (data ?? []).map((item) => item.show ?? item);
return (
<section>
<h1>Browse Shows</h1>
<SearchBar value={query} onChange={setQuery} />
{loading && <p>Loadingβ¦</p>}
{error && <p className="error">Couldn't load shows: {error}</p>}
{!loading && !error && shows.length === 0 && <p>No shows found.</p>}
<div className="grid">
{shows.map((show) => (
<ShowCard key={show.id} show={show} />
))}
</div>
</section>
);
}
export default Shows;
And a plain controlled SearchBar:
// src/components/SearchBar.jsx
function SearchBar({ value, onChange }) {
return (
<input
type="search"
className="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search TV showsβ¦"
aria-label="Search TV shows"
/>
);
}
export default SearchBar;
β Why the hook, not copy-paste?
Shows, Home, and ShowDetail all fetch. Without the hook, each repeats the same 20 lines of loading/error/abort logic β three places to keep in sync and three places for a bug to hide. With useFetch the logic lives once, and each page becomes a one-liner: const { data, loading, error } = useFetch(url).
Stage 4 β Dynamic Detail Route
Now the payoff of that /shows/:id route. The colon makes id a URL parameter β a slot that matches any value. The ShowDetail page reads it with useParams and fetches that one show. Because it's a real URL, /shows/431 is bookmarkable and shareable, and the Back button just works.
// src/pages/ShowDetail.jsx
import { useParams, Link } from 'react-router-dom';
import { useFetch } from '../hooks/useFetch';
import { showUrl } from '../api/tvmaze';
import { useFavorites } from '../hooks/useFavorites';
function ShowDetail() {
const { id } = useParams(); // "431" from /shows/431
const { data: show, loading, error } = useFetch(showUrl(id));
const { isFavorite, addFavorite, removeFavorite } = useFavorites();
if (loading) return <p>Loadingβ¦</p>;
if (error) return <p className="error">Couldn't load this show: {error}</p>;
if (!show) return null;
const favorited = isFavorite(show.id);
return (
<article className="detail">
<Link to="/shows">β Back to browse</Link>
<h1>{show.name}</h1>
{show.image && <img src={show.image.medium} alt={show.name} />}
<p>β {show.rating?.average ?? 'N/A'} · {show.genres.join(', ')}</p>
{/* summary is a snippet of HTML from the API */}
<div dangerouslySetInnerHTML={{ __html: show.summary ?? '' }} />
<button onClick={() => (favorited ? removeFavorite(show.id) : addFavorite(show))}>
{favorited ? 'β
Remove favorite' : 'β Add favorite'}
</button>
</article>
);
}
export default ShowDetail;
β οΈ dangerouslySetInnerHTML β used deliberately
TVMaze returns each summary as a small HTML string. React blocks raw HTML by default to prevent cross-site-scripting, so rendering it requires the pointedly-named dangerouslySetInnerHTML. It's acceptable here because the source is a known, trusted API. Never do this with arbitrary user input β that's exactly the XSS hole the warning name is about.
The ShowCard is what links here. It wraps the tile in a <Link> to the detail route and carries its own favorite toggle (built next stage):
// src/components/ShowCard.jsx
import { Link } from 'react-router-dom';
import { useFavorites } from '../hooks/useFavorites';
function ShowCard({ show }) {
const { isFavorite, addFavorite, removeFavorite } = useFavorites();
const favorited = isFavorite(show.id);
return (
<div className="card">
<Link to={`/shows/${show.id}`}>
{show.image && <img src={show.image.medium} alt={show.name} />}
<h3>{show.name}</h3>
</Link>
<button
className="fav-btn"
aria-pressed={favorited}
aria-label={favorited ? `Remove ${show.name} from favorites` : `Add ${show.name} to favorites`}
onClick={() => (favorited ? removeFavorite(show.id) : addFavorite(show))}
>
{favorited ? 'β
' : 'β'}
</button>
</div>
);
}
export default ShowCard;
Stage 5 β Global State (Context + useReducer)
Favorites are the app's shared truth: a card adds one, the detail page toggles one, the Favorites page lists them, and the count could show in the nav. Threading that through props to every corner would be miserable. This is exactly what Context solves β and because the state has clear transitions (add / remove / clear), we pair it with useReducer for predictable updates.
Picture the shape: one provider holds the state, hands it to a context, and any component reads it directly through a hook β dispatching actions back up.
useReducer(reducer, init)"] --> Ctx[("FavoritesContext
value = state + dispatch")] Ctx --> Card["useFavorites() in ShowCard"] Ctx --> Detail["useFavorites() in ShowDetail"] Ctx --> Page["useFavorites() in Favorites page"] Card -->|"dispatch(ADD / REMOVE)"| Provider Detail -->|"dispatch(ADD / REMOVE)"| Provider Page -->|"dispatch(REMOVE / CLEAR)"| Provider
The reducer is a pure function: given the current state and an action, it returns the next state β always immutably. Exporting it on its own (not just inside the provider) is what lets us unit-test it in Stage 7:
// src/context/FavoritesContext.jsx
import { createContext, useReducer, useEffect } from 'react';
export const initialState = { items: [] };
// Pure function β no side effects, always returns a NEW state object.
export function favoritesReducer(state, action) {
switch (action.type) {
case 'ADD':
// Ignore duplicates so the same show can't be added twice.
if (state.items.some((s) => s.id === action.show.id)) return state;
return { items: [...state.items, action.show] };
case 'REMOVE':
return { items: state.items.filter((s) => s.id !== action.id) };
case 'CLEAR':
return { items: [] };
default:
return state;
}
}
export const FavoritesContext = createContext(null);
export function FavoritesProvider({ children }) {
// Lazy initializer: seed state from localStorage exactly once.
const [state, dispatch] = useReducer(favoritesReducer, initialState, (init) => {
try {
const saved = localStorage.getItem('favorites');
return saved ? { items: JSON.parse(saved) } : init;
} catch {
return init;
}
});
// Persist whenever the list changes.
useEffect(() => {
localStorage.setItem('favorites', JSON.stringify(state.items));
}, [state.items]);
return (
<FavoritesContext.Provider value={{ state, dispatch }}>
{children}
</FavoritesContext.Provider>
);
}
Wrap the app in the provider so every route is inside it. It goes inside the router in main.jsx:
// src/main.jsx β add the provider around <App />
import { FavoritesProvider } from './context/FavoritesContext';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<FavoritesProvider>
<App />
</FavoritesProvider>
</BrowserRouter>
</StrictMode>
);
Finally, the convenience hook that every component actually calls. It reads the raw context and returns a clean, intention-revealing API β components never touch dispatch directly:
// src/hooks/useFavorites.js
import { useContext } from 'react';
import { FavoritesContext } from '../context/FavoritesContext';
export function useFavorites() {
const ctx = useContext(FavoritesContext);
if (!ctx) {
throw new Error('useFavorites must be used inside a FavoritesProvider');
}
const { state, dispatch } = ctx;
return {
favorites: state.items,
isFavorite: (id) => state.items.some((s) => s.id === id),
addFavorite: (show) => dispatch({ type: 'ADD', show }),
removeFavorite: (id) => dispatch({ type: 'REMOVE', id }),
clearFavorites: () => dispatch({ type: 'CLEAR' }),
};
}
Now the Favorites page is trivial β it just reads the global list. No props, no fetching; the data is simply there:
// src/pages/Favorites.jsx
import { useFavorites } from '../hooks/useFavorites';
import ShowCard from '../components/ShowCard';
function Favorites() {
const { favorites, clearFavorites } = useFavorites();
if (favorites.length === 0) {
return <p>No favorites yet β tap β on any show to save it here.</p>;
}
return (
<section>
<h1>Your Favorites ({favorites.length})</h1>
<button onClick={clearFavorites}>Clear all</button>
<div className="grid">
{favorites.map((show) => (
<ShowCard key={show.id} show={show} />
))}
</div>
</section>
);
}
export default Favorites;
π‘ Context + useReducer vs. Redux
For app-wide state with a handful of actions, Context + useReducer is the Redux pattern in miniature β a single store, a pure reducer, dispatched actions β with zero dependencies. Redux earns its keep when the state graph gets large and you want devtools and middleware. Learning this first makes next week's Redux feel like an upgrade, not a new idea.
Stage 6 β Lazy-Load the Routes
Right now every page ships in one big bundle the user downloads before seeing anything β even the Favorites page they may never open. Code splitting fixes that: React.lazy turns each page into its own chunk that downloads only when its route is first visited, and <Suspense> shows a fallback while that chunk loads. Swap the plain imports in App.jsx for lazy ones:
// src/App.jsx β now with code-split routes
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
// Each import() becomes a separate JS chunk, fetched on first visit.
const Home = lazy(() => import('./pages/Home'));
const Shows = lazy(() => import('./pages/Shows'));
const ShowDetail = lazy(() => import('./pages/ShowDetail'));
const Favorites = lazy(() => import('./pages/Favorites'));
const NotFound = lazy(() => import('./pages/NotFound'));
function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={wrap(<Home />)} />
<Route path="shows" element={wrap(<Shows />)} />
<Route path="shows/:id" element={wrap(<ShowDetail />)} />
<Route path="favorites" element={wrap(<Favorites />)} />
<Route path="*" element={wrap(<NotFound />)} />
</Route>
</Routes>
);
}
// Small helper so each lazy page gets its own Suspense boundary.
function wrap(node) {
return <Suspense fallback={<p>Loading pageβ¦</p>}>{node}</Suspense>;
}
export default App;
Don't forget the NotFound page the catch-all route points at:
// src/pages/NotFound.jsx
import { Link } from 'react-router-dom';
function NotFound() {
return (
<section>
<h1>404 β Page not found</h1>
<p>That route doesn't exist.</p>
<Link to="/">Go home</Link>
</section>
);
}
export default NotFound;
π Prove the split is real
Run npm run build and look at the output: instead of one index.js, you'll see several small chunks (one per lazy page). In the browser's Network tab, open the app and watch a new JS file download the first time you click "Favorites" β and only then. That deferred download is the whole point: a faster first paint.
Stage 7 β Test with RTL
An app you can't test is an app you're afraid to change. You don't need full coverage for this project β the requirement is at least one meaningful test. We'll write two, each showing a different style.
1. A pure-logic test (the reducer)
Because the reducer is an exported pure function, testing it needs no DOM at all β just call it and assert on the returned state. Pure functions are the easiest thing in any codebase to test, which is a great reason to keep logic in them:
// src/__tests__/favoritesReducer.test.js
import { describe, it, expect } from 'vitest';
import { favoritesReducer, initialState } from '../context/FavoritesContext';
const showA = { id: 1, name: 'Show A' };
describe('favoritesReducer', () => {
it('adds a show', () => {
const next = favoritesReducer(initialState, { type: 'ADD', show: showA });
expect(next.items).toHaveLength(1);
expect(next.items[0].name).toBe('Show A');
});
it('ignores duplicate adds', () => {
const once = favoritesReducer(initialState, { type: 'ADD', show: showA });
const twice = favoritesReducer(once, { type: 'ADD', show: showA });
expect(twice.items).toHaveLength(1);
});
it('removes a show by id', () => {
const withA = favoritesReducer(initialState, { type: 'ADD', show: showA });
const empty = favoritesReducer(withA, { type: 'REMOVE', id: 1 });
expect(empty.items).toHaveLength(0);
});
it('does not mutate the previous state', () => {
const next = favoritesReducer(initialState, { type: 'ADD', show: showA });
expect(next).not.toBe(initialState); // new object reference
expect(initialState.items).toHaveLength(0); // original untouched
});
});
2. A component test (React Testing Library)
RTL's philosophy is to test what the user sees and does, not internal implementation. We render ShowCard, find the favorite button by its accessible label, click it, and assert the label flips. Because the card uses the favorites context and a <Link>, we wrap it in the same providers the real app uses:
// src/__tests__/ShowCard.test.jsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { FavoritesProvider } from '../context/FavoritesContext';
import ShowCard from '../components/ShowCard';
const show = { id: 42, name: 'Test Show', image: null };
function renderCard() {
return render(
<MemoryRouter>
<FavoritesProvider>
<ShowCard show={show} />
</FavoritesProvider>
</MemoryRouter>
);
}
describe('ShowCard', () => {
it('shows the show name', () => {
renderCard();
expect(screen.getByText('Test Show')).toBeInTheDocument();
});
it('toggles favorite when the button is clicked', async () => {
const user = userEvent.setup();
renderCard();
// Starts as "Add"
const addBtn = screen.getByRole('button', { name: /add test show to favorites/i });
await user.click(addBtn);
// Now the accessible label reflects the favorited state
expect(
screen.getByRole('button', { name: /remove test show from favorites/i })
).toBeInTheDocument();
});
});
Run everything with npm test. Vitest finds both files, runs them in the jsdom environment, and reports green. The toBeInTheDocument() matcher comes from @testing-library/jest-dom β import it once in a setup file, or add import '@testing-library/jest-dom' at the top of the test.
β Query by role and label, not by class
Notice the tests find elements the way a user (or screen reader) would β by visible text and by accessible role/label β never by CSS class or test-id where a real handle exists. Tests written this way survive refactors of your markup and, as a bonus, push you to build accessible components. That's why ShowCard's button has an aria-label.
Stretch Goals
Done with the required build and hungry for more? Pick whatever excites you β none are needed to pass the rubric.
- π’ Favorites badge β show the live count next to the Favorites nav link (read
favorites.lengthinLayout) - π Sync search to the URL β use
useSearchParamsso/shows?q=breakingis shareable and survives refresh - β³ Debounce the search β reuse the
useDebouncehook from Week 4 so you fetch only after the user pauses typing - π§ Nested detail tabs β add child routes like
/shows/:id/castwith a second<Outlet />inside the detail page - π Error boundary β wrap the routes in an error boundary so one page crashing doesn't blank the whole app
- π§ͺ More tests β add a test for the empty Favorites state, or for the 404 route rendering
- π Deploy β ship to Netlify/Vercel with an SPA redirect so deep links like
/shows/42load directly
β οΈ Deploying an SPA: the refresh-on-a-deep-link trap
Locally the router handles /shows/42. On a static host, refreshing that URL asks the server for a file that doesn't exist β 404. The fix is a catch-all redirect to index.html (a _redirects file with /* /index.html 200 on Netlify, or a vercel.json rewrite) so the browser loads the app and lets React Router take over. Every SPA deploy needs this.
Self-Check Rubric
Before you call this done, grade yourself. Aim for "yes" across the first two columns; the stretch column is bonus.
| Area | Meets expectations (required) | Exceeds (stretch) |
|---|---|---|
| Routing & layout | Nested routes share a Layout via <Outlet />; nav switches pages without reload; a 404 catch-all works |
Nested child routes (tabs) with a second outlet |
| Data fetching | Live API data with loading + error states, handled by a reusable useFetch hook |
Search synced to the URL via useSearchParams; debounced input |
| Dynamic route | /shows/:id reads the param with useParams and renders one show; the URL is shareable |
Back/forward and deep-link refresh both work in production |
| Global state | Favorites via Context + useReducer, updated immutably, readable from any page |
Live count badge in the nav; persists to localStorage |
| Performance | Routes lazy-loaded with React.lazy + <Suspense>; build shows separate chunks |
Error boundary around the routes |
| Testing & quality | At least one passing RTL/Vitest test; no console errors or key warnings | Multiple tests; deployed live with an SPA redirect |
π§ͺ Final testing checklist
- β Clicking nav links changes the URL and the outlet β with no full-page reload
- β Searching fetches live results; a bad network shows the error message, not a blank screen
- β Clicking a card opens
/shows/<id>and shows that show's details - β Adding a favorite on one page shows it on the Favorites page and survives a refresh
- β Visiting a nonsense URL renders the 404 page
- β The Network tab shows a page's chunk downloading only on first visit
- β
npm testpasses; no red errors or "unique key" warnings in the console
Summary
π What You Built
- A real multi-page React app with React Router v6 β nested routes sharing a layout through
<Outlet />, plus a 404 catch-all - A dynamic detail route (
/shows/:id) reading its param withuseParamsβ real, shareable URLs - A reusable
useFetchcustom hook that centralizes loading, error, and request-cancellation logic - A global Favorites store with
Context+useReducer, updated immutably and persisted tolocalStorage - Code-split routes via
React.lazy+<Suspense>for a lighter first load - Tests β a pure reducer unit test and a React Testing Library test that clicks a button and asserts on what the user sees
This project is proof that Week 5 stuck. You took routing, context, reducers, custom hooks, code splitting, and testing β six ideas that arrived as separate lessons β and combined them into one coherent app. That combination is professional React: small testable pieces, state in exactly the right place, and pages loading only when needed.
π Additional Resources
- React Router β Official tutorial
- React β
useReducerreference - React β Scaling up with Reducer and Context
- React β
lazyand code splitting - React Testing Library β Introduction
- TVMaze β Free public API docs
π What's Next?
You just built the Redux pattern by hand β a single store, a pure reducer, dispatched actions, and components reading shared state. Week 6 opens with Redux principles and architecture, which formalizes exactly this into a battle-tested library with devtools, middleware, and performance features that pay off once an app's state grows past what Context comfortably handles. Because you've lived the pattern, Redux will read as a familiar upgrade rather than a new concept.
π You finished Week 5!
You shipped a multi-page React app with routing, global state, and tests. Deploy it, share the URL, and add it to your portfolio β this is the kind of app that gets you hired.