Module 9: Projects
Project 2: Recipe Finder (Routing, Server State and Favorites)
Build a multi-page recipe browser with React Router, TanStack Query, a debounced search stored in the URL, a persisted Zustand favorites store, a lazy route and a real deployment.
The first project was all client-side state. This one is closer to what most jobs look like: pages, a remote API, loading and error states, caching, and a small amount of global state. It uses modules 4 to 7 together.
You'll build Recipe Finder: search a recipe list, open a recipe, save favorites, and deploy it.
The API#
We'll use DummyJSON, a free public API that needs no key. Before writing code, open these in your browser and read the JSON. Reading the real response first is a habit worth having, since your code is only as right as your idea of the data.
https://dummyjson.com/recipes?limit=12&skip=0
https://dummyjson.com/recipes/search?q=pizza&limit=12&skip=0
https://dummyjson.com/recipes/1
The list endpoints return an object shaped like { recipes: [...], total, skip, limit }. A recipe includes fields such as id, name, image, ingredients (array of strings), instructions (array of strings), prepTimeMinutes, cookTimeMinutes, servings, difficulty, cuisine, caloriesPerServing, rating and tags. Confirm the exact fields in the response and in the DummyJSON docs before relying on them.
What you're building#
/ Home: search box, grid of recipe cards, pagination
/recipe/:id Detail: image, meta info, ingredients, steps, favorite button
/favorites Saved recipes
* Not found page
Requirements#
Functional
- Home. A search input filters recipes by name. Typing is debounced (about 400 ms). The search text is reflected in the URL as
?q=, so a shared link opens the same results. - A grid of cards, 12 per page, with Previous and Next buttons. Paging must not flash back to a blank spinner. The old page stays visible while the next loads.
- Detail. Shows the image, name, cuisine, difficulty, total time, servings, calories, the ingredient list and numbered steps.
- Favorites. A heart toggle on cards and on the detail page. The Favorites page lists saved recipes. Favorites persist across reloads.
- Show a clear loading, error (with a retry button) and empty ("No recipes match your search") state on every data screen.
- A navigation bar with the current page highlighted, and a badge showing the number of favorites.
- A real 404 page for unknown URLs and for recipe ids that don't exist.
Technical
- React Router with a shared layout and
Outlet. - TanStack Query for all remote data. No
useEffectfetching. - One API module. Components never call
fetchoraxiosdirectly. - Zustand with the
persistmiddleware for favorites. Components select only what they need. - The detail route is lazy-loaded with a
Suspensefallback. - Deployed publicly, with deep links surviving a refresh.
Setup#
npm create vite@latest recipe-finder -- --template react
cd recipe-finder
npm install react-router @tanstack/react-query axios zustand
npm install -D @tanstack/react-query-devtools vitest jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event
src/
├── lib/
│ └── api.js
├── features/
│ ├── recipes/
│ │ ├── queries.js
│ │ ├── RecipeCard.jsx
│ │ └── RecipeGrid.jsx
│ └── favorites/
│ └── useFavorites.js
├── hooks/
│ └── useDebounce.js
├── pages/
│ ├── Home.jsx
│ ├── RecipeDetail.jsx
│ ├── Favorites.jsx
│ └── NotFound.jsx
├── Layout.jsx
└── main.jsx
Step 1: The API module#
// src/lib/api.js
import axios from "axios";
const api = axios.create({
baseURL: "https://dummyjson.com",
timeout: 10000,
});
export const PAGE_SIZE = 12;
export async function fetchRecipes({ q = "", page = 1 }) {
const skip = (page - 1) * PAGE_SIZE;
const path = q ? "/recipes/search" : "/recipes";
const { data } = await api.get(path, {
params: { q: q || undefined, limit: PAGE_SIZE, skip },
});
return data; // { recipes, total, skip, limit }
}
export async function fetchRecipe(id) {
const { data } = await api.get(`/recipes/${id}`);
return data;
}
Axios rejects on non-2xx responses, so a missing recipe (a 404 from the API) throws and lands in TanStack Query's error state without extra code.
Step 2: Queries#
// src/features/recipes/queries.js
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { fetchRecipe, fetchRecipes } from "../../lib/api.js";
export function useRecipes({ q, page }) {
return useQuery({
queryKey: ["recipes", { q, page }],
queryFn: () => fetchRecipes({ q, page }),
placeholderData: keepPreviousData, // keep showing the old page while loading the next
staleTime: 1000 * 60 * 5,
});
}
export function useRecipe(id) {
return useQuery({
queryKey: ["recipe", id],
queryFn: () => fetchRecipe(id),
staleTime: 1000 * 60 * 10,
retry: false, // a 404 won't fix itself on retry
});
}
Note the key. It contains both q and page, because the data depends on both. Forget one and you'll show the wrong results.
Step 3: Favorites store#
// src/features/favorites/useFavorites.js
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useFavorites = create(
persist(
(set) => ({
byId: {},
toggle: (recipe) =>
set((state) => {
if (state.byId[recipe.id]) {
const { [recipe.id]: _removed, ...rest } = state.byId;
return { byId: rest };
}
return {
byId: {
...state.byId,
[recipe.id]: { id: recipe.id, name: recipe.name, image: recipe.image },
},
};
}),
}),
{ name: "recipe-finder:favorites" }
)
);
// selectors
export const selectFavoriteCount = (s) => Object.keys(s.byId).length;
export const selectIsFavorite = (id) => (s) => Boolean(s.byId[id]);
The store keeps just the three fields the Favorites page needs, so that page works without any network request. A heart button will use the selector so only that button re-renders when its own recipe is toggled:
const isFav = useFavorites(selectIsFavorite(recipe.id));
const toggle = useFavorites((s) => s.toggle);
Step 4: Routes and layout#
// src/main.jsx
import { StrictMode, lazy } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Layout from "./Layout.jsx";
import Home from "./pages/Home.jsx";
import Favorites from "./pages/Favorites.jsx";
import NotFound from "./pages/NotFound.jsx";
const queryClient = new QueryClient();
const router = createBrowserRouter([
{
path: "/",
Component: Layout,
children: [
{ index: true, Component: Home },
{
path: "recipe/:id",
lazy: async () => {
const { default: RecipeDetail } = await import("./pages/RecipeDetail.jsx");
return { Component: RecipeDetail };
},
},
{ path: "favorites", Component: Favorites },
{ path: "*", Component: NotFound },
],
},
]);
createRoot(document.getElementById("root")).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>
);
The lazy property on a route object is the router's own version of code splitting, so you don't need React.lazy here. Using it keeps the detail page out of the first download.
In Layout, render a NavLink for each page (remember end on the home link), a favorites count badge from selectFavoriteCount, and an <Outlet />.
Step 5: The Home page#
This is the trickiest piece. The text box needs to feel instant, but requests should only fire after a pause, and the URL should reflect the settled search.
// src/pages/Home.jsx
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { useDebounce } from "../hooks/useDebounce.js";
import { useRecipes } from "../features/recipes/queries.js";
import { PAGE_SIZE } from "../lib/api.js";
import RecipeGrid from "../features/recipes/RecipeGrid.jsx";
export default function Home() {
const [params, setParams] = useSearchParams();
const [text, setText] = useState(params.get("q") ?? ""); // initial value from the URL
const [page, setPage] = useState(1);
const q = useDebounce(text.trim(), 400);
// reflect the settled search in the URL without adding history entries
useEffect(() => {
setParams(q ? { q } : {}, { replace: true });
}, [q, setParams]);
const { data, isPending, isError, error, refetch, isFetching } = useRecipes({ q, page });
const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 0;
return (
<section>
<label htmlFor="search">Search recipes</label>
<input
id="search"
type="search"
value={text}
onChange={(e) => {
setText(e.target.value);
setPage(1); // a new search starts at page 1
}}
/>
{isPending && <p>Loading recipes...</p>}
{isError && (
<div role="alert">
<p>Couldn't load recipes: {error.message}</p>
<button onClick={() => refetch()}>Try again</button>
</div>
)}
{data && data.recipes.length === 0 && <p>No recipes match your search.</p>}
{data && data.recipes.length > 0 && <RecipeGrid recipes={data.recipes} />}
{data && totalPages > 1 && (
<nav aria-label="Pagination">
<button disabled={page === 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
<span>Page {page} of {totalPages}</span>
<button disabled={page === totalPages} onClick={() => setPage((p) => p + 1)}>Next</button>
{isFetching && <span> Updating...</span>}
</nav>
)}
</section>
);
}
Check what this code does and doesn't handle. useDebounce is the hook from module 4, and you'll need to copy it into src/hooks. The effect writes to the URL, so a pasted link like /?q=pizza loads with the box filled in and the results filtered. What it doesn't do: it doesn't react to someone editing the URL by hand after the page has loaded, and page isn't in the URL. Making page part of the URL, so the Back button works for pagination, is a good first improvement. Think about what has to change.
Step 6: Detail and Favorites pages#
Build RecipeDetail on your own using this outline.
function RecipeDetail() {
const { id } = useParams();
const { data: recipe, isPending, isError, error } = useRecipe(id);
// loading → skeleton or text
// error → if error.response?.status === 404, render the not-found message
// data → image, name, meta list, ingredients (ul), instructions (ol), FavoriteButton
}
Things to decide as you build it: how to format total time (prepTimeMinutes + cookTimeMinutes), what to do with an id like abc (the API will 404, so handle that path), and where the heart button should live so cards and the detail page share one FavoriteButton component.
Favorites reads Object.values(byId) from the store and renders links to /recipe/:id. It's a plain, synchronous page. Show an empty state with a link back to Home when there are no favorites.
Step 7: Tests#
Write at least two:
- A unit test for the favorites store: toggling adds an item, toggling again removes it. Reset the store between tests using
useFavorites.setState({ byId: {} }). - A component test for
RecipeCardor the heart button: clicking it toggles the accessible state. Give the button anaria-pressedattribute and assert on it.
For anything that needs the router or the query client, use a helper like renderWithProviders from the testing lesson, with retry: false on the test QueryClient.
Step 8: Deploy#
- Push the repo to GitHub.
- Deploy to Vercel or Netlify, using the build command
npm run buildand output folderdist. - Add the rewrite rule (
vercel.jsonorpublic/_redirects) so refreshing/recipe/3works. - Open the live URL in a private window, search, open a recipe, favorite it, refresh, and confirm it all still works.
Acceptance checklist#
- Searching waits for a pause in typing before sending a request. Check the Network tab.
-
/?q=pastaopens with the results filtered and the input filled in. - Switching pages doesn't flash an empty state.
- Going to a recipe, then Back, shows the previous list instantly from the cache.
- An invalid id shows a not-found message, not a crash or an endless spinner.
- Favorites survive a reload, and the navbar count stays in sync.
- Nothing in
src/pagesor components importsaxios. - The built bundle has a separate chunk for the detail page.
- Refreshing a deep link on the deployed site works.
- Lint, tests and build all pass.
Stretch goals#
- Put the page number in the URL so Back and Forward work for pagination.
- Add filters (cuisine or difficulty) as additional search params, and include them in the query key.
- Add an optimistic "favorite" that also tells a fake server (a mutation with rollback on failure).
- Use
useSuspenseQuerywith Suspense boundaries instead ofisPendingchecks. - Add skeleton cards that match the real card layout to reduce layout shift.
- Build the UI with Tailwind and shadcn/ui components.
Where people get stuck#
- Missing something from the
queryKeyand showing stale or wrong data. - Putting server data into Zustand as well as TanStack Query, then fighting to keep them in sync. Only the favorites list belongs in the store.
- Selecting the whole Zustand store in every card, so toggling one heart re-renders every card.
- Forgetting the rewrite rule, then getting 404 on refresh in production only.
- Not handling the error state because "the API never fails". Turn off your network in DevTools and see.
Practice#
Do these after the acceptance checklist passes.
- Search for the same term twice with the Network tab open. The second search should not hit the network, because
staleTimeinuseRecipesis five minutes. SetstaleTimeto0, repeat, and note what changes. Put the original value back. - Switch DevTools to Offline and search for a new term. Confirm the error state and the "Try again" button appear, then go back online and click it. If the page stays stuck, check what
refetchis wired to. - Add a field to the search state (for example a
sortparam) and trace what must change: the URL, thequeryKeyinuseRecipes, and the test helper. Write the list down before you touch the code, then check yourself.