Skip to content

Thinking in React

Every chapter so far has taught one piece: components, props, state, events, lists. This chapter is where they come together into a method. Given a design, how do you decide what the components are, which values are state, and where that state should live? There's a repeatable way to work through it, and once you've done it a few times you'll reach for the same five steps on almost every screen you build.

We'll walk one small UI end to end: a searchable product list. It shows a set of products, a search box to filter them by name, and a checkbox to hide anything out of stock. Here's the data it renders:

jsx
const PRODUCTS = [
  { id: 1, name: 'Apple', price: '$1', stocked: true },
  { id: 2, name: 'Dragonfruit', price: '$1', stocked: false },
  { id: 3, name: 'Passionfruit', price: '$2', stocked: true },
]

Step 1: Break the design into a component tree

Look at the design and draw boxes around the pieces. A good box is one thing that does one job, the same instinct you use when deciding what a function should do. Our little screen splits cleanly into a few:

  • FilterableProductList wraps the whole thing.
  • SearchBar holds the text input and the in-stock checkbox.
  • ProductTable shows the list of products.
  • ProductRow is a single row in that table.

Those nest inside each other, so they form a component tree:

FilterableProductList
├── SearchBar
└── ProductTable
    └── ProductRow  (one per product)

There's no single correct split, and the components chapter's rule of thumb applies here: keep each one small and give it a clear job. A row knows how to draw one product; the table knows how to lay out rows; the search bar knows about the controls. If a piece starts doing two unrelated things, that's usually a sign to split it.

Step 2: Build a static version with props only

Now build a version that renders the data but does nothing yet. No clicking, no filtering, no typing that changes anything. Data flows one way, down the tree, through props. No useState appears anywhere in this step.

jsx
function FilterableProductList({ products }) {
  return (
    <div>
      <SearchBar />
      <ProductTable products={products} />
    </div>
  )
}

function ProductTable({ products }) {
  return (
    <table>
      <tbody>
        {products.map(product => (
          <ProductRow key={product.id} product={product} />
        ))}
      </tbody>
    </table>
  )
}

function ProductRow({ product }) {
  return (
    <tr>
      <td>{product.name}</td>
      <td>{product.price}</td>
    </tr>
  )
}

function SearchBar() {
  return (
    <form>
      <input type="text" placeholder="Search..." />
      <label>
        <input type="checkbox" /> Only show products in stock
      </label>
    </form>
  )
}

This renders the full list every time. The search box and checkbox are on screen, but they don't do anything yet. That's the point of the step: you get the whole layout working from props alone, and you confirm the tree from step 1 actually holds together, before any of the moving parts are involved. The products array comes in as a prop at the top and gets passed down; nothing here remembers or changes anything.

Step 3: Find the minimal state

Now make it interactive, and the first question is which values need to be state at all. State is expensive to keep correct, so you want as little of it as possible. The rest you'll calculate.

The test for each value is short. Ask two things:

  1. Does it change over time?
  2. Is it something you can't calculate from another value you already have?

If both are yes, it's state. If either is no, it isn't. Walk the values in our UI through that test:

  • The list of products. It's passed in and doesn't change while the user pokes at the page, so it's not state. It's a prop.
  • The search text the user typed. It changes over time, and there's nothing to calculate it from. It's state.
  • Whether the in-stock checkbox is on. Same story: it changes, and nothing derives it. It's state.
  • The filtered list actually shown on screen. It changes, but you can compute it from the products, the search text, and the checkbox. So it is not state.

That last one is the rule worth burning in: derive, do not duplicate. The visible list is the products passed through the current filters, so you calculate it during render instead of storing a second copy in state:

jsx
const visible = products.filter(product => {
  const matchesText = product.name
    .toLowerCase()
    .includes(filterText.toLowerCase())
  const matchesStock = !inStockOnly || product.stocked
  return matchesText && matchesStock
})

If you had stored visible in its own useState, you'd have to remember to update it every single time the products, the text, or the checkbox changed. Miss one and the screen shows a stale list. Deriving it means there's only one source of truth, and it can never fall out of sync, because it's recomputed fresh on every render. So the minimal state here is exactly two values: filterText and inStockOnly.

Step 4: Decide where each piece of state should live

You have two pieces of state. Now figure out which component owns each one. The rule is to put state in the closest common parent of every component that needs it, which is the same idea the lifting state up chapter goes into in depth.

Work out who needs each value:

  • SearchBar needs both values, because it renders the input and the checkbox that show them.
  • ProductTable needs both values too, because it filters the rows using them.

SearchBar and ProductTable are siblings. A value can only flow down through props, so neither sibling can hand state to the other. The closest component that sits above both of them is FilterableProductList. That's where the state goes:

jsx
import { useState } from 'react'

function FilterableProductList({ products }) {
  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>
  )
}

The state lives in the parent, and the values flow down to both children as props. ProductTable reads them to build its visible list; SearchBar reads them to fill in the input and the checkbox.

Step 5: Add the interaction that updates state

The values reach the children, but the user still can't change them. Data only flows down, so to send a change back up you pass the setters down as props and let the child call them. FilterableProductList already passes onFilterTextChange and onInStockOnlyChange above. SearchBar wires them to the inputs:

jsx
function SearchBar({
  filterText,
  inStockOnly,
  onFilterTextChange,
  onInStockOnlyChange,
}) {
  return (
    <form>
      <input
        type="text"
        placeholder="Search..."
        value={filterText}
        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>
  )
}

Now the loop is closed. Typing in the box calls onFilterTextChange, which is setFilterText up in the parent. That updates the state, the parent re-renders, the new filterText flows back down to both children, ProductTable recomputes its visible list, and the screen matches what the user typed. The checkbox works the same way. State goes down as a value, changes come back up as a function call, and the derived list follows along on its own.

That's the whole method: draw the component tree, build it static with props, find the minimal state, lift it to the right owner, then wire the interaction. Almost any screen you meet yields to those five steps.

Step 3 is the one that rewards care, because "minimal" is stricter than it first looks. The working question is: what is the smallest set of values from which every other value on screen can be recomputed? For our list that's filterText and inStockOnly, and everything visible is a pure function of those two plus the incoming products. Anything you can derive during render, you should. The filtered list is the clearest case, but the same logic rules out storing a count of matches, a hasResults boolean, or a sorted copy of the array. Each of those is a function of state you already hold, so keeping it in its own useState buys you a second value to keep in sync and a new way to be wrong. Derived values can't go stale, because they don't persist; they're recalculated on every render from the one source of truth.

The trade-off to watch on the other side is deriving something expensive on every render. Reach for useMemo only when profiling shows a real cost. The default stays: derive during render, and promote a value to state only when it changes over time and nothing produces it.

Step 1 has its own judgment call, and it cuts both ways. Split too little and a component grows into a tangle that's hard to reuse or reason about. Split too much and you get a spray of tiny components threading props through layers that exist only to pass them along, which is its own kind of hard to follow. The useful test is responsibility and reuse: a boundary earns its place when the piece has one clear job, when it repeats (a ProductRow rendered once per item), or when pulling it out makes the parent readable. A boundary that only ever renders in one spot and forwards its props untouched is usually one you can inline. Let real duplication and real complexity pull components apart, rather than splitting on the guess that you'll need the seams later.

JunoFive steps from design to React Here's the recipe you can lean on: draw boxes around the pieces to get your components, build it with props first so it shows the data and nothing more, then find the few values that actually change and can't be worked out from something else, those are your state. Put each one in the closest component above everything that needs it, and pass a setter down so a click or a keystroke can update it. The list you show on screen isn't state; you calculate it from the products and the filters every time.
JunoFive steps from design to React The method is the same every time: component tree, static version with props only, minimal state, lift it to the closest common parent, wire the interaction. The state question is the sharp one, if a value changes over time and you can't derive it, it's state, otherwise compute it during render. Derive, don't duplicate: store filterText and inStockOnly, and calculate the visible list from them so it can never fall out of sync.
JunoFive steps from design to React Minimal state means the smallest set everything else is a pure function of; here, filterText and inStockOnly, with the filtered list, counts, and flags all derived during render rather than stored. Extra state is another way to desync, and useMemo earns its place only once profiling shows a real cost. Component boundaries are the other judgment call: split for a real job, repetition, or readability, and inline the pass-through wrappers you added on spec.

Next up: Accessible React, where the interface you just designed becomes usable by everyone.