cf.completefrontend
My progress
FUNDAMENTALS · MODULE 10

Normalizing state

Storing each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.

The problem: one user, three views

Picture a team chat app. On the left is a sidebar of people. In the middle is a channel with 200 messages, each with the sender's name and avatar. On the right is a thread panel with replies, and each reply also shows its author.

One person, Priya, appears in all three places: once in the sidebar, 40 times in the channel, and 6 times in the thread.

Priya changes her display name to "Priya S". The server pushes the change over the WebSocket. The sidebar code finds Priya in its list and updates her.

The channel and the thread still say "Priya". Now the screen disagrees with itself, and the user wonders which one is true.

Nobody wrote a bug on purpose. The app stored the data the way the server sent it: each message came with a full copy of its author inside it. So the app holds 47 separate copies of Priya, and updating one copy does nothing to the other 46.

Where the copies come from

Most APIs return nested (also called denormalized) data: related objects are embedded inside the object that refers to them. That is convenient for the server and for the first render. A GET /channels/c42/messages response might look like this:

json
[
  { "id": "m1", "text": "Shipping today",
    "author": { "id": "u7", "name": "Priya", "avatar": "/a/u7.png" } },
  { "id": "m2", "text": "Nice!",
    "author": { "id": "u3", "name": "Sam", "avatar": "/a/u3.png" } },
  { "id": "m3", "text": "Docs are up",
    "author": { "id": "u7", "name": "Priya", "avatar": "/a/u7.png" } }
]

Messages m1 and m3 each hold their own copy of user u7. The thread endpoint returns more copies, the members endpoint returns one more. If you put each response into state as it is, your state is a pile of overlapping snapshots taken at different times.

Two nested API responses drawn as trees. Channel c42 holds messages m1, m2, and m3, whose embedded authors are u7 Priya, u3 Sam, and u7 Priya. Thread t9 holds reply r1, whose embedded author is u7 Priya. The three u7 boxes are marked as copies of the same user.channel c42thread t9message m1message m2message m3reply r1Priya u7Sam u3Priya u7Priya u7one user, three copiesrename u7: find and fix every one
Stored as the server sent it, one user becomes many separate objects. Renaming u7 means finding and fixing every copy, in every response the app kept.

What the copies cost you

  • Stale views. An update must find and change every copy. Miss one and the screen shows two truths at once, like the old name in the thread and the new one in the sidebar.
  • Update code that knows too much. To rename a user you have to know every place a user can hide: messages, replies, reactions, mentions, the member list. Each new feature adds a place, and the rename code has to learn about it.
  • Wasted work and memory. 47 copies of the same user take 47 times the memory, and a deep update creates new objects all the way down the tree, so many components re-render even when only one name changed.

Normalizing state is the fix. You store every entity once, in a lookup table keyed by its id. Everything else (a message's author, a channel's message list, the sidebar's member list) stores only ids. Renaming Priya becomes one write to one place, and every view that reads u7 shows the new name on the next render.

This lesson builds that idea from the ground up: the shape of a normalized store, how to turn a nested response into it, how to model relationships, how to read it back for rendering without slowing down, how to handle updates and deletes, which tools do it for you, and when you should not bother at all.

Check your understanding · Exercise 1

The chat app stores each API response as it arrives. Priya appears in 40 channel messages, 6 thread replies, and the sidebar. A rename event comes in and the reducer updates only the sidebar list. What does the user see? Pick one.

The mental model: tables and pointers

Think of your phone's contact list. A text message does not carry a copy of your friend's name and photo. It carries a phone number, and the phone looks the number up in one contact list when it draws the conversation. Change the photo once and every old conversation shows the new one, because there was only ever one copy.

A normalized store works the same way, and it borrows its name from databases. In a relational database you would never store a customer's address inside every order row. You keep a customers table and an orders table, and each order holds a customer_id. Normalizing client state is the same rule applied to the JavaScript objects in your app.

Two shapes: entity tables and id lists

Every normalized store is built from just two shapes. Learn these and you can read any store, whether it was written by hand, by Redux Toolkit, or by a GraphQL client.

  • Entity tables. An entity is one kind of thing with its own id: a user, a message, a channel. Each kind gets one table, a plain object (or Map) from id to the entity. users["u7"] is Priya. Finding any entity is one lookup, not a search through an array.
  • Lists of ids. Anything that has an order or a membership is an array of ids: the messages in channel c42, the members in the sidebar, the results of a search. The list owns the order; the table owns the data.

Inside an entity, every reference to another entity is also an id. A message has authorId: "u7" instead of an author object. A channel has messageIds: ["m1", "m2", "m3"] instead of an array of messages. Nothing contains anything else; everything points.

ts
type User = { id: string; name: string; avatar: string };
type Message = { id: string; text: string; authorId: string };
type Channel = { id: string; name: string; messageIds: string[] };

type Store = {
  users: Record<string, User>;
  messages: Record<string, Message>;
  channels: Record<string, Channel>;
  sidebar: string[]; // user ids, in display order
  lists: Record<string, string[]>; // query results, like "search:priya"
};

// Renaming Priya is one write, wherever she appears.
function rename(s: Store, id: string, name: string): Store {
  return { ...s, users: { ...s.users, [id]: { ...s.users[id], name } } };
}
One table per entity type; references and lists hold ids only.
A normalized store. The list for channel c42 holds the ids m1, m2, m3, each pointing at a row in the messages table. The messages table has id, text, and author columns: m1 ship it by u7, m2 nice! by u3, m3 docs up by u7. Author ids point at the users table, which has u7 Priya and u3 Sam once each. The sidebar list holds the ids u7 and u3.channel c42messagesusersidtextauthorm1m1ship itu7m2m2nice!u3m3m3docs upu7u7Priyau3Samsidebaru7u3lists hold ids, in ordereach row is stored oncereferences are ids, like authorId
Lists hold ids in order, rows point at each other by id, and each user is stored once. Renaming Priya changes one cell: users.u7.name.

One source of truth per fact

The rule underneath both shapes is simple: every fact lives in exactly one place. Priya's name is a fact; it lives in users.u7.name. The order of messages in channel c42 is a fact; it lives in channels.c42.messageIds. Who wrote message m1 is a fact; it lives in messages.m1.authorId.

When each fact has one home, updates become small and local. The rename reducer above does not know that messages, threads, or reactions exist. It changes one field. Every view that wants a name asks the users table for it at render time, so every view agrees, always.

The same rule tells you what not to store. A message count for a channel is messageIds.length, so storing a separate messageCount field creates a second home for the same fact, and the two will drift. Compute it when you read instead. This is called derived state, and keeping it out of the store is part of normalizing.

Why keyed objects and not arrays

You could keep users in an array and find Priya with users.find((u) => u.id === "u7"). That is a scan: 5,000 users means up to 5,000 comparisons, and a channel of 200 messages does it 200 times per render. A keyed object answers in one step, no matter how big it gets.

Keyed objects also make merging easy. When a new response contains u7 again, you write users["u7"] = incoming (or merge the fields) and the old copy is replaced. With an array you would have to find the old copy first, or you would end up with two Priyas in the same list.

Check your understanding · Exercise 2

Which of these belong in a normalized store for the chat app? Select all that apply.

Normalizing a response, step by step

Servers send nested data on purpose. One request returns everything a screen needs, and the server does not know which other screens your app has open. So the work of normalizing happens in the browser, in the data layer, right after a response arrives and before it reaches the store or any component.

The algorithm is short: walk the response. For every object that is an entity, take out the nested entities it holds, put each one in its table, and leave its id behind. Then put the object itself in its table and return its id. Because nested entities can hold more nested entities, the walk is recursive.

A worked example

Take the messages from chapter 1, wrapped in their channel: channel c42 with messages m1, m2, m3, and authors u7, u3, u7. The normalizer needs one piece of extra knowledge, a schema: which fields hold which kind of entity. Here, a channel's messages field holds messages, and a message's author field holds a user.

  1. Start at the channel. Its messages field is a list of messages, so normalize each one.
  2. At m1, the author field holds a user. Normalize it: u7 goes into users, and m1 keeps authorId: "u7". Then m1 goes into messages.
  3. At m2, the same: u3 goes into users, m2 goes into messages.
  4. At m3, the author is u7 again. It is already in users, so the new copy is merged into the existing row instead of making a second one.
  5. Back at the channel: its messages field becomes messageIds: ["m1", "m2", "m3"], and the channel goes into channels. The function returns "c42".

The result has three tables (2 users, 3 messages, 1 channel) and no duplicates. Priya exists once. Step through it below and watch each table fill.

Normalizing the channel responseStep 1 of 6: The response arrives

On the left, a nested channel response: channel c42 holding messages m1, m2, and m3, each with an embedded author (u7 Priya, u3 Sam, u7 Priya). On the right, three tables fill in as the normalizer walks it: users ends with u7 and u3, messages with m1, m2, m3 and their author ids, and channels with c42 holding the ids m1, m2, m3.channel c42message m1author: u7 Priyamessage m2author: u3 Sammessage m3author: u7 Priyausersmessageschannels
A nested channel response comes in. The tables are empty. The normalizer starts at the channel and sees that its messages field holds messages.

The response arrives: A nested channel response comes in. The tables are empty. The normalizer starts at the channel and sees that its messages field holds messages.

A tiny normalizer you can read

Here is the whole algorithm in about 20 lines. It is a teaching version of what normalizr does, and it runs in Node as written: it stores each user once, merges partial rows, and round-trips with the denormalizer in chapter 5.

js
// For each type: which field holds which type, and the id field to leave behind.
const schema = {
  channel: { messages: { type: "message", many: true, key: "messageIds" } },
  message: { author: { type: "user", key: "authorId" } },
  user: {},
};

function normalize(type, data, store) {
  if (Array.isArray(data)) return data.map((item) => normalize(type, item, store));
  const entity = { ...data };
  for (const [field, rel] of Object.entries(schema[type])) {
    if (entity[field] === undefined) continue;
    entity[rel.key] = normalize(rel.type, entity[field], store);
    delete entity[field];
  }
  const table = (store[type] ??= {});
  table[entity.id] = { ...table[entity.id], ...entity }; // merge, never drop fields
  return entity.id;
}
Walk the data, replace each nested entity with its id, and merge every entity into its table.

Calling normalize("channel", response, store) returns "c42" and fills store.channel, store.message, and store.user. The caller keeps the returned id (or list of ids) as the result of this request: "channel c42 is open" or "the search for priya returned [u7, u12]".

Merge rows, do not replace them

Notice the spread on the second-to-last line: the new row is merged into the old one. That matters because different endpoints return different slices of the same entity. The members endpoint might return { id, name, avatar, title, timezone }, while each message embeds a slim { id, name, avatar }.

If the message response replaced the row, Priya would lose her title and timezone, and the profile card would suddenly render blanks. Merging field by field keeps what you already know and updates what the new response says. The same thing happens in Apollo Client, which merges the fields of objects with the same id by default.

Check your understanding · Exercise 3

You normalize a search response of 30 messages written by 4 different users. What does the store gain? Select all that apply.

Relationships: one-to-many, many-to-many, and who owns the list

Once entities live in separate tables, every relationship between them becomes an id field. There are only three kinds of relationship, and each has a standard shape. Getting the shape right decides how easy your updates will be.

Many-to-one: a single id

Many messages have one author. Store the id on the "many" side: message.authorId. This is the most common relationship and the easiest one. Renaming the author never touches messages, and moving a message to another author is one field change.

One-to-many: a list of ids on the parent

One channel has many messages, and you can store this from either side: channel.messageIds on the parent, or message.channelId on each child. They answer different questions. The list on the parent holds the order (the server decided it), and gives you the channel's messages in one lookup. The id on the child answers "which channel is this message in?" in one lookup.

Pick one side to own the relationship, usually the parent list, because order is a fact you cannot compute. If you also keep channelId on the child, treat it as a read-only hint. If both sides are writable, every add, move, and delete must update both, and one day a message will be in channel c42's list while saying it belongs to channel c9.

Many-to-many: a list on one side, or a join table

Users join many channels and channels have many members. For small sets, keep a list on the side you read most: channel.memberIds if the app often shows a channel's members. If you also need "which channels is Priya in?", either compute it by scanning channels (fine for 50 channels) or add a second list and keep both in sync in one reducer.

For large or rich relationships, use a join table, exactly as a database would. Each membership becomes its own entity: memberships["c42:u7"] = { channelId: "c42", userId: "u7", role: "admin", joinedAt: "2026-03-01" }. Now the relationship can carry its own data (role, join date, mute setting), and adding or removing a member is one row, not two list edits.

A graph of entities. Channel c42 points to messages m1 and m2 through its messageIds list. Both messages point to user u7 through authorId. Two membership rows, c42:u7 and c42:u3, each point to channel c42 and to one user, u7 or u3, through channelId and userId.authorIdc42m1m2u7u3c42:u7c42:u3channelmessageIdsuserIdchannelIduserjoin table: memberships
One-to-many is an ordered id list on the parent (messageIds); many-to-one is one id on the child (authorId); many-to-many with its own data is a join table of rows like c42:u7.
RelationshipExampleStore it asWatch out for
Many-to-oneMessage to authormessage.authorIdNothing much: the easy case
One-to-manyChannel to messageschannel.messageIds (ordered)A second writable channelId on the child drifts
Many-to-many, smallPost to tagspost.tagIdsReverse lookups need a scan or a second list
Many-to-many, richUser to channel with a roleJoin table memberships keyed c42:u7Delete the join rows when either side is deleted

Lists that belong to a query, not an entity

Some lists are not a property of any entity: the home feed, the results for the search "priya", page 3 of the archive. Keep these in their own place, keyed by what produced them: lists["search:priya"] = ["u7", "u12"]. They are still just ids pointing into the tables, so the search results show Priya's new name with no extra code.

This is also how paginated lists work. Each page appends ids to the same list, so lists["channel:c42"] grows from 50 ids to 100 as the user scrolls up, while the messages table simply gains rows. The pagination lesson covers cursors; normalization only decides that the list holds ids.

Check your understanding · Exercise 4

A project board shows cards in columns. A card can carry several labels, and each label has a name and colour that admins can rename. How should the store model labels? Pick one.

Reading it back: denormalizing for render

Components want the nested shape. A message row needs the text and the author's name and avatar together. So at read time you put the pieces back together: look up the message, then look up its author. This is denormalizing, and it happens in small functions called selectors, which take the store and return exactly what one view needs.

A tiny denormalizer

The general version follows the same schema in the other direction: for each id field, look the id up and put the entity back. It skips ids whose entity is gone, which matters for deletes (chapter 7).

js
function denormalize(type, id, store) {
  const entity = store[type]?.[id];
  if (!entity) return undefined; // a dangling id: the entity was deleted
  const out = { ...entity };
  for (const [field, rel] of Object.entries(schema[type])) {
    if (out[rel.key] === undefined) continue;
    out[field] = rel.many
      ? out[rel.key].map((x) => denormalize(rel.type, x, store)).filter(Boolean)
      : denormalize(rel.type, out[rel.key], store);
    delete out[rel.key];
  }
  return out;
}

// After a rename, every message built from the store shows the new name.
store.user.u7 = { ...store.user.u7, name: "Priya S" };
denormalize("channel", "c42", store).messages[2].author.name; // "Priya S"
The mirror of normalize: follow each id back to its table. Missing entities are dropped, not crashed on.

In a real app you rarely denormalize a whole tree. Rebuilding a channel with 500 messages on every render would undo much of the benefit. Instead, each component selects only its own small piece.

Pass ids down, select at the leaf

The pattern that scales is: the parent selects a list of ids, and each child selects its own entity by id. The channel view reads channels.c42.messageIds and renders one MessageRow per id. Each MessageRow reads its message and its author from the store.

tsx
function Channel({ id }: { id: string }) {
  const ids = useStore((s) => s.channels[id].messageIds);
  return ids.map((mid) => <MessageRow key={mid} id={mid} />);
}

function MessageRow({ id }: { id: string }) {
  const message = useStore((s) => s.messages[id]);
  const author = useStore((s) => s.users[message.authorId]);
  return <p><b>{author.name}</b> {message.text}</p>;
}
The list subscribes to ids; each row subscribes to its own message and author.

Now look at what a rename does. The reducer makes a new users object and a new u7 row. The channel's messageIds array did not change, so Channel does not re-render.

Each MessageRow compares its selected values: the message is the same object, but rows written by Priya get a new author object, so exactly those rows re-render. Rows by Sam see the same u3 object and skip.

This only works because unchanged rows keep the same object identity. Reducers that copy only the path they change (the spread pattern, or Immer in Redux Toolkit) give you that for free. A reducer that deep-clones the whole store on each change would make every row re-render.

Three layers. At the bottom, the store holds channels, messages, and users; users.u7 was just renamed. In the middle, selectors read the ids of c42, a message by id, and a user by id; the user selector returns a new object for u7. On top, the Channel component selects only the ids and skips rendering, while each MessageRow selects its message and author and re-renders only if its author is u7.componentsChannelsame ids: skipsMessageRowre-renders if author u7selectorsids of c42message by iduser by idstorechannelsmessagesusersu7 renamed
Pass ids down and select at the leaf. A rename gives one user a new object, so only the rows that select that user re-render; the list's id array is unchanged and the list skips.

Memoized selectors for derived views

Some views need a computed shape: "the 5 most recent messages that mention me, with their authors". A selector that builds a new array every time it runs returns a new object on every store change, even an unrelated one, and the component re-renders each time.

A memoized selector remembers the inputs it saw and the output it returned. createSelector from Reselect (re-exported by Redux Toolkit) takes input selectors and a result function, and reruns the result function only when an input changed by reference. If the messages and users tables are the same objects as last time, it returns the same array, and React skips the render.

ts
import { createSelector } from "@reduxjs/toolkit";

const selectMentions = createSelector(
  [(s: RootState) => s.messages, (s: RootState) => s.users, (s: RootState) => s.me],
  (messages, users, me) =>
    Object.values(messages)
      .filter((m) => m.mentionIds.includes(me))
      .slice(-5)
      .map((m) => ({ ...m, author: users[m.authorId] })),
);
Recomputes only when messages, users, or me change by reference; otherwise returns the previous array.

Check your understanding · Exercise 5

In the Channel and MessageRow setup above, Sam (u3) changes his avatar. Sam wrote 12 of the 200 messages. Which components re-render? Pick one.

By hand, normalizr, or createEntityAdapter

You now know enough to normalize by hand, and for a small app that is fine. Most teams use a tool for one or both halves of the job: turning responses into tables (the normalizer) and keeping tables with ready-made update and select functions (the store). Here are the common choices and what each one actually does.

normalizr: schemas for nested responses

normalizr is the classic library for the first half. You describe each entity once with new schema.Entity("users"), say which fields hold which entities, and call normalize(data, schema). It returns { result, entities }: the top-level ids and a table per entity type. It also ships denormalize for the reverse.

js
import { normalize, schema } from "normalizr";

const user = new schema.Entity("users");
const message = new schema.Entity("messages", { author: user });
const channel = new schema.Entity("channels", { messages: [message] });

const { result, entities } = normalize(response, channel);
// result: "c42"
// entities.users: { u7: {...}, u3: {...} }
// entities.messages.m1: { id: "m1", text: "...", author: "u7" }
Same idea as the tiny normalizer: the nested field keeps its name but now holds an id.

Two details differ from our version. normalizr keeps the field name (author: "u7", not authorId), which saves a rename but makes it easy to forget the field now holds an id. And its GitHub repository is archived (read-only), so it gets no fixes or new features. It still works, and its schema idea lives on in other tools.

Redux Toolkit's createEntityAdapter: tables with methods

createEntityAdapter covers the second half. It defines a table shape, { ids: [], entities: {} }, and gives you reducer helpers for it: addMany, upsertMany, updateOne, removeOne, setAll, and more. getSelectors() gives you selectById, selectAll, selectIds, selectEntities, and selectTotal. The ids array keeps an order, and an optional sortComparer keeps it sorted.

ts
import { createEntityAdapter, createSlice } from "@reduxjs/toolkit";

const usersAdapter = createEntityAdapter<User>();

const usersSlice = createSlice({
  name: "users",
  initialState: usersAdapter.getInitialState(),
  reducers: {
    usersReceived: usersAdapter.upsertMany, // merge fields into existing rows
    userRenamed: (state, action) =>
      usersAdapter.updateOne(state, { id: action.payload.id, changes: { name: action.payload.name } }),
  },
});

export const { selectById: selectUserById } = usersAdapter.getSelectors((s: RootState) => s.users);
One adapter per entity type. upsertMany merges; setMany replaces whole rows.

The adapter does not read nested responses. A common pairing is: normalize the response (by hand or with normalizr), then dispatch upsertMany into each slice. Note the difference between upsertOne, which shallow-merges into an existing row, and setOne, which replaces it: chapter 3's partial-entity problem in one method name.

GraphQL clients do it all automatically

Apollo Client and Relay normalize every response for you, and their hooks denormalize for each query. Apollo keys each object by __typename plus id (like User:7); Relay keys records by id alone, so it expects ids that are unique across all types. If your app is on GraphQL, you already have a normalized store; the GraphQL lesson covers how those caches work and where they go wrong.

A matrix of four approaches against four properties: unpacks nested responses, CRUD helpers, selectors, and maintained. by hand: unpacks nested responses you, CRUD helpers you, selectors you, maintained yours. normalizr: unpacks nested responses yes, CRUD helpers no, selectors partly, maintained archived. RTK adapter: unpacks nested responses no, CRUD helpers yes, selectors yes, maintained yes. Apollo, Relay: unpacks nested responses auto, CRUD helpers auto, selectors hooks, maintained yes.unpacksCRUDselectorsmaintainedby handyouyouyouyoursnormalizryesnopartlyarchivedRTK adapternoyesyesyesApollo, Relayautoautohooksyesunpacks: turns nested responses into tables. you: you write it
No single tool does everything outside GraphQL. A common REST pairing is a small normalizer at the fetch boundary feeding Redux Toolkit entity adapters.

Decision How should a REST-based React app normalize its shared entities?

By hand, in the data layer

A small normalize per endpoint, or one schema-driven function like the tiny normalizer

Tables in Redux, Zustand, or a React context store

Pros

  • No dependency; easy to read
  • Full control over merge rules and id keys

Cons

  • You write and test the merge, delete, and selector code yourself
  • Grows messy past 5 or 6 entity types

normalizr plus your own store

Describe schemas once; call normalize on every response

Merge entities into your tables

Pros

  • Handles deep nesting, arrays, and unions with little code
  • Same schema drives denormalize

Cons

  • Archived: no fixes or new features
  • Still need your own update, delete, and selector code

Redux Toolkit entity adapters

One createEntityAdapter per type

Normalize responses (by hand or normalizr), then upsertMany

Pros

  • Tested CRUD reducers and selectors
  • Sorted ids, Immer updates, DevTools

Cons

  • Requires Redux
  • Does not unpack nested responses on its own

What we pick: For a Redux app with many shared entities, use entity adapters for the tables and a small normalizer (hand-written or normalizr) at the fetch boundary. For a tiny app, write it by hand. For GraphQL, let Apollo or Relay do it. And before any of these, check chapter 8: many apps do not need a normalized store at all.

Check your understanding · Exercise 6

Your users slice uses createEntityAdapter. The members endpoint gives full users; message responses embed { id, name, avatar } only. Which method should the message handler call for the embedded users? Pick one.

Updates and deletes: where the hard cases live

Writes are where normalization pays off, and also where its own new problems show up. An update in a normalized store is easy. A delete is harder than it looks, and memory needs an owner. This chapter goes through all three.

Updates come from three places

  • Your own mutations. The user renames a channel. The PATCH response returns the updated channel; upsert it. With an optimistic update you write the new name first and roll back on failure, which the optimistic updates lesson covers.
  • Pushed events. A WebSocket or server-sent event says user.updated for u7. Upsert the payload into users. The event handler does not need to know which screens show users.
  • Refetches. Any response that happens to contain an entity refreshes it. Opening Priya's profile fetches her full row, and every message row picks up the latest avatar as a side effect.

All three end in the same small write: merge fields into one row. That is the whole point. In the nested store, each source needed its own code to hunt down copies. Try both stores in the lab below.

Store users as
The rename handler updates copies in
Three views of a chat: a sidebar, the #general channel, and a thread. Each view holds its own copies of user u7. Press Rename to send a user.updated event for u7.SidebarPriyaSamLee#generalPriya: ship itSam: nice!Priya: docs upThreadPriya: on itPriya: done1 copy of u7name: Priya2 copies of u7name: Priya2 copies of u7name: Priyanot renamed yet
Nested: each view holds its own copies, so the rename only reaches the views the handler was written to update. Every new view means more handler code.
Handler code paths: 1 of 3 views

Press Rename to send a user.updated event for u7.

One event, every view

Follow a pushed rename from the server to the screen: the socket handler upserts one row, and the store notifies its subscribers. Each subscriber's selector runs, and only the ones whose result changed cause a render. The sidebar, the channel, and the thread all update in the same frame, because they all read the same row.

A sequence diagram with six lifelines: server, socket, store, sidebar, rows, and thread. The server pushes u7 renamed to the socket handler. The handler sends upsert u7 to the store. The store then notifies the sidebar that u7 changed, the 40 message rows by u7, and the 6 thread replies by u7.serversocketstoresidebarrowsthreadrenamedupsert u7changed40 rows6 repliesone write, every reader of u7 renders
The socket handler knows nothing about views. It makes one write; the store tells its subscribers, and only the readers of u7 render again, all in the same frame.

Deletes and dangling ids

Deleting is where normalization moves the problem instead of solving it. Delete message m2 from the messages table, and the channel's messageIds still holds "m2". So does the search results list, and the "pinned" list. An id that points at nothing is a dangling id, and a row that does messages[id].text on it throws and blanks the screen.

Use two defences together. First, the delete reducer removes the id from every list it knows about. Second, every list selector skips ids whose entity is missing, as the tiny denormalizer does with .filter(Boolean). The first keeps the store clean; the second means a list you forgot shows one fewer item instead of crashing.

ts
function deleteMessage(s: Store, id: string): Store {
  const { [id]: _gone, ...messages } = s.messages;
  const drop = (ids: string[]) => ids.filter((x) => x !== id);
  const channels = Object.fromEntries(
    Object.entries(s.channels).map(([cid, c]) => [cid, { ...c, messageIds: drop(c.messageIds) }]),
  );
  const lists = Object.fromEntries(Object.entries(s.lists).map(([k, ids]) => [k, drop(ids)]));
  return { ...s, messages, channels, lists };
}

// Defence two: readers skip ids that point at nothing.
const selectChannelMessages = (s: Store, cid: string) =>
  s.channels[cid].messageIds.map((id) => s.messages[id]).filter(Boolean);
Remove the entity, remove its id from every list, and still make readers tolerant of misses.

Deletes can also cascade. Deleting a channel should delete its messages, and the memberships in the join table. Deleting a user usually should not delete their messages; instead the user row is replaced by a tombstone like { id: "u7", name: "Deleted user", deleted: true }, so old messages still render. Decide per relationship, and write it down.

Two panels after deleting message m2. Left, the entity only was dropped: the messages table holds m1 and m3, but the channel list still holds m1, m2, m3 and the pinned list still holds m2, both marked as dangling. Right, the entity and its ids were dropped: the channel list holds m1 and m3 and the pinned list is empty.drop the entity onlymessages tablem1m3channel c42 idsm1m2m3pinned idsm2drop the entity and its idsmessages tablem1m3channel c42 idsm1m3pinned idsempty
A delete must reach every list that holds the id. Whatever it misses becomes a dangling id, so readers should also skip ids whose entity is gone.

Memory: tables only grow unless someone trims them

A normalized store never forgets on its own. Scroll back through a year of a busy channel and the messages table holds 20,000 rows long after you left. Each row may be 1 KB in memory, so that is about 20 MB the tab keeps for nothing.

Give memory an owner. When a list is no longer on screen, trim it (keep the newest 200 ids) and then drop entities that no list or entity points at any more. This is a small garbage collection pass. Apollo Client ships one: cache.evict() removes an object and cache.gc() removes everything unreachable.

Check your understanding · Exercise 7

A message is deleted. The reducer removes it from messages and from its channel's messageIds, but nobody remembered the pinned list. What happens, and what limits the damage? Select all that apply.

When not to normalize

Normalizing has real costs: schemas to keep in sync with the API, merge rules, delete cascades, garbage collection, and selectors for every view. Many apps never need it. The state categories lesson splits state into server, client UI, URL, form, and stream state; normalization is only ever a question for server state that several views share.

The per-query cache is usually enough

TanStack Query and SWR cache by request, not by entity. The response for the key ["channel", "c42"] and the response for ["user", "u7"] are stored separately, nested, as the server sent them. Priya can exist in five cached responses at once. That sounds like the problem from chapter 1, but these libraries handle it a different way.

Instead of finding every copy, you invalidate. After a rename, call queryClient.invalidateQueries({ queryKey: ["channel"] }) and every channel query is marked stale; the ones on screen refetch, and the rest refetch when they are next used. The copies never need to agree for long, because each one is replaced by a fresh server answer within a round trip.

ts
const rename = useMutation({
  mutationFn: (name: string) => api.patch(`/users/${id}`, { name }),
  onSuccess: (user) => {
    queryClient.setQueryData(["user", user.id], user); // the one copy we know exactly
    queryClient.invalidateQueries({ queryKey: ["channel"] }); // refetch the rest
    queryClient.invalidateQueries({ queryKey: ["thread"] });
  },
});
Per-query caches trade a refetch for not having to find every copy.

The trade is plain. Invalidation costs a few requests and a short delay (one refetch round trip, so it depends on the network) before the other views catch up, and the mutation code must know which query keys might contain a user. Normalization costs a schema and the write-side code in chapter 7, but updates every view in the same frame with no requests.

A decision tree. Is the same entity shown in two or more views at once? If no, use a per-query cache. If yes: do updates arrive by push and must every view agree at once? If no, invalidate queries and use setQueryData helpers. If yes: is the app on GraphQL? If yes, use the Apollo or Relay cache. If no, use a normalized store with entity adapters.same entity in 2+ views?per-query cachepushed, must agree at once?invalidatesetQueryData helperson GraphQL?Apollo or Relaynormalized storeentity adaptersnonoyesyesyesno
Most apps stop at the first or second answer. A normalized store is the last step, for shared entities that change live.

Decision How should the app keep shared server data consistent across views?

Per-query cache with invalidation

TanStack Query or SWR; each screen's response cached under its key

After a write, setQueryData for the exact copy and invalidate the related keys

Pros

  • Least code; no schemas or merge rules
  • The server stays the source of truth; refetches fix any drift

Cons

  • Extra requests after each write
  • Views disagree for a moment; pushed events need a key-to-invalidate mapping

Normalized client store

Entity tables plus id lists (Redux Toolkit adapters, or hand-written)

Every response and event is upserted; views select by id

Pros

  • One write updates every view in the same frame, no request
  • Works offline and with high-frequency pushed events

Cons

  • Schemas, deletes, and garbage collection are your job
  • More code to review and test

Normalized GraphQL client

Apollo Client (keys by __typename and id) or Relay (keys by id)

Mutations return the changed fields; the cache merges them

Pros

  • Normalization with almost no app code
  • Fragments let each component declare its own fields

Cons

  • Requires GraphQL
  • Lists still need cache updates, directives, or refetches after adds and deletes

What we pick: Start with a per-query cache: it is right for most CRUD apps, dashboards, and content sites. Move to a normalized store when the same entities appear in many views at once and change often from pushed events, as in chat, social feeds, email, and collaborative boards. If you are on GraphQL, you get normalization from Apollo or Relay without choosing.

The middle ground

You can mix the two. Keep TanStack Query for fetching, caching, and retries, but for the one or two entities that show up everywhere (the current user, the members of a workspace), write a small setQueryData helper that walks the cached queries and patches the matching objects. It is targeted normalization without building a store. Past three or four such helpers, a real normalized layer is cleaner.

Check your understanding · Exercise 8

Which of these apps clearly benefit from a normalized store? Select all that apply.

In real products, and what it does for speed

Normalized stores show up wherever one object appears in many places at once. A few patterns are worth knowing by name, because interviewers often ask how a product you use every day keeps itself consistent.

  • GraphQL apps. Every app on Apollo Client or Relay has a normalized cache keyed by id (Apollo adds the type). Relay came out of Facebook, where one person's name and photo appear in posts, comments, chat, and notifications at once.
  • Redux apps with entity adapters. The Redux style guide recommends normalized state for collections, and createEntityAdapter is its standard tool. Chat clients, email clients, and project trackers built on Redux usually keep users, threads, and items this way.
  • Editors and boards. Design tools and Kanban boards store a flat table of nodes or cards with parent ids and ordered child id lists. Moving a card is two list edits and one field change, not a deep tree rewrite.

What it does for speed and memory

Three performance wins come from the same shape. Lookups are one step: messages[id] instead of scanning 5,000 messages. Memory holds one copy per entity: a channel of 200 messages from 12 people stores 12 user rows, not 200. Renders stay small: a rename creates one new user object, so only components that read that user re-render, as chapter 5 showed.

The nested store loses on all three. Take a channel of 200 messages (40 by Priya), a thread of 6 replies by her, and a sidebar of 20 members. To rename her in the nested store, the reducer rewrites all 47 copies (40 messages, 6 replies, and the sidebar entry) plus the arrays that hold them. The channel, the thread, and the sidebar all get new arrays and re-render their lists: up to 226 rows render if rows are not memoized.

In the normalized store the three lists keep the same id arrays, and exactly the 47 rows that read u7 re-render.

Two bar groups for renaming Priya in a chat with a 200-message channel, a 6-reply thread, and a 20-member sidebar. Objects written: nested 47 copies, normalized 1 row. Rows rendered again, without memoized rows: nested up to 226, normalized 47.objects written for one renamenested47normalized1rows rendered againnested226normalized47
Same rename, same screen. The nested store writes every copy and makes every list render again; the normalized store writes one row and renders only the readers of that row. Each group has its own scale.

Normalizing also has a cost on the way in: the normalizer walks every response. For typical responses that is small next to parsing the JSON, but it grows with response size and is slower on low-end phones, so measure it on a real device. For very large responses, do it off the main thread in a worker.

Or ask the server to send normalized data in the first place. Some APIs return { data, included } with each related object listed once, as JSON:API compound documents do.

Using it in a system design interview

Normalization comes up in almost every frontend design question with a feed, a chat, an inbox, or a board. It usually arrives in the data model part of the answer, right after you name the entities and the API. Bring it up yourself when you notice the same entity on two parts of the screen.

What to draw and say

  1. Name the entities and where they repeat. "Users appear in messages, replies, reactions, and the member list." This shows you saw the problem before solving it.
  2. Draw the tables and lists. Three or four boxes: users, messages, channels, and a lists box with channel:c42 -> [m1, m2]. Write one example row with an id field.
  3. Walk one update. "A user.updated event upserts one row; every row that selects that user re-renders." Then walk one delete, including dangling ids.
  4. Say when you would not. "If this were a simple CRUD admin, I would use TanStack Query and invalidate instead."

How deep to go by level

A mid-level answer explains entities by id and lists of ids, and shows that one write updates every view. A senior answer adds the read path (select at the leaf, memoized selectors, stable references), merge rules for partial entities, and the delete and memory story. A staff answer weighs normalizing against invalidation for this product, and names who owns the schema as the API changes.

Keep it short unless asked. Two minutes on the store shape and one update is usually enough; save the cascade and garbage collection details for a follow-up question.

Summary

  • Nested server responses hold copies of the same entity; stored as they are, the copies drift and views disagree.
  • Normalize: one table per entity type keyed by id, lists of ids for order and membership, and id fields for every relationship.
  • Every fact lives in one place. Do not store derived values like counts; compute them on read.
  • Normalize at the fetch boundary with a schema, and merge rows field by field so slim copies do not erase full ones.
  • Read by passing ids down and selecting at the leaf; use memoized selectors for derived views so unchanged data keeps its identity.
  • Deletes leave dangling ids: remove ids from every list, make readers skip missing entities, and decide cascades per relationship.
  • Tools: normalizr for nested responses, Redux Toolkit entity adapters for tables, Apollo and Relay normalize GraphQL for you.
  • Many apps do not need it: a per-query cache with invalidation is simpler until the same entities appear in many live views.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

In a chat app, a user changes their display name. How does your state make every message, reply, and sidebar entry show the new name?

Question 2

The API returns nested responses. Where and how do you normalize them?

Question 3

You normalized your store. How do you delete a message safely?

Question 4

Your team uses TanStack Query. Would you add a normalized store?

Question 5

How do you keep a normalized store fast when rendering a list of 2,000 items?

Practice it

These design problems lean on normalizing state.

References

Read next