// Projekt Einstieg · etwa 3 Stunden Arbeit
Rezept-Browser
Sie bauen eine Single-Page-App in React, die Rezepte aus einem JavaScript-Array durchblättert. Ein Suchfeld und Tag-Schaltflächen grenzen die Liste ein; ein Klick auf ein Rezept zeigt seine Zutaten, und ein Portionsrechner rechnet jede Menge neu aus. Es geht um die State-Fähigkeiten dieser Stufe: nur den minimalen State halten (die Suche, den gewählten Tag, die gewählte Id, die Portionen), alles andere beim Rendern ableiten, die Daten nie verändern, Listeneinträgen stabile Keys geben und den Rechner mit einem Key zurücksetzen, wenn ein anderes Rezept gewählt wird. Tests mit der React Testing Library prüfen die App so, wie jemand sie bedient: über Rollen und sichtbaren Text. Es ist reine Übung: Die Daten bleiben auf der Seite, und nichts wird irgendwohin gesendet.
Was das fertige Programm kann
- Der Standardexport App von main.jsx rendert eine Überschrift Recipe browser, ein Suchfeld mit der Beschriftung Search, eine Gruppe von Tag-Schaltflächen mit der Beschriftung Tags, eine Zeile mit der Anzahl, die Rezeptliste und den Detailbereich. recipes.js ist vorgegeben und darf von der App nie verändert werden.
- Die Liste (ein <ul> mit der Beschriftung Recipes) zeigt eine Schaltfläche pro Rezept, in der Reihenfolge von recipes.js, jedes <li> mit der Id des Rezepts als Key. Die Zeile mit der Anzahl lautet 1 recipe oder N recipes für das Gezeigte. Passt nichts, ersetzt No recipes match. die Liste.
- Die Suche behält die Rezepte, deren Titel den Text enthält, ohne Rücksicht auf Groß- und Kleinschreibung und auf Leerzeichen am Anfang oder Ende. Eine leere Suche zeigt jedes Rezept.
- Die Tag-Gruppe beginnt mit All und hat dann eine Schaltfläche pro Tag, jeden Tag einmal, sortiert (breakfast, dinner, lunch, sweet, vegan). Ein Klick auf einen Tag zeigt nur Rezepte mit diesem Tag; ein erneuter Klick auf denselben Tag oder auf All zeigt wieder alle. Die gewählte Schaltfläche hat aria-pressed true, die anderen false. Tag und Suche gelten zusammen.
- Ein Klick auf ein Rezept wählt es über seine Id aus. Das Detail zeigt den Titel als Überschrift der Ebene 2, die Minuten, die Portionszeile (1 serving oder N servings) und die Zutaten in einem <ul> mit der Beschriftung Ingredients. Ist das gewählte Rezept durch Suche oder Tag verborgen, ersetzt Pick a recipe to see its ingredients. das Detail.
- Schaltflächen mit den Beschriftungen Fewer servings und More servings ändern die Portionen um eins; die Portionen fallen nie unter 1 (Fewer servings ist bei 1 deaktiviert). Jede Menge ist Menge × Portionen ÷ Portionen des Rezepts, gezeigt mit höchstens zwei Nachkommastellen und ohne Nullen am Ende (83.33, 0.5, 50), gefolgt von der Einheit, falls vorhanden, und dem Namen: 50 g flour, 0.5 eggs.
- Ein anderes Rezept startet seinen Rechner mit seinen eigenen Portionen: RecipeDetail wird mit key={selected.id} gerendert.
- Die Komponenten sind rein: React gibt im Strict Mode keine Warnungen aus, im State steht nichts, was sich aus anderem State berechnen lässt, und die Tag-Liste entsteht, ohne recipes.js an Ort und Stelle zu sortieren.
Aufbau der Startdateien
- main.jsx
- Ihre Arbeit: TAGS, matches(), die Komponente App mit ihrem State für die Suche, den gewählten Tag und die gewählte Id, die gefilterte Liste und der Detailbereich. In einem Vite-Projekt ist das src/App.jsx.
- RecipeDetail.jsx
- Ihre Arbeit: formatAmount() und die Komponente RecipeDetail mit ihrem State für die Portionen und der umgerechneten Zutatenliste.
- recipes.js
- Die Daten: fünf Rezepte mit id, title, tags, minutes, servings und ingredients. Vorgegeben; bitte nicht ändern.
- main.test.jsx
- Die Abnahmetests (node:test mit React Testing Library und user-event). Die Seite führt sie mit Prüfen aus; lokal gibt es noch keinen Prüfbefehl.
Etappen
Etappe 1
Eine Liste mit stabilen Keys
Rendern Sie die Liste mit map aus recipes und geben Sie jedem <li> key={recipe.id}. Bauen Sie TAGS aus den Tags aller Rezepte mit einem Set, in ein neues Array verteilt und sortiert, und rendern Sie All plus eine Schaltfläche pro Tag. Die Anzahl richtet sich nach der Länge des Gezeigten.
Prüfungen, die nach dieser Etappe bestehen:
- Listet jedes Rezept in der Reihenfolge der Daten, mit Anzahl
- Die Tag-Schaltflächen sind All plus jeder Tag einmal, sortiert
Etappe 2
Suche
Schreiben Sie matches(recipe, query, tag): die Suche trimmen und kleinschreiben, prüfen, ob der kleingeschriebene Titel sie enthält und ob tag null oder einer der Tags des Rezepts ist. Berechnen Sie shown = recipes.filter(...) beim Rendern, nicht im State, und zeigen Sie No recipes match., wenn es leer ist.
Prüfungen, die nach dieser Etappe bestehen:
- Die Suche findet Titel ohne Rücksicht auf Groß- und Kleinschreibung und Leerzeichen am Rand
- Eine Suche ohne Treffer sagt das
Etappe 3
Nach Tag filtern
Legen Sie State für den gewählten Tag an, null für All. Eine Tag-Schaltfläche setzt ihn, oder setzt ihn auf null zurück, wenn er schon gewählt ist; All setzt null. Übergeben Sie aria-pressed={tag === name}.
Prüfungen, die nach dieser Etappe bestehen:
- Ein Tag filtert die Liste, ein zweiter Klick zeigt wieder alle
- Suche und Tag wirken zusammen
Etappe 4
Die Detailansicht
Speichern Sie nur die Id des gewählten Rezepts. Suchen Sie das gewählte Rezept beim Rendern in shown; ist es dort nicht, zeigen Sie statt des Details Pick a recipe to see its ingredients.
Prüfungen, die nach dieser Etappe bestehen:
- Ein Klick auf ein Rezept zeigt seine Details
- Ein vom Filter verborgenes Rezept wird nicht im Detail gezeigt
Etappe 5
Der Portionsrechner
Halten Sie in RecipeDetail die Portionen im State, beginnend bei recipe.servings. Die Schaltflächen nutzen Updater-Funktionen; Fewer servings ist bei 1 deaktiviert. formatAmount rundet mit Math.round(amount * 100) / 100. Rendern Sie RecipeDetail mit key={selected.id}, damit ein anderes Rezept frisch beginnt.
Prüfungen, die nach dieser Etappe bestehen:
- Weniger und mehr Portionen rechnen die Mengen um
- Mengen haben höchstens zwei Nachkommastellen
- Die Portionen fallen nie unter 1
- Ein anderes Rezept beginnt mit seinen eigenen Portionen
Etappe 6
Rein und ruhig
Prüfen Sie, dass nichts recipes.js verändert (kein sort oder push auf dem importierten Array) und dass React keine Warnungen ausgibt, auch nicht zu fehlenden Keys.
Prüfungen, die nach dieser Etappe bestehen:
- Die Rezeptdaten werden nie verändert
- React gibt keine Warnungen aus
Im Browser bauen
Bearbeiten Sie unten main.jsx; die anderen Startdateien bleiben, wie sie sind. „Code prüfen“ führt alle Abnahmetests aus, rechnen Sie also bis zum letzten Meilenstein mit Fehlschlägen.
Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.
Beim ersten Ausführen lädt Ihr Browser React und den Compiler herunter, der JSX in JavaScript übersetzt (bis zu 2 MB), und speichert beides im Cache. Ihre Komponenten laufen in Ihrem Browser und bleiben auf Ihrem Gerät.
Ihre Seite
Ihre Seite läuft in einem abgeschotteten Rahmen. Sie hat keinen Zugriff auf diese Website, und ihr localStorage liegt nur für diesen Lauf im Speicher: Der nächste Lauf beginnt leer.
Führen Sie das Programm aus, um seine Seite hier zu sehen.
Auf dem eigenen Rechner bauen
Legen Sie einen Ordner mit diesen Startdateien an, installieren Sie React 19.3 und arbeiten Sie die Etappen ab. Die Abnahmetests starten Sie jederzeit mit:
Startdateien als eine .zip herunterladen (Startdateien und main.test.jsx)Für die Prüfungen gibt es noch keinen Befehl auf Ihrem Computer. Sie stehen in main.test.jsx, geschrieben mit node:test und React Testing Library.
Legen Sie main.jsx in einem Vite-React-Projekt (npm create vite@latest, Vorlage react) als App.jsx in src/ ab und starten Sie es mit npm run dev. React rendert es dort wie hier in <StrictMode>.
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'}
]
}
];
Abnahmetests
Das Projekt ist fertig, wenn jede Prüfung in main.test.jsx besteht. Lesen Sie sie vor dem Start: Sie sind die Spezifikation, als Code geschrieben.
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('Listet jedes Rezept in der Reihenfolge der Daten, mit Anzahl', () => {
render(<App />);
assert.deepEqual(titles(), ['Pancakes', 'Tomato soup', 'Porridge', 'Lentil curry', 'Fruit salad'], 'die Liste zeigt ' + JSON.stringify(titles()));
assert.ok(shows('5 recipes'), 'keine Zeile "5 recipes"');
});
test('Die Suche findet Titel ohne Rücksicht auf Groß- und Kleinschreibung und Leerzeichen am Rand', async () => {
const user = userEvent.setup();
render(<App />);
await user.type(screen.getByRole('searchbox', {name: 'Search'}), ' SOUP ');
assert.deepEqual(titles(), ['Tomato soup'], 'die Suche nach " SOUP " zeigt ' + JSON.stringify(titles()));
assert.ok(shows('1 recipe'), 'keine Zeile "1 recipe" bei einem Treffer');
});
test('Eine Suche ohne Treffer sagt das', async () => {
const user = userEvent.setup();
render(<App />);
await user.type(screen.getByRole('searchbox', {name: 'Search'}), 'pizza');
assert.deepEqual(titles(), [], 'die Suche nach "pizza" zeigt ' + JSON.stringify(titles()));
assert.ok(shows('No recipes match.'), 'keine Meldung "No recipes match."');
assert.ok(shows('0 recipes'), 'keine Zeile "0 recipes"');
});
test('Die Tag-Schaltflächen sind All plus jeder Tag einmal, sortiert', () => {
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'], 'die Tag-Schaltflächen sind ' + JSON.stringify(names));
});
test('Ein Tag filtert die Liste, ein zweiter Klick zeigt wieder alle', async () => {
const user = userEvent.setup();
render(<App />);
await user.click(tagButton('vegan'));
assert.deepEqual(titles(), ['Tomato soup', 'Porridge', 'Lentil curry', 'Fruit salad'], 'der Tag vegan zeigt ' + JSON.stringify(titles()));
assert.equal(tagButton('vegan').getAttribute('aria-pressed'), 'true', 'die Schaltfläche vegan ist nicht gedrückt');
assert.equal(tagButton('All').getAttribute('aria-pressed'), 'false', 'All ist noch gedrückt, obwohl vegan gewählt ist');
await user.click(tagButton('vegan'));
assert.equal(titles().length, 5, 'ein zweiter Klick auf vegan zeigt ' + titles().length + ' Rezepte');
assert.equal(tagButton('All').getAttribute('aria-pressed'), 'true', 'All ist nach dem zweiten Klick nicht gedrückt');
});
test('Suche und Tag wirken zusammen', 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" zeigt ' + JSON.stringify(titles()));
await user.click(tagButton('dinner'));
assert.deepEqual(titles(), [], 'dinner plus "pan" zeigt ' + JSON.stringify(titles()));
});
test('Ein Klick auf ein Rezept zeigt seine Details', async () => {
const user = userEvent.setup();
render(<App />);
assert.ok(screen.queryByRole('heading', {level: 2}) === null, 'vor jedem Klick wird schon ein Rezept gezeigt');
await user.click(recipeButton('Lentil curry'));
const heading = screen.queryByRole('heading', {level: 2});
assert.equal(heading?.textContent, 'Lentil curry', 'die Detailüberschrift ist ' + JSON.stringify(heading?.textContent));
assert.ok(shows('3 servings'), 'keine Zeile "3 servings"');
assert.deepEqual(ingredients(), ['250 g red lentils', '400 ml coconut milk', '2 tbsp curry paste'], 'die Zutaten sind ' + JSON.stringify(ingredients()));
});
test('Ein vom Filter verborgenes Rezept wird nicht im Detail gezeigt', 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 wird nach der Wahl von vegan noch gezeigt');
assert.ok(shows('Pick a recipe to see its ingredients.'), 'kein Hinweis "Pick a recipe to see its ingredients."');
});
test('Weniger und mehr Portionen rechnen die Mengen um', 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'), 'keine Zeile "1 serving" nach drei Klicks auf Fewer servings');
assert.deepEqual(ingredients(), ['50 g flour', '75 ml milk', '0.5 eggs', '0.25 tbsp sugar'], 'Pancakes für 1 sind ' + 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 für 3 ist ' + JSON.stringify(ingredients()));
});
test('Mengen haben höchstens zwei Nachkommastellen', 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 für 1 ist ' + JSON.stringify(ingredients()));
});
test('Die Portionen fallen nie unter 1', () => {
render(<App />);
fireEvent.click(recipeButton('Porridge'));
fireEvent.click(screen.getByRole('button', {name: 'Fewer servings'}));
assert.ok(shows('1 serving'), 'Porridge fiel unter 1 Portion');
assert.deepEqual(ingredients(), ['50 g oats', '250 ml oat milk'], 'Porridge für 1 ist ' + JSON.stringify(ingredients()));
});
test('Ein anderes Rezept beginnt mit seinen eigenen Portionen', 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 beginnt nicht mit 2 Portionen');
assert.deepEqual(ingredients(), ['800 g tomatoes', '1 onion', '2 tbsp olive oil'], 'Tomato soup ist ' + JSON.stringify(ingredients()));
});
test('Die Rezeptdaten werden nie verändert', 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, 'die Rezepte in recipes.js wurden verändert');
});
test('React gibt keine Warnungen aus', 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 während der Bedienung der App');
});
Das fertige Programm starten
npm run devReferenzlösung
Versuchen Sie zuerst die Etappen. Diese Lösung besteht alle Abnahmetests und die Typprüfung.
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'}
]
}
];
Weiterentwickeln
- Zeigen Sie auf jeder Tag-Schaltfläche die Zahl der Rezepte mit diesem Tag, beim Rendern berechnet.
- Lassen Sie die Suche auch Zutatennamen finden und heben Sie hervor, welche Zutat passte.
- Fügen Sie einen Favoriten-Tag hinzu, den man pro Rezept umschalten kann, in App als State über die Id gehalten.
- Fügen Sie eine Sortierauswahl hinzu (nach Titel oder nach Minuten), die eine Kopie der gezeigten Liste sortiert, nie recipes.js selbst.
- Verlegen Sie Suchfeld und Tag-Gruppe in eigene Komponenten und reichen Sie Werte und Handler als Props hinunter.
Projekte sind Übung: Ihre Prüfungen laufen im Browser oder auf Ihrem Rechner und zählen nie für eine Bescheinigung.
Quellen
- Thinking in React (react.dev)
- Rendering Lists: keeping list items in order with key (react.dev)
- Preserving and Resetting State (react.dev)
- Updating Arrays in State (react.dev)
- <StrictMode> (react.dev API reference)
- React Testing Library: Introduction (testing-library.com)
Zuletzt geprüft am 5. Oktober 2026
Zurück zum Kurs React