Concept
A typical API response is nested and relational: a post has an author object, which has its own fields; a post has an array of comments, each with its own author. Storing that shape directly in Redux state, as-is, causes two concrete problems.
// ❌ nested, as returned by the API
const state = {
posts: [
{ id: 1, title: "Hello", author: { id: 10, name: "Ada" }, comments: [{ id: 100, text: "Nice!", author: { id: 11, name: "Grace" } }] },
{ id: 2, title: "World", author: { id: 10, name: "Ada" }, comments: [] },
],
};Problem 1, updating one nested field requires deep-cloning the whole path. Renaming Ada from every post she authored means walking the entire posts array, finding every match, and rebuilding each one immutably (per the no-mutation rule from Actions & Reducers), an O(n) scan, with deeply nested spread logic.
Problem 2, the same author data is duplicated across every post/comment that references it. Ada's { id: 10, name: "Ada" } object appears wherever she's referenced; updating her name in one place doesn't update it anywhere else unless every duplicate is found and rewritten too.
Normalized shape: { ids: [], entities: {} }
// ✅ normalized
const state = {
posts: {
ids: [1, 2],
entities: {
1: { id: 1, title: "Hello", author: 10, comments: [100] }, // author/comments are IDs, not nested objects
2: { id: 2, title: "World", author: 10, comments: [] },
},
},
authors: {
ids: [10, 11],
entities: {
10: { id: 10, name: "Ada" },
11: { id: 11, name: "Grace" },
},
},
This is exactly the shape a relational database would use, separate tables, joined by foreign-key-style IDs, instead of nested/duplicated objects. Each "table" (posts, authors, comments) is itself { ids: [...], entities: { [id]: {...} } }: ids is an ordered array (for list rendering), entities is a lookup object keyed by ID (for O(1) direct access).
What normalization actually buys you
Renaming Ada is now a single, O(1) update, no scanning, no nested spreading:
function authorsReducer(state, action) {
if (action.type === "author/renamed") {
const { id, name } = action.payload;
return {
...state,
entities: { ...state.entities, [id]: { ...state.entities[id], name } }, // ONE entity touched
};
}
return state;
}Every post and comment referencing author 10 automatically reflects the update, because they only ever stored the ID, 10, never a duplicated copy of the name.
Finding a specific item by ID is O(1), state.posts.entities[42], instead of state.posts.find(p => p.id === 42), an O(n) scan through a potentially large array, repeated every time any component needs that one post.
The tradeoff: reassembling relational views needs selectors
const selectPostWithAuthor = createSelector(
[(state, postId) => state.posts.entities[postId], (state) => state.authors.entities],
(post, authors) => ({ ...post, author: authors[post.author] }) // re-attach the FULL author object for rendering
);Normalization pushes the "reassemble the nested shape a component actually wants to render" work into selectors, exactly the memoized-selector pattern from Selectors & Reselect. This is a deliberate tradeoff: the store stays flat and update-cheap, and the (memoized, so not repeated needlessly) cost of reconstructing a nested view is paid only where it's actually needed for rendering.