Thinking in React
React is not just a library — it is a way of thinking about UIs. Once you internalize the React mental model, designing new features becomes systematic: you break the UI into components, identify the minimal state required, figure out where that state lives, and wire up the data flow. This five-step process is the official React design methodology.
We will walk through a concrete example: a product filter table that lets a user search products and toggle between showing all products or only in-stock ones. This is the same example from React's official docs, worked through in depth.
The Data and the Mockup
Imagine we receive this JSON from an API and a static mockup from a designer:
// The data coming from the API
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
Look at the mockup and draw boxes around every distinct piece of UI. Each box that does one cohesive thing becomes a component. A useful heuristic: a component should have a single reason to change (the single-responsibility principle).
For the product filter table, we can identify:
FilterableProductTable— the entire app; owns the search stateSearchBar— the text input and checkboxProductTable— the table header + rowsProductCategoryRow— a category header row (e.g. "Fruits")ProductRow— a single product row
The hierarchy (indentation = child of):
FilterableProductTable
SearchBar
ProductTable
ProductCategoryRow
ProductRowStep 2 — Build a Static Version
Before adding any interactivity, build a version that renders the UI from the data using only props — no state at all. Static versions are easier to build because you only have to think about rendering, not about what happens when things change.
// A static version — props only, no useState anywhere
interface Product {
category: string
price: string
stocked: boolean
name: string
}
function ProductRow({ product }: { product: Product }) {
const nameStyle = product.stocked ? undefined : { color: 'red' }
return (
<tr>
<td style={nameStyle}>{product.name}</td>
<td>{product.price}</td>
</tr>
)
}
function ProductCategoryRow({ category }: { category: string }) {
return (
<tr>
<th colSpan={2}>{category}</th>
</tr>
)
}
function ProductTable({ products }: { products: Product[] }) {
const rows: React.ReactNode[] = []
let lastCategory: string | null = null
for (const product of products) {
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>
<thead><tr><th>Name</th><th>Price</th></tr></thead>
<tbody>{rows}</tbody>
</table>
)
}
function SearchBar() {
return (
<form>
<input type="text" placeholder="Search..." />
<label>
<input type="checkbox" />
{' Only show products in stock'}
</label>
</form>
)
}
function FilterableProductTable({ products }: { products: Product[] }) {
return (
<div>
<SearchBar />
<ProductTable products={products} />
</div>
)
}Step 3 — Find the Minimal Representation of State
Now identify the absolute minimum state your interactive UI needs. The key principle is DRY (Don't Repeat Yourself): if a value can be computed from other data, it should not be state.
Ask three questions about each piece of data:
Does it come from a parent via props? → Not state.
Does it stay the same over time (never changes)? → Not state.
Can you compute it from other existing state or props? → Not state, compute it.
Data | State? | Reason |
|---|---|---|
The original products list | No | Passed in from the parent via props |
The search text the user typed | Yes | Changes over time, cannot be computed |
The checkbox value | Yes | Changes over time, cannot be computed |
The filtered list of products | No | Can be computed from products + search text + checkbox |
So we need exactly two pieces of state: the search text string and the boolean for the checkbox. The filtered list is derived — we compute it during render.
Step 4 — Identify Where State Should Live
For each piece of state, find the component that should own it. React data flows one direction — top to bottom. State must live in a component that is an ancestor of every component that needs to read or set it.
The algorithm:
List every component that renders something based on that state.
Find their nearest common ancestor in the component tree.
The state belongs in that ancestor (or higher up if needed).
In our example, SearchBar displays the filter text and checkbox. ProductTable filters the rows based on them. Their nearest common ancestor is FilterableProductTable — so the state lives there:
import { useState } from 'react'
function FilterableProductTable({ products }: { products: Product[] }) {
// State lives here — the nearest common ancestor of SearchBar and ProductTable
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>
)
}Step 5 — Add Inverse Data Flow
React data flows down — but UI events happen in child components. To update state in a parent from a child, the parent passes its state setter function down as a prop. The child calls it when the user interacts. This is called inverse data flow or lifting state up.
interface SearchBarProps {
filterText: string
inStockOnly: boolean
onFilterTextChange: (text: string) => void
onInStockOnlyChange: (checked: boolean) => void
}
function SearchBar({
filterText,
inStockOnly,
onFilterTextChange,
onInStockOnlyChange,
}: SearchBarProps) {
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>
)
}And ProductTable uses the filter values to compute the visible rows directly during render — no extra state needed:
interface ProductTableProps {
products: Product[]
filterText: string
inStockOnly: boolean
}
function ProductTable({ products, filterText, inStockOnly }: ProductTableProps) {
const rows: React.ReactNode[] = []
let lastCategory: string | null = null
for (const product of products) {
// Apply filters — computed, not stored in state
if (!product.name.toLowerCase().includes(filterText.toLowerCase())) continue
if (inStockOnly && !product.stocked) continue
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>
<thead><tr><th>Name</th><th>Price</th></tr></thead>
<tbody>{rows}</tbody>
</table>
)
}The Complete Wired-Up App
// Everything assembled
const PRODUCTS: Product[] = [
{ 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' },
]
export default function App() {
return <FilterableProductTable products={PRODUCTS} />
}The Five Steps at a Glance
Break the UI into a component hierarchy — draw boxes around each distinct piece; name each box
Build a static version — render with props only, no state, no event handlers
Find minimal state — eliminate anything that can be computed or comes from props
Identify where state lives — find the nearest common ancestor of all components that need the state
Add inverse data flow — pass setter functions down as props so children can update parent state
When to Break These Rules
Global state (authentication, theme, locale) — lifting to the nearest common ancestor would mean the root component. Use Context or a state manager instead.
Server state (fetched data) — asynchronous data with loading/error states is better managed by libraries like TanStack Query or SWR than raw useState.
Form state — deeply nested form fields benefit from specialized solutions (React Hook Form, Formik) that avoid lifting every keystroke to a common ancestor.