Building a React app isn't just about knowing the APIs — it's about a way of thinking. This lesson walks through the mental model you should use when designing any React UI.
Step 1: Break the UI into Components
Start with a mockup or design and draw boxes around every distinct piece. Each box is a component. A component should do one thing (the single-responsibility principle).
Consider a product filtering page:
┌─────────────────────────────────┐
│ FilterableProductTable │
│ ┌─────────────────────────────┐ │
│ │ SearchBar │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ ProductTable │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ ProductCategoryRow │ │ │
│ │ ├─────────────────────────┤ │ │
│ │ │ ProductRow │ │ │
│ │ │ ProductRow │ │ │
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘This gives us five components with a clear hierarchy:
FilterableProductTable(root)SearchBarProductTableProductCategoryRowProductRow
Step 2: Build a Static Version First
Build the UI with hardcoded data and zero state. This separates the layout work from the logic work:
interface Product {
name: string;
price: string;
category: string;
inStock: boolean;
}
function ProductRow({ product }: { product: Product }) {
return (
<tr>
<td className={product.inStock ? "" : "text-red-500"}>
{product.name}
</td>
<td>{product.price}</td>
</tr>
);
}
function ProductCategoryRow({ category }: { category: string }) {
return (
<tr>
<th colSpan={2} className="text-left font-bold pt-4">
{category}
</th>
</tr>
);
}
function ProductTable({ products }: { products: Product[] }) {
const rows: React.ReactNode[] = [];
let lastCategory = "";
products.forEach((product) => {
if (product.category !== lastCategory) {
rows.push(
<ProductCategoryRow key={product.category} category={product.category} />
);
lastCategory = product.category;
}
rows.push(<ProductRow key={product.name} product={product} />);
});
return (
<table className="w-full">
<thead>
<tr>
<th className="text-left">Name</th>
<th className="text-left">Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
}No useState, no event handlers. Just props flowing down. This approach helps you get the structure right before adding complexity.
Step 3: Identify the State
Look at your data and ask three questions about each piece:
- Is it passed from a parent via props? Not state.
- Does it remain unchanged over time? Not state.
- Can you compute it from existing state or props? Not state.
For our product filter, the candidates are:
- The product list — passed as a prop, not state.
- The search text — changes over time, can't be computed, state.
- The "in stock only" checkbox — changes over time, can't be computed, state.
- The filtered products — computed from the product list + search + checkbox, not state.
Step 4: Decide Where State Lives
For each piece of state, find the component that should own it:
- Identify every component that renders something based on that state.
- Find their closest common parent.
- That parent (or a component above it) should own the state.
Both SearchBar and ProductTable need the filter values, so the state belongs in FilterableProductTable:
import { useState } from "react";
function SearchBar({
query,
inStockOnly,
onQueryChange,
onInStockChange,
}: {
query: string;
inStockOnly: boolean;
onQueryChange: (q: string) => void;
onInStockChange: (checked: boolean) => void;
}) {
return (
<div className="space-y-2 mb-4">
<input
type="text"
value={query}
onChange={(e)=> onQueryChange(e.target.value)}
placeholder="Search..."
className="border rounded px-3 py-1 w-full"
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={inStockOnly}
onChange={(e)=> onInStockChange(e.target.checked)}
/>
Only show products in stock
</label>
</div>
);
}Step 5: Wire Up the Data Flow
Now connect everything. The parent owns the state, passes values down, and provides callbacks for children to request changes:
import { useState } from "react";
interface Product {
name: string;
price: string;
category: string;
inStock: boolean;
}
const PRODUCTS: Product[] = [
{ category: "Fruits", name: "Apple", price: "$1", inStock: true },
{ category: "Fruits", name: "Dragonfruit", price: "$1", inStock: true },
{ category: "Fruits", name: "Passionfruit", price: "$2", inStock: false },
{ category: "Vegetables", name: "Spinach", price: "$2", inStock: true },
{ category: "Vegetables", name: "Pumpkin", price: "$4", inStock: false },
{ category: "Vegetables", name: "Peas", price: "$1", inStock: true },
];
function FilterableProductTable() {
const [query, setQuery] = useState("");
const [inStockOnly, setInStockOnly] = useState(false);
const filtered = PRODUCTS.filter((product) => {
if (inStockOnly && !product.inStock) return false;
if (!product.name.toLowerCase().includes(query.toLowerCase())) return false;
return true;
});
return (
<div className="max-w-md mx-auto p-4">
<SearchBar
query={query}
inStockOnly={inStockOnly}
onQueryChange={setQuery}
onInStockChange={setInStockOnly}
/>
<ProductTable products={filtered} />
</div>
);
}Notice the clear pattern:
- State lives in the parent.
- Values flow down as props.
- Changes flow up through callbacks.
- Derived data (filtered list) is computed during render — no extra state needed.
Common Patterns
Derived State vs. Stored State
Never store state that can be computed. If you have a list and a search query, compute the filtered list during render:
// Good — derived during render
const filteredItems = items.filter((item) =>
item.name.includes(query)
);
// Bad — duplicated state that can get out of sync
const [filteredItems, setFilteredItems] = useState(items);Component Responsibility
Keep components focused. A good rule of thumb: if you can describe what a component does without using the word "and," it's the right size. If you say "it manages the search and displays the table and handles pagination," break it up.
Colocation
Keep state as close as possible to where it's used. Don't hoist state to a top-level provider if only one subtree needs it. Start local; lift up only when required.
Summary
- Break the UI into a component hierarchy based on single responsibility.
- Build a static version first with no state — just props.
- Identify the minimal state by eliminating props, constants, and derived values.
- Place state in the closest common parent of the components that need it.
- Connect the flow with props (down) and callbacks (up).
This five-step process applies to every React feature you build, from a single form to an entire application.