Skip to content
aviral gupta

// B5.6 · ~40 min · Beginner

Build: a searchable product table

In this build you make a product table with a search box and an in-stock checkbox: the state lives in one parent, and the inputs change it through props.

Lesson 6 of 6 in B5 Managing state

End of the module

You will be able to

  • Split a UI into components and name the minimal state it needs
  • Filter a list while rendering, ignoring case, and add category rows
  • Pass state down and setters up, so the inputs change the table
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B5.4. You type hi into the box, then click Leo, which changes name and so changes the key of Panel. What does the box show?

    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. Predict · Activity 2 of 7

    Predict before you read on. products is the array of six products from data.js. NameList lowercases the name and the search text. It gets the text AP. Which names does it list?

    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. Practice · Activity 3 of 7

    Fill in the string method, so that matches({ name: 'Apple' }, 'AP') is true: the name is compared in lowercase, like the search text.

    function matches(product, filterText) {
      return product.name.____().includes(filterText.toLowerCase());
    }
    return product.name.().includes(filterText.toLowerCase());
  4. Practice · Activity 4 of 7

    Match each piece of the product table with its job.

  5. Practice · Activity 5 of 7

    The parent keeps filterText in state, starting as an empty string, and passes only the value. SearchBox has no onChange. You type ap. What does the box show?

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

    Brain teaser. NameList is the one that checks inStockOnly, with six products of which four are in stock. This checkbox reads e.target.value instead of e.target.checked. You click it twice. How many <li> does the list show?

    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. Apply · Activity 7 of 7

    Mini-task: a searchable list. In main.jsx, write a FruitSearch component with the fruits Apple, Banana, Cherry, Grape and Lemon. State holds the search text only. A search box filters the fruits, ignoring case, and a <p> reads 5 of 5 shown at first and 1 of 5 shown after typing AN. Show the fruits in a <ul> with keys. Run it and type into the box.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The whole searchable table, with a scripted user

Before you build it in steps, here is the finished app of react.dev's "Thinking in React", in this lesson's own code: products in data.js, five components, two pieces of state in FilterableProductTable, setters passed down to SearchBar. A run only mounts, so timers at the bottom of main.jsx type FRUIT into the box, then check the box, and print the rows after each step.

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' }
];

Run it with

npm run dev

Output

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 matched Dragonfruit and Passionfruit although their names are written fruit: both sides are lowercased.
  • Checking the box removed Passionfruit, which is not in stock. The Vegetables heading disappeared in the first step, because no vegetable matched.
  • Nothing stores the filtered rows: ProductTable builds them again on every render from the products and the two state values.
  • The exercises build this app in three steps: filter, wire the inputs, add the category rows.
Change it and run it

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.

Exercises

Exercise 1 of 3

Filter the table

The static version works and the state is wired: FilterableProductTable owns filterText and inStockOnly, and SearchBar updates them. But ProductTable ignores both props and always shows all six products. Make it show only the products whose name includes filterText, ignoring upper and lower case, and, when inStockOnly is true, only the products that are stocked. Both rules apply together. Compute the rows while rendering; do not add state.

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.

Hints
  1. Hint 1

    Inside products.forEach, return early for a product that must not show, before rows.push.

  2. Hint 2

    Compare lowercase with lowercase: product.name.toLowerCase().includes(filterText.toLowerCase()).

  3. Hint 3

    If (inStockOnly && !product.stocked) return; skips the unstocked products when the box is checked.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

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} />;
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

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('All six products show at first', () => {
  render(<App />);
  assert.deepEqual(productTexts(), ['Apple$1', 'Dragonfruit$1', 'Passionfruit$2', 'Spinach$2', 'Pumpkin$4', 'Peas$1'], 'at first the rows are ' + JSON.stringify(productTexts()));
});

test('The search text filters by part of the name', () => {
  render(<App />);
  search('fruit');
  assert.deepEqual(productTexts(), ['Dragonfruit$1', 'Passionfruit$2'], 'after typing fruit the rows are ' + JSON.stringify(productTexts()));
});

test('The search ignores upper and lower case', () => {
  render(<App />);
  search('PEA');
  assert.deepEqual(productTexts(), ['Peas$1'], 'after typing PEA the rows are ' + JSON.stringify(productTexts()));
});

test('The checkbox hides products that are not in stock', () => {
  render(<App />);
  toggle();
  assert.deepEqual(productTexts(), ['Apple$1', 'Dragonfruit$1', 'Spinach$2', 'Peas$1'], 'with the box checked the rows are ' + JSON.stringify(productTexts()));
  toggle();
  assert.deepEqual(productTexts(), ['Apple$1', 'Dragonfruit$1', 'Passionfruit$2', 'Spinach$2', 'Pumpkin$4', 'Peas$1'], 'after unchecking the box the rows are ' + JSON.stringify(productTexts()));
});

test('The search text and the checkbox work together', () => {
  render(<App />);
  search('fruit');
  toggle();
  assert.deepEqual(productTexts(), ['Dragonfruit$1'], 'with fruit and the box checked the rows are ' + 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' }
];

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.

Run the program:

npm run dev

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.

Exercise 2 of 3

Wire the inputs (inverse data flow)

ProductTable filters correctly, but typing and clicking do nothing: SearchBar renders the box and the checkbox from its props and cannot change the state that FilterableProductTable owns. Pass the setters down as onFilterTextChange and onInStockOnlyChange, and call them from the onChange handlers of the two inputs: the box reports e.target.value, the checkbox e.target.checked. Keep the state in the parent.

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.

Hints
  1. Hint 1

    In FilterableProductTable, add onFilterTextChange={setFilterText} and onInStockOnlyChange={setInStockOnly} to <SearchBar />.

  2. Hint 2

    In SearchBar, accept the two functions as props, next to filterText and inStockOnly.

  3. Hint 3

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

Show a solution

One way to solve it. Yours can look different and still pass the checks.

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} />;
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

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('Typing in the box changes the table', () => {
  render(<App />);
  search('spin');
  assert.deepEqual(productTexts(), ['Spinach$2'], 'after typing spin the rows are ' + JSON.stringify(productTexts()));
});

test('The box shows what was typed', () => {
  render(<App />);
  search('fruit');
  assert.ok(screen.getByRole('textbox').value === 'fruit', 'the box shows ' + JSON.stringify(screen.getByRole('textbox').value) + ' instead of fruit');
});

test('Clearing the box shows all products again', () => {
  render(<App />);
  search('fruit');
  search('');
  assert.deepEqual(productTexts(), ['Apple$1', 'Dragonfruit$1', 'Passionfruit$2', 'Spinach$2', 'Pumpkin$4', 'Peas$1'], 'after clearing the box the rows are ' + JSON.stringify(productTexts()));
});

test('Checking the box filters, and the box stays checked', () => {
  render(<App />);
  toggle();
  assert.ok(box().checked === true, 'the checkbox is not checked after a click');
  assert.deepEqual(productTexts(), ['Apple$1', 'Dragonfruit$1', 'Spinach$2', 'Peas$1'], 'with the box checked the rows are ' + 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' }
];

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.

Run the program:

npm run dev

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.

Exercise 3 of 3

Add the category rows

The table lists products only. Add a heading row, a <tr> with one <th colSpan="2"> holding the category name, in front of the first product of each category, so that Fruits and Vegetables show once each. A category with no matching product must show no heading, whatever the search text or the checkbox. Build it in a ProductCategoryRow component and give each heading row a key. The products are sorted by category already.

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.

Hints
  1. Hint 1

    Write a ProductCategoryRow component that returns <tr><th colSpan="2">{category}</th></tr>.

  2. Hint 2

    In ProductTable, remember the category of the last product you added: let lastCategory = null.

  3. Hint 3

    After the two early returns, if (product.category !== lastCategory) push a heading row; then push the product and set lastCategory.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

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} />;
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

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('Each category gets one heading row above its products', () => {
  render(<App />);
  assert.deepEqual(
    rowTexts(),
    ['NamePrice', 'Fruits', 'Apple$1', 'Dragonfruit$1', 'Passionfruit$2', 'Vegetables', 'Spinach$2', 'Pumpkin$4', 'Peas$1'],
    'the rows are ' + JSON.stringify(rowTexts())
  );
});

test('A category without matching products loses its heading', () => {
  render(<App />);
  search('pea');
  assert.deepEqual(rowTexts(), ['NamePrice', 'Vegetables', 'Peas$1'], 'after typing pea the rows are ' + JSON.stringify(rowTexts()));
});

test('The checkbox keeps the headings of categories that still have products', () => {
  render(<App />);
  toggle();
  assert.deepEqual(
    rowTexts(),
    ['NamePrice', 'Fruits', 'Apple$1', 'Dragonfruit$1', 'Vegetables', 'Spinach$2', 'Peas$1'],
    'with the box checked the rows are ' + JSON.stringify(rowTexts())
  );
});

test('No match leaves only the header row', () => {
  render(<App />);
  search('zzz');
  assert.deepEqual(rowTexts(), ['NamePrice'], 'after typing zzz the rows are ' + 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' }
];

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.

Run the program:

npm run dev

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.

Common mistakes

A value without onChange

import { useState } from 'react';

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

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

What React prints

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

Why, and the fix

The input is controlled by filterText, and nothing changes the state, so React warns and the box ignores typing. Call the parent: pass onFilterTextChange={setFilterText} down, and write onChange={e => onFilterTextChange(e.target.value)}. If the field should never change, readOnly stops the warning.

Rows without a 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>
  );
}

What React prints

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

Why, and the fix

Rows pushed into an array need keys, just like rows from map. Give each row one that stays with the item: <tr key={product.name}>, or key={product.category} on a heading row. A key from the index would shift when the filter removes rows above.

Reading a field the products do not have

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>
  );
}

What React prints

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

Why, and the fix

The products have a name, not a title, so product.title is undefined and the first render throws when it calls toLowerCase (Chrome and Node wording; Firefox and Safari word it differently). Read the field the data has: product.name.toLowerCase().

React in the browser: React 19.3.0 (development build) and React Testing Library, MIT, served from this site; JSX is compiled by TypeScript 6.0.3, Apache-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Components first, then the minimal state

Draw a box around each piece of the mockup and name it: FilterableProductTable holds everything, SearchBar takes the input, ProductTable filters and lists, ProductCategoryRow shows a heading, ProductRow shows one product. Build a static version first, with props and no state: it takes a lot of typing and little thinking. Then ask which data changes over time. The product list arrives as a prop, so it is not state. The search text and the checkbox value change and cannot be computed, so they are state. The filtered list can be computed from those three, so it is not state.

The state lives in the common parent

SearchBar shows filterText and inStockOnly, and ProductTable filters by them. Their closest common parent is FilterableProductTable, so it owns the two useState calls and passes the values down as props. ProductTable then builds the rows while rendering: skip a product when its name, lowercased, does not include the lowercased filterText, or when inStockOnly is on and the product is not stocked. Nothing stores the filtered list, so it cannot drift out of sync. Category rows work the same way: add a heading whenever the category differs from the last one shown.

Inverse data flow: setters go down as props

Data flows down, but a typed letter starts in SearchBar and must reach the parent. React makes that explicit. A controlled input, value={filterText}, ignores typing until the state changes. So the parent also passes its setters down, onFilterTextChange={setFilterText}, and SearchBar calls them from onChange: onChange={e => onFilterTextChange(e.target.value)}. A checkbox reports e.target.checked, not e.target.value. After the parent sets the state, it renders again, and the new props reach the input and the table.

Sources

Last reviewed October 5, 2026