ReactThinking in React

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:

TS
// 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 state

  • SearchBar — the text input and checkbox

  • ProductTable — the table header + rows

  • ProductCategoryRow — a category header row (e.g. "Fruits")

  • ProductRow — a single product row

The hierarchy (indentation = child of):

Text
FilterableProductTable
  SearchBar
  ProductTable
    ProductCategoryRow
    ProductRow
Note
When in doubt about whether something should be its own component, apply the single-responsibility principle: if a component ends up doing more than one thing, extract it. This also makes testing easier — small components are trivial to test in isolation.
Step 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.

TSX
// 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>
  )
}
Tip
Build top-down for smaller apps (start with FilterableProductTable) or bottom-up for larger apps (start with ProductRow). Bottom-up lets you test each piece in isolation before wiring them together.
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:

  1. Does it come from a parent via props? → Not state.

  2. Does it stay the same over time (never changes)? → Not state.

  3. 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.

Warning
A common beginner mistake is storing derived data in state: const [filteredProducts, setFilteredProducts] = useState([]). This creates a synchronization problem — every time the filter changes, you have to remember to also update the filtered list. Compute derived values directly in the render function instead.
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:

  1. List every component that renders something based on that state.

  2. Find their nearest common ancestor in the component tree.

  3. 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:

TSX
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.

TSX
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:

TSX
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

TSX
// 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
  1. Break the UI into a component hierarchy — draw boxes around each distinct piece; name each box

  2. Build a static version — render with props only, no state, no event handlers

  3. Find minimal state — eliminate anything that can be computed or comes from props

  4. Identify where state lives — find the nearest common ancestor of all components that need the state

  5. Add inverse data flow — pass setter functions down as props so children can update parent state

Note
This process scales from small components to entire application architectures. When you are confused about where a piece of data should live, running through the five steps always leads to the right answer.
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.