Skip to content
React rc state-management 4 min read

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.

ConcernNested / duplicatedNormalized by id
Lookup by idScan an arraybyId[id] — O(1)
Update an entityFind every copyOne write
Source of truthMultiple copiesSingle entry
Ordering / listsCoupled to dataSeparate id array
Deep updatesNested spreadsShallow, 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.

updateOne expects { id, changes } — a partial patch — while upsertOne takes 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 createEntityAdapter whenever 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.
Last updated June 14, 2026
Was this helpful?