webdev.complete
⚛️ React: The Mental Model
⚛️React
Lesson 79 of 117
25 min

Thinking in React

Split UI into a tree. Identify minimal state. Lift it up.

React doesn't tell you howto break a UI into pieces. It just gives you the pieces. Most beginners stare at a design and freeze. The React team published a five-step process years ago that still works perfectly. Memorize it once and you'll never freeze again.

The starting point: a mock

Imagine a designer hands you this: a product list with a search box and an "only in stock" checkbox. The current code is one giantApp.js with everything hardcoded.

tsx
// The mess we're starting with
export default function App() {
  return (
    <div>
      <input placeholder="Search..." />
      <label>
        <input type="checkbox" /> Only show products in stock
      </label>
      <table>
        <thead><tr><th>Name</th><th>Price</th></tr></thead>
        <tbody>
          <tr><td>Apple</td><td>$1</td></tr>
          <tr><td>Dragonfruit</td><td>$1</td></tr>
          <tr style={{ color: "red" }}><td>Passionfruit</td><td>$2</td></tr>
        </tbody>
      </table>
    </div>
  );
}

We want this to be a real component tree backed by data and state. Here are the five steps.

Step 1: split the UI into a component tree

Draw boxes around every logical region of the mock. Use the single responsibility principle. If a region does more than one thing, split it. For our example:

  • FilterableProductTable (the whole thing)
  • SearchBar (the search input + checkbox)
  • ProductTable (the table)
  • ProductCategoryRow (a category heading row)
  • ProductRow (a single product row)

Step 2: build a static version with no state

First, just make it render with props. No useState, no events, nothing interactive. You pass data in at the top and let it flow down. This proves your component split is good.

tsx
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 ProductTable({ products }) {
  return (
    <table>
      <thead><tr><th>Name</th><th>Price</th></tr></thead>
      <tbody>
        {products.map((p) => <ProductRow key={p.name} product={p} />)}
      </tbody>
    </table>
  );
}
Static first, always
Resist the urge to add state in step 2. Building the static version forces you to confirm your component boundaries are right. Adding state later is one line of code. Restructuring components later is a nightmare.

Step 3: find the minimal state

Look at every piece of data on the screen and ask three questions:

  1. Does it change over time? If no, it's not state, it's a prop or a constant.
  2. Is it passed in from a parent? If yes, it's a prop, not state.
  3. Can you derive it from other state or props? If yes, derive it. Don't store it.

For our example:

  • The list of products: passed in, not state.
  • The search text: changes over time, can't be derived. State.
  • The "in stock only" flag: same answer. State.
  • The filtered products you actually display: derivable from products + filters. Not state. Compute it during render.
Derived state is the #1 source of bugs
If you store filtered products in state, you have to remember to update them whenever the source changes. You will forget. Then the UI lies. Compute derived values during render every time.

Step 4: decide where state lives

Find the closest common ancestor of every component that needs to read or write each piece of state. That's where the state goes.

  • SearchBar writes the search text. ProductTable reads it.
  • Closest common ancestor: FilterableProductTable. That's where useState goes.

This is called lifting state up. The state lives in the parent. The children get it as props.

Step 5: wire events back up

Data flows down, events flow up. The parent owns the state, so the parent owns the setter. Pass that setter (or a wrapper) down as a prop. Children call it.

tsx
function SearchBar({ text, onTextChange }) {
  return (
    <input
      value={text}
      onChange={(e) => onTextChange(e.target.value)}
      placeholder="Search..."
    />
  );
}

function FilterableProductTable({ products }) {
  const [text, setText] = useState("");
  const [inStockOnly, setInStockOnly] = useState(false);

  return (
    <>
      <SearchBar text={text} onTextChange={setText} />
      <ProductTable products={products.filter(/* ... */)} />
    </>
  );
}

That's the whole flow. Down, up, done.

Try it: the full transformation

Below is the finished version of the example. Notice that FilterableProductTable is the only stateful component. Everything else is a pure renderer of props.

import { useState } from "react";

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" },
];

function ProductRow({ product }) {
  const name = product.stocked
    ? product.name
    : <span style={{ color: "#dc2626" }}>{product.name}</span>;
  return <tr><td>{name}</td><td>{product.price}</td></tr>;
}

function ProductCategoryRow({ category }) {
  return <tr><th colSpan="2" style={{ background: "#f3f4f6" }}>{category}</th></tr>;
}

function ProductTable({ products, filterText, inStockOnly }) {
  const rows = [];
  let lastCategory = null;

  for (const product of products) {
    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} />);
    }
    rows.push(<ProductRow key={product.name} product={product} />);
    lastCategory = product.category;
  }

  return (
    <table style={{ width: "100%", marginTop: 12, fontFamily: "system-ui" }}>
      <thead><tr><th align="left">Name</th><th align="left">Price</th></tr></thead>
      <tbody>{rows}</tbody>
    </table>
  );
}

function SearchBar({ filterText, inStockOnly, onFilterTextChange, onInStockOnlyChange }) {
  return (
    <form style={{ fontFamily: "system-ui" }}>
      <input
        type="text"
        value={filterText}
        placeholder="Search..."
        onChange={(e) => onFilterTextChange(e.target.value)}
        style={{ padding: 6, width: "100%", marginBottom: 6 }}
      />
      <label>
        <input
          type="checkbox"
          checked={inStockOnly}
          onChange={(e) => onInStockOnlyChange(e.target.checked)}
        />
        {" "}Only show products in stock
      </label>
    </form>
  );
}

function FilterableProductTable({ products }) {
  const [filterText, setFilterText] = useState("");
  const [inStockOnly, setInStockOnly] = useState(false);

  return (
    <div style={{ padding: 16, maxWidth: 480 }}>
      <SearchBar
        filterText={filterText}
        inStockOnly={inStockOnly}
        onFilterTextChange={setFilterText}
        onInStockOnlyChange={setInStockOnly}
      />
      <ProductTable
        products={products}
        filterText={filterText}
        inStockOnly={inStockOnly}
      />
    </div>
  );
}

export default function App() {
  return <FilterableProductTable products={PRODUCTS} />;
}

Quiz

Quiz1 / 3

You have a list of products and the user can filter them. Where does the filtered list belong?

Recap

  • Step 1: break the mock into a component tree by responsibility.
  • Step 2: build it static. Props only, no state, no handlers.
  • Step 3: identify minimal state. If it's derivable, don't store it.
  • Step 4: put each state at the nearest common ancestor of its readers.
  • Step 5: pass setters down as props so children can request changes.