Web Development with React
ProjectIntermediate 60 min· Project and career · 19 of 21

Project: recipe finder app

Build and finish a recipe finder with React, Vite and React Router: search recipes from a free public API, open a detail page with ingredients and steps, and save favourites that survive a page refresh. Ready to deploy in the next lesson.

What you'll build: a three-page recipe finder.

- Home (/): a search box. Type a dish like chicken or paneer and see matching recipes as cards. The search is stored in the URL (/?q=chicken) so you can share it and the back button works. - Recipe page (/recipe/:id): photo, category, ingredients with measures, and the cooking steps. - Favourites (/favourites): recipes you saved with a heart button, remembered in the browser with localStorage.

It uses everything from the track: components and props, state, useEffect fetching with loading and error states, a custom hook, routing, and lists with keys.

Data source: TheMealDB, a free recipe API that needs no sign-up. The free test key 1 is built into the URLs. It is meant for learning and small projects; for a real product, read its terms on the TheMealDB website. It has mostly international recipes, so searching for paneer may return little. Try chicken, lamb, rice, egg, curry.

Prerequisites: the lessons up to *Routing with React Router*. Node.js installed.

Stage 0: look at the API first

Always read the data before designing the screen. Open these two URLs in your browser:

- https://www.themealdb.com/api/json/v1/1/search.php?s=chicken returns { "meals": [ ... ] } where each meal has idMeal, strMeal (name), strCategory, strArea, strInstructions, strMealThumb (image) and more. - https://www.themealdb.com/api/json/v1/1/lookup.php?i=52772 returns one meal in the same shape.

Two oddities to plan for. When nothing matches, meals is null, not an empty list. And ingredients are not a list: they are numbered fields strIngredient1 to strIngredient20, with matching strMeasure1 to strMeasure20, and unused ones are empty strings. We will write a small helper that turns them into a clean list.

Stage 1: create the project

Prompt · Plan the app (optional AI step)
I am building a recipe finder with React (Vite, plain JavaScript) and React Router, using the TheMealDB API (search.php?s= and lookup.php?i=). Pages: home with search, recipe detail, favourites saved in localStorage.

Give me a file and component structure and a build order in small steps, so the app works after each step. Do not write the code yet.

What to expect: A good plan has an api file, a favourites hook, three page components and a card component. If the AI suggests Redux or a backend, that is more than this project needs.

Set up

  1. 1Run npm create vite@latest recipe-finder and choose React, then JavaScript. Follow the prompts the template shows.
  2. 2Run cd recipe-finder, npm install, then npm install react-router-dom.
  3. 3Run npm run dev and open the address shown (usually http://localhost:5173).
  4. 4Delete src/App.css, replace the contents of src/index.css later in this lesson, and remove the starter content inside src/App.jsx when we rewrite it.

The finished structure will be:

File structure
src/
  main.jsx
  App.jsx
  api.js
  useFavourites.js
  index.css
  components/
    RecipeCard.jsx
  pages/
    Home.jsx
    RecipePage.jsx
    Favourites.jsx

Stage 2: the data layer

Keep all network code in one file. Components then say searchRecipes('chicken') and never know about URLs. If the API changes, you fix one file.

Why encodeURIComponent? A search like fish & chips would otherwise break the URL. Why ?? []? Because the API returns null for no results, and the rest of the app is simpler if it always gets a list.

src/api.js
const BASE = 'https://www.themealdb.com/api/json/v1/1';

export async function searchRecipes(query) {
  const res = await fetch(`${BASE}/search.php?s=${encodeURIComponent(query)}`);
  if (!res.ok) throw new Error('Search failed');
  const data = await res.json();
  return data.meals ?? [];
}

export async function getRecipe(id) {
  const res = await fetch(`${BASE}/lookup.php?i=${encodeURIComponent(id)}`);
  if (!res.ok) throw new Error('Could not load the recipe');
  const data = await res.json();
  return data.meals ? data.meals[0] : null;
}

// Turns strIngredient1..20 and strMeasure1..20 into [{ name, measure }]
export function getIngredients(meal) {
  const list = [];
  for (let i = 1; i <= 20; i++) {
    const name = (meal[`strIngredient${i}`] || '').trim();
    const measure = (meal[`strMeasure${i}`] || '').trim();
    if (name) list.push({ name, measure });
  }
  return list;
}

Stage 3: a hook for favourites

Favourites need to survive a refresh, so we store the recipe ids in localStorage, which keeps text in the browser. We wrap this in a custom hook so pages stay simple. Reading storage can fail (private mode, corrupted data), so the read is wrapped in try/catch. The initial value is given to useState as a function, so the storage is read only once, not on every render.

src/useFavourites.js
import { useCallback, useEffect, useState } from 'react';

const KEY = 'recipe-finder-favourites';

function readIds() {
  try {
    const saved = JSON.parse(localStorage.getItem(KEY));
    return Array.isArray(saved) ? saved : [];
  } catch {
    return [];
  }
}

export function useFavourites() {
  const [ids, setIds] = useState(readIds);

  useEffect(() => {
    try {
      localStorage.setItem(KEY, JSON.stringify(ids));
    } catch {
      // Storage may be unavailable; the app still works for this visit
    }
  }, [ids]);

  const toggle = useCallback((id) => {
    setIds((current) =>
      current.includes(id) ? current.filter((x) => x !== id) : [...current, id]
    );
  }, []);

  const isFavourite = (id) => ids.includes(id);

  return { ids, toggle, isFavourite };
}

toggle adds the id if it is missing and removes it if present. It returns new arrays instead of changing the old one, as React requires.

Stage 4: shell, routes and styles

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 './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>
);
src/App.jsx
import { NavLink, Route, Routes } from 'react-router-dom';
import Home from './pages/Home.jsx';
import RecipePage from './pages/RecipePage.jsx';
import Favourites from './pages/Favourites.jsx';

export default function App() {
  return (
    <div className="app">
      <header className="header">
        <h1>Recipe Finder</h1>
        <nav>
          <NavLink to="/" end>Search</NavLink>
          <NavLink to="/favourites">Favourites</NavLink>
        </nav>
      </header>

      <main>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/recipe/:id" element={<RecipePage />} />
          <Route path="/favourites" element={<Favourites />} />
          <Route path="*" element={<p>Page not found.</p>} />
        </Routes>
      </main>
    </div>
  );
}
src/index.css
* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  background: #fafaf7;
  color: #222;
}

.app { max-width: 960px; margin: 0 auto; padding: 16px; }

.header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 16px;
}
.header h1 { margin: 0; font-size: 1.4rem; }
.header nav { display: flex; gap: 16px; }
.header a { color: #555; text-decoration: none; padding: 4px 0; }
.header a.active { color: #b45309; border-bottom: 2px solid #b45309; }

.search { display: flex; gap: 8px; margin-bottom: 16px; }
.search input { flex: 1; padding: 10px; font-size: 1rem; }
button { cursor: pointer; padding: 10px 14px; font-size: 1rem; }

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 16px;
  list-style: none;
  padding: 0;
}

.card {
  position: relative;
  background: white;
  border: 1px solid #e5e5e0;
  border-radius: 8px;
  overflow: hidden;
}
.card img { width: 100%; height: 160px; object-fit: cover; display: block; }
.card a { display: block; padding: 12px; color: inherit; text-decoration: none; font-weight: 600; }
.card small { display: block; font-weight: 400; color: #666; margin-top: 4px; }
.card .fav {
  position: absolute;
  top: 8px;
  right: 8px;
  padding: 6px 10px;
  border-radius: 999px;
  border: none;
  background: rgba(255, 255, 255, 0.9);
}

.detail img { max-width: 100%; border-radius: 8px; }
.error { color: #b91c1c; }
.muted { color: #666; }

The pages do not exist yet, so the app will show an error until Stage 5 to 7 are done. Create each file as you go.

Stage 5: the recipe card and the home page

RecipeCard is a presentational component: it receives a recipe and a favourite state through props and does no fetching. The heart button sits outside the link so that clicking it does not navigate. The aria-pressed attribute and a clear aria-label make the toggle button usable with a screen reader.

src/components/RecipeCard.jsx
import { Link } from 'react-router-dom';

export default function RecipeCard({ recipe, isFavourite, onToggle }) {
  return (
    <li className="card">
      <img
        src={recipe.strMealThumb}
        alt={recipe.strMeal}
        width="300"
        height="160"
        loading="lazy"
      />
      <Link to={`/recipe/${recipe.idMeal}`}>
        {recipe.strMeal}
        {recipe.strCategory && (
          <small>
            {recipe.strCategory}
            {recipe.strArea ? ` · ${recipe.strArea}` : ''}
          </small>
        )}
      </Link>
      <button
        type="button"
        className="fav"
        aria-pressed={isFavourite}
        aria-label={isFavourite ? `Remove ${recipe.strMeal} from favourites` : `Save ${recipe.strMeal} to favourites`}
        onClick={() => onToggle(recipe.idMeal)}
      >
        {isFavourite ? '♥' : '♡'}
      </button>
    </li>
  );
}

Now the home page. Design decisions worth understanding:

- The text in the box is local state (text), but the submitted query lives in the URL (useSearchParams). That gives shareable links and a working back button. - The fetch effect depends on q. It uses an ignore flag in its cleanup, so if you search twice quickly, the slower, older response cannot overwrite the newer one (a race condition). - status is one of idle, loading, ready or error, which keeps the rendering logic clear.

src/pages/Home.jsx
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { searchRecipes } from '../api.js';
import { useFavourites } from '../useFavourites.js';
import RecipeCard from '../components/RecipeCard.jsx';

export default function Home() {
  const [params, setParams] = useSearchParams();
  const q = params.get('q') ?? '';

  const [text, setText] = useState(q);
  const [recipes, setRecipes] = useState([]);
  const [status, setStatus] = useState('idle');
  const { toggle, isFavourite } = useFavourites();

  useEffect(() => {
    if (!q.trim()) {
      setRecipes([]);
      setStatus('idle');
      return;
    }

    let ignore = false;
    setStatus('loading');

    searchRecipes(q)
      .then((result) => {
        if (ignore) return;
        setRecipes(result);
        setStatus('ready');
      })
      .catch(() => {
        if (!ignore) setStatus('error');
      });

    return () => {
      ignore = true;
    };
  }, [q]);

  function handleSubmit(event) {
    event.preventDefault();
    const trimmed = text.trim();
    setParams(trimmed ? { q: trimmed } : {});
  }

  return (
    <section>
      <form className="search" onSubmit={handleSubmit} role="search">
        <label htmlFor="q" className="muted">Search recipes</label>
        <input
          id="q"
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="e.g. chicken"
        />
        <button type="submit">Search</button>
      </form>

      {status === 'idle' && <p className="muted">Type a dish or an ingredient to begin.</p>}
      {status === 'loading' && <p>Searching...</p>}
      {status === 'error' && <p className="error">Something went wrong. Check your connection and try again.</p>}
      {status === 'ready' && recipes.length === 0 && (
        <p>No recipes found for "{q}". Try another word.</p>
      )}

      <ul className="grid">
        {recipes.map((r) => (
          <RecipeCard
            key={r.idMeal}
            recipe={r}
            isFavourite={isFavourite(r.idMeal)}
            onToggle={toggle}
          />
        ))}
      </ul>
    </section>
  );
}

The label and input sit in a flex row from the .search CSS, which is fine for this project; style it to taste. The label is visible text but you may make it visually hidden later.

Check your work
At http://localhost:5173/ you see the header, a search box and the hint 'Type a dish or an ingredient to begin.' Search for chicken: a grid of cards with photos appears and the address bar changes to /?q=chicken. Refresh the page: the same results load. Search for zzzz: 'No recipes found for "zzzz"'. Clicking a heart fills it. (The recipe page and Favourites page links will show errors until the next stages.)

Stage 6: the recipe detail page

useParams gives the id from the URL. We fetch the full recipe, then show the ingredients (using our helper) and the steps. The API returns instructions as one block of text with line breaks, so we split on new lines and show each non-empty line as a paragraph. A recipe that does not exist gives null, which we treat as 'not found' instead of crashing.

src/pages/RecipePage.jsx
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getIngredients, getRecipe } from '../api.js';
import { useFavourites } from '../useFavourites.js';

export default function RecipePage() {
  const { id } = useParams();
  const [recipe, setRecipe] = useState(null);
  const [status, setStatus] = useState('loading');
  const { toggle, isFavourite } = useFavourites();

  useEffect(() => {
    let ignore = false;
    setStatus('loading');

    getRecipe(id)
      .then((result) => {
        if (ignore) return;
        setRecipe(result);
        setStatus(result ? 'ready' : 'missing');
      })
      .catch(() => {
        if (!ignore) setStatus('error');
      });

    return () => {
      ignore = true;
    };
  }, [id]);

  if (status === 'loading') return <p>Loading recipe...</p>;
  if (status === 'error') return <p className="error">Could not load this recipe.</p>;
  if (status === 'missing') return <p>Recipe not found. <Link to="/">Back to search</Link></p>;

  const ingredients = getIngredients(recipe);
  const steps = recipe.strInstructions
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter(Boolean);
  const saved = isFavourite(recipe.idMeal);

  return (
    <article className="detail">
      <p><Link to="/">Back to search</Link></p>
      <h2>{recipe.strMeal}</h2>
      <p className="muted">{recipe.strCategory} · {recipe.strArea}</p>
      <img src={recipe.strMealThumb} alt={recipe.strMeal} width="480" />

      <p>
        <button type="button" aria-pressed={saved} onClick={() => toggle(recipe.idMeal)}>
          {saved ? 'Remove from favourites' : 'Save to favourites'}
        </button>
      </p>

      <h3>Ingredients</h3>
      <ul>
        {ingredients.map((item) => (
          <li key={item.name}>{item.measure} {item.name}</li>
        ))}
      </ul>

      <h3>Method</h3>
      {steps.map((line, index) => (
        <p key={index}>{line}</p>
      ))}
    </article>
  );
}

Keys: ingredient names are unique within a recipe so they work as keys. The method lines have no id, and the list never reorders, so the index is acceptable here.

Stage 7: the favourites page

We stored only ids, so this page must fetch each recipe. Promise.all runs the requests in parallel and waits for all of them. If a favourite no longer exists, getRecipe returns null, and we filter those out. Removing a favourite here removes the card straight away because we filter the displayed list by the current ids.

src/pages/Favourites.jsx
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getRecipe } from '../api.js';
import { useFavourites } from '../useFavourites.js';
import RecipeCard from '../components/RecipeCard.jsx';

export default function Favourites() {
  const { ids, toggle, isFavourite } = useFavourites();
  const [recipes, setRecipes] = useState([]);
  const [status, setStatus] = useState('loading');

  useEffect(() => {
    let ignore = false;

    // Load the saved recipes once, when the page opens
    Promise.all(ids.map((id) => getRecipe(id)))
      .then((results) => {
        if (ignore) return;
        setRecipes(results.filter(Boolean));
        setStatus('ready');
      })
      .catch(() => {
        if (!ignore) setStatus('error');
      });

    return () => {
      ignore = true;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  if (status === 'loading') return <p>Loading favourites...</p>;
  if (status === 'error') return <p className="error">Could not load your favourites.</p>;

  const visible = recipes.filter((r) => ids.includes(r.idMeal));

  if (visible.length === 0) {
    return <p>No favourites yet. <Link to="/">Find a recipe</Link> and tap the heart.</p>;
  }

  return (
    <ul className="grid">
      {visible.map((r) => (
        <RecipeCard
          key={r.idMeal}
          recipe={r}
          isFavourite={isFavourite(r.idMeal)}
          onToggle={toggle}
        />
      ))}
    </ul>
  );
}

The effect intentionally runs once, so the eslint comment tells the linter that this is on purpose. An alternative design is to store the name and thumbnail with each id so no fetching is needed.

Stage 8: run and test by hand

Manual test list

  1. Search for chicken: cards load and the URL becomes /?q=chicken.
  2. Press the browser Back and Forward buttons: the results change with the URL.
  3. Search for gibberish: you see the 'No recipes found' message, not a blank page.
  4. Turn off your internet in DevTools (Network tab, Offline) and search: you see the error message.
  5. Open a recipe: ingredients have measures, and the method is split into paragraphs.
  6. Save two recipes, open Favourites, refresh the page: both are still there.
  7. Remove one in Favourites: its card disappears immediately.
  8. Open /recipe/0 directly: you see 'Recipe not found'.
  9. Use only the keyboard (Tab, Enter): you can search, open a recipe and toggle a favourite.
Prompt · Ask AI to explain an error
My React app (Vite, React Router) shows this error:

[paste the exact error message and stack trace]

Here is the file where it happens:

[paste the component]

Explain in simple words what the error means and why it happens here. Show the smallest fix. Do not rewrite the whole file.

What to expect: Typical causes in this project: a missing import or a wrong file path, forgetting that meals can be null, or calling a hook inside a condition.

Things that commonly go wrong

1. A blank page and an error about useNavigate or useParams outside a Router: BrowserRouter is missing from main.jsx. 2. 'Cannot read properties of null': a search with no results returns meals: null. We handle it in api.js with ?? []. 3. Images stretch or jump: keep object-fit: cover and fixed heights on cards. 4. The recipe page works by clicking but shows a 404 after refresh once deployed: single-page apps need a host rewrite rule. The next lesson (*Deploy and share your app*) covers this.

Stretch goals

Try it: Add a category filter

TheMealDB has https://www.themealdb.com/api/json/v1/1/list.php?c=list, which returns the category names, and filter.php?c=Seafood, which returns the meals in a category (name, image and id only). Add a category dropdown to the home page that stores its choice in the URL (?category=Seafood).

Try it: Share favourites across pages with Zustand

Right now, each page that calls useFavourites has its own copy of the state, which works only because pages remount when you navigate. Move the favourites into a Zustand store (see *Managing app state*) so that a heart on any page updates everywhere at once. Persist it to localStorage with Zustand's persist middleware.

Try it: Quality pass

1. Replace the manual fetching with TanStack Query (useQuery with the query key ['search', q]). 2. Write two tests with Vitest and Testing Library: the card shows the name, and clicking the heart calls onToggle. See *Testing React components*. 3. Run Lighthouse and fix any accessibility problem it reports. 4. Add a Surprise me button using https://www.themealdb.com/api/json/v1/1/random.php.

Key takeaways

  1. Read the API's real responses first and plan for odd shapes (null results, numbered ingredient fields).
  2. Keep fetch code in one file and let components deal with loading, error, empty and ready states.
  3. Put things people may share or bookmark, like a search query, in the URL with useSearchParams.
  4. Use an ignore flag in effect cleanup so old responses never overwrite newer ones.
  5. A custom hook (useFavourites) hides storage details and keeps pages simple.
  6. Test the unhappy paths by hand: empty results, offline, missing pages, keyboard-only.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why is the search text stored in the URL (useSearchParams) instead of only in state?

2. What does the ignore flag in the effect cleanup protect against?

3. TheMealDB returns { meals: null } when nothing matches. Where is it best handled?

4. Why is the initial favourites state written as useState(readIds) and not useState(readIds())?

5. After deploying, opening /recipe/52772 directly gives a 404, although in-app links work. What is the cause?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free