Project 4
Project 4
Book Search with Routing and Debounced Search
A search results page and a separate detail page, navigated with React Router, with search-as-you-type that doesn't hammer the API
Difficulty: Advanced
Introduces: React Router, debouncing
This project reaches slightly ahead of where the chapter-by-chapter courses are at — it needs two ideas that don't have their own Fundamentals chapter yet: routing (multiple "pages" in a single-page app) and debouncing (delaying a search request until typing actually pauses). Both get a working primer below; React Router gets its proper full chapter in Intermediate Chapter 5, and this project is a hands-on first taste of it ahead of that.
Skills This Project Exercises
React Router basics
URL params (useParams)
Debounced search
useEffect cleanup
Loading & error state
List + detail view
React Router — the minimum you need for this project
Install it with npm install react-router-dom. Four pieces cover this whole project: <BrowserRouter> wraps the app once, at the top; <Routes>/<Route path="..." element={...}> define which component renders for which URL; <Link to="..."></Link> navigates without a full page reload; and useParams() reads a dynamic piece of the URL (like a book's id) inside the component that route renders.
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/book/:id" element={<BookDetailPage />} />
</Routes>
Debouncing — the minimum you need for this project
Firing a fetch on every single keystroke wastes requests and can show results out of order as old, slow responses arrive after newer ones. Debouncing waits for a short pause in typing (e.g. 400ms with nothing new typed) before actually firing the request — using setTimeout inside a useEffect, with the cleanup function (from Fundamentals Chapter 8) cancelling the pending timeout if the user types again before it fires.
useEffect(() => {
const timeoutId = setTimeout(() => {
// the actual fetch goes here, using the current query value
}, 400);
return () => clearTimeout(timeoutId);
}, [query]);
Suggested Data Source
The Open Library Search API is free and needs no API key, similar to Open-Meteo in Project 2 — https://openlibrary.org/search.json?q=<query> returns a list of matching books, and https://openlibrary.org/works/<id>.json returns details for one specific book.
Requirements
- A search page at
/with a text input — typing should debounce before firing a search request, not fire on every keystroke. - Search results render as a list, each showing at least a title and author, and linking to a detail page for that specific book.
- A detail page at a URL like
/book/:id, fetching and showing more information about that one specific book based on the id in the URL. - The detail page includes a way to navigate back to the search page (a
<Link>, not the browser's own back button only). - Both pages handle loading and error states properly, reusing the status-based pattern from Project 2.
- Navigating between the two pages does not cause a full page reload — that's the whole point of using React Router instead of plain
<a>tags.
Suggested Component Breakdown
App (BrowserRouter + Routes)
├── SearchPage // route "/" — owns query state + debounced results
│ ├── SearchInput // controlled text input
│ └── ResultsList // maps results to ResultItem, each wrapped in a Link
│ └── ResultItem (×N)
└── BookDetailPage // route "/book/:id" — reads id via useParams, fetches detail
A Reasonable Build Order
- Set up
react-router-domwith two placeholder page components and a working<Link>between them, before any fetching exists at all — confirm navigation itself works first. - Build the search input with a non-debounced fetch (firing on every keystroke) to confirm the API call and result rendering work correctly.
- Convert the search effect to the debounced version, confirming with the browser's network tab that requests are no longer firing on every single keystroke.
- Make each result item a
<Link to={`/book/${id}`}>, carrying the right id into the URL. - Build
BookDetailPage, reading the id withuseParams()and fetching that specific book's details in auseEffectkeyed on the id. - Add loading/error handling to both pages last, once the core search → detail flow works end to end.
A stale request can still "win" the race
If the user types a new search before the previous request finishes, both requests are still in flight — and there's no strict guarantee the newer one resolves last. A query value captured in a ref, or an ignore-flag set in the effect's cleanup function, are both reasonable ways to discard a response that's no longer for the current search term. It's fine to skip this for a first pass and revisit it once the basic flow works.
Stretch Goals
- Extract the debounce logic into a reusable
useDebouncecustom hook (another preview of Intermediate Chapter 4). - Add pagination or "load more" to the search results.
- Persist a small "recently viewed books" list to
localStorage, shown on the search page. - Show a loading skeleton instead of a plain "Loading..." message.
- Add a 404-style fallback route for any URL that doesn't match either page.
This project has no single solution file — the requirements above and your own judgment are the spec. Compare notes against the suggested component breakdown once you have something working, not before.