Thinking in React
Build a searchable tea table the React way: split a design into components, build it static, find the minimal state, place it, and add inverse data flow.
- 01Turn a design into componentsDraw boxes around the parts of a mockup, name them, and arrange them into a component hierarchy that matches the data.
- 02Find the minimal state and its ownerUse three questions to sort the data into props, state, and values you work out, then put each piece of state in the closest common parent.
- 03Wire data both waysPass data down as props, and let a child change its parent's state by calling a function the parent passed down.
From a design to a working table
You've learned the pieces one at a time: components, props, state, lifting state up. This lesson puts them together on one screen, the way you'd build it at work. A designer hands you a mockup of a tea shop's price list: a search box, an "in stock" checkbox, and a table of teas grouped by category. The shop's server sends the teas as data like this:
export const TEAS = [
{ category: "Green", name: "Sencha", price: 4, stocked: true },
{ category: "Green", name: "Matcha", price: 9, stocked: false },
{ category: "Green", name: "Jasmine", price: 5, stocked: true },
{ category: "Black", name: "Assam", price: 3, stocked: true },
{ category: "Black", name: "Earl Grey", price: 4, stocked: false },
{ category: "Herbal", name: "Mint", price: 3, stocked: true },
{ category: "Herbal", name: "Chamomile", price: 4, stocked: true },
];Each tea has four fields: its category, its name, its price in dollars, and whether it's stocked. Lines 3 and 6 are sold out. (This block is a module the live examples below import, so it has no result of its own.)
Building a page like this in React usually takes the same five steps. The first two give you a page that shows the data. The last three make it respond to the user:
How to read the table: each row is one step, what you do in it, and what you have at the end of it.
| Step | What you do | What you have after it |
|---|---|---|
| 1. Break the design into components | Draw a box around each part of the mockup and name it. | A component hierarchy: which component sits inside which. |
| 2. Build a static version | Write the components with props only, from the data. | A page that shows the data but doesn't respond yet. |
| 3. Find the minimal state | Ask three questions about each piece of data. | The few values that change and can't be worked out: here, two. |
| 4. Decide where the state lives | Find the closest common parent of the components that use it. | useState in the right component, passed down as props. |
| 5. Add inverse data flow | Pass setters down, and call them from the inputs. | A page that responds to typing and clicking. |
Nobody fits a kitchen by running wires first. You plan the cupboards, fit them, then work out what needs power and where the switches go, and only then run the wires. Each stage is easy because the one before it is finished.
- In real life: Mark each cupboard, drawer, and shelf on the plan
- In React: Step 1: draw a box around each component in the mockup
- In real life: Fit the cupboards before the water and power
- In React: Step 2: build the static version with props, no state
- In real life: List what has to move or switch on: taps and lights
- In React: Step 3: find the few values that change
- In real life: Put each switch where everyone who needs it can reach it
- In React: Step 4: keep the state in the closest common parent
- In real life: Run wires from the switches to the lights
- In React: Step 5: pass setters down so inputs can change the state
Thinking in React means building a screen in five steps. Break the design into components, build a static version with props, find the smallest set of values that change, put each in the closest common parent of the components that use it, and pass setters down so the inputs can change it.
Step 1: Break the design into components
Here is the mockup, written out by hand as one big component, the way a web page without React might look. It runs, but nothing in it responds yet:
export default function App() { return ( <div> <form> <input type="text" placeholder="Search teas..." /> <label> <input type="checkbox" /> Only show teas in stock </label> </form> <table> <caption>Showing 7 of 7 teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> <tbody> <tr><th colSpan="2">Green</th></tr> <tr><td>Sencha</td><td>$4</td></tr> <tr><td>Matcha (sold out)</td><td>$9</td></tr> <tr><td>Jasmine</td><td>$5</td></tr> </tbody> <tbody> <tr><th colSpan="2">Black</th></tr> <tr><td>Assam</td><td>$3</td></tr> <tr><td>Earl Grey (sold out)</td><td>$4</td></tr> </tbody> <tbody> <tr><th colSpan="2">Herbal</th></tr> <tr><td>Mint</td><td>$3</td></tr> <tr><td>Chamomile</td><td>$4</td></tr> </tbody> </table> </div> );}Lines 4 to 9 are the search form. Lines 10 to 31 are the table: line 11 is its caption, and each <tbody> is one category, a heading row and then one row per tea, like lines 15 to 20 for Green. Notice how the same row shape repeats on lines 17 to 19, 23 and 24, and 28 and 29. Repeated shapes are a hint: each one wants to be a component.
Start by drawing a box around every part of the mockup and naming it. A good box does one job, the way a good function does. If a part grows too big, split it into smaller boxes. When the data is well organised, the boxes often match its shape: the data has teas grouped by category, and so does the table.
CategoryRow and TeaRow are written once and used for every category and every tea.This screen has five components:
TeaShopholds the whole screen.SearchBartakes what the user types and ticks.TeaTableshows the teas that match, and how many.CategoryRowshows one category's heading.TeaRowshows one tea.
A component that sits inside another's box in the mockup is its child in the hierarchy, the same render tree Your UI as a tree drew. The table's header row, Name and Price, has no component of its own: it's small and belongs to TeaTable. If it grew, say with sort buttons, it could get one. Try the hierarchy on the real page:
TeaRow| Name | Price |
|---|---|
| Green | |
| Sencha | $4 |
| Matcha (sold out) | $9 |
| Jasmine | $5 |
| Black | |
| Assam | $3 |
| Earl Grey (sold out) | $4 |
| Herbal | |
| Mint | $3 |
| Chamomile | $4 |
Pick a component in the tree.
Shows one tea's name and price, and says when it's sold out. 7 on the page, one per tea.
Step 2: Build a static version
Now write the components, and have them show the data, with no state at all. This example has three files, in tabs: App.jsx, teas.js (the data from the overview), and styles.css for the table's spacing:
import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas }) { const shown = teas; const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar() { return ( <form> <input type="text" placeholder="Search teas..." /> <label> <input type="checkbox" /> Only show teas in stock </label> </form> );} function TeaShop({ teas }) { return ( <div> <SearchBar /> <TeaTable teas={teas} /> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}Read it from the bottom up. Line 63 passes the data to TeaShop as the teas prop. TeaShop (lines 53 to 60) passes it on to TeaTable on line 57. In TeaTable, line 22 names the teas to show, all of them for now. Line 23 lists the categories once each, in order: map takes each tea's category, and a Set drops the repeats. Lines 30 to 37 make one <tbody> per category, with a CategoryRow and, on lines 33 to 35, a TeaRow for each of its teas, made with filter and map and given a key. Line 12 adds "(sold out)" to a tea that isn't stocked.
The result looks exactly like the mockup. Press Edit & run, open teas.js, and add a line: { category: "Herbal", name: "Rooibos", price: 5, stocked: true },. A new row appears under Herbal, and the caption says Showing 8 of 8 teas. You changed only the data. You can type in the search box, too, but nothing filters: no code reads it yet.
Why no state yet? A static version is mostly typing and little thinking; interactivity is the other way round. Doing them separately makes each easier. You can build top-down, starting from TeaShop, or bottom-up, starting from TeaRow. On small screens like this one, top-down is usually easier; on big projects, bottom-up lets you test small pieces first.
Data here flows one way: down, from App to TeaShop to TeaTable to each row. React calls this one-way data flow, and it means you can always find where a value came from by looking up the tree.
Step 3: Find the minimal state
To make the page respond, some data must change over time. That's state. The rule is to keep as little of it as possible, and work everything else out from it: the Choosing the state structure lesson calls the leftovers redundant state. List every piece of data on the page, and ask three questions about each:
- Does it stay the same over time? Then it isn't state.
- Is it passed in from a parent as a prop? Then it isn't state.
- Can you work it out from other state or props? Then it definitely isn't state.
What's left is state. Try the questions on the five pieces of data the tea table has:
not state- Does it stay the same over time?Yes. The server sends it once; nothing on this page changes it.
- Is it passed in from a parent as a prop?Yes. App passes it to TeaShop as the
teasprop. - Can you work it out from other state or props?No. It comes from the server.
Not state. It never changes here, and it arrives as a prop.
Only two pieces are state: the text in the search box and whether the box is ticked. The list of teas comes in as a prop and never changes here. The teas that match, and how many there are, can be worked out from the teas and those two values every time the component renders.
Check that the two are enough. In this version, App passes them in as fixed values for now, and TeaTable filters by them:
import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas, filterText, inStockOnly }) { const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar() { return ( <form> <input type="text" placeholder="Search teas..." /> <label> <input type="checkbox" /> Only show teas in stock </label> </form> );} function TeaShop({ teas, filterText, inStockOnly }) { return ( <div> <SearchBar /> <TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} /> </div> );} export default function App() { return <TeaShop teas={TEAS} filterText="" inStockOnly={false} />;}TeaTable now takes filterText and inStockOnly (line 21). Lines 22 to 25 work out the teas to show: line 23 drops sold-out teas when inStockOnly is true, and line 24 keeps the names that contain the text, ignoring capitals (toLowerCase and includes). Line 66 passes the two values in from App.
Press Edit & run and change line 66 to filterText="m" inStockOnly={true}. The table shows Jasmine, Assam, Mint, and Chamomile, and the caption says Showing 4 of 7 teas. Matcha has an m too, but it's sold out. Every screen the user could see comes from these two values.
There are two kinds of data a component gets in React, props and state. How to read the table: each row asks one question, answered for each.
| Question | Props | State |
|---|---|---|
| Where does it come from? | From the parent, written on the component's tag. | From the component itself, with useState. |
| Can the component change it? | No. Props are read-only. | Yes, with its setter, which asks for a new render. |
| What is it like? | A function's arguments. | A component's memory. |
| In the tea table | teas, and the filters TeaTable and SearchBar receive. | filterText and inStockOnly, in TeaShop. |
Step 4: Decide where the state lives
Now find which component owns each piece of state: the one that keeps it with useState. Do what Sharing state between components did:
- List every component that shows or uses it.
SearchBarshows the text and the tick, andTeaTablefilters by them. - Find their closest common parent: the lowest component with all of them inside it. Here,
TeaShop. - Keep the state there. If no component fits, make a new one just to hold it, above the common parent.
SearchBar shows both filters and TeaTable filters by them. The closest component above both is TeaShop, so it keeps the two pieces of state and passes them down as props.The two pieces of state always go together, so both live in TeaShop:
import { useState } from "react";import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas, filterText, inStockOnly }) { const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar({ filterText, inStockOnly }) { return ( <form> <input type="text" placeholder="Search teas..." value={filterText} /> <label> <input type="checkbox" checked={inStockOnly} /> Only show teas in stock </label> </form> );} function TeaShop({ teas }) { const [filterText, setFilterText] = useState(""); const [inStockOnly, setInStockOnly] = useState(false); return ( <div> <SearchBar filterText={filterText} inStockOnly={inStockOnly} /> <TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} /> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}Lines 58 and 59 add the two pieces of state to TeaShop, starting empty and unticked. Line 62 passes them to SearchBar, and line 63 to TeaTable. SearchBar now shows them: line 49 gives the text box value={filterText}, and line 51 gives the checkbox checked={inStockOnly}. App no longer passes them in.
Press Edit & run and change useState("") on line 58 to useState("mint"): the box says mint, and the table shows only Mint. Now type in the box. Nothing happens. The box is controlled: React shows exactly filterText, and nothing sets it, so every key you press is undone. React also prints a warning in the console (Pop out shows it): You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. The checkbox gets the same warning for checked. Step 5 fixes both.
Step 5: Add inverse data flow
So far, data only flows down. To let the user change the filters, data must also flow the other way: the inputs, deep in SearchBar, must change state that TeaShop owns. Only TeaShop can call setFilterText, so it passes that function down, and SearchBar calls it. This is called inverse data flow. Type mi in the box:
import { useState } from "react";import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas, filterText, inStockOnly }) { const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar({ filterText, inStockOnly, onFilterTextChange, onInStockOnlyChange }) { return ( <form> <input type="text" placeholder="Search teas..." value={filterText} onChange={(event) => onFilterTextChange(event.target.value)} /> <label> <input type="checkbox" checked={inStockOnly} onChange={(event) => onInStockOnlyChange(event.target.checked)} />{" "} Only show teas in stock </label> </form> );} function TeaShop({ teas }) { const [filterText, setFilterText] = useState(""); const [inStockOnly, setInStockOnly] = useState(false); return ( <div> <SearchBar filterText={filterText} inStockOnly={inStockOnly} onFilterTextChange={setFilterText} onInStockOnlyChange={setInStockOnly} /> <TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} /> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}Lines 75 and 76 pass TeaShop's setters to SearchBar as onFilterTextChange and onInStockOnlyChange. SearchBar takes them on line 46. Line 53 calls onFilterTextChange(event.target.value) on every key, and line 59 calls onInStockOnlyChange with whether the box is now ticked. (Handler props named on… come from Responding to events.)
Typing m sets filterText to "m": TeaShop renders again, the box shows the new text, and TeaTable shows the five teas with an m: Showing 5 of 7 teas. Typing i makes it "mi", and only Jasmine, Mint, and Chamomile are left: Showing 3 of 7 teas. Tick the box, and sold-out teas go too.
TeaShop passes filterText to both children. A key press comes back up: SearchBar calls onFilterTextChange("mi"), which is TeaShop's setFilterText, and both children get the new value.Each change makes a round trip: up as a call, down as props. Follow it on the real table. Each key and tick adds a line:
type and tick| Name | Price |
|---|---|
| Green | |
| Sencha | $4 |
| Matcha (sold out) | $9 |
| Jasmine | $5 |
| Black | |
| Assam | $3 |
| Earl Grey (sold out) | $4 |
| Herbal | |
| Mint | $3 |
| Chamomile | $4 |
0 shownNothing typed yet.
Type in the box, or tick it.
Type mi in the search box, one letter at a time, then tick Only show teas in stock. Each change goes up as a call and comes back down as props.
React makes this flow explicit. Some libraries keep an input and a variable in sync on their own, called two-way binding. In React, you write the call yourself, so you can always see who changes what.
A new feature, the same five steps
The shop now wants a price slider: "Up to $5". Run the steps again. Step 1: a new box, PriceFilter. Step 2: it shows a slider. Step 3: the picked price changes and can't be worked out, so it's state; the slider's top end can be worked out from the teas, so it isn't. Step 4: PriceFilter shows it and TeaTable uses it, so it lives in TeaShop. Step 5: pass the setter down. Drag the slider to 4:
import { useState } from "react";import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas, filterText, inStockOnly, maxPrice }) { const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; if (tea.price > maxPrice) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function PriceFilter({ maxPrice, highest, onMaxPriceChange }) { return ( <label> Up to ${maxPrice}{" "} <input type="range" min="1" max={highest} value={maxPrice} onChange={(event) => onMaxPriceChange(Number(event.target.value))} /> </label> );} function TeaShop({ teas }) { const highest = Math.max(...teas.map((tea) => tea.price)); const [filterText, setFilterText] = useState(""); const [inStockOnly, setInStockOnly] = useState(false); const [maxPrice, setMaxPrice] = useState(highest); return ( <div> <SearchBar filterText={filterText} inStockOnly={inStockOnly} onFilterTextChange={setFilterText} onInStockOnlyChange={setInStockOnly} /> <PriceFilter maxPrice={maxPrice} highest={highest} onMaxPriceChange={setMaxPrice} /> <TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} maxPrice={maxPrice} /> </div> );} function SearchBar({ filterText, inStockOnly, onFilterTextChange, onInStockOnlyChange }) { return ( <form> <input type="text" placeholder="Search teas..." value={filterText} onChange={(event) => onFilterTextChange(event.target.value)} /> <label> <input type="checkbox" checked={inStockOnly} onChange={(event) => onInStockOnlyChange(event.target.checked)} />{" "} Only show teas in stock </label> </form> );} export default function App() { return <TeaShop teas={TEAS} />;}Line 63 works out the highest price, 9, from the teas, on every render. Line 66 is the new state, maxPrice, starting at that highest price, so every tea shows at first. PriceFilter (lines 47 to 60) shows the slider and calls onMaxPriceChange with the new number on line 56. Line 75 passes it the state and the setter, and line 76 passes maxPrice to TeaTable, where line 25 drops the teas that cost more. At 4, the caption says Showing 5 of 7 teas; tick the box too, and it's 4.
- The highest price the user picked on the slider
- Whether "Only show teas in stock" is ticked
- The list of teas from the server
- The slider's top end: the most expensive tea's price
- The teas at or under the picked price
- The "Showing 4 of 7 teas" caption
- The table's column names, Name and Price
- The text typed in the search box
Sort each piece of data for the price slider feature: is it state, or not, and why not?
When a screen grows many pieces of state that change together, or that components far apart need, the same steps lead you to a reducer and context: step 4 picks the owner, and a provider component holds the state there.
Common mix-ups
- "Anything that changes on screen needs its own state." No. The rows and the count change, but they're worked out from two pieces of state. Keeping them too means copies to keep in sync.
- "State belongs in the component that shows it." No. It belongs in the closest common parent of every component that uses it.
SearchBarshows the text, butTeaShopowns it. - "A child updates its parent by changing a prop." No. Props are read-only. The child calls a function the parent passed down, and the parent sets its own state.
- "You must get the components right before writing any code." No. The hierarchy is a first guess. Split a component when it grows, or merge two when they always change together.
- "An input with value is broken if typing does nothing." No. It's waiting for step 5. React shows the state, so add
onChangeand set the state there. - "Props and state are two names for the same thing." No. Props come from the parent and are read-only. State is the component's own memory, which it can change. A parent's state often becomes its children's props.
Practice exercises
Each one uses the tea table. Before you change code, ask which step the task belongs to. Press Edit & run to change the code; the result updates as you type.
App passes filterText="a" and inStockOnly={true}. What does the caption say? Answer first, then press Edit & run to check.
import { TEAS } from "./teas.js";
function CategoryRow({ category }) {
return (
<tr>
<th colSpan="2">{category}</th>
</tr>
);
}
function TeaRow({ tea }) {
const name = tea.stocked ? tea.name : `${tea.name} (sold out)`;
return (
<tr>
<td>{name}</td>
<td>${tea.price}</td>
</tr>
);
}
function TeaTable({ teas, filterText, inStockOnly }) {
const shown = teas.filter((tea) => {
if (inStockOnly && !tea.stocked) return false;
return tea.name.toLowerCase().includes(filterText.toLowerCase());
});
const categories = [...new Set(shown.map((tea) => tea.category))];
return (
<table>
<caption>Showing {shown.length} of {teas.length} teas</caption>
<thead>
<tr><th>Name</th><th>Price</th></tr>
</thead>
{categories.map((category) => (
<tbody key={category}>
<CategoryRow category={category} />
{shown
.filter((tea) => tea.category === category)
.map((tea) => <TeaRow key={tea.name} tea={tea} />)}
</tbody>
))}
</table>
);
}
function SearchBar() {
return (
<form>
<input type="text" placeholder="Search teas..." />
<label>
<input type="checkbox" /> Only show teas in stock
</label>
</form>
);
}
function TeaShop({ teas, filterText, inStockOnly }) {
return (
<div>
<SearchBar />
<TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} />
</div>
);
}
export default function App() {
return <TeaShop teas={TEAS} filterText="a" inStockOnly={true} />;
}import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas, filterText, inStockOnly }) { const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar() { return ( <form> <input type="text" placeholder="Search teas..." /> <label> <input type="checkbox" /> Only show teas in stock </label> </form> );} function TeaShop({ teas, filterText, inStockOnly }) { return ( <div> <SearchBar /> <TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} /> </div> );} export default function App() { return <TeaShop teas={TEAS} filterText="a" inStockOnly={true} />;}Six names contain an a, and inStockOnly drops the two sold-out ones, Matcha and Earl Grey. That leaves Sencha, Jasmine, Assam, and Chamomile: Showing 4 of 7 teas.
This static version keeps the data in App.jsx. Add an Oolong tea, Tieguanyin, $6, in stock. How many category headings does the table show now?
const TEAS = [ { category: "Green", name: "Sencha", price: 4, stocked: true }, { category: "Green", name: "Matcha", price: 9, stocked: false }, { category: "Green", name: "Jasmine", price: 5, stocked: true }, { category: "Black", name: "Assam", price: 3, stocked: true }, { category: "Black", name: "Earl Grey", price: 4, stocked: false }, { category: "Herbal", name: "Mint", price: 3, stocked: true }, { category: "Herbal", name: "Chamomile", price: 4, stocked: true },]; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas }) { const shown = teas; const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar() { return ( <form> <input type="text" placeholder="Search teas..." /> <label> <input type="checkbox" /> Only show teas in stock </label> </form> );} function TeaShop({ teas }) { return ( <div> <SearchBar /> <TeaTable teas={teas} /> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}const TEAS = [ { category: "Green", name: "Sencha", price: 4, stocked: true }, { category: "Green", name: "Matcha", price: 9, stocked: false }, { category: "Green", name: "Jasmine", price: 5, stocked: true }, { category: "Black", name: "Assam", price: 3, stocked: true }, { category: "Black", name: "Earl Grey", price: 4, stocked: false }, { category: "Herbal", name: "Mint", price: 3, stocked: true }, { category: "Herbal", name: "Chamomile", price: 4, stocked: true }, { category: "Oolong", name: "Tieguanyin", price: 6, stocked: true },]; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas }) { const shown = teas; const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar() { return ( <form> <input type="text" placeholder="Search teas..." /> <label> <input type="checkbox" /> Only show teas in stock </label> </form> );} function TeaShop({ teas }) { return ( <div> <SearchBar /> <TeaTable teas={teas} /> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}TeaTable lists the categories from the data, so a tea with a new category gets a new heading and a new <tbody> on its own. The table shows Green, Black, Herbal, and Oolong: 4 headings, and Showing 8 of 8 teas.
Typing filters the table, but the checkbox can't be ticked. Add inverse data flow for it. With the search box empty, what does the caption say after you tick it?
import { useState } from "react";import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas, filterText, inStockOnly }) { const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar({ filterText, inStockOnly, onFilterTextChange }) { return ( <form> <input type="text" placeholder="Search teas..." value={filterText} onChange={(event) => onFilterTextChange(event.target.value)} /> <label> <input type="checkbox" checked={inStockOnly} />{" "} Only show teas in stock </label> </form> );} function TeaShop({ teas }) { const [filterText, setFilterText] = useState(""); const [inStockOnly, setInStockOnly] = useState(false); return ( <div> <SearchBar filterText={filterText} inStockOnly={inStockOnly} onFilterTextChange={setFilterText} /> <TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} /> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}import { useState } from "react";import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas, filterText, inStockOnly }) { const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar({ filterText, inStockOnly, onFilterTextChange, onInStockOnlyChange }) { return ( <form> <input type="text" placeholder="Search teas..." value={filterText} onChange={(event) => onFilterTextChange(event.target.value)} /> <label> <input type="checkbox" checked={inStockOnly} onChange={(event) => onInStockOnlyChange(event.target.checked)} />{" "} Only show teas in stock </label> </form> );} function TeaShop({ teas }) { const [filterText, setFilterText] = useState(""); const [inStockOnly, setInStockOnly] = useState(false); return ( <div> <SearchBar filterText={filterText} inStockOnly={inStockOnly} onFilterTextChange={setFilterText} onInStockOnlyChange={setInStockOnly} /> <TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} /> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}The checkbox now calls TeaShop's setInStockOnly with event.target.checked. Ticking it sets inStockOnly to true, and the table drops Matcha and Earl Grey: Showing 5 of 7 teas.
This version keeps the count in state too. Tick the box and compare the count with the list. Fix it by removing state, not adding code. After your fix, what does the count say once you tick the box?
import { useState } from "react";import { TEAS } from "./teas.js"; function TeaShop({ teas }) { const [filterText, setFilterText] = useState(""); const [inStockOnly, setInStockOnly] = useState(false); const [count, setCount] = useState(teas.length); const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); function handleTextChange(text) { setFilterText(text); setCount(teas.filter((tea) => tea.name.toLowerCase().includes(text.toLowerCase())).length); } return ( <div> <form> <input type="text" placeholder="Search teas..." value={filterText} onChange={(event) => handleTextChange(event.target.value)} /> <label> <input type="checkbox" checked={inStockOnly} onChange={(event) => setInStockOnly(event.target.checked)} />{" "} Only show teas in stock </label> </form> <p>Showing {count} of {teas.length} teas</p> <ul> {shown.map((tea) => ( <li key={tea.name}>{tea.name}</li> ))} </ul> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}import { useState } from "react";import { TEAS } from "./teas.js"; function TeaShop({ teas }) { const [filterText, setFilterText] = useState(""); const [inStockOnly, setInStockOnly] = useState(false); const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); return ( <div> <form> <input type="text" placeholder="Search teas..." value={filterText} onChange={(event) => setFilterText(event.target.value)} /> <label> <input type="checkbox" checked={inStockOnly} onChange={(event) => setInStockOnly(event.target.checked)} />{" "} Only show teas in stock </label> </form> <p>Showing {shown.length} of {teas.length} teas</p> <ul> {shown.map((tea) => ( <li key={tea.name}>{tea.name}</li> ))} </ul> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}The count could always be worked out: it's shown.length. Keeping it in state made a copy that only the text box updated, so ticking the box left it behind. Worked out on every render, it's always right: Showing 5 of 7 teas.
Add buttons All, Green, Black, and Herbal that show one category at a time, starting with All. Then pick Black and tick the box. What does the caption say?
import { useState } from "react";import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas, filterText, inStockOnly }) { const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar({ filterText, inStockOnly, onFilterTextChange, onInStockOnlyChange }) { return ( <form> <input type="text" placeholder="Search teas..." value={filterText} onChange={(event) => onFilterTextChange(event.target.value)} /> <label> <input type="checkbox" checked={inStockOnly} onChange={(event) => onInStockOnlyChange(event.target.checked)} />{" "} Only show teas in stock </label> </form> );} function TeaShop({ teas }) { const [filterText, setFilterText] = useState(""); const [inStockOnly, setInStockOnly] = useState(false); return ( <div> <SearchBar filterText={filterText} inStockOnly={inStockOnly} onFilterTextChange={setFilterText} onInStockOnlyChange={setInStockOnly} /> <TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} /> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}import { useState } from "react";import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas, filterText, inStockOnly, pickedCategory }) { const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; if (pickedCategory !== "All" && tea.category !== pickedCategory) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function CategoryChips({ pickedCategory, onPickedCategoryChange }) { return ( <p> {["All", "Green", "Black", "Herbal"].map((name) => ( <button key={name} aria-pressed={pickedCategory === name} onClick={() => onPickedCategoryChange(name)}> {name} </button> ))} </p> );} function SearchBar({ filterText, inStockOnly, onFilterTextChange, onInStockOnlyChange }) { return ( <form> <input type="text" placeholder="Search teas..." value={filterText} onChange={(event) => onFilterTextChange(event.target.value)} /> <label> <input type="checkbox" checked={inStockOnly} onChange={(event) => onInStockOnlyChange(event.target.checked)} />{" "} Only show teas in stock </label> </form> );} function TeaShop({ teas }) { const [filterText, setFilterText] = useState(""); const [inStockOnly, setInStockOnly] = useState(false); const [pickedCategory, setPickedCategory] = useState("All"); return ( <div> <SearchBar filterText={filterText} inStockOnly={inStockOnly} onFilterTextChange={setFilterText} onInStockOnlyChange={setInStockOnly} /> <CategoryChips pickedCategory={pickedCategory} onPickedCategoryChange={setPickedCategory} /> <TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} pickedCategory={pickedCategory} /> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}pickedCategory is a third piece of state in TeaShop. CategoryChips shows which button is pressed and calls onPickedCategoryChange, and TeaTable filters by it. With Black picked and the box ticked, only Assam is left: Showing 1 of 7 teas.
Add a Cheapest first checkbox that sorts the teas in each category by price. Sort a copy, never the prop (Updating arrays in state shows why). With it ticked, which tea is the last row under Green?
import { useState } from "react";import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas, filterText, inStockOnly }) { const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {shown .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar({ filterText, inStockOnly, onFilterTextChange, onInStockOnlyChange }) { return ( <form> <input type="text" placeholder="Search teas..." value={filterText} onChange={(event) => onFilterTextChange(event.target.value)} /> <label> <input type="checkbox" checked={inStockOnly} onChange={(event) => onInStockOnlyChange(event.target.checked)} />{" "} Only show teas in stock </label> </form> );} function TeaShop({ teas }) { const [filterText, setFilterText] = useState(""); const [inStockOnly, setInStockOnly] = useState(false); return ( <div> <SearchBar filterText={filterText} inStockOnly={inStockOnly} onFilterTextChange={setFilterText} onInStockOnlyChange={setInStockOnly} /> <TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} /> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}import { useState } from "react";import { TEAS } from "./teas.js"; function CategoryRow({ category }) { return ( <tr> <th colSpan="2">{category}</th> </tr> );} function TeaRow({ tea }) { const name = tea.stocked ? tea.name : `${tea.name} (sold out)`; return ( <tr> <td>{name}</td> <td>${tea.price}</td> </tr> );} function TeaTable({ teas, filterText, inStockOnly, cheapestFirst }) { const shown = teas.filter((tea) => { if (inStockOnly && !tea.stocked) return false; return tea.name.toLowerCase().includes(filterText.toLowerCase()); }); const ordered = cheapestFirst ? [...shown].sort((a, b) => a.price - b.price) : shown; const categories = [...new Set(shown.map((tea) => tea.category))]; return ( <table> <caption>Showing {shown.length} of {teas.length} teas</caption> <thead> <tr><th>Name</th><th>Price</th></tr> </thead> {categories.map((category) => ( <tbody key={category}> <CategoryRow category={category} /> {ordered .filter((tea) => tea.category === category) .map((tea) => <TeaRow key={tea.name} tea={tea} />)} </tbody> ))} </table> );} function SearchBar({ filterText, inStockOnly, cheapestFirst, onFilterTextChange, onInStockOnlyChange, onCheapestFirstChange }) { return ( <form> <input type="text" placeholder="Search teas..." value={filterText} onChange={(event) => onFilterTextChange(event.target.value)} /> <label> <input type="checkbox" checked={inStockOnly} onChange={(event) => onInStockOnlyChange(event.target.checked)} />{" "} Only show teas in stock </label> <label> <input type="checkbox" checked={cheapestFirst} onChange={(event) => onCheapestFirstChange(event.target.checked)} />{" "} Cheapest first </label> </form> );} function TeaShop({ teas }) { const [filterText, setFilterText] = useState(""); const [inStockOnly, setInStockOnly] = useState(false); const [cheapestFirst, setCheapestFirst] = useState(false); return ( <div> <SearchBar filterText={filterText} inStockOnly={inStockOnly} cheapestFirst={cheapestFirst} onFilterTextChange={setFilterText} onInStockOnlyChange={setInStockOnly} onCheapestFirstChange={setCheapestFirst} /> <TeaTable teas={teas} filterText={filterText} inStockOnly={inStockOnly} cheapestFirst={cheapestFirst} /> </div> );} export default function App() { return <TeaShop teas={TEAS} />;}cheapestFirst is state in TeaShop, shown by a checkbox in SearchBar. TeaTable sorts a copy, so the teas prop stays untouched, and the categories keep their order because they come from shown. Under Green, Sencha ($4), Jasmine ($5), then Matcha ($9) is last.
Check your understanding
For each question, ask which step it belongs to, and what the three questions say about each piece of data.
Question 1 of 7Which of these is state in the tea table?
Choose an answer to see the explanation.
Question 2 of 7Why isn't "how many teas match" kept in state?
Choose an answer to see the explanation.
Question 3 of 7SearchBar shows the search text, and TeaTable filters by it. Where should
filterTextlive?Choose an answer to see the explanation.
Question 4 of 7What does this page show?
Read the code, then predictconst teas = [ { name: "Sencha", stocked: true }, { name: "Matcha", stocked: false }, { name: "Mint", stocked: true }, { name: "Chamomile", stocked: true }, ]; function TeaCount({ filterText, inStockOnly }) { const shown = teas.filter( (tea) => (!inStockOnly || tea.stocked) && tea.name.toLowerCase().includes(filterText) ); return <p>Showing {shown.length} of {teas.length} teas</p>; } export default function App() { return <TeaCount filterText="m" inStockOnly={true} />; }Choose an answer to see the explanation.
Question 5 of 7In step 4, the box is written
<input value={filterText} />with noonChange. What happens when you type in it?Choose an answer to see the explanation.
Question 6 of 7What is inverse data flow?
Choose an answer to see the explanation.
Question 7 of 7Why build a static version before adding any state?
Choose an answer to see the explanation.
Key takeaways
- Step 1: draw a box around each part of the design, one job per box, and arrange the boxes into a hierarchy.
- Step 2: build a static version with props only. Data flows down, from the top component to the rows.
- Step 3: keep the minimal state. Data that never changes, comes in as a prop, or can be worked out from other values isn't state.
- Step 4: keep each piece of state in the closest common parent of the components that use it.
- Step 5: pass setters down as
on…props, and call them from the inputs. That's inverse data flow. - A new feature runs through the same steps: find its state, find its owner, and wire it both ways.
Remember the one-liner.
Components from the design, a static version from props, the least state in the right place, and setters passed down.
Coming next: Forms and inputs. It looks closely at the inputs this table used: controlled and uncontrolled fields, reading a whole form, and checking what the user typed.