Skip to content
aviral gupta

// Beginner project · about 3 hours of work

Recipe browser

You build a single-page React app that browses recipes from a JavaScript array. A search box and tag buttons narrow the list; clicking a recipe shows its ingredients, and a servings scaler recalculates every amount. The point is the level's state skills: keep the minimal state (the query, the chosen tag, the selected id, the servings), derive everything else while rendering, never change the data, give list items stable keys, and reset the scaler with a key when another recipe is chosen. React Testing Library tests check the app the way a user would use it, by roles and visible text. It is practice only: the data stays on the page, and nothing is sent anywhere.

What the finished program does

  • main.jsx's default export App renders a heading Recipe browser, a search field labelled Search, a group of tag buttons labelled Tags, a count line, the recipe list and the detail area. recipes.js is given and must never be changed by the app.
  • The list (a <ul> labelled Recipes) shows one button per recipe, in the order of recipes.js, each <li> with the recipe's id as its key. The count line says 1 recipe or N recipes for what is shown. When nothing matches, the list is replaced by No recipes match.
  • The search keeps the recipes whose title contains the text, ignoring upper and lower case and spaces at either end. An empty search shows every recipe.
  • The tag group starts with All and then has one button per tag, each tag once, sorted (breakfast, dinner, lunch, sweet, vegan). Clicking a tag shows only recipes with that tag; clicking the same tag again, or All, shows every tag again. The chosen button has aria-pressed true, the others false. Tag and search apply together.
  • Clicking a recipe selects it by id. The detail shows its title as a level-2 heading, its minutes, the servings line (1 serving or N servings) and its ingredients in a <ul> labelled Ingredients. When the selected recipe is hidden by the search or tag, the detail is replaced by Pick a recipe to see its ingredients.
  • Buttons labelled Fewer servings and More servings change the servings by one; servings never go below 1 (Fewer servings is disabled at 1). Each amount is amount × servings ÷ the recipe's servings, shown with at most two decimals and no trailing zeros (83.33, 0.5, 50), followed by the unit when it has one and the name: 50 g flour, 0.5 eggs.
  • Choosing another recipe starts its scaler at that recipe's own servings: RecipeDetail is rendered with key={selected.id}.
  • Components are pure: React prints no warnings in Strict Mode, nothing is stored in state that can be computed from other state, and the tag list is built without sorting recipes.js in place.

Starter layout

main.jsx
Your work: TAGS, matches(), the App component with its state for the query, the chosen tag and the selected id, the filtered list and the detail area. In a Vite project this is src/App.jsx.
RecipeDetail.jsx
Your work: formatAmount() and the RecipeDetail component with its servings state and the scaled ingredient list.
recipes.js
The data: five recipes with id, title, tags, minutes, servings and ingredients. Given; do not change it.
main.test.jsx
The acceptance tests (node:test with React Testing Library and user-event). The page runs them with Check; locally there is no check command yet.

Milestones

  1. Milestone 1

    A list with stable keys

    Render the list from recipes with map and give each <li> key={recipe.id}. Build TAGS from every recipe's tags with a Set, spread into a new array and sorted, and render All plus one button per tag. The count line uses the length of what is shown.

    Checks that pass once this milestone is done:

    • Lists every recipe in data order, with a count
    • The tag buttons are All plus every tag once, sorted
  2. Milestone 2

    Search

    Write matches(recipe, query, tag): trim and lower-case the query, check that the lower-cased title includes it, and that tag is null or one of the recipe's tags. Compute shown = recipes.filter(...) during render, not in state, and show No recipes match. when it is empty.

    Checks that pass once this milestone is done:

    • Search matches titles, ignoring case and outer spaces
    • A search with no match says so
  3. Milestone 3

    Filter by tag

    Add state for the chosen tag, null for All. A tag button sets it, or sets it back to null when it is already chosen; All sets null. Pass aria-pressed={tag === name}.

    Checks that pass once this milestone is done:

    • A tag filters the list, and a second click shows all again
    • Search and tag work together
  4. Milestone 4

    The detail view

    Store only the selected recipe's id. Find the selected recipe in shown while rendering; when it is not there, show Pick a recipe to see its ingredients. instead of the detail.

    Checks that pass once this milestone is done:

    • Clicking a recipe shows its details
    • A recipe the filter hides is not shown in detail
  5. Milestone 5

    The servings scaler

    In RecipeDetail, keep servings in state starting at recipe.servings. The buttons use updater functions; Fewer servings is disabled at 1. formatAmount rounds with Math.round(amount * 100) / 100. Render RecipeDetail with key={selected.id}, so another recipe starts fresh.

    Checks that pass once this milestone is done:

    • Fewer and more servings scale the amounts
    • Amounts keep at most two decimals
    • Servings never go below 1
    • Another recipe starts at its own servings
  6. Milestone 6

    Pure and quiet

    Check that nothing changes recipes.js (no sort or push on the imported array) and that React prints no warnings, missing keys included.

    Checks that pass once this milestone is done:

    • The recipe data is never changed
    • React prints no warnings

Build it in the browser

Edit main.jsx below; the other starter files stay as they are. “Check my code” runs every acceptance test, so expect failures until the last milestone.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Build it on your computer

Make a folder with these starter files and React 19.3, then work through the milestones. Run the acceptance tests at any point with:

Download the starter as one .zip (starter files and main.test.jsx)

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

main.jsx

import {useState} from 'react';
import {recipes} from './recipes.js';
import {RecipeDetail} from './RecipeDetail.jsx';

// TODO: every tag once, sorted, without changing the recipes.
const TAGS = [];

// TODO: true when the title contains the query (trimmed, any case)
// and the recipe has the tag (null means any tag).
export function matches(recipe, query, tag) {
  return true;
}

export default function App() {
  const [query, setQuery] = useState('');
  // TODO: state for the chosen tag (null = All) and the selected recipe's id.
  const selected = recipes[0];

  return (
    <main>
      <h1>Recipe browser</h1>
      <label htmlFor="search">Search</label>
      <input id="search" type="search" value={query} onChange={(event) => setQuery(event.target.value)} />
      <div role="group" aria-label="Tags">
        <button type="button" aria-pressed={true}>
          All
        </button>
        {/* TODO: one button per tag in TAGS, with aria-pressed */}
      </div>
      <p>{recipes.length + ' recipes'}</p>
      {/* TODO: show only the matching recipes, "No recipes match." when none, and a stable key per item. */}
      <ul aria-label="Recipes">
        {recipes.map((recipe) => (
          <li>
            <button type="button">{recipe.title}</button>
          </li>
        ))}
      </ul>
      <RecipeDetail recipe={selected} />
    </main>
  );
}

RecipeDetail.jsx

// TODO: import useState and keep the chosen servings in state, starting at recipe.servings.

// TODO: at most two decimals, without trailing zeros: 83.333 -> "83.33", 0.5 -> "0.5".
export function formatAmount(amount) {
  return String(amount);
}

export function RecipeDetail({recipe}) {
  const servings = recipe.servings;

  return (
    <section aria-label={recipe.title}>
      <h2>{recipe.title}</h2>
      <p>{recipe.minutes} minutes</p>
      <p>
        <button type="button" aria-label="Fewer servings">
          -
        </button>{' '}
        <span>{servings + ' servings'}</span>{' '}
        <button type="button" aria-label="More servings">
          +
        </button>
      </p>
      {/* TODO: scale each amount by servings / recipe.servings. */}
      <ul aria-label="Ingredients">
        {recipe.ingredients.map((item) => (
          <li key={item.name}>{[formatAmount(item.amount), item.unit, item.name].filter(Boolean).join(' ')}</li>
        ))}
      </ul>
    </section>
  );
}

recipes.js

// The recipes the app shows. Amounts are for the recipe's own servings.
export const recipes = [
  {
    id: 'pancakes',
    title: 'Pancakes',
    tags: ['breakfast', 'sweet'],
    minutes: 25,
    servings: 4,
    ingredients: [
      {name: 'flour', amount: 200, unit: 'g'},
      {name: 'milk', amount: 300, unit: 'ml'},
      {name: 'eggs', amount: 2, unit: ''},
      {name: 'sugar', amount: 1, unit: 'tbsp'}
    ]
  },
  {
    id: 'tomato-soup',
    title: 'Tomato soup',
    tags: ['lunch', 'vegan'],
    minutes: 40,
    servings: 2,
    ingredients: [
      {name: 'tomatoes', amount: 800, unit: 'g'},
      {name: 'onion', amount: 1, unit: ''},
      {name: 'olive oil', amount: 2, unit: 'tbsp'}
    ]
  },
  {
    id: 'porridge',
    title: 'Porridge',
    tags: ['breakfast', 'vegan'],
    minutes: 10,
    servings: 1,
    ingredients: [
      {name: 'oats', amount: 50, unit: 'g'},
      {name: 'oat milk', amount: 250, unit: 'ml'}
    ]
  },
  {
    id: 'lentil-curry',
    title: 'Lentil curry',
    tags: ['dinner', 'vegan'],
    minutes: 35,
    servings: 3,
    ingredients: [
      {name: 'red lentils', amount: 250, unit: 'g'},
      {name: 'coconut milk', amount: 400, unit: 'ml'},
      {name: 'curry paste', amount: 2, unit: 'tbsp'}
    ]
  },
  {
    id: 'fruit-salad',
    title: 'Fruit salad',
    tags: ['sweet', 'vegan'],
    minutes: 15,
    servings: 6,
    ingredients: [
      {name: 'apples', amount: 2, unit: ''},
      {name: 'oranges', amount: 3, unit: ''},
      {name: 'grapes', amount: 300, unit: 'g'}
    ]
  }
];

Acceptance tests

The project is done when every check in main.test.jsx passes. Read them before you start: they are the spec, written as code.

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, within, fireEvent} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './main.jsx';
import {recipes} from './recipes.js';

const titles = () => {
  const list = screen.queryByRole('list', {name: 'Recipes'});
  return list ? within(list).getAllByRole('button').map((button) => button.textContent) : [];
};
const tagButton = (name) => within(screen.getByRole('group', {name: 'Tags'})).getByRole('button', {name});
const recipeButton = (title) => within(screen.getByRole('list', {name: 'Recipes'})).getByRole('button', {name: title});
const ingredients = () => {
  const list = screen.queryByRole('list', {name: 'Ingredients'});
  return list ? within(list).getAllByRole('listitem').map((item) => item.textContent) : [];
};
const shows = (text) => screen.queryByText(text) !== null;

test('Lists every recipe in data order, with a count', () => {
  render(<App />);
  assert.deepEqual(titles(), ['Pancakes', 'Tomato soup', 'Porridge', 'Lentil curry', 'Fruit salad'], 'the list shows ' + JSON.stringify(titles()));
  assert.ok(shows('5 recipes'), 'no line "5 recipes"');
});

test('Search matches titles, ignoring case and outer spaces', async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.type(screen.getByRole('searchbox', {name: 'Search'}), '  SOUP ');
  assert.deepEqual(titles(), ['Tomato soup'], 'searching "  SOUP " shows ' + JSON.stringify(titles()));
  assert.ok(shows('1 recipe'), 'no line "1 recipe" for one match');
});

test('A search with no match says so', async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.type(screen.getByRole('searchbox', {name: 'Search'}), 'pizza');
  assert.deepEqual(titles(), [], 'searching "pizza" shows ' + JSON.stringify(titles()));
  assert.ok(shows('No recipes match.'), 'no message "No recipes match."');
  assert.ok(shows('0 recipes'), 'no line "0 recipes"');
});

test('The tag buttons are All plus every tag once, sorted', () => {
  render(<App />);
  const names = within(screen.getByRole('group', {name: 'Tags'})).getAllByRole('button').map((button) => button.textContent);
  assert.deepEqual(names, ['All', 'breakfast', 'dinner', 'lunch', 'sweet', 'vegan'], 'the tag buttons are ' + JSON.stringify(names));
});

test('A tag filters the list, and a second click shows all again', async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.click(tagButton('vegan'));
  assert.deepEqual(titles(), ['Tomato soup', 'Porridge', 'Lentil curry', 'Fruit salad'], 'the tag vegan shows ' + JSON.stringify(titles()));
  assert.equal(tagButton('vegan').getAttribute('aria-pressed'), 'true', 'the vegan button is not pressed');
  assert.equal(tagButton('All').getAttribute('aria-pressed'), 'false', 'All is still pressed while vegan is chosen');
  await user.click(tagButton('vegan'));
  assert.equal(titles().length, 5, 'a second click on vegan shows ' + titles().length + ' recipes');
  assert.equal(tagButton('All').getAttribute('aria-pressed'), 'true', 'All is not pressed after the second click');
});

test('Search and tag work together', async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.click(tagButton('breakfast'));
  await user.type(screen.getByRole('searchbox', {name: 'Search'}), 'pan');
  assert.deepEqual(titles(), ['Pancakes'], 'breakfast plus "pan" shows ' + JSON.stringify(titles()));
  await user.click(tagButton('dinner'));
  assert.deepEqual(titles(), [], 'dinner plus "pan" shows ' + JSON.stringify(titles()));
});

test('Clicking a recipe shows its details', async () => {
  const user = userEvent.setup();
  render(<App />);
  assert.ok(screen.queryByRole('heading', {level: 2}) === null, 'a recipe is shown before any click');
  await user.click(recipeButton('Lentil curry'));
  const heading = screen.queryByRole('heading', {level: 2});
  assert.equal(heading?.textContent, 'Lentil curry', 'the detail heading is ' + JSON.stringify(heading?.textContent));
  assert.ok(shows('3 servings'), 'no line "3 servings"');
  assert.deepEqual(ingredients(), ['250 g red lentils', '400 ml coconut milk', '2 tbsp curry paste'], 'the ingredients are ' + JSON.stringify(ingredients()));
});

test('A recipe the filter hides is not shown in detail', async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.click(recipeButton('Pancakes'));
  await user.click(tagButton('vegan'));
  assert.ok(screen.queryByRole('heading', {level: 2}) === null, 'Pancakes is still shown after choosing vegan');
  assert.ok(shows('Pick a recipe to see its ingredients.'), 'no hint "Pick a recipe to see its ingredients."');
});

test('Fewer and more servings scale the amounts', async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.click(recipeButton('Pancakes'));
  for (let i = 0; i < 3; i++) await user.click(screen.getByRole('button', {name: 'Fewer servings'}));
  assert.ok(shows('1 serving'), 'no line "1 serving" after three clicks on Fewer servings');
  assert.deepEqual(ingredients(), ['50 g flour', '75 ml milk', '0.5 eggs', '0.25 tbsp sugar'], 'pancakes for 1 are ' + JSON.stringify(ingredients()));
  await user.click(recipeButton('Porridge'));
  await user.click(screen.getByRole('button', {name: 'More servings'}));
  await user.click(screen.getByRole('button', {name: 'More servings'}));
  assert.deepEqual(ingredients(), ['150 g oats', '750 ml oat milk'], 'porridge for 3 is ' + JSON.stringify(ingredients()));
});

test('Amounts keep at most two decimals', async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.click(recipeButton('Lentil curry'));
  await user.click(screen.getByRole('button', {name: 'Fewer servings'}));
  await user.click(screen.getByRole('button', {name: 'Fewer servings'}));
  assert.deepEqual(ingredients(), ['83.33 g red lentils', '133.33 ml coconut milk', '0.67 tbsp curry paste'], 'lentil curry for 1 is ' + JSON.stringify(ingredients()));
});

test('Servings never go below 1', () => {
  render(<App />);
  fireEvent.click(recipeButton('Porridge'));
  fireEvent.click(screen.getByRole('button', {name: 'Fewer servings'}));
  assert.ok(shows('1 serving'), 'porridge went below 1 serving');
  assert.deepEqual(ingredients(), ['50 g oats', '250 ml oat milk'], 'porridge for 1 is ' + JSON.stringify(ingredients()));
});

test('Another recipe starts at its own servings', async () => {
  const user = userEvent.setup();
  render(<App />);
  await user.click(recipeButton('Pancakes'));
  await user.click(screen.getByRole('button', {name: 'More servings'}));
  await user.click(screen.getByRole('button', {name: 'More servings'}));
  await user.click(recipeButton('Tomato soup'));
  assert.ok(shows('2 servings'), 'Tomato soup does not start at 2 servings');
  assert.deepEqual(ingredients(), ['800 g tomatoes', '1 onion', '2 tbsp olive oil'], 'tomato soup is ' + JSON.stringify(ingredients()));
});

test('The recipe data is never changed', async () => {
  const before = JSON.stringify(recipes);
  const user = userEvent.setup();
  render(<App />);
  await user.click(tagButton('sweet'));
  await user.click(recipeButton('Fruit salad'));
  await user.click(screen.getByRole('button', {name: 'Fewer servings'}));
  await user.type(screen.getByRole('searchbox', {name: 'Search'}), 'a');
  assert.equal(JSON.stringify(recipes), before, 'the recipes in recipes.js were changed');
});

test('React prints no warnings', async () => {
  const warnings = [];
  const original = console.error;
  console.error = (...args) => warnings.push(args.join(' '));
  try {
    const user = userEvent.setup();
    render(<App />);
    await user.click(tagButton('vegan'));
    await user.click(recipeButton('Porridge'));
    await user.click(screen.getByRole('button', {name: 'More servings'}));
  } finally {
    console.error = original;
  }
  assert.deepEqual(warnings, [], 'console.error while using the app');
});

Run the finished program

npm run dev
Reference solution

Try the milestones first. This solution passes every acceptance test and the type checker.

main.jsx

import {useState} from 'react';
import {recipes} from './recipes.js';
import {RecipeDetail} from './RecipeDetail.jsx';

// Every tag once, sorted. Spread into a new array first: sort() changes the array it is called on.
const TAGS = [...new Set(recipes.flatMap((recipe) => recipe.tags))].sort();

export function matches(recipe, query, tag) {
  const text = query.trim().toLowerCase();
  return recipe.title.toLowerCase().includes(text) && (tag === null || recipe.tags.includes(tag));
}

export default function App() {
  const [query, setQuery] = useState('');
  const [tag, setTag] = useState(null);
  const [selectedId, setSelectedId] = useState(null);

  // Derived during render, not stored: the shown list and the selected recipe.
  const shown = recipes.filter((recipe) => matches(recipe, query, tag));
  const selected = shown.find((recipe) => recipe.id === selectedId);

  return (
    <main>
      <h1>Recipe browser</h1>
      <label htmlFor="search">Search</label>
      <input id="search" type="search" value={query} onChange={(event) => setQuery(event.target.value)} />
      <div role="group" aria-label="Tags">
        <button type="button" aria-pressed={tag === null} onClick={() => setTag(null)}>
          All
        </button>
        {TAGS.map((name) => (
          <button key={name} type="button" aria-pressed={tag === name} onClick={() => setTag(tag === name ? null : name)}>
            {name}
          </button>
        ))}
      </div>
      <p>{shown.length === 1 ? '1 recipe' : shown.length + ' recipes'}</p>
      {shown.length === 0 ? (
        <p>No recipes match.</p>
      ) : (
        <ul aria-label="Recipes">
          {shown.map((recipe) => (
            <li key={recipe.id}>
              <button type="button" onClick={() => setSelectedId(recipe.id)}>
                {recipe.title}
              </button>
            </li>
          ))}
        </ul>
      )}
      {selected ? <RecipeDetail key={selected.id} recipe={selected} /> : <p>Pick a recipe to see its ingredients.</p>}
    </main>
  );
}

RecipeDetail.jsx

import {useState} from 'react';

// At most two decimals, without trailing zeros: 83.333 -> "83.33", 0.5 -> "0.5", 50 -> "50".
export function formatAmount(amount) {
  return String(Math.round(amount * 100) / 100);
}

export function RecipeDetail({recipe}) {
  // Starts at the recipe's own servings; the key in App resets it for another recipe.
  const [servings, setServings] = useState(recipe.servings);

  return (
    <section aria-label={recipe.title}>
      <h2>{recipe.title}</h2>
      <p>{recipe.minutes} minutes</p>
      <p>
        <button type="button" aria-label="Fewer servings" disabled={servings === 1} onClick={() => setServings((s) => Math.max(1, s - 1))}>
          -
        </button>{' '}
        <span>{servings === 1 ? '1 serving' : servings + ' servings'}</span>{' '}
        <button type="button" aria-label="More servings" onClick={() => setServings((s) => s + 1)}>
          +
        </button>
      </p>
      <ul aria-label="Ingredients">
        {recipe.ingredients.map((item) => (
          <li key={item.name}>{[formatAmount((item.amount * servings) / recipe.servings), item.unit, item.name].filter(Boolean).join(' ')}</li>
        ))}
      </ul>
    </section>
  );
}

recipes.js

// The recipes the app shows. Amounts are for the recipe's own servings.
export const recipes = [
  {
    id: 'pancakes',
    title: 'Pancakes',
    tags: ['breakfast', 'sweet'],
    minutes: 25,
    servings: 4,
    ingredients: [
      {name: 'flour', amount: 200, unit: 'g'},
      {name: 'milk', amount: 300, unit: 'ml'},
      {name: 'eggs', amount: 2, unit: ''},
      {name: 'sugar', amount: 1, unit: 'tbsp'}
    ]
  },
  {
    id: 'tomato-soup',
    title: 'Tomato soup',
    tags: ['lunch', 'vegan'],
    minutes: 40,
    servings: 2,
    ingredients: [
      {name: 'tomatoes', amount: 800, unit: 'g'},
      {name: 'onion', amount: 1, unit: ''},
      {name: 'olive oil', amount: 2, unit: 'tbsp'}
    ]
  },
  {
    id: 'porridge',
    title: 'Porridge',
    tags: ['breakfast', 'vegan'],
    minutes: 10,
    servings: 1,
    ingredients: [
      {name: 'oats', amount: 50, unit: 'g'},
      {name: 'oat milk', amount: 250, unit: 'ml'}
    ]
  },
  {
    id: 'lentil-curry',
    title: 'Lentil curry',
    tags: ['dinner', 'vegan'],
    minutes: 35,
    servings: 3,
    ingredients: [
      {name: 'red lentils', amount: 250, unit: 'g'},
      {name: 'coconut milk', amount: 400, unit: 'ml'},
      {name: 'curry paste', amount: 2, unit: 'tbsp'}
    ]
  },
  {
    id: 'fruit-salad',
    title: 'Fruit salad',
    tags: ['sweet', 'vegan'],
    minutes: 15,
    servings: 6,
    ingredients: [
      {name: 'apples', amount: 2, unit: ''},
      {name: 'oranges', amount: 3, unit: ''},
      {name: 'grapes', amount: 300, unit: 'g'}
    ]
  }
];

Take it further

  • Show the recipe count per tag on each tag button, computed while rendering.
  • Let the search also match ingredient names, and highlight which ingredient matched.
  • Add a Favourites tag the user can toggle per recipe, kept in state by id in App.
  • Add a sort choice (by title or by minutes) that sorts a copy of the shown list, never recipes.js itself.
  • Move the search box and tag group into their own components, passing values and handlers down as props.

Projects are practice: your checks run in your browser or on your computer and never count toward a certificate.