Learn Redux - createEntityAdapter & Normalization
Series/Learn Redux/Episode 10
Episode 10 of 23

Learn Redux - createEntityAdapter & Normalization

This episode covers state normalization: createEntityAdapter to manage collections with automated CRUD like setAll, upsertOne, and removeMany, plus the built-in selectAll and selectById selectors and consistent data sorting.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Storing collection data in a plain array feels easy at first, but it becomes fragile as the app grows. Updating one item means finding its position in the array, deleting one item means filtering, and two components that need the same item keep duplicates. Episode 10 introduces state normalization and its helper, createEntityAdapter, which manages collections with the ids + entities pattern.

The ids + entities pattern stores each item once in a dictionary keyed by id, and the order is kept separately as a list of ids. The result: lookups by id run in O(1), there's no data duplication across slices, and the entire CRUD surface is available as battle-tested helpers.

Understanding ids and entities Normalization

The Normalized State Shape

Look at the state shape produced by the adapter:

State ter-normalisasi
{
  "ids": [1, 2, 3],
  "entities": {
    "1": { "id": 1, "title": "Halo Dunia" },
    "2": { "id": 2, "title": "Redux Toolkit" },
    "3": { "id": 3, "title": "Normalisasi Data" }
  }
}

ids holds the order, entities holds the data. Updating item number 2 is just overwriting the entities["2"] entry — without touching any other item. Relations between entities also become easier, for example storing a userId array inside a post object instead of copying the entire user data.

Why This Matters

  • Reduces data duplication and the risk of inconsistency across slices.
  • Updates and deletes become O(1) because they're id-based.
  • Selector caching is more effective because object references stay stable.

Creating the Adapter and Slice

createEntityAdapter

createEntityAdapter accepts a selectId option for collections without an id field, and a sortComparer to set the default ordering:

JSsrc/features/posts/postsAdapter.ts
import { createEntityAdapter } from "@reduxjs/toolkit"
 
interface Post {
  id: number
  title: string
  createdAt: number
}
 
export const postsAdapter = createEntityAdapter<Post>({
  selectId: (post) => post.id,
  sortComparer: (a, b) => a.createdAt - b.createdAt,
})

sortComparer determines the order of ids. Here a post with a smaller createdAt is placed first, so the list is automatically sorted by creation time. If it's omitted, the order follows insertion order.

An Adapter-Based Slice

The adapter provides ready-made reducers: setAll, addOne, addMany, upsertOne, upsertMany, updateOne, removeOne, and removeMany. All of them are used together with Immer inside reducers:

JSsrc/features/posts/postsSlice.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"
import { postsAdapter } from "./postsAdapter"
 
export const fetchPosts = createAsyncThunk(
  "posts/fetchPosts",
  async () => {
    const res = await fetch("/api/posts")
    return res.json()
  },
)
 
const postsSlice = createSlice({
  name: "posts",
  initialState: postsAdapter.getInitialState({ status: "idle" }),
  reducers: {
    addPost: postsAdapter.addOne,
    removePost: postsAdapter.removeOne,
    removeManyPosts: postsAdapter.removeMany,
  },
  extraReducers: (builder) => {
    builder.addCase(fetchPosts.fulfilled, (state, action) => {
      postsAdapter.setAll(state, action.payload)
      state.status = "succeeded"
    })
  },
})

getInitialState creates an initial state containing empty ids, empty entities, plus the extra status field. The single line postsAdapter.setAll(state, action.payload) replaces all the manual logic for storing a fetch result.

Automated CRUD in Practice

Adding and Updating

The three most commonly used operations: upsertOne to insert a new item or overwrite an old one, addMany to add many items at once, and updateOne to modify an item by id:

JSReducers CRUD lengkap
import { createSlice } from "@reduxjs/toolkit"
import { postsAdapter } from "./postsAdapter"
 
const postsSlice = createSlice({
  name: "posts",
  initialState: postsAdapter.getInitialState({ status: "idle" }),
  reducers: {
    upsertPost: postsAdapter.upsertOne,
    updatePost: {
      reducer: (state, action) => {
        postsAdapter.updateOne(state, action.payload)
      },
      prepare: (id: number, changes: Partial<Post>) => ({
        payload: { id, changes },
      }),
    },
    clearPosts: postsAdapter.removeAll,
  },
})

updateOne accepts an { id, changes } object so the reducer stays one line. Every helper takes state as its first argument and returns the new result — no manual return needed.

Removing Many Items

For bulk operations like filtering a list, removeMany accepts an array of ids or a function predicate:

JSMenghapus berdasarkan predikat
removeOldPosts: (state, action) => {
  const cutoff = action.payload
  postsAdapter.removeMany(state, (post) => post.createdAt < cutoff)
}

The predicate is passed to removeMany as a callback that evaluates each entity. The adapter removes every item that satisfies the condition in a single state operation.

Built-in Selectors and Sorting

selectAll, selectById, and selectEntities

The adapter provides selectors that work against the slice state. To keep them type-safe, combine them with a root-level selector:

JSsrc/features/posts/postsSelectors.ts
import { createSelector } from "@reduxjs/toolkit"
import { postsAdapter } from "./postsAdapter"
import type { RootState } from "../../app/store"
 
export const { selectAll, selectById, selectEntities, selectIds } =
  postsAdapter.getSelectors((state: RootState) => state.posts)
 
export const selectPostById = (id: number) =>
  createSelector(selectById, (entity) => entity?.[id])
 
export const selectRecentPosts = createSelector(
  [selectAll],
  (posts) => posts.filter((p) => p.createdAt > Date.now() - 86400000).slice(0, 5),
)

getSelectors takes a slice-selecting function, then produces selectors that read directly from the root state. selectById essentially selects the entire entities; wrap it with createSelector when you want to pick out one specific item.

Tip

selectAll returns a new array every time it's called if you filter in the component. Compose it through createSelector like the example above so the result stays memoized and doesn't trigger excessive re-renders.

Conclusion

Normalizing with createEntityAdapter changes how we manage collections: state that was once an array full of manual calculations now becomes an ids + entities pair with one-line CRUD. Id-based operations are cheap, ordering is kept consistent through sortComparer, and built-in selectors keep data access in components concise.

Key takeaways:

  • Normalized state consists of ids for order and entities for the data dictionary.
  • createEntityAdapter generates CRUD reducers: setAll, upsertOne, updateOne, removeMany.
  • getInitialState can be given extra fields like status.
  • getSelectors produces selectAll, selectById, selectEntities, and selectIds.
  • sortComparer keeps the ids ordering consistent.
  • Combine the adapter with createSelector for memoized derived data.

In the next episode, episode 11 covers store structure & combineSlices — how to combine modular reducers, leverage lazy loading for code-splitting, and design healthy per-feature state shapes for large applications.

Learn Redux - createEntityAdapter & Normalization | Learn Redux