🗃️ Normalized State Structure
When the same user appears in a post, three comments, and a notification, storing that user four times is asking for trouble. Normalization borrows a page from relational databases: keep one copy of every entity, keyed by its id, and let everything else point to it. Do this and updates become one-line changes instead of recursive tree walks.
Week 6 · Day 5 (Friday: Advanced Redux Patterns) · Lecture 1
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why deeply nested (denormalized) state causes duplication and buggy updates
- Design a normalized shape using an
{ byId, allIds }lookup table keyed by id - Update a single entity in
O(1)without touching anything that references it - Use Redux Toolkit's
createEntityAdapterto generate reducers and selectors for free - Write a memoized selector that denormalizes data back into the shape your UI needs
- Recognize when the
normalizrlibrary earns its place versus doing it by hand
Estimated Time: 70 minutes
Project: Normalize a blog's posts/authors/comments and build a denormalizing selector.
In This Lesson
What Is Normalized State?
Normalization is a way of arranging data so that each piece of information lives in exactly one place. Instead of nesting an author object inside every post that references them, you store all authors in one table and let posts hold just the author's id. It's the same idea a relational database uses when it splits data across tables joined by foreign keys.
📚 The Library Catalog Analogy
Imagine a library that wrote the full author biography onto the inside cover of every single book. When an author moves cities, a librarian would have to find and re-write hundreds of covers — and would surely miss a few. Real libraries keep a central catalog: each author has one record, and every book simply lists a catalog number that points to it. Update the author once, and every book instantly reflects the change. Normalized Redux state is that central catalog.
Today we're in Redux's advanced patterns. Normalization is the foundation the next two lessons build on: memoized selectors read from this flat shape, and the DevTools you'll set up afterward make its clean action-by-action diffs a joy to inspect.
The Problem With Nested State
When you fetch data from a typical REST API, it arrives as a deeply nested tree: posts contain author objects, comments contain their own author objects, and the same person shows up again and again. Drop that straight into your store and every copy drifts out of sync the moment one of them changes.
What denormalized state looks like
// ❌ Denormalized: John Doe is stored THREE times
const state = {
posts: [
{
id: "post1",
title: "First Post",
author: { id: "user1", name: "John Doe", email: "john@x.com" },
comments: [
{ id: "c1", text: "Great post!",
author: { id: "user2", name: "Jane Smith", email: "jane@x.com" } },
{ id: "c2", text: "Thanks for sharing!",
author: { id: "user1", name: "John Doe", email: "john@x.com" } } // dup!
]
},
{
id: "post2",
title: "Second Post",
author: { id: "user2", name: "Jane Smith", email: "jane@x.com" }, // dup!
comments: []
}
]
};
The update that hurts
Now John changes his email. Because his data is scattered, you must crawl the entire tree and patch every copy — carefully, immutably, without missing one:
// ❌ Painful: rebuild the whole tree just to change one email
function updateUserEmail(state, userId, newEmail) {
return {
posts: state.posts.map(post => ({
...post,
author: post.author.id === userId
? { ...post.author, email: newEmail }
: post.author,
comments: post.comments.map(comment => ({
...comment,
author: comment.author.id === userId
? { ...comment.author, email: newEmail }
: comment.author
}))
}))
};
}
⚠️ Why this matters
That function is fragile, slow, and forces a re-render of every post even though only one user changed. Miss a single branch and your UI shows John's old email in one spot and his new one in another. Normalization deletes this entire category of bug.
The Normalized Shape: byId & allIds
The standard normalized shape stores each entity type as an object keyed by id (byId) plus an array that remembers order (allIds). Lookups become instant, and order is preserved separately from the data.
byId table and references it by id.// ✅ Normalized: every entity stored exactly once
const state = {
users: {
byId: {
user1: { id: "user1", name: "John Doe", email: "john@x.com" },
user2: { id: "user2", name: "Jane Smith", email: "jane@x.com" }
},
allIds: ["user1", "user2"]
},
posts: {
byId: {
post1: { id: "post1", title: "First Post",
author: "user1", comments: ["c1", "c2"] }, // id references
post2: { id: "post2", title: "Second Post",
author: "user2", comments: [] }
},
allIds: ["post1", "post2"]
},
comments: {
byId: {
c1: { id: "c1", text: "Great post!", author: "user2", post: "post1" },
c2: { id: "c2", text: "Thanks!", author: "user1", post: "post1" }
},
allIds: ["c1", "c2"]
}
};
The update that no longer hurts
// ✅ One entity, one place, O(1) update
function updateUserEmail(state, userId, newEmail) {
return {
...state,
users: {
...state.users,
byId: {
...state.users.byId,
[userId]: { ...state.users.byId[userId], email: newEmail }
}
}
};
}
// posts and comments are untouched — they only held the id "user1".
✅ Why this is better
Only the users slice changed reference, so only components reading that user re-render. There is no tree to crawl, nothing to miss, and no way for two copies to disagree. This is the payoff normalization exists for.
Four Normalization Principles
Every normalized store follows the same four rules. Internalize these and you can normalize any API response on sight.
1. Each entity type gets its own table
// Split articles, authors, and categories into separate tables
const normalized = {
articles: { byId: { 1: { id: 1, title: "Redux Tutorial", author: 1, category: 1 } }, allIds: [1] },
authors: { byId: { 1: { id: 1, name: "Dan Abramov" } }, allIds: [1] },
categories: { byId: { 1: { id: 1, name: "Programming" } }, allIds: [1] }
};
2. Each entity is keyed by its id
Objects give you constant-time lookup; arrays force a linear scan. This is the single biggest performance reason to normalize.
// ✅ Object keyed by id — O(1) lookup
const byId = { user1: { id: "user1", name: "John" },
user2: { id: "user2", name: "Jane" } };
const user = byId["user1"]; // instant
// ❌ Array — O(n) scan grows with your data
const list = [{ id: "user1", name: "John" }, { id: "user2", name: "Jane" }];
const same = list.find(u => u.id === "user1"); // walks the array
3. Relationships are represented by ids
// ✅ Store ids, not embedded objects
const post = {
id: "post1",
author: "user1", // one-to-one → single id
comments: ["c1", "c2"] // one-to-many → array of ids
};
4. Arrays of ids preserve order
Objects don't guarantee key order, so keep any meaningful ordering in a separate allIds (or a UI-specific list). Sorting and filtering then just rearrange a small array of strings, never the entities themselves.
const state = {
posts: {
byId: {
post1: { id: "post1", title: "First" },
post2: { id: "post2", title: "Second" },
post3: { id: "post3", title: "Third" }
},
allIds: ["post1", "post2", "post3"] // canonical order
},
ui: {
postFeed: ["post2", "post1", "post3"] // a different, view-specific order
}
};
createEntityAdapter: normalization for free
Writing byId/allIds reducers by hand is repetitive. Redux Toolkit's createEntityAdapter generates them for you. It stores data in a { ids: [], entities: {} } shape (the same idea — entities is byId, ids is allIds) and hands you prebuilt reducers and memoized selectors.
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
// 1) Create an adapter. sortComparer keeps `ids` in a chosen order.
const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt) // newest first
});
// 2) getInitialState() returns { ids: [], entities: {} }
// and you can bolt extra fields onto it.
const initialState = postsAdapter.getInitialState({
loading: false,
error: null
});
// 3) The adapter's CRUD helpers mutate Immer draft state for you.
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postsLoaded: postsAdapter.setAll, // replace everything
postAdded: postsAdapter.addOne, // insert one
postUpdated: postsAdapter.updateOne, // { id, changes }
postRemoved: postsAdapter.removeOne // by id
}
});
export const { postsLoaded, postAdded, postUpdated, postRemoved } = postsSlice.actions;
// 4) getSelectors() gives you memoized selectors keyed to this slice.
export const {
selectAll: selectAllPosts, // returns an ordered array
selectById: selectPostById, // O(1) lookup by id
selectIds: selectPostIds
} = postsAdapter.getSelectors(state => state.posts);
export default postsSlice.reducer;
💡 The adapter's CRUD toolkit
Beyond the four above, you get addMany, upsertOne/upsertMany (insert or merge), updateMany, removeMany, and removeAll. Each keeps ids and entities consistent and re-applies your sortComparer automatically — you never manage the two by hand.
💡 Rule of thumb: If a slice holds a collection of things with ids, reach forcreateEntityAdapterfirst. Hand-rolledbyId/allIdsis worth understanding, but rarely worth writing in RTK projects.
Denormalizing for the UI
Normalized state is perfect for storing data but awkward for rendering it — a component wants a post with its author object and comment objects attached, not a pile of ids. The fix is a denormalizing selector: it reassembles the tree on read. Memoize it with createSelector (next lesson's star) so the reassembly only reruns when its inputs actually change.
import { createSelector } from '@reduxjs/toolkit';
import { selectPostById, selectAllPosts } from './postsSlice';
// Rebuild a single post with its author and fully-hydrated comments.
export const selectPostWithDetails = createSelector(
[
(state, postId) => selectPostById(state, postId),
state => state.users.entities,
state => state.comments.entities
],
(post, usersById, commentsById) => {
if (!post) return null;
return {
...post,
author: usersById[post.author],
comments: (post.comments ?? []).map(commentId => ({
...commentsById[commentId],
author: usersById[commentsById[commentId]?.author]
}))
};
}
);
// For a LIST view, denormalize lightly — just enough to render.
export const selectPostSummaries = createSelector(
[selectAllPosts, state => state.users.entities],
(posts, usersById) =>
posts.map(post => ({
...post,
author: usersById[post.author],
commentCount: post.comments?.length ?? 0 // count, not full comments
}))
);
// PostDetail.jsx — the component stays blissfully simple
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { selectPostWithDetails } from './postsSelectors';
function PostDetail() {
const { postId } = useParams();
const post = useSelector(state => selectPostWithDetails(state, postId));
if (!post) return <p>Post not found</p>;
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author?.name}</p>
<section>
<h3>Comments</h3>
{post.comments.map(c => (
<div key={c.id}>
<p>{c.text}</p>
<small>— {c.author?.name}</small>
</div>
))}
</section>
</article>
);
}
⚠️ Denormalize on read, never store it
Keep the assembled tree in a selector, not in the store. If you save denormalized copies back into state, you've reintroduced exactly the duplication you normalized away. The store holds the single source of truth; selectors compute the shapes your components ask for.
The normalizr Library
You can normalize an API response by hand, but for deeply nested payloads the normalizr library does it declaratively. You describe your entities and their relationships as schemas, and normalize() flattens any matching data into the { result, entities } shape.
// npm install normalizr
import { normalize, schema } from 'normalizr';
// Define the shape of your entities and how they relate.
const user = new schema.Entity('users');
const comment = new schema.Entity('comments', { author: user });
const post = new schema.Entity('posts', { author: user, comments: [comment] });
const apiResponse = {
id: "post1",
title: "My Post",
author: { id: "user1", name: "John Doe" },
comments: [
{ id: "c1", text: "Nice post!", author: { id: "user2", name: "Jane Smith" } }
]
};
const normalized = normalize(apiResponse, post);
/* normalized =
{
result: "post1",
entities: {
users: { user1: {...}, user2: {...} },
comments: { c1: { id: "c1", text: "Nice post!", author: "user2" } },
posts: { post1: { id: "post1", title: "My Post",
author: "user1", comments: ["c1"] } }
}
} */
// Pass an array schema to normalize a list; result becomes an array of ids.
const list = normalize([apiResponse], [post]);
// list.result === ["post1"]
Wire it into a thunk so data is flattened the moment it arrives — normalize at the API boundary, before it ever reaches your reducers:
export const fetchPosts = () => async (dispatch) => {
const res = await fetch('/api/posts?_embed=comments&_expand=author');
const data = await res.json();
const { entities } = normalize(data, [post]);
dispatch(usersLoaded(entities.users ?? {}));
dispatch(postsLoaded(entities.posts ?? {}));
dispatch(commentsLoaded(entities.comments ?? {}));
};
💡 Do you actually need normalizr?
For flat or shallow responses, createEntityAdapter.setAll() alone is plenty — reach for normalizr when payloads are deeply nested with repeated entities across levels. Many modern apps skip it entirely by letting RTK Query cache normalized data for them.
Practice & Quiz
🏋️ Exercise 1: Normalize by hand
Goal: Write normalizeUsers(list) that turns an array of user objects into the { byId, allIds } shape.
const users = [
{ id: "u1", name: "Ada" },
{ id: "u2", name: "Grace" }
];
function normalizeUsers(list) {
// TODO: return { byId: {...}, allIds: [...] }
}
console.log(normalizeUsers(users));
// { byId: { u1: {id:"u1",name:"Ada"}, u2: {id:"u2",name:"Grace"} },
// allIds: ["u1", "u2"] }
💡 Hint
Use Array.prototype.reduce to build byId, and list.map(u => u.id) for allIds. Or build both in one reduce.
✅ Solution
function normalizeUsers(list) {
return {
byId: list.reduce((acc, user) => {
acc[user.id] = user;
return acc;
}, {}),
allIds: list.map(user => user.id)
};
}
🏋️ Exercise 2: A denormalizing selector
Goal: Given normalized posts and users, write selectPostWithAuthor(state, postId) that returns the post with its full author object attached (or null if the post is missing).
💡 Hint
Look the post up in state.posts.byId[postId], bail out with null if it's absent, then spread it and replace the author id with state.users.byId[post.author].
✅ Solution
function selectPostWithAuthor(state, postId) {
const post = state.posts.byId[postId];
if (!post) return null;
return {
...post,
author: state.users.byId[post.author]
};
}
// Wrap in createSelector in a real app so it memoizes.
🎯 Quick Quiz
Question 1: Why do we key entities by id in an object instead of keeping them in an array?
Question 2: In a normalized store, how should a post reference its author?
Question 3: What shape does createEntityAdapter().getInitialState() return?
Best Practices & Pitfalls
✅ Do
- Store each entity once, in a table keyed by id
- Reference related entities by id, never by embedding objects
- Reach for
createEntityAdapterfor any id-based collection - Normalize at the API boundary (in the thunk/query), before data hits reducers
- Denormalize on read with memoized selectors (see the next lesson)
- Keep UI state (loading, current filter, selected id) separate from entities
❌ Don't
- Store the same entity in two places and hope they stay in sync
- Nest entities inside other entities in the store
- Denormalize inside reducers or save assembled trees back into state
- Store derived data (counts, totals, sorted copies) — compute it in selectors
- Mix normalized and denormalized copies of the same data
⚠️ Don't normalize everything
Normalization shines for shared, relational data — users, posts, products. A one-off piece of UI state (the active tab, a modal's open flag) has no relationships and no duplication risk; leaving it as a plain value is clearer than forcing it into a table.
Summary
🎉 Key Takeaways
- Normalized state stores each entity once, keyed by id — like a database table
- The classic shape is
{ byId, allIds }; RTK's adapter calls it{ entities, ids } - Relationships are stored as ids, turning painful tree updates into
O(1)patches createEntityAdaptergenerates CRUD reducers and memoized selectors for you- Rebuild UI-friendly trees with denormalizing selectors — never store them
- Use
normalizrfor deeply nested API payloads; skip it for shallow data
📚 Additional Resources
- Redux — Normalizing State Shape
- Redux Toolkit — createEntityAdapter
- Redux — Updating Normalized Data
- normalizr — library documentation
🚀 What's Next?
You now have a flat, single-source-of-truth store. The catch: reading useful shapes out of it means computing derived data on every render. The next lesson, Selectors and reselect, shows how createSelector memoizes that work so denormalizing and filtering happen only when the data actually changes.
🎉 Well structured!
You've turned a tangled tree into a clean relational store. Every advanced Redux pattern from here rests on this shape.