Normalizing State
When your app holds relational data — users, posts, comments, the same entity referenced from many places — storing it as deeply nested objects or duplicated copies quickly becomes a source of subtle bugs. Normalization is the practice of flattening that data into a lookup table keyed by id, plus arrays of ids that describe order and relationships. It is the same idea databases use, and it makes reads, writes, and cache invalidation predictable.
Why nested and duplicated state causes bugs
Imagine a feed where each post embeds its full author object:
const state = {
posts: [
{ id: "p1", text: "Hello", author: { id: "u1", name: "Ada" } },
{ id: "p2", text: "World", author: { id: "u1", name: "Ada" } },
],
};
Ada appears twice. If she changes her name, you must find and update every copy. Miss one and the UI shows stale data. Nesting also forces deep, error-prone immutable updates and makes it hard to answer simple questions like “give me user u1” without scanning arrays.
Duplicated data has no single source of truth. The moment the same entity lives in two places, the two copies can disagree — and they eventually will.
The normalized shape
A normalized slice keeps each entity type in a byId map and tracks order with an allIds array (or a relationship array on the parent):
const state = {
users: {
byId: {
u1: { id: "u1", name: "Ada" },
},
allIds: ["u1"],
},
posts: {
byId: {
p1: { id: "p1", text: "Hello", authorId: "u1" },
p2: { id: "p2", text: "World", authorId: "u1" },
},
allIds: ["p1", "p2"],
},
};
Now u1 exists exactly once. Posts reference her by authorId instead of embedding her. Renaming Ada is a single write.
| Concern | Nested / duplicated | Normalized by id |
|---|---|---|
| Lookup by id | Scan an array | byId[id] — O(1) |
| Update an entity | Find every copy | One write |
| Source of truth | Multiple copies | Single entry |
| Ordering / lists | Coupled to data | Separate id array |
| Deep updates | Nested spreads | Shallow, flat |
Looking up and updating by id
Reads become direct property access, and components select only the ids they need so they re-render less:
function PostItem({ postId }) {
const post = useSelector((s) => s.posts.byId[postId]);
const author = useSelector((s) => s.users.byId[post.authorId]);
return (
<article>
<h3>{author.name}</h3>
<p>{post.text}</p>
</article>
);
}
function Feed() {
const ids = useSelector((s) => s.posts.allIds);
return ids.map((id) => <PostItem key={id} postId={id} />);
}
Updates touch one entry without disturbing the rest:
// Rename the user once — every post that references u1 reflects it.
function userReducer(state, action) {
if (action.type === "user/renamed") {
const { id, name } = action.payload;
return {
...state,
byId: { ...state.byId, [id]: { ...state.byId[id], name } },
};
}
return state;
}
Normalizing incoming data
API responses usually arrive nested, so normalize at the boundary:
function normalizePosts(rawPosts) {
const users = { byId: {}, allIds: [] };
const posts = { byId: {}, allIds: [] };
for (const raw of rawPosts) {
const { author, ...rest } = raw;
if (!users.byId[author.id]) {
users.byId[author.id] = author;
users.allIds.push(author.id);
}
posts.byId[raw.id] = { ...rest, authorId: author.id };
posts.allIds.push(raw.id);
}
return { users, posts };
}
For complex graphs, the normalizr library does this declaratively from a schema definition, but a hand-written reducer is enough for most apps.
createEntityAdapter
Redux Toolkit ships createEntityAdapter, which generates the normalized shape ({ ids: [], entities: {} }), prebuilt reducer helpers, and memoized selectors so you do not write the boilerplate yourself:
import {
createEntityAdapter,
createSlice,
configureStore,
} from "@reduxjs/toolkit";
const usersAdapter = createEntityAdapter();
const usersSlice = createSlice({
name: "users",
initialState: usersAdapter.getInitialState(),
reducers: {
usersReceived: usersAdapter.setAll,
userUpserted: usersAdapter.upsertOne,
userRemoved: usersAdapter.removeOne,
},
});
export const { usersReceived, userUpserted, userRemoved } =
usersSlice.actions;
const store = configureStore({
reducer: { users: usersSlice.reducer },
});
store.dispatch(usersReceived([{ id: "u1", name: "Ada" }]));
store.dispatch(userUpserted({ id: "u1", name: "Ada Lovelace" }));
const selectors = usersAdapter.getSelectors((s) => s.users);
console.log(selectors.selectById(store.getState(), "u1"));
console.log(selectors.selectIds(store.getState()));
Output:
{ id: 'u1', name: 'Ada Lovelace' }
[ 'u1' ]
The adapter gives you CRUD reducers (addOne, addMany, setAll, upsertOne, updateOne, removeOne, and more) and memoized selectors (selectAll, selectById, selectIds, selectEntities, selectTotal) for free. You can also pass sortComparer to keep ids ordered automatically.
updateOneexpects{ id, changes }— a partial patch — whileupsertOnetakes a full entity and inserts it if missing. Reaching for the wrong one is a common cause of “my update did nothing” bugs.
Best Practices
- Normalize as data enters the store, not scattered across components.
- Keep one entity table per type; reference other entities by id, never by embedding.
- Use id arrays to encode order and relationships separately from the entities themselves.
- Reach for
createEntityAdapterwhenever you use Redux Toolkit — it removes the boilerplate and ships memoized selectors. - Select the narrowest data each component needs (often just an id) to minimize re-renders.
- Do not over-normalize tiny, non-shared, read-only data — flat is a tool, not a religion.