skip to content

A React table receives a `rows` prop and tracks the highlighted row with `const [selectedRow, setSelectedRow] = useState(null)` holding the whole row object. Why is storing the row's id usually the better model, and what breaks in the object version?

level: middleimportance: should knowfreq 54%

answer

  1. can I compute it from what I have?
  2. the row already lives in rows
  3. refetch invalidates a captured object
  4. identity comparison stops matching
  5. store the id, derive the row

basics

~20 s

Storing the object duplicates data the rows prop already owns, so the stored copy goes stale when rows are refetched or edited. Store the id — the minimal fact React cannot compute — and look the row up during render.

solid answer

~50 s

The row object is not new information: it already lives in `rows`. Copying it into state means the same record exists twice, and only one copy updates when the list is refetched or a field is edited, so the detail pane can show old data. Identity comparisons like `row === selectedRow` also stop matching once the parent produces fresh objects, which usually shows up as the highlight silently disappearing. Store the minimal fact the component genuinely owns — `selectedId` — and derive the row during render with `rows.find(r => r.id === selectedId)`. That gives one source of truth, and it forces you to handle the case where the selected row is no longer in the list, which is a real product decision rather than a stale object hanging around. The same reasoning turns multi-select into a `Set` of ids rather than an array of objects.

code

jsx · 27 lines
jsx
import { useState } from 'react';

function Table({ rows }) {
  // Minimal state: only the identity of the selection
  const [selectedId, setSelectedId] = useState(null);

  // Derived: always reflects the current rows prop
  const selectedRow = rows.find((r) => r.id === selectedId) ?? null;

  return (
    <div>
      <ul>
        {rows.map((row) => (
          <li
            key={row.id}
            aria-selected={row.id === selectedId}
            onClick={() => setSelectedId(row.id)}
          >
            {row.name}
          </li>
        ))}
      </ul>
      {selectedId !== null && selectedRow === null && <p>That record was removed.</p>}
      {selectedRow && <pre>{JSON.stringify(selectedRow, null, 2)}</pre>}
    </div>
  );
}

go deeper

for a junior

Be able to say that the row object already exists in the rows prop, so keeping a copy in state stores the same fact twice. Show the id-plus-find version and use row.id for the key and for the highlight check.

for a middle

Explain both concrete failures — a captured object going stale after a refetch, and reference comparison breaking when the parent creates new objects — and write the derived lookup, including what happens when find returns undefined.

for a senior

Treat it as a modelling audit: enumerate the illegal states redundant variables allow, and drive the design toward the smallest set that cannot represent a contradiction. Handle the deleted-selection case deliberately and note that ids serialise into the URL.

for a principal

Own the convention across the codebase: selection and other cross-cutting UI facts are expressed as ids so they can move into the URL or be persisted, and reviews challenge any state variable that duplicates server-owned data. Decide deliberately where the client's cache of records lives.

## Minimal state A well-modelled component holds the smallest set of values from which everything it renders can be computed. The test for each candidate variable is: can I compute this from props or from another state variable at render time? If yes, it is derived and does not belong in `useState`. Whatever survives that filter is the minimal state — the facts nothing else in the component can produce. In a selectable table, the only thing the table genuinely owns is *which* row is selected. The row's contents belong to whoever owns `rows`. So the minimal state is an identifier, and the selected row is derived: ```jsx const [selectedId, setSelectedId] = useState(null); const selectedRow = rows.find((r) => r.id === selectedId) ?? null; ``` ## What breaks when you store the object **Stale content.** The parent refetches `rows` and a row's `status` changes from `pending` to `paid`. The table re-renders with the new list, but `selectedRow` still points at the object captured at click time, so the detail pane shows `pending`. Nothing errors; the UI is just wrong until the user clicks again. **Broken identity.** Code like `rows.map(r => <Row highlighted={r === selectedRow} />)` compares object references. After a refetch, the parent's new objects are structurally identical but referentially different, so no row matches and the highlight vanishes. Candidates usually reach for a deep comparison here, which is a symptom-level fix for a modelling problem. With `r.id === selectedId` the comparison is on a value that survives serialisation, refetching and re-creation. **Deleted rows linger.** If the selected record is removed server-side, the object version keeps rendering a row that no longer exists. The id version returns `undefined` from `find`, which forces you to decide what that means: clear the selection, show an empty state, or show "this record was removed". The model surfaces the case instead of hiding it. **Serialisability.** An id round-trips cleanly through a URL query parameter, `sessionStorage`, or a reducer's action log; a captured object graph does not. Modelling selection as an id keeps the door open to "selection lives in the URL", which is often where it belongs. ## Recognising redundant state generally The same audit catches the whole family: - `items` **and** `itemCount` — the count is `items.length`. - `items` **and** `filteredItems` — derive from `items` plus `query`. - `user` **and** `isLoggedIn` — derive as `user !== null`. - `rows` **and** `selectedRow` — derive from `rows` plus `selectedId`. - `startDate`, `endDate` **and** `durationDays` — derive the third. Each pair has the same shape: one variable is a function of the others, so the pair admits states that should be impossible, such as an empty `items` with `itemCount` of 3. Fewer variables mean fewer illegal combinations. This is the same instinct as database normalisation, applied to a component. ## The cost, and when to pay it Deriving means a lookup on every render. For a table you already render row by row, an `Array.prototype.find` over the same array is negligible; if the list is large and the lookup shows up in a profile, build a `Map` from id to row rather than reverting to a stored copy. There is one honest exception: when the selected entity is genuinely *not* derivable from what the component has. If the detail pane is loaded from a separate endpoint, or the list is paginated and the selected record may not be on the current page, then the fetched record is new information and storing it is correct — but even then the id remains the thing the user's selection *means*, and the fetched record is cached data keyed by that id, not a second copy of a list entry. ## What an interviewer is listening for They want the phrase "the row already exists in `rows`, so storing it duplicates a fact", the two concrete failure modes (stale content, broken referential comparison), the derived lookup written correctly, and an explicit answer for what happens when the selected id is no longer present. Extra credit for noting that ids are serialisable, so the selection can move to the URL if the product wants shareable links.

  • How does this change for multi-select?
    Hold a `Set` of ids in state and derive the selected records with a filter. Membership tests stay O(1) and remain correct across refetches, whereas an array of captured objects has every problem of the single-object case plus duplicate handling. Remember that mutating a Set in place will not re-render — create a new Set when you update it.
  • The list is large and the find shows up in a profile. What now?
    Build a lookup structure rather than re-introducing a copy: derive a `Map` from id to row once per render, memoise it if the profile justifies it, and index into it. The model stays the same — one source of truth, selection expressed as an id — and only the lookup cost changes.
  • When is storing the whole selected record actually correct?
    When it is not derivable from what the component has: the detail is fetched from its own endpoint, or the list is paginated and the selected record may not be on the current page. Then the record is fetched data cached against the id, not a duplicate of a list entry — and the id still represents what the user selected.

saying these in an interview costs you the question

  • Storing the object saves a lookup, so it is faster
  • Deep-compare the objects to keep the highlight working
  • Keep both the id and the object, to be safe
  • Derived values must be memoised or the table will be slow
  • itemCount in state is fine because it is only a number

context