Thinking in React and lifting state

React · Practical · 15 min

Deciding where state lives is the core design decision in every React app; get it right and the rest follows.

The React docs describe a design process that scales from a widget to an app:

  • Break the UI into a component hierarchy. Draw boxes around parts of the mockup; each box with one responsibility is a component.
  • Build a static version that renders from props, with no state.
  • Find the minimal state. Ask of each piece of data: is it passed in from a parent? Does it stay the same over time? Can it be computed from other state or props? If any answer is yes, it's not state. In a filterable product table, the product list (a prop) and the filtered list (derived) aren't state; the search text and the "in stock only" checkbox are.
  • Decide who owns each piece of state: the closest common parent of all components that use it.
  • Add inverse data flow: children change the owner's state by calling callbacks passed down as props.

When two siblings need the same data, lift the state up: remove it from the children, put it in their parent, and pass the value and a setter-like callback down. The children become controlled components, driven by props, which makes them easy to coordinate (only one accordion panel open at a time, say).

Data flows down through props and events flow up through callbacks. That one-way flow is why React apps stay debuggable: for any value on screen, there's exactly one component that owns it.

Avoid duplicating state (storing selectedItem as a copy when selectedId would do) and avoid contradictory state (isSending and isSent both true). If state lives too high, many components re-render needlessly and props get passed through many layers; context and composition help with that later.

Example

import { useState } from "react";

function Panel({ title, isActive, onShow, children }) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? <p>{children}</p> : <button onClick={onShow}>Show</button>}
    </section>
  );
}

// State lifted to the common parent: only one panel can be open.
export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <>
      <Panel title="About" isActive={activeIndex === 0} onShow={() => setActiveIndex(0)}>
        Almaty is the largest city in Kazakhstan.
      </Panel>
      <Panel title="Etymology" isActive={activeIndex === 1} onShow={() => setActiveIndex(1)}>
        The name comes from the Kazakh word for apple.
      </Panel>
    </>
  );
}

Remember

  • State is data that changes, isn't passed in, and can't be computed from anything else
  • Put state in the closest common parent of the components that use it
  • Data flows down as props; changes flow up through callback props

Watch out for

  • Keeping the same data in two components' state and trying to sync them instead of lifting it up.

Check yourself

  1. A SearchBar and a ProductTable (siblings) both need the search text. Where should that state live?
  2. Which of these should NOT be state in a filterable list?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: react.dev/learn/thinking-in-react