Skip to main content

🗃️ 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 createEntityAdapter to generate reducers and selectors for free
  • Write a memoized selector that denormalizes data back into the shape your UI needs
  • Recognize when the normalizr library 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.

graph TD A[Nested / Denormalized State] --> B[Same user copied in many places] A --> C[Update must find every copy] A --> D[Miss one copy and data desyncs] A --> E[Reducers become recursive tree walks]

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.

Nested state on the left with duplicated users; normalized state on the right with one users table referenced by id Nested (duplicated) post1 → author {John, john@x} comment2 → author {John, john@x} post2 → author {Jane, jane@x} 🔁 John stored twice, Jane once Normalized (by id) users.byId user1: {John, john@x} user2: {Jane, jane@x} posts.byId post1: {author: "user1"} → id ref ✅ each entity stored exactly once
Nested state duplicates entities; normalized state keeps one copy in a 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 for createEntityAdapter first. Hand-rolled byId/allIds is 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 createEntityAdapter for 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
  • createEntityAdapter generates CRUD reducers and memoized selectors for you
  • Rebuild UI-friendly trees with denormalizing selectors — never store them
  • Use normalizr for deeply nested API payloads; skip it for shallow data

📚 Additional Resources

🚀 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.