skip to content

Why does Redux recommend storing relational data in a normalized shape with byId and allIds, and how does that change reducer code?

level: middleimportance: should knowfreq 48%

answer

  1. treat the store like a database
  2. one copy of each entity
  3. look up by ID, order by array
  4. shallow update paths

basics

~20 s

Normalizing stores each entity type once, in a byId lookup plus an allIds order array, with relations held as IDs, so each record has one copy, updates touch one short path, and unrelated components keep their references.

solid answer

~40 s

Nested API data duplicates entities, for example the same author inside every post and comment. That makes updates error-prone, because one change must be applied in several places, and deeply nested reducers are hard to write. It also costs renders, because an immutable update copies every ancestor and hands new references to components that did not change. Redux's docs recommend treating part of the store like a database: one "table" per entity type, items in an object keyed by ID (`byId`), an `allIds` array for order, and relations stored as IDs. Reducer code gets simpler: updating one entity is `{ ...state.byId, [id]: { ...state.byId[id], ...changes } }`, adding touches `byId` and `allIds`, and removing deletes from both. Components rebuild the nested view when they read it.

go deeper

for a junior

Know the shape: byId holds entities keyed by ID, allIds holds the order, and relations store IDs instead of nested objects.

for a middle

Explain the three problems normalization fixes (duplication, deep updates, needless renders) and write add, update and remove cases for a byId/allIds slice.

for a senior

Design the boundary: where API responses get flattened, how selectors rebuild views efficiently, and how deletes clean up references across tables.

for a principal

Decide how much of the domain is normalized client-side versus fetched as ready-made views, weighing consistency against code and payload complexity.

## The problem with nested data APIs often return nested, denormalized data: ```js [{ id: 'p1', author: { id: 'u1', name: 'Ada' }, comments: [{ id: 'c1', author: { id: 'u1', name: 'Ada' } }] }] ``` Stored as-is in Redux, this causes three concrete problems, all listed in Redux's own guide to normalizing state: - **Duplication.** User `u1` appears in the post and in the comment. Renaming her means finding and updating every copy; miss one and the UI contradicts itself. - **Nested reducer logic.** Updating a comment's text means copying the post array, the post, the comments array and the comment. Deep path-copying is verbose and a common source of accidental mutation. - **Unnecessary renders.** Because an immutable update copies every ancestor, editing one comment gives the whole post, and the posts array, new references. Components that read those objects re-render even though what they display did not change. ## The normalized shape The recommendation is to treat a portion of the store **like a database**: 1. **One "table" per entity type**: `posts`, `users`, `comments`. 2. **Items stored by ID** in an object: `byId: { u1: {...}, u2: {...} }`. 3. **Relations stored as IDs**: a post has `author: 'u1'` and `comments: ['c1']`, not embedded objects. 4. **Order stored separately** in an array of IDs: `allIds: ['u1', 'u2']`. ```js { users: { byId: { u1: { id: 'u1', name: 'Ada' } }, allIds: ['u1'] }, posts: { byId: { p1: { id: 'p1', author: 'u1', comments: ['c1'] } }, allIds: ['p1'] }, comments: { byId: { c1: { id: 'c1', author: 'u1', text: '…' } }, allIds: ['c1'] } } ``` ## How reducer code changes | Operation | Nested shape | Normalized shape | |---|---|---| | Update one entity | Copy every ancestor on its path | Copy `byId` and that one entity | | Rename a user | Find and update every embedded copy | Update `users.byId[id]` once | | Add an entity | Insert into the right nested array | Add to `byId`, push ID onto `allIds` | | Remove an entity | Filter it out of every nested location | Delete from `byId`, filter `allIds`, remove ID references | | Look up by ID | Search arrays | `byId[id]`, constant time | Typical normalized reducer cases: - **Update**: `byId: { ...state.byId, [id]: { ...state.byId[id], ...changes } }`, with `allIds` untouched. - **Add**: `byId: { ...state.byId, [e.id]: e }` and `allIds: [...state.allIds, e.id]`. - **Remove**: build `byId` without the key and `allIds: state.allIds.filter(x => x !== id)`, then clean up any other entities that referenced it. Because each entity type is its own slice, `combineReducers` can own the tables, and one event-style action, for example "comment added", can be handled by both the `comments` slice (add the entity) and the `posts` slice (append the ID to the post's `comments`). ## A worked update: one event, two tables Suppose the user adds comment `c2` to post `p1`, and the app dispatches one event-style action, `comments/commentAdded`, with the new comment in its payload: 1. The **`comments`** slice adds the entity: a new `byId` with `c2` added, and `allIds` with `'c2'` appended. 2. The **`posts`** slice handles the same action: it copies `byId` and post `p1`, and appends `'c2'` to that post's `comments` ID array. 3. The **`users`** slice ignores the action and returns its state by reference. Only the root, the `comments` slice (its `byId` and `allIds`), the `posts` slice with its `byId`, and post `p1` get new references. Every other post, every user and every component reading them is untouched, which is exactly the render isolation the nested shape could not give you. ## What it costs - **Normalization on the way in.** Something must flatten API responses before they reach reducers, usually in the code that handles the response. - **Denormalization on the way out.** Components need the nested view back; that becomes a read-time lookup, typically in selector functions, which should be memoized when they build new objects. - **Referential cleanup.** Deleting an entity means removing its ID from every relation, which the nested shape did implicitly. ## When not to bother Normalize **relational data that is shared or updated in place**. A small, read-only list that no other entity references can stay an array. The goal is one copy of each record and short update paths, not a database schema for every value. ## Tooling Redux Toolkit's `createEntityAdapter` generates the same structure under the names `ids` and `entities`, with prebuilt add, update and remove reducers. Knowing the hand-written shape explains what it does.

  • Why keep an allIds array when the byId object already has all the keys?
    allIds records order explicitly, such as sort order or server order, and gives a stable array to map over when rendering a list. Relying on object key order is fragile: integer-like keys are ordered numerically, not by insertion. Keeping order as an ID array also means reordering changes one small array, not the entity objects.
  • Where should the nested view that a component needs be rebuilt from normalized state?
    At read time, in a selector function that looks up the IDs and assembles the view. If the selector builds new objects or arrays, memoize it so repeated reads with unchanged inputs return the same reference and do not cause re-renders.

It is the difference between a spreadsheet where every row repeats the customer's full address and a database with a customers table that orders reference by customer ID: change the address once, and every order shows the new one.

saying these in an interview costs you the question

  • Normalization means deep-freezing the state tree
  • Embedding related objects is faster because lookups are avoided
  • allIds is redundant because byId already has every key
  • Every array in state should be normalized, even small read-only lists
  • Relations should hold the related object so components do not need lookups