Zum Inhalt springen
aviral gupta

// B5.6 · ca. 40 Min. · Einstieg

Build: eine durchsuchbare Produkttabelle

In diesem Build bauen Sie eine Produkttabelle mit Suchfeld und Lager-Checkbox: Der State liegt in einem Elternteil, und die Eingaben ändern ihn über Props.

Lektion 6 von 6 in B5 State verwalten

Ende des Moduls

Danach können Sie

  • Eine Oberfläche in Komponenten teilen und den minimalen State benennen, den sie braucht
  • Eine Liste beim Rendern filtern, ohne auf Groß- und Kleinschreibung zu achten, und Kategoriezeilen ergänzen
  • State nach unten und Setter nach oben reichen, damit die Eingaben die Tabelle ändern
  1. Aufwärmen · Aufgabe 1 von 7

    Zum Aufwärmen aus Lektion B5.4. Sie tippen hi ins Feld und klicken dann auf Leo; das ändert name und damit den Key von Panel. Was zeigt das Feld?

    function Panel() {
      const [text, setText] = useState('');
    
      return (
        <input value={text} onChange={e => setText(e.target.value)} />
      );
    }
    
    function Notes() {
      const [name, setName] = useState('Mia');
    
      return (
        <>
          <button onClick={() => setName('Leo')}>Leo</button>
          <Panel key={name} />
        </>
      );
    }
  2. Vorhersagen · Aufgabe 2 von 7

    Sagen Sie voraus, bevor Sie weiterlesen. products ist das Array der sechs Produkte aus data.js. NameList schreibt Namen und Suchtext klein. Es bekommt den Text AP. Welche Namen listet es?

    function NameList({ products, filterText, inStockOnly }) {
      const shown = products.filter(product =>
        product.name.toLowerCase().includes(filterText.toLowerCase())
      );
    
      return (
        <ul>
          {shown.map(product => (
            <li key={product.name}>{product.name}</li>
          ))}
        </ul>
      );
    }
  3. Üben · Aufgabe 3 von 7

    Ergänzen Sie die String-Methode, sodass matches({ name: 'Apple' }, 'AP') true ergibt: Der Name wird wie der Suchtext in Kleinbuchstaben verglichen.

    function matches(product, filterText) {
      return product.name.____().includes(filterText.toLowerCase());
    }
    return product.name.().includes(filterText.toLowerCase());
  4. Üben · Aufgabe 4 von 7

    Ordnen Sie jedem Teil der Produkttabelle seine Aufgabe zu.

  5. Üben · Aufgabe 5 von 7

    Der Elternteil hält filterText im State, beginnend mit einem leeren String, und reicht nur den Wert weiter. SearchBox hat kein onChange. Sie tippen ap. Was zeigt das Feld?

    function SearchBox({ filterText }) {
      return <input type="text" value={filterText} placeholder="Search..." />;
    }
    
    function Demo() {
      const [filterText] = useState('');
    
      return <SearchBox filterText={filterText} />;
    }
  6. Denksport · Aufgabe 6 von 7

    Knobelaufgabe. NameList ist die Variante, die inStockOnly prüft, mit sechs Produkten, von denen vier auf Lager sind. Diese Checkbox liest e.target.value statt e.target.checked. Sie klicken zweimal darauf. Wie viele <li> zeigt die Liste?

    function StockFilter() {
      const [inStockOnly, setInStockOnly] = useState(false);
    
      return (
        <>
          <label>
            <input
              type="checkbox"
              checked={inStockOnly}
              onChange={e => setInStockOnly(e.target.value)}
            />
            Only show products in stock
          </label>
          <NameList products={products} filterText="" inStockOnly={inStockOnly} />
        </>
      );
    }
  7. Anwenden · Aufgabe 7 von 7

    Mini-Aufgabe: eine durchsuchbare Liste. Schreiben Sie in main.jsx eine Komponente FruitSearch mit den Früchten Apple, Banana, Cherry, Grape und Lemon. Der State enthält nur den Suchtext. Ein Suchfeld filtert die Früchte, ohne auf Groß- und Kleinschreibung zu achten, und ein <p> lautet zunächst 5 of 5 shown und nach der Eingabe von AN 1 of 5 shown. Zeigen Sie die Früchte in einer <ul> mit Keys. Führen Sie es aus und tippen Sie ins Feld.

    Prüfen Sie Ihr Ergebnis anhand dieser Liste

Selbst programmieren

Lesen Sie das ausgearbeitete Beispiel und lösen Sie dann die Übungen. Ihr Code läuft in Ihrem Browser oder auf Ihrem Computer und wird nie hochgeladen.

Ausgearbeitetes Beispiel

Die ganze durchsuchbare Tabelle, mit einem Skript als Nutzer

Bevor Sie sie in Schritten bauen, hier die fertige App aus „Thinking in React“ von react.dev, in dem eigenen Code dieser Lektion: Produkte in data.js, fünf Komponenten, zwei State-Werte in FilterableProductTable, Setter, die an SearchBar nach unten gereicht werden. Ein Lauf mountet nur, daher tippen Timer am Ende von main.jsx FRUIT ins Feld, haken dann die Checkbox an und geben nach jedem Schritt die Zeilen aus.

main.jsx

import { useState } from 'react';
import { products } from './data.js';

function ProductCategoryRow({ category }) {
  return (
    <tr>
      <th colSpan="2">{category}</th>
    </tr>
  );
}

function ProductRow({ product }) {
  const name = product.stocked ? product.name : (
    <span style={{ color: 'red' }}>{product.name}</span>
  );

  return (
    <tr>
      <td>{name}</td>
      <td>{product.price}</td>
    </tr>
  );
}

function ProductTable({ products, filterText, inStockOnly }) {
  const rows = [];
  let lastCategory = null;

  products.forEach(product => {
    if (!product.name.toLowerCase().includes(filterText.toLowerCase())) {
      return;
    }
    if (inStockOnly && !product.stocked) {
      return;
    }
    if (product.category !== lastCategory) {
      rows.push(
        <ProductCategoryRow category={product.category} key={product.category} />
      );
    }
    rows.push(<ProductRow product={product} key={product.name} />);
    lastCategory = product.category;
  });

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
        </tr>
      </thead>
      <tbody>{rows}</tbody>
    </table>
  );
}

function SearchBar({
  filterText,
  inStockOnly,
  onFilterTextChange,
  onInStockOnlyChange
}) {
  return (
    <form>
      <input
        type="text"
        value={filterText}
        placeholder="Search..."
        onChange={e => onFilterTextChange(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={inStockOnly}
          onChange={e => onInStockOnlyChange(e.target.checked)}
        />
        {' '}Only show products in stock
      </label>
    </form>
  );
}

function FilterableProductTable({ products }) {
  const [filterText, setFilterText] = useState('');
  const [inStockOnly, setInStockOnly] = useState(false);

  return (
    <div>
      <SearchBar
        filterText={filterText}
        inStockOnly={inStockOnly}
        onFilterTextChange={setFilterText}
        onInStockOnlyChange={setInStockOnly}
      />
      <ProductTable
        products={products}
        filterText={filterText}
        inStockOnly={inStockOnly}
      />
    </div>
  );
}

export default function App() {
  return <FilterableProductTable products={products} />;
}

// A run only mounts. Act like a user: type FRUIT, then check the box.
const rows = () =>
  [...document.querySelectorAll('tbody tr')].map(tr => tr.textContent).join(' | ');

setTimeout(() => {
  const input = document.querySelector('input[type="text"]');
  const setValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value').set;
  setValue.call(input, 'FRUIT');
  input.dispatchEvent(new input.ownerDocument.defaultView.Event('input', { bubbles: true }));
}, 50);
setTimeout(() => console.log('FRUIT:', rows()), 100);
setTimeout(() => document.querySelector('input[type="checkbox"]').click(), 150);
setTimeout(() => console.log('FRUIT + in stock:', rows()), 200);

data.js

export const products = [
  { category: 'Fruits', price: '$1', stocked: true, name: 'Apple' },
  { category: 'Fruits', price: '$1', stocked: true, name: 'Dragonfruit' },
  { category: 'Fruits', price: '$2', stocked: false, name: 'Passionfruit' },
  { category: 'Vegetables', price: '$2', stocked: true, name: 'Spinach' },
  { category: 'Vegetables', price: '$4', stocked: false, name: 'Pumpkin' },
  { category: 'Vegetables', price: '$1', stocked: true, name: 'Peas' }
];

Ausführen mit

npm run dev

Ausgabe

FRUIT: Fruits | Dragonfruit$1 | Passionfruit$2
FRUIT + in stock: Fruits | Dragonfruit$1
<!-- #root -->
<div id="root"><div><form><input placeholder="Search..." type="text" value="FRUIT"><label><input type="checkbox"> Only show products in stock</label></form><table><thead><tr><th>Name</th><th>Price</th></tr></thead><tbody><tr><th colspan="2">Fruits</th></tr><tr><td>Dragonfruit</td><td>$1</td></tr></tbody></table></div></div>
  • FRUIT passte auf Dragonfruit und Passionfruit, obwohl deren Namen fruit kleingeschrieben enthalten: Beide Seiten werden kleingeschrieben.
  • Das Anhaken der Checkbox entfernte Passionfruit, das nicht auf Lager ist. Die Überschrift Vegetables verschwand schon im ersten Schritt, weil kein Gemüse passte.
  • Nichts speichert die gefilterten Zeilen: ProductTable baut sie bei jedem Rendern neu aus den Produkten und den beiden State-Werten.
  • Die Übungen bauen diese App in drei Schritten: filtern, die Eingaben verdrahten, die Kategoriezeilen ergänzen.
Ändern und ausführen

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.

Übungen

Übung 1 von 3

Die Tabelle filtern

Die statische Version funktioniert und der State ist verdrahtet: FilterableProductTable besitzt filterText und inStockOnly, und SearchBar aktualisiert sie. Aber ProductTable ignoriert beide Props und zeigt immer alle sechs Produkte. Lassen Sie es nur die Produkte zeigen, deren Name filterText enthält, ohne auf Groß- und Kleinschreibung zu achten, und, wenn inStockOnly true ist, nur die Produkte auf Lager. Beide Regeln gelten zusammen. Berechnen Sie die Zeilen beim Rendern; fügen Sie keinen State hinzu.

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.

Hinweise
  1. Hinweis 1

    Brechen Sie in products.forEach mit return früh ab, wenn ein Produkt nicht erscheinen soll, vor rows.push.

  2. Hinweis 2

    Vergleichen Sie klein mit klein: product.name.toLowerCase().includes(filterText.toLowerCase()).

  3. Hinweis 3

    if (inStockOnly && !product.stocked) return; überspringt die Produkte ohne Lagerbestand, wenn die Checkbox angehakt ist.

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

import { useState } from 'react';
import { products } from './data.js';

function ProductRow({ product }) {
  const name = product.stocked ? product.name : (
    <span style={{ color: 'red' }}>{product.name}</span>
  );

  return (
    <tr>
      <td>{name}</td>
      <td>{product.price}</td>
    </tr>
  );
}

function ProductTable({ products, filterText, inStockOnly }) {
  const rows = [];

  products.forEach(product => {
    if (!product.name.toLowerCase().includes(filterText.toLowerCase())) {
      return;
    }
    if (inStockOnly && !product.stocked) {
      return;
    }
    rows.push(<ProductRow product={product} key={product.name} />);
  });

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
        </tr>
      </thead>
      <tbody>{rows}</tbody>
    </table>
  );
}

function SearchBar({
  filterText,
  inStockOnly,
  onFilterTextChange,
  onInStockOnlyChange
}) {
  return (
    <form>
      <input
        type="text"
        value={filterText}
        placeholder="Search..."
        onChange={e => onFilterTextChange(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={inStockOnly}
          onChange={e => onInStockOnlyChange(e.target.checked)}
        />
        {' '}Only show products in stock
      </label>
    </form>
  );
}

function FilterableProductTable({ products }) {
  const [filterText, setFilterText] = useState('');
  const [inStockOnly, setInStockOnly] = useState(false);

  return (
    <div>
      <SearchBar
        filterText={filterText}
        inStockOnly={inStockOnly}
        onFilterTextChange={setFilterText}
        onInStockOnlyChange={setInStockOnly}
      />
      <ProductTable
        products={products}
        filterText={filterText}
        inStockOnly={inStockOnly}
      />
    </div>
  );
}

export default function App() {
  return <FilterableProductTable products={products} />;
}
Auf dem eigenen Computer ausführen

Installieren Sie React 19.3 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.jsx

import { useState } from 'react';
import { products } from './data.js';

function ProductRow({ product }) {
  const name = product.stocked ? product.name : (
    <span style={{ color: 'red' }}>{product.name}</span>
  );

  return (
    <tr>
      <td>{name}</td>
      <td>{product.price}</td>
    </tr>
  );
}

function ProductTable({ products, filterText, inStockOnly }) {
  // TODO: show only the products that match filterText and inStockOnly.
  const rows = [];

  products.forEach(product => {
    rows.push(<ProductRow product={product} key={product.name} />);
  });

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
        </tr>
      </thead>
      <tbody>{rows}</tbody>
    </table>
  );
}

function SearchBar({
  filterText,
  inStockOnly,
  onFilterTextChange,
  onInStockOnlyChange
}) {
  return (
    <form>
      <input
        type="text"
        value={filterText}
        placeholder="Search..."
        onChange={e => onFilterTextChange(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={inStockOnly}
          onChange={e => onInStockOnlyChange(e.target.checked)}
        />
        {' '}Only show products in stock
      </label>
    </form>
  );
}

function FilterableProductTable({ products }) {
  const [filterText, setFilterText] = useState('');
  const [inStockOnly, setInStockOnly] = useState(false);

  return (
    <div>
      <SearchBar
        filterText={filterText}
        inStockOnly={inStockOnly}
        onFilterTextChange={setFilterText}
        onInStockOnlyChange={setInStockOnly}
      />
      <ProductTable
        products={products}
        filterText={filterText}
        inStockOnly={inStockOnly}
      />
    </div>
  );
}

export default function App() {
  return <FilterableProductTable products={products} />;
}

main.test.jsx

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

// Every row's text: "NamePrice" for the header, "Apple$1" for a product, "Fruits" for a category.
const rowTexts = () => screen.getAllByRole('row').map((tr) => tr.textContent);
const productTexts = () => rowTexts().slice(1);
const search = (value) => fireEvent.change(screen.getByRole('textbox'), {target: {value}});
const box = () => screen.getByRole('checkbox', {name: 'Only show products in stock'});
const toggle = () => fireEvent.click(box());

test('Zunächst erscheinen alle sechs Produkte', () => {
  render(<App />);
  assert.deepEqual(productTexts(), ['Apple$1', 'Dragonfruit$1', 'Passionfruit$2', 'Spinach$2', 'Pumpkin$4', 'Peas$1'], 'zu Beginn lauten die Zeilen ' + JSON.stringify(productTexts()));
});

test('Der Suchtext filtert nach einem Teil des Namens', () => {
  render(<App />);
  search('fruit');
  assert.deepEqual(productTexts(), ['Dragonfruit$1', 'Passionfruit$2'], 'nach der Eingabe von fruit lauten die Zeilen ' + JSON.stringify(productTexts()));
});

test('Die Suche ignoriert Groß- und Kleinschreibung', () => {
  render(<App />);
  search('PEA');
  assert.deepEqual(productTexts(), ['Peas$1'], 'nach der Eingabe von PEA lauten die Zeilen ' + JSON.stringify(productTexts()));
});

test('Die Checkbox blendet Produkte aus, die nicht auf Lager sind', () => {
  render(<App />);
  toggle();
  assert.deepEqual(productTexts(), ['Apple$1', 'Dragonfruit$1', 'Spinach$2', 'Peas$1'], 'bei angehakter Checkbox lauten die Zeilen ' + JSON.stringify(productTexts()));
  toggle();
  assert.deepEqual(productTexts(), ['Apple$1', 'Dragonfruit$1', 'Passionfruit$2', 'Spinach$2', 'Pumpkin$4', 'Peas$1'], 'nach dem Abhaken der Checkbox lauten die Zeilen ' + JSON.stringify(productTexts()));
});

test('Suchtext und Checkbox wirken zusammen', () => {
  render(<App />);
  search('fruit');
  toggle();
  assert.deepEqual(productTexts(), ['Dragonfruit$1'], 'mit fruit und angehakter Checkbox lauten die Zeilen ' + JSON.stringify(productTexts()));
});

data.js

export const products = [
  { category: 'Fruits', price: '$1', stocked: true, name: 'Apple' },
  { category: 'Fruits', price: '$1', stocked: true, name: 'Dragonfruit' },
  { category: 'Fruits', price: '$2', stocked: false, name: 'Passionfruit' },
  { category: 'Vegetables', price: '$2', stocked: true, name: 'Spinach' },
  { category: 'Vegetables', price: '$4', stocked: false, name: 'Pumpkin' },
  { category: 'Vegetables', price: '$1', stocked: true, name: 'Peas' }
];

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>.

Programm ausführen:

npm run dev

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.

Übung 2 von 3

Die Eingaben verdrahten (inverser Datenfluss)

ProductTable filtert richtig, aber Tippen und Klicken bewirken nichts: SearchBar rendert Feld und Checkbox aus seinen Props und kann den State, den FilterableProductTable besitzt, nicht ändern. Reichen Sie die Setter als onFilterTextChange und onInStockOnlyChange nach unten und rufen Sie sie aus den onChange-Handlern der beiden Eingaben auf: Das Feld meldet e.target.value, die Checkbox e.target.checked. Lassen Sie den State im Elternteil.

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.

Hinweise
  1. Hinweis 1

    Ergänzen Sie in FilterableProductTable bei <SearchBar /> onFilterTextChange={setFilterText} und onInStockOnlyChange={setInStockOnly}.

  2. Hinweis 2

    Nehmen Sie in SearchBar die beiden Funktionen als Props an, neben filterText und inStockOnly.

  3. Hinweis 3

    Textfeld: onChange={e => onFilterTextChange(e.target.value)}. Checkbox: onChange={e => onInStockOnlyChange(e.target.checked)}.

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

import { useState } from 'react';
import { products } from './data.js';

function ProductRow({ product }) {
  const name = product.stocked ? product.name : (
    <span style={{ color: 'red' }}>{product.name}</span>
  );

  return (
    <tr>
      <td>{name}</td>
      <td>{product.price}</td>
    </tr>
  );
}

function ProductTable({ products, filterText, inStockOnly }) {
  const rows = [];

  products.forEach(product => {
    if (!product.name.toLowerCase().includes(filterText.toLowerCase())) {
      return;
    }
    if (inStockOnly && !product.stocked) {
      return;
    }
    rows.push(<ProductRow product={product} key={product.name} />);
  });

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
        </tr>
      </thead>
      <tbody>{rows}</tbody>
    </table>
  );
}

function SearchBar({
  filterText,
  inStockOnly,
  onFilterTextChange,
  onInStockOnlyChange
}) {
  return (
    <form>
      <input
        type="text"
        value={filterText}
        placeholder="Search..."
        onChange={e => onFilterTextChange(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={inStockOnly}
          onChange={e => onInStockOnlyChange(e.target.checked)}
        />
        {' '}Only show products in stock
      </label>
    </form>
  );
}

function FilterableProductTable({ products }) {
  const [filterText, setFilterText] = useState('');
  const [inStockOnly, setInStockOnly] = useState(false);

  return (
    <div>
      <SearchBar
        filterText={filterText}
        inStockOnly={inStockOnly}
        onFilterTextChange={setFilterText}
        onInStockOnlyChange={setInStockOnly}
      />
      <ProductTable
        products={products}
        filterText={filterText}
        inStockOnly={inStockOnly}
      />
    </div>
  );
}

export default function App() {
  return <FilterableProductTable products={products} />;
}
Auf dem eigenen Computer ausführen

Installieren Sie React 19.3 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.jsx

import { useState } from 'react';
import { products } from './data.js';

function ProductRow({ product }) {
  const name = product.stocked ? product.name : (
    <span style={{ color: 'red' }}>{product.name}</span>
  );

  return (
    <tr>
      <td>{name}</td>
      <td>{product.price}</td>
    </tr>
  );
}

function ProductTable({ products, filterText, inStockOnly }) {
  const rows = [];

  products.forEach(product => {
    if (!product.name.toLowerCase().includes(filterText.toLowerCase())) {
      return;
    }
    if (inStockOnly && !product.stocked) {
      return;
    }
    rows.push(<ProductRow product={product} key={product.name} />);
  });

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
        </tr>
      </thead>
      <tbody>{rows}</tbody>
    </table>
  );
}

// TODO: the box and the checkbox ignore the user. Call the parent when they change.
function SearchBar({ filterText, inStockOnly }) {
  return (
    <form>
      <input type="text" value={filterText} placeholder="Search..." />
      <label>
        <input type="checkbox" checked={inStockOnly} />
        {' '}Only show products in stock
      </label>
    </form>
  );
}

function FilterableProductTable({ products }) {
  const [filterText, setFilterText] = useState('');
  const [inStockOnly, setInStockOnly] = useState(false);

  return (
    <div>
      <SearchBar filterText={filterText} inStockOnly={inStockOnly} />
      <ProductTable
        products={products}
        filterText={filterText}
        inStockOnly={inStockOnly}
      />
    </div>
  );
}

export default function App() {
  return <FilterableProductTable products={products} />;
}

main.test.jsx

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

// Every row's text: "NamePrice" for the header, "Apple$1" for a product, "Fruits" for a category.
const rowTexts = () => screen.getAllByRole('row').map((tr) => tr.textContent);
const productTexts = () => rowTexts().slice(1);
const search = (value) => fireEvent.change(screen.getByRole('textbox'), {target: {value}});
const box = () => screen.getByRole('checkbox', {name: 'Only show products in stock'});
const toggle = () => fireEvent.click(box());

test('Tippen ins Feld ändert die Tabelle', () => {
  render(<App />);
  search('spin');
  assert.deepEqual(productTexts(), ['Spinach$2'], 'nach der Eingabe von spin lauten die Zeilen ' + JSON.stringify(productTexts()));
});

test('Das Feld zeigt, was getippt wurde', () => {
  render(<App />);
  search('fruit');
  assert.ok(screen.getByRole('textbox').value === 'fruit', 'das Feld zeigt ' + JSON.stringify(screen.getByRole('textbox').value) + ' statt fruit');
});

test('Leeren des Felds zeigt wieder alle Produkte', () => {
  render(<App />);
  search('fruit');
  search('');
  assert.deepEqual(productTexts(), ['Apple$1', 'Dragonfruit$1', 'Passionfruit$2', 'Spinach$2', 'Pumpkin$4', 'Peas$1'], 'nach dem Leeren des Felds lauten die Zeilen ' + JSON.stringify(productTexts()));
});

test('Anhaken der Checkbox filtert, und sie bleibt angehakt', () => {
  render(<App />);
  toggle();
  assert.ok(box().checked === true, 'die Checkbox ist nach einem Klick nicht angehakt');
  assert.deepEqual(productTexts(), ['Apple$1', 'Dragonfruit$1', 'Spinach$2', 'Peas$1'], 'bei angehakter Checkbox lauten die Zeilen ' + JSON.stringify(productTexts()));
});

data.js

export const products = [
  { category: 'Fruits', price: '$1', stocked: true, name: 'Apple' },
  { category: 'Fruits', price: '$1', stocked: true, name: 'Dragonfruit' },
  { category: 'Fruits', price: '$2', stocked: false, name: 'Passionfruit' },
  { category: 'Vegetables', price: '$2', stocked: true, name: 'Spinach' },
  { category: 'Vegetables', price: '$4', stocked: false, name: 'Pumpkin' },
  { category: 'Vegetables', price: '$1', stocked: true, name: 'Peas' }
];

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>.

Programm ausführen:

npm run dev

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.

Übung 3 von 3

Die Kategoriezeilen ergänzen

Die Tabelle listet nur Produkte. Ergänzen Sie vor dem ersten Produkt jeder Kategorie eine Überschriftszeile, ein <tr> mit einem <th colSpan="2"> für den Kategorienamen, sodass Fruits und Vegetables je einmal erscheinen. Eine Kategorie ohne passendes Produkt darf keine Überschrift zeigen, egal welcher Suchtext oder welche Checkbox gilt. Bauen Sie sie in einer Komponente ProductCategoryRow und geben Sie jeder Überschriftszeile einen Key. Die Produkte sind schon nach Kategorie sortiert.

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.

Hinweise
  1. Hinweis 1

    Schreiben Sie eine Komponente ProductCategoryRow, die <tr><th colSpan="2">{category}</th></tr> zurückgibt.

  2. Hinweis 2

    Merken Sie sich in ProductTable die Kategorie des zuletzt hinzugefügten Produkts: let lastCategory = null.

  3. Hinweis 3

    Fügen Sie nach den beiden frühen returns bei if (product.category !== lastCategory) eine Überschriftszeile hinzu; dann das Produkt, und setzen Sie lastCategory.

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

import { useState } from 'react';
import { products } from './data.js';

function ProductCategoryRow({ category }) {
  return (
    <tr>
      <th colSpan="2">{category}</th>
    </tr>
  );
}

function ProductRow({ product }) {
  const name = product.stocked ? product.name : (
    <span style={{ color: 'red' }}>{product.name}</span>
  );

  return (
    <tr>
      <td>{name}</td>
      <td>{product.price}</td>
    </tr>
  );
}

function ProductTable({ products, filterText, inStockOnly }) {
  const rows = [];
  let lastCategory = null;

  products.forEach(product => {
    if (!product.name.toLowerCase().includes(filterText.toLowerCase())) {
      return;
    }
    if (inStockOnly && !product.stocked) {
      return;
    }
    if (product.category !== lastCategory) {
      rows.push(
        <ProductCategoryRow category={product.category} key={product.category} />
      );
    }
    rows.push(<ProductRow product={product} key={product.name} />);
    lastCategory = product.category;
  });

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
        </tr>
      </thead>
      <tbody>{rows}</tbody>
    </table>
  );
}

function SearchBar({
  filterText,
  inStockOnly,
  onFilterTextChange,
  onInStockOnlyChange
}) {
  return (
    <form>
      <input
        type="text"
        value={filterText}
        placeholder="Search..."
        onChange={e => onFilterTextChange(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={inStockOnly}
          onChange={e => onInStockOnlyChange(e.target.checked)}
        />
        {' '}Only show products in stock
      </label>
    </form>
  );
}

function FilterableProductTable({ products }) {
  const [filterText, setFilterText] = useState('');
  const [inStockOnly, setInStockOnly] = useState(false);

  return (
    <div>
      <SearchBar
        filterText={filterText}
        inStockOnly={inStockOnly}
        onFilterTextChange={setFilterText}
        onInStockOnlyChange={setInStockOnly}
      />
      <ProductTable
        products={products}
        filterText={filterText}
        inStockOnly={inStockOnly}
      />
    </div>
  );
}

export default function App() {
  return <FilterableProductTable products={products} />;
}
Auf dem eigenen Computer ausführen

Installieren Sie React 19.3 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.jsx

import { useState } from 'react';
import { products } from './data.js';

function ProductRow({ product }) {
  const name = product.stocked ? product.name : (
    <span style={{ color: 'red' }}>{product.name}</span>
  );

  return (
    <tr>
      <td>{name}</td>
      <td>{product.price}</td>
    </tr>
  );
}

function ProductTable({ products, filterText, inStockOnly }) {
  const rows = [];

  products.forEach(product => {
    if (!product.name.toLowerCase().includes(filterText.toLowerCase())) {
      return;
    }
    if (inStockOnly && !product.stocked) {
      return;
    }
    rows.push(<ProductRow product={product} key={product.name} />);
  });

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
        </tr>
      </thead>
      <tbody>{rows}</tbody>
    </table>
  );
}

function SearchBar({
  filterText,
  inStockOnly,
  onFilterTextChange,
  onInStockOnlyChange
}) {
  return (
    <form>
      <input
        type="text"
        value={filterText}
        placeholder="Search..."
        onChange={e => onFilterTextChange(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={inStockOnly}
          onChange={e => onInStockOnlyChange(e.target.checked)}
        />
        {' '}Only show products in stock
      </label>
    </form>
  );
}

function FilterableProductTable({ products }) {
  const [filterText, setFilterText] = useState('');
  const [inStockOnly, setInStockOnly] = useState(false);

  return (
    <div>
      <SearchBar
        filterText={filterText}
        inStockOnly={inStockOnly}
        onFilterTextChange={setFilterText}
        onInStockOnlyChange={setInStockOnly}
      />
      <ProductTable
        products={products}
        filterText={filterText}
        inStockOnly={inStockOnly}
      />
    </div>
  );
}

export default function App() {
  return <FilterableProductTable products={products} />;
}

main.test.jsx

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

// Every row's text: "NamePrice" for the header, "Apple$1" for a product, "Fruits" for a category.
const rowTexts = () => screen.getAllByRole('row').map((tr) => tr.textContent);
const productTexts = () => rowTexts().slice(1);
const search = (value) => fireEvent.change(screen.getByRole('textbox'), {target: {value}});
const box = () => screen.getByRole('checkbox', {name: 'Only show products in stock'});
const toggle = () => fireEvent.click(box());

test('Jede Kategorie bekommt eine Überschriftszeile über ihren Produkten', () => {
  render(<App />);
  assert.deepEqual(
    rowTexts(),
    ['NamePrice', 'Fruits', 'Apple$1', 'Dragonfruit$1', 'Passionfruit$2', 'Vegetables', 'Spinach$2', 'Pumpkin$4', 'Peas$1'],
    'die Zeilen lauten ' + JSON.stringify(rowTexts())
  );
});

test('Eine Kategorie ohne passende Produkte verliert ihre Überschrift', () => {
  render(<App />);
  search('pea');
  assert.deepEqual(rowTexts(), ['NamePrice', 'Vegetables', 'Peas$1'], 'nach der Eingabe von pea lauten die Zeilen ' + JSON.stringify(rowTexts()));
});

test('Die Checkbox behält die Überschriften der Kategorien, die noch Produkte haben', () => {
  render(<App />);
  toggle();
  assert.deepEqual(
    rowTexts(),
    ['NamePrice', 'Fruits', 'Apple$1', 'Dragonfruit$1', 'Vegetables', 'Spinach$2', 'Peas$1'],
    'bei angehakter Checkbox lauten die Zeilen ' + JSON.stringify(rowTexts())
  );
});

test('Ohne Treffer bleibt nur die Kopfzeile', () => {
  render(<App />);
  search('zzz');
  assert.deepEqual(rowTexts(), ['NamePrice'], 'nach der Eingabe von zzz lauten die Zeilen ' + JSON.stringify(rowTexts()));
});

data.js

export const products = [
  { category: 'Fruits', price: '$1', stocked: true, name: 'Apple' },
  { category: 'Fruits', price: '$1', stocked: true, name: 'Dragonfruit' },
  { category: 'Fruits', price: '$2', stocked: false, name: 'Passionfruit' },
  { category: 'Vegetables', price: '$2', stocked: true, name: 'Spinach' },
  { category: 'Vegetables', price: '$4', stocked: false, name: 'Pumpkin' },
  { category: 'Vegetables', price: '$1', stocked: true, name: 'Peas' }
];

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>.

Programm ausführen:

npm run dev

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.

Häufige Fehler

Ein value ohne onChange

import { useState } from 'react';

export default function Search() {
  const [filterText, setFilterText] = useState('');

  return (
    <input type="text" value={filterText} placeholder="Search..." />
  );
}

Was React ausgibt

You provided a `value` prop to a form field without an `onChange` handler.

Warum, und die Lösung

Das Eingabefeld wird von filterText kontrolliert, und nichts ändert den State, also warnt React, und das Feld ignoriert Tippen. Rufen Sie den Elternteil auf: Reichen Sie onFilterTextChange={setFilterText} nach unten und schreiben Sie onChange={e => onFilterTextChange(e.target.value)}. Soll sich das Feld nie ändern, beendet readOnly die Warnung.

Zeilen ohne Key

const products = [
  { category: 'Fruits', name: 'Apple' },
  { category: 'Fruits', name: 'Dragonfruit' }
];

export default function ProductTable() {
  const rows = [];

  products.forEach(product => {
    rows.push(
      <tr>
        <td>{product.name}</td>
      </tr>
    );
  });

  return (
    <table>
      <tbody>{rows}</tbody>
    </table>
  );
}

Was React ausgibt

Each child in a list should have a unique "key" prop.

Warum, und die Lösung

Zeilen, die in ein Array gepusht werden, brauchen Keys, genau wie Zeilen aus map. Geben Sie jeder einen, der beim Eintrag bleibt: <tr key={product.name}>, oder key={product.category} bei einer Überschriftszeile. Ein Key aus dem Index würde verrutschen, wenn der Filter darüberliegende Zeilen entfernt.

Ein Feld lesen, das die Produkte nicht haben

const products = [
  { category: 'Fruits', name: 'Apple' },
  { category: 'Fruits', name: 'Dragonfruit' }
];

export default function ProductList({ filterText = '' }) {
  const shown = products.filter(product =>
    product.title.toLowerCase().includes(filterText.toLowerCase())
  );

  return (
    <ul>
      {shown.map(product => (
        <li key={product.name}>{product.name}</li>
      ))}
    </ul>
  );
}

Was React ausgibt

TypeError: Cannot read properties of undefined (reading 'toLowerCase')

Warum, und die Lösung

Die Produkte haben einen name, keinen title, also ist product.title undefined, und das erste Rendern wirft beim Aufruf von toLowerCase einen Fehler (Wortlaut von Chrome und Node; Firefox und Safari formulieren ihn anders). Lesen Sie das Feld, das die Daten haben: product.name.toLowerCase().

React im Browser: React 19.3.0 (Entwicklungs-Build) und React Testing Library, MIT, von dieser Website ausgeliefert; JSX übersetzt TypeScript 6.0.3, Apache-2.0. Lizenz und Quellcode

Abschlussquiz

5 Fragen, ohne Hinweise. Ab 80 % ist die Lektion abgeschlossen.

Erledigen Sie zuerst alle Aufgaben oben, um das Abschlussquiz freizuschalten.

Problem melden

Etwas ist falsch oder unklar? Beschreiben Sie es kurz, dann wird es geprüft und korrigiert.

#

Mindestens 20 Zeichen.

Nur, wenn Sie eine Antwort wünschen.

Kernideen

Erst die Komponenten, dann der minimale State

Zeichnen Sie um jeden Teil des Entwurfs einen Kasten und benennen Sie ihn: FilterableProductTable enthält alles, SearchBar nimmt die Eingabe entgegen, ProductTable filtert und listet, ProductCategoryRow zeigt eine Überschrift, ProductRow zeigt ein Produkt. Bauen Sie zuerst eine statische Version, mit Props und ohne State: Das bedeutet viel Tippen und wenig Denken. Fragen Sie dann, welche Daten sich über die Zeit ändern. Die Produktliste kommt als Prop an, ist also kein State. Der Suchtext und der Wert der Checkbox ändern sich und lassen sich nicht berechnen, sind also State. Die gefilterte Liste lässt sich aus diesen dreien berechnen, ist also kein State.

Der State liegt im gemeinsamen Elternteil

SearchBar zeigt filterText und inStockOnly, und ProductTable filtert danach. Der nächste gemeinsame Elternteil beider ist FilterableProductTable; er besitzt die beiden useState-Aufrufe und reicht die Werte als Props nach unten. ProductTable baut die Zeilen dann beim Rendern: Ein Produkt wird übersprungen, wenn sein kleingeschriebener Name den kleingeschriebenen filterText nicht enthält, oder wenn inStockOnly an ist und das Produkt nicht auf Lager ist. Nichts speichert die gefilterte Liste, sie kann also nicht aus dem Takt geraten. Kategoriezeilen funktionieren ebenso: Eine Überschrift kommt dazu, sobald sich die Kategorie von der zuletzt gezeigten unterscheidet.

Inverser Datenfluss: Setter gehen als Props nach unten

Daten fließen nach unten, aber ein getippter Buchstabe beginnt in SearchBar und muss den Elternteil erreichen. React macht das ausdrücklich. Ein kontrolliertes Eingabefeld, value={filterText}, ignoriert Tippen, bis sich der State ändert. Also reicht der Elternteil auch seine Setter nach unten, onFilterTextChange={setFilterText}, und SearchBar ruft sie aus onChange auf: onChange={e => onFilterTextChange(e.target.value)}. Eine Checkbox meldet e.target.checked, nicht e.target.value. Nachdem der Elternteil den State gesetzt hat, rendert er neu, und die neuen Props erreichen Feld und Tabelle.

Quellen

Zuletzt geprüft am 5. Oktober 2026