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:
[
{ "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.
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.
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 } } };
}
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.
- Start at the channel. Its
messagesfield is a list of messages, so normalize each one. - At
m1, theauthorfield holds a user. Normalize it:u7goes intousers, andm1keepsauthorId: "u7". Thenm1goes intomessages. - At
m2, the same:u3goes intousers,m2goes intomessages. - At
m3, the author isu7again. It is already inusers, so the new copy is merged into the existing row instead of making a second one. - Back at the channel: its
messagesfield becomesmessageIds: ["m1", "m2", "m3"], and the channel goes intochannels. 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
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.
// 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;
}
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.
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.| Relationship | Example | Store it as | Watch out for |
|---|---|---|---|
| Many-to-one | Message to author | message.authorId | Nothing much: the easy case |
| One-to-many | Channel to messages | channel.messageIds (ordered) | A second writable channelId on the child drifts |
| Many-to-many, small | Post to tags | post.tagIds | Reverse lookups need a scan or a second list |
| Many-to-many, rich | User to channel with a role | Join table memberships keyed c42:u7 | Delete 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).
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"
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.
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>;
}
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.
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.
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] })),
);
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.
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" }
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.
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);
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.
Decision How should a REST-based React app normalize its shared entities?
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
PATCHresponse 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.updatedforu7. Upsert the payload intousers. 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.
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.
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.
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);
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.
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.
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"] });
},
});
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.
Decision How should the app keep shared server data consistent across views?
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
createEntityAdapteris 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.
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
- 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.
- Draw the tables and lists. Three or four boxes:
users,messages,channels, and alistsbox withchannel:c42 -> [m1, m2]. Write one example row with an id field. - Walk one update. "A
user.updatedevent upserts one row; every row that selects that user re-renders." Then walk one delete, including dangling ids. - 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?
Messages and replies do not contain the user; they hold an authorId. Users live once in a users table keyed by id. The rename, whether it comes back from my own PATCH or arrives as a user.updated WebSocket event, is upserted into users[id], one write.
Each message row selects its message and its author by id, so only the rows that read that user get a new object and re-render. The lists keep the same id arrays and skip rendering. Every view updates in the same frame with no extra request.
Question 2
The API returns nested responses. Where and how do you normalize them?
In the data layer, right after the response arrives and before it enters the store. A schema says which fields hold which entity types; a normalizer walks the response, moves each nested entity to its table, and leaves an id behind. It returns the top-level ids, which I store as the list for that request.
Rows are merged field by field, not replaced, because endpoints return different slices of the same entity. If out-of-order responses are a risk, I compare a version or updatedAt before merging. For tooling I would use Redux Toolkit entity adapters for the tables and a small normalizer or normalizr at the boundary.
Question 3
You normalized your store. How do you delete a message safely?
Remove the entity from messages, and remove its id from every list that can hold it: the channel, search results, pinned items. As a second defence, list selectors skip ids with no entity, so a forgotten list shows one fewer item instead of crashing.
Then decide cascades per relationship: deleting a channel deletes its messages and memberships; deleting a user usually leaves a tombstone so old messages still render. A test that checks every listed id exists catches forgotten lists early.
Question 4
Your team uses TanStack Query. Would you add a normalized store?
Usually not. A per-query cache with setQueryData for the exact copy and invalidateQueries for the rest is simpler, and the server stays the source of truth. The cost is a few refetches and a short delay before other views catch up.
I would switch when the same entities appear in many views and change often from pushed events, as in chat or a live board, or when we need offline edits. For one or two shared entities, a small helper that patches cached queries is a middle step.
Question 5
How do you keep a normalized store fast when rendering a list of 2,000 items?
The list selects only the id array; each row selects its own entity by id, so an update re-renders only the rows whose data changed. Reducers copy only the changed path, so unchanged rows keep the same object identity.
Derived views use memoized selectors, and I check their cache size: with Reselect 4 a shared selector misses when called with many ids, so I make one per row; Reselect 5 caches per argument. For 2,000 rows I would also virtualize the list, which is a separate concern from normalization.
Practice it
These design problems lean on normalizing state.
- Design a like button for millions of usersEasy
- Design an infinite scroll listMedium
- Design a file-explorer tree viewMedium
- Design a nested comments threadMedium
- Design a notification centerMedium
- Design a Kanban boardHard
- Design a news feedHard
- Design undo and redoHard
- Design Twitter (X)Hard
- Design DiscordHard
- Design NotionHard
- Design Google CalendarHard
- Design Trello or JiraHard
- Design Google DriveHard
- Design SlackHard
- Design a code review diff viewerHard
- Design a no-code website builderHard
References
- Normalizing State Shape (Redux docs) The canonical write-up of entity tables and id lists.
- createEntityAdapter (Redux Toolkit docs) Every CRUD method and selector, including upsert versus set.
- Deriving Data with Selectors (Redux docs) Memoized selectors, createSelector, and their cache-size pitfalls.
- normalizr on GitHub The schema-based normalizer; archived but still a clear reference.
- Caching in Apollo Client How a GraphQL client normalizes by type and id.
- Query Invalidation (TanStack Query docs) The per-query alternative to a normalized store.
- JSON:API compound documents An API format that sends related objects once, in included.
Read next
- Kinds of stateServer data, UI toggles, the URL, form drafts, and live streams are five different kinds of state, each with its own home: knowing which is which is the first step of every frontend design.
- GraphQLA typed query language for APIs: the client asks one endpoint for exactly the fields a screen needs, across many related objects, in one round trip, and pays for it with harder caching and more work on the server.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Server-state librariesTanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.
- In-memory cachesData your app keeps as live JavaScript objects in the tab's memory, keyed and bounded, so that going back, switching tabs, or opening the same thing twice is instant: the fastest cache you have, and the easiest one to let grow out of control.
- Cache invalidationHow every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.