In React-Redux, a 500-row table re-renders every row when one row's status changes; how do you scope useSelector so only that row re-renders?
answer
- the parent selects too much
- pass ids down, not objects
- each row subscribes to itself
- memo stops parent-driven renders
basics
~20 sHave the parent select only the row ids, wrap Row in React.memo and pass each an id, and let each Row select its own entity by id. A status change then changes only that row's selected value, so only it re-renders.
solid answer
~40 sIf the table does `useSelector(state => state.rows)`, any row update produces a new rows collection, the table re-renders, and every `Row` renders with it. Push the subscription down: the table selects **only ids** — a reference that stays the same when a row's fields change — and renders `<Row key={id} id={id} />`, where `Row` is wrapped in `React.memo`. Each `Row` calls `useSelector(state => state.rows.entities[id])`. After a status change, only that row's selector returns a new object, so only that row re-renders; `React.memo` keeps the others from re-rendering because the parent did not change their props. The trade-off is 500 subscriptions, each running its selector after every dispatch, so per-row selectors must be **O(1) lookups** in normalised state, never an array `find`. Also write rows defensively: a deleted id can briefly select `undefined`.
code
tsx · 28 linesimport { memo } from 'react'
import { useSelector } from 'react-redux'
import type { RootState } from './store'
const selectJobIds = (s: RootState) => s.jobs.ids
const selectJobById = (s: RootState, id: string) => s.jobs.entities[id]
export function JobsTable() {
const ids = useSelector(selectJobIds)
return (
<table>
<tbody>
{ids.map((id) => <JobRow key={id} id={id} />)}
</tbody>
</table>
)
}
const JobRow = memo(function JobRow({ id }: { id: string }) {
const job = useSelector((s: RootState) => selectJobById(s, id))
if (!job) return null
return (
<tr>
<td>{job.name}</td>
<td>{job.status}</td>
</tr>
)
})go deeper
Recognise that the component calling useSelector re-renders with everything beneath it, so a parent selecting a whole list renders every row.
Explain the ids-in-parent, entity-by-id-in-row pattern and why rows need React.memo in addition to their own useSelector.
Weigh subscription count against render cost, keep row selectors O(1) on normalised state, stabilise derived id lists, and handle deleted entities safely.
Set data-shape conventions, such as normalised collections and id-based props, that make fine-grained subscriptions the default rather than a later performance fix.
## Where the cost comes from Consider a table of jobs with a live status column: ```tsx function JobsTable() { const jobs = useSelector((state: RootState) => state.jobs.list) return jobs.map((job) => <JobRow key={job.id} job={job} />) } ``` When one job's status changes, the reducer produces a new `list` array with one new job object. `useSelector` sees a new reference, `JobsTable` re-renders, and React renders all 500 `JobRow` children. Even though 499 job objects kept their references, rows without `React.memo` render anyway because their parent did. The root cause is **selector granularity**: the component that subscribes owns a value much larger than what changed. ## Push subscriptions down to the rows 1. **Normalise** the data so rows can be looked up by id: `{ ids: string[], entities: Record<string, Job> }`. 2. The table selects **only the ids**. Editing one job's fields replaces that entity but leaves the `ids` array as the same reference, so the table does not re-render on a status change. 3. Render `<JobRow key={id} id={id} />` and wrap `JobRow` in `React.memo`, so a row only renders when its `id` prop changes or its own subscription fires. 4. Each row selects its own entity: `useSelector((state) => state.jobs.entities[id])`. After a status change, every row's selector runs, but only the changed row's selector returns a new reference, so only that row re-renders. ## Why React.memo is still needed `useSelector` decides re-renders caused by **store** changes. It does not stop a component from rendering because its **parent** rendered. When the table re-renders — for example because a row was added and `ids` changed — every child renders again unless it is memoised. `React.memo` on the row makes those parent-driven renders skip rows whose `id` did not change. ## The price of many subscriptions Granular subscriptions move work from rendering to selector evaluation: - 500 mounted rows means 500 `useSelector` subscriptions; - after **every** dispatch, all 500 selectors run and compare; - so each per-row selector must be cheap: an object lookup by id is O(1), whereas `state.jobs.list.find(j => j.id === id)` costs O(n) per row and O(n²) per dispatch. | Approach | Renders per status change | Selector work per dispatch | |---|---|---| | Table selects whole list | 1 table + 500 rows | 1 selector | | Table selects ids, rows select by id (memo rows) | 1 row | 1 + 500 O(1) lookups | | Rows select with `find` over an array | 1 row | 500 O(n) scans | For very long lists, virtualising the table so that only visible rows are mounted reduces both numbers. ## Selecting ids safely - If ids come straight from state, as with an entity adapter's `ids`, the reference is stable. - If the table derives ids — sorted or filtered — the derivation returns a new array each run; memoise it, or pass `shallowEqual` so an array with the same ids in the same order compares equal. ## Rows whose entity disappears When a job is deleted, a row's subscription can run with an `id` whose entity is already gone before the table re-renders without that row. React-Redux's docs call this the **stale props / zombie child** problem. Write per-row selectors and components defensively: - `const job = useSelector((s) => s.jobs.entities[id])`; - `if (!job) return null`. ## Measure before and after - Record the status update in a profiler first: the commit should show the table and all rows before the change, and a single row after it. - Watch for **dispatch storms**. If a job emits progress events many times per second, even cheap row selectors run 500 times per event; batching progress into fewer actions, or throttling them, can matter more than selector shape. - Re-check after adding features: a new column that reads an aggregate in every row, such as a total, quietly widens each row's subscription again. ## Checklist for the interview - identify the over-broad selector in the parent; - select ids in the parent, entities by id in memoised rows; - keep per-row selectors O(1) on normalised state; - handle the deleted-entity case; - measure before and after with the React profiler rather than assuming.
- The React-Redux table now selects sorted ids with a selector that calls sort. Why do all rows render again on every dispatch?Sorting builds a new array each run, so `useSelector` sees a new ids reference after every dispatch and re-renders the table. Memoise the sorted-ids selector so it returns the same array until ids or sort order change, or pass `shallowEqual` so equal id lists compare equal.
- Is it a problem that 500 React-Redux rows each run their selector after every dispatch?Usually not, provided each selector is a constant-time lookup and returns an existing reference. It becomes a problem when row selectors scan arrays or build new objects. For very long lists, virtualisation reduces the number of mounted rows and therefore subscriptions.
saying these in an interview costs you the question
- useSelector also stops re-renders caused by the parent rendering
- Selecting the whole list in the parent is fine once rows are memoised
- Per-row selectors can use find because each row only runs its own
- More subscriptions always means worse performance
- A row selector can assume its entity always exists