Normalized state is a way of keeping data in your app so that each item is stored exactly once. There's one table for each kind of item, such as users, projects, and tasks: an object that maps each item's id to the item. Wherever one item contained another, it holds that item's id instead. The word comes from databases, where normalizing means arranging tables so that the same fact isn't stored in two places.
APIs often send data nested, the way a screen shows it: a project with its owner, and its tasks, each with the person it's assigned to. Then the same item turns up in several places. Here Ada owns the project and is also assigned the first task, so the data holds two copies of her:
const project = {
id: "p1",
name: "Launch",
owner: { id: "u1", name: "Ada" },
tasks: [
{ id: "t1", title: "Write docs", assignee: { id: "u1", name: "Ada" } },
{ id: "t2", title: "Fix login", assignee: { id: "u2", name: "Alan" } },
],
};
If Ada changes her name, every copy has to change, and nothing tells you where the copies are. Miss one, and the page shows two names for the same person, like a phone number written in several notebooks: when it changes, one always gets missed. Normalized, the same data has a table per kind of item, and each nested item is replaced by its id. Reading it back means looking ids up in their tables:
const entities = {
projects: {
p1: { id: "p1", name: "Launch", owner: "u1", tasks: ["t1", "t2"] },
},
tasks: {
t1: { id: "t1", title: "Write docs", assignee: "u1" },
t2: { id: "t2", title: "Fix login", assignee: "u2" },
},
users: {
u1: { id: "u1", name: "Ada" },
u2: { id: "u2", name: "Alan" },
},
};
const task = entities.tasks.t2;
entities.users[task.assignee].name; // "Alan"
entities.projects.p1.tasks.map((id) => entities.tasks[id].title); // ["Write docs", "Fix login"]
| Job | With nested data | With normalized data |
|---|---|---|
| Rename Ada | Change every copy: the project's owner, the assignee of t1, and any others. | Change users.u1, the only copy. |
Find task t2 | Search the tasks of every project. | Read tasks.t2: one lookup by key, O(1), however many tasks there are. |
| Show who owns the project | Read project.owner.name. | Look the id up: users[project.owner].name. |
| Keep the tasks in order | The tasks array is in order. | The array of ids, ["t1", "t2"], keeps the order. |
That's why Redux's documentation recommends keeping state normalized, and Redux Toolkit's createEntityAdapter keeps each kind of item as { ids, entities }: an array of ids, in order, and an object of items by id. Client caches work the same way: Apollo Client's cache stores each object once, under an id made from its type and its id, such as User:10, so a change to it shows up everywhere it's used.
Libraries such as normalizr turn nested API data into this shape: you describe it with a schema, which says which fields hold which kind of item, and get the tables back. One detail when you look items up: object keys are always strings, so users[10] and users["10"] are the same entry, and Object.keys(users) gives "10", not 10.
An API often returns nested data: a post with its author, and its comments with theirs. The same user can appear in many places, so changing them means finding every copy. Normalizing stores each item once, in a table by id, and puts the item's id wherever it was nested. Write normalize(data, schema), which does that.
A schema describes one kind of item: { key, fields }. key names its table, like "users". fields is optional: it maps the name of each field that holds nested items to their schema, either a schema for one item, or an array holding a schema, like [comment], for a list of them. Every item has an id.
schema is the schema of data itself: a schema when data is one item, or an array holding one when data is a list.
normalize returns { result, entities }. result is the id of data, or an array of ids when it's a list. entities is an object with one table for each kind of item found, named by its key. Each table is an object that maps each item's id to a copy of the item in which every field named in fields holds an id, or an array of ids, in place of the nested items.
Each entry is a new object: don't change data. Fields that aren't named in fields are copied as they are, and a field named in fields that's missing, null, or undefined stays that way.
The same item (same key, same id) can appear more than once. Merge its copies into one entry: each later copy adds its fields to the entry, replacing fields with the same name.
const user = { key: "users" };
const comment = { key: "comments", fields: { author: user } };
const post = { key: "posts", fields: { author: user, comments: [comment] } };
const posts = [
{
id: 1,
title: "Hello",
author: { id: 10, name: "Ada" },
comments: [{ id: 100, text: "Nice", author: { id: 11, name: "Alan" } }],
},
{ id: 2, title: "Again", author: { id: 10, name: "Ada" }, comments: [] },
];
normalize(posts, [post]);
// {
// result: [1, 2],
// entities: {
// users: { 10: { id: 10, name: "Ada" }, 11: { id: 11, name: "Alan" } },
// comments: { 100: { id: 100, text: "Nice", author: 11 } },
// posts: {
// 1: { id: 1, title: "Hello", author: 10, comments: [100] },
// 2: { id: 2, title: "Again", author: 10, comments: [] },
// },
// },
// }
const user = { key: "users" };
expect(normalize({ id: 1, name: "Ada" }, user)).toEqual({ result: 1, entities: { users: { 1: { id: 1, name: "Ada" } } } });
const user = { key: "users" };
expect(normalize([{ id: 1, name: "Ada" }, { id: 2, name: "Alan" }], [user])).toEqual({
result: [1, 2],
entities: { users: { 1: { id: 1, name: "Ada" }, 2: { id: 2, name: "Alan" } } },
});
expect(normalize([], [user])).toEqual({ result: [], entities: {} });
const user = { key: "users" };
const post = { key: "posts", fields: { author: user } };
expect(normalize({ id: 1, title: "Hi", author: { id: 10, name: "Ada" } }, post)).toEqual({
result: 1,
entities: {
posts: { 1: { id: 1, title: "Hi", author: 10 } },
users: { 10: { id: 10, name: "Ada" } },
},
});
const user = { key: "users" };
const comment = { key: "comments", fields: { author: user } };
const post = { key: "posts", fields: { comments: [comment] } };
const data = { id: 1, comments: [{ id: 100, text: "a", author: { id: 10 } }, { id: 101, text: "b", author: { id: 11 } }] };
expect(normalize(data, post)).toEqual({
result: 1,
entities: {
posts: { 1: { id: 1, comments: [100, 101] } },
comments: { 100: { id: 100, text: "a", author: 10 }, 101: { id: 101, text: "b", author: 11 } },
users: { 10: { id: 10 }, 11: { id: 11 } },
},
});
const user = { key: "users" };
const post = { key: "posts", fields: { author: user } };
const meta = { views: 3 };
const tags = ["js"];
const { entities } = normalize({ id: 1, meta, tags, author: { id: 10, prefs: { theme: "dark" } } }, post);
expect(entities.posts[1].meta).toBe(meta);
expect(entities.posts[1].tags).toBe(tags);
expect(entities.users[10]).toEqual({ id: 10, prefs: { theme: "dark" } });
const user = { key: "users" };
const comment = { key: "comments" };
const post = { key: "posts", fields: { author: user, comments: [comment] } };
const { entities } = normalize([{ id: 1, author: null, comments: null }, { id: 2 }, { id: 3, author: undefined }], [post]);
expect(entities).toEqual({
posts: { 1: { id: 1, author: null, comments: null }, 2: { id: 2 }, 3: { id: 3, author: undefined } },
});
const user = { key: "users" };
const post = { key: "posts", fields: { author: user } };
const { entities } = normalize(
[
{ id: 1, author: { id: 10, name: "Ada", city: "London" } },
{ id: 2, author: { id: 10, name: "Ada L.", avatar: "ada.png" } },
],
[post],
);
expect(entities.users).toEqual({ 10: { id: 10, name: "Ada L.", city: "London", avatar: "ada.png" } });
const user = { key: "users" };
user.fields = { manager: user, reports: [user] };
const data = { id: 1, name: "Ada", reports: [{ id: 2, name: "Alan", manager: { id: 1 } }] };
expect(normalize(data, user)).toEqual({
result: 1,
entities: { users: { 1: { id: 1, name: "Ada", reports: [2] }, 2: { id: 2, name: "Alan", manager: 1 } } },
});
const user = { key: "users" };
const post = { key: "posts", fields: { author: user } };
const author = { id: 10, name: "Ada" };
const data = [{ id: 1, author }];
const { entities } = normalize(data, [post]);
expect(data).toEqual([{ id: 1, author: { id: 10, name: "Ada" } }]);
expect(data[0].author).toBe(author);
expect(entities.posts[1]).not.toBe(data[0]);
expect(entities.users[10]).not.toBe(author);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Normalize nested data. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.