🪴 GoDeep
Search
← Shipping Software

Components, props and state

Read a React component tree the way React runs it: props in, JSX out, state as the only thing that makes the screen change. You'll be able to trace where a value comes from, spot mutated state and prop drilling, and judge whether a component an agent wrote is put together sanely.

12 cards · 6 quiz questions · 10 min read

An agent has built you a dashboard: a study picker, a search box, a table of survey responses, a row of summary tiles. It works. Now you need to add a status filter, and the file is 400 lines of functions that call each other in no obvious order. This article is about the small number of rules React actually runs on, so that a component tree you didn’t write becomes readable rather than mysterious.

A component is a function that returns a description

Strip away the vocabulary and a React component is a JavaScript function. It takes one argument — an object of props, the values its parent passed in — and returns JSX, which looks like HTML but compiles to ordinary function calls producing plain objects.

// props in, description of UI out. Nothing here touches the page.
function SummaryTile({ label, value }: { label: string; value: number }) {
  return (
    <div className="tile">
      <span className="tile-label">{label}</span>
      <span className="tile-value">{value.toLocaleString()}</span>
    </div>
  );
}

The important word is description. SummaryTile does not create a div. It returns an object that says “a div, with these children”. React compares that description with the one from the previous render and works out the smallest set of real DOM changes needed. This is why you never see DOM code in a well-written component — and why document.getElementById('total').innerText = ... inside a component is a genuine smell. React will overwrite it on the next render, and the two sources of truth will disagree.

UI is a function of state. For the same props and state, a component renders the same output. You don’t change the screen; you change state, and the screen follows.

Props versus state

Props flow down and are read-only. Mutating props.rows inside a child is one of the clearest signs an agent has lost the thread.

State is memory a component owns, declared with useState, which survives re-renders and asks React for a new one when it changes.

function ResponsesPanel({ studyId }: { studyId: string }) {
  const [query, setQuery] = useState("");           // state: changes over time
  const { data: rows = [] } = useResponses(studyId); // props/derived: comes from outside

  // Derived during render — NOT stored in state.
  const visible = rows.filter((r) =>
    r.respondentId.toLowerCase().includes(query.toLowerCase())
  );

  return <ResponseTable rows={visible} query={query} onQueryChange={setQuery} />;
}

React’s own guidance is to find the minimal but complete representation of UI state. Three questions settle almost every case. Does the value stay the same over time? Not state. Is it passed in by a parent? That’s a prop. Can you calculate it from props or other state? Calculate it during render.

The mistake to watch for is an agent storing derived values:

// Before: two sources of truth that drift apart
const [rows, setRows] = useState<Response[]>([]);
const [filtered, setFiltered] = useState<Response[]>([]);
const [total, setTotal] = useState(0);

function onSearch(q: string) {
  const next = rows.filter((r) => r.respondentId.includes(q));
  setFiltered(next);        // stale the moment `rows` changes for any other reason
  setTotal(next.length);
}
// After: one source of truth, everything else computed
const [rows, setRows] = useState<Response[]>([]);
const [query, setQuery] = useState("");

const filtered = rows.filter((r) => r.respondentId.includes(query));
const total = filtered.length;   // free, and never stale

When a dashboard shows a count that disagrees with the number of rows on screen, this is nearly always why.

Immutable updates, and why they are not optional

React decides whether to re-render by comparing the new state value with the old one by identity. Same object reference means “nothing changed”.

// Before: mutates, so React sees the same array and skips the re-render
rows.push(newRow);
setRows(rows);

// After: a new array, so identity differs and React re-renders
setRows([...rows, newRow]);

// Objects use the same idea — copy, then override the field
setFilters({ ...filters, status: "complete" });

// Editing one row in a list
setRows(rows.map((r) => (r.id === id ? { ...r, flagged: true } : r)));

Related trap: the state variable is a constant belonging to this render. Calling setQuery("abc") does not change query on the line below; it schedules a re-render. Updates in the same event are also batched, so setCount(count + 1) twice in a row increments once. Use the function form when the new value depends on the old one: setCount(c => c + 1).

Lifting state up, and controlled inputs

When two components must agree — a filter bar and the table it filters — the state belongs in their closest common parent. That is lifting state up: one owner, values down as props, changes up as callbacks.

A controlled input is the same pattern applied to a form field: React state holds the truth, the DOM just displays it.

// Controlled: value comes from state, every keystroke goes back to state
<input value={query} onChange={(e) => setQuery(e.target.value)} />

// Uncontrolled: the DOM owns the value; you read it with a ref or on submit
<input defaultValue={query} ref={inputRef} />

Passing value with no onChange gives you a field the user cannot type into — a “frozen input” bug, and React warns about it in the console. Mixing value and defaultValue on the same field is another common agent error.

Keys, and why the wrong row gets ticked

Lists need a key so React can match this render’s items to the previous one’s.

// Before: position-based identity
{rows.map((row, i) => <ResponseRow key={i} row={row} />)}

// After: stable identity from the data
{rows.map((row) => <ResponseRow key={row.id} row={row} />)}

With index keys, deleting row 1 shifts everything up, and React happily reuses row 3’s DOM node and local state for what is now a different response. Ticked checkboxes and half-typed notes follow the wrong record. If you only ever append and never delete, sort or filter, index keys hide the bug — until someone adds a sort.

Composition beats drilling

Prop drilling is threading a prop through components that don’t use it, to reach something deep. Before reaching for context or a store, try composition: pass the element itself.

// Before: `studyId` drilled through two components that don't care about it
<Layout studyId={studyId}>
  <Sidebar studyId={studyId} />
</Layout>

// After: the element is built where the data already lives
<Layout sidebar={<StudyFilters studyId={studyId} />} />

Context and stores earn their place for genuinely app-wide, changing values — the signed-in user, the theme, a feature flag. An agent that reaches for a global store to move one value one level down has made the tree harder to read, not easier.

Reading someone else’s component tree

A reliable recipe when you need to find where a number on screen comes from:

StepWhat you do
1Search the repo for the visible label text to find the leaf component
2Read its props in the function signature — that’s its whole input
3Search for that component’s name to find the parent rendering it
4Repeat upwards until you hit a useState, a context provider, or a fetching hook

React DevTools does the same thing interactively: select an element and its props and hook values are listed, with the owning component highlighted. For agent-written code this is usually faster than reading, because it shows you what the tree is rather than what the file suggests.

What to check in your own code

  • Grep for document.getElementById, .innerHTML and querySelector inside src/components — each one is React and something else fighting over the DOM.
  • Grep for .push(, .sort(, .splice( and .reverse( on a value that came from useState — all four mutate in place. (sort and reverse mutate; map, filter and slice don’t.)
  • Grep for key={i} and key={index} and check whether that list can ever be reordered or filtered.
  • Grep for useState and, for each one, ask whether the value could be computed instead. Two states holding the same information is a bug waiting for a deadline.
  • Look for any fetch or axios call sitting directly in a component body rather than in a handler or a hook.
  • Check every <input> has either value and onChange, or defaultValue — never value alone, never both.

Sources

  • React documentation — Thinking in React website The five-step method, the props-versus-state distinction, and 'find the minimal but complete representation of UI state'.
  • React documentation — Updating Objects in State website Why state must be replaced rather than mutated, and the spread-syntax patterns for doing it.
  • React documentation — Sharing State Between Components website Lifting state up and the controlled-versus-uncontrolled component distinction.
  • React documentation — Render and Commit website The trigger, render, commit sequence that explains why nothing happens until state changes.