Thinking in React
React asks you to think about a UI differently than imperative DOM code does. Instead of writing step-by-step instructions for changing the page, you describe what the screen should look like for any given data, and React keeps the DOM in sync. The classic five-step process below turns a static design into a working, interactive React app. We’ll build a small example along the way: a filterable product list.
The example we’re building
Imagine a designer hands you a mock-up: a search box, a checkbox to show only in-stock items, and a table of products grouped by category. The data arrives from an API as a flat array:
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" },
];
Step 1: Break the UI into a component hierarchy
Start by drawing boxes around every piece of the mock-up and naming each one. A good guide is the single responsibility principle: a component should ideally do one thing. If it grows, decompose it. It often maps cleanly to your data model, too.
For our mock-up the boxes nest like this:
FilterableProductTable— the whole thingSearchBar— the text input and checkboxProductTable— the listProductCategoryRow— a category heading rowProductRow— a single product row
Step 2: Build a static version
Now build a version that renders the UI from the data but has no interactivity yet. Static versions are the bulk of the typing and the least of the thinking, so it’s easier to keep them separate. Pass data down through props; do not use state here—state is reserved for data that changes over time.
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 ProductCategoryRow({ category }) {
return (
<tr>
<th colSpan="2">{category}</th>
</tr>
);
}
function ProductTable({ products }) {
const rows = [];
let lastCategory = null;
products.forEach((product) => {
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>
);
}
Build top-down or bottom-up. For small apps, building top-down (start with
FilterableProductTable) is usually simplest. On larger projects, building bottom-up and testing each leaf component as you go tends to scale better.
Step 3: Find the minimal but complete state
To make the UI interactive, you need state—data that changes over time in response to the user. The trick is to find the minimal set of state. Apply DRY: derive everything else on demand. Walk through each piece of data and ask three questions:
| Question | If yes… |
|---|---|
| Does it stay the same over time? | It’s a prop, not state. |
| Is it passed in from a parent? | It’s a prop, not state. |
| Can you compute it from existing state or props? | It’s derived, not state. |
For our app, the product list is passed in (prop), and the visible rows can be computed by filtering. That leaves exactly two pieces of real state:
- The search text the user typed.
- Whether the “in stock only” checkbox is checked.
Step 4: Decide where state should live
State must live in one component. To choose which, find every component that renders something based on that state, then locate their closest common parent—or a component above it. State should live there; this is called lifting state up.
Both SearchBar (which displays the values) and ProductTable (which uses them to filter) need this state. Their closest common parent is FilterableProductTable, so the state goes there:
import { useState } from "react";
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>
);
}
The values now flow down as props, but the inputs can’t change them yet.
Step 5: Add inverse data flow
Data flows down, but events flow back up. To let a child update the parent’s state, the parent passes down the setter functions (or wrappers around them). The child calls them in its event handlers—this is “inverse data flow.”
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>
);
}
Wire the setters from the parent and filter the products before passing them down:
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState("");
const [inStockOnly, setInStockOnly] = useState(false);
const visible = products.filter((p) => {
if (inStockOnly && !p.stocked) return false;
return p.name.toLowerCase().includes(filterText.toLowerCase());
});
return (
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly}
onFilterTextChange={setFilterText}
onInStockOnlyChange={setInStockOnly}
/>
<ProductTable products={visible} />
</div>
);
}
Now typing in the box or toggling the checkbox updates state, React re-renders, and the table reflects the new data automatically. You never touched the DOM directly.
Best practices
- Decompose components by single responsibility; if a component is doing two jobs, split it.
- Build the static, prop-driven version first—don’t reach for state until you add interactivity.
- Keep state minimal: anything you can derive from props or other state should be computed, not stored.
- Lift state to the closest common parent of every component that reads it, rather than duplicating it.
- Pass setter functions down so children can request changes; let data flow down and events flow up.
- Give list items stable, meaningful
keyvalues (here,product.nameandcategory) so React reconciles efficiently.