Redux Toolkit 12 ⚛️ Entity Adapter — Managing normalized state collections
An entity adapter is a set of prebuilt reducers and selectors for managing a normalized collection of items in the store. The previous chapters covered slices, thunks, and the async lifecycle. This chapter covers the structure that the store uses for collections of entities with IDs—users, todos, posts, comments—and the adapter that generates the CRUD operations without the developer writing the spread or the map for every update. The createEntityAdapter function accepts an id selector and an optional sortComparer, and it returns an object with the reducers and selectors that operate on a normalized state shape.
The normalized shape is { ids: [], entities: {} }. The ids array preserves the order, and the entities object maps each ID to the item. The adapter’s addOne, addMany, upsertOne, updateOne, removeOne, and setAll methods produce the new state from the old state and the payload. The selectors read from the shape and return the array of items, the item by ID, or the IDs. The LFCE exam does not cover Redux Toolkit, but the pattern is the standard for normalized state in Redux, and the adapter is the tool that makes it practical.
Key point: The adapter expects the state to have the ids and entities fields at the top level of the slice’s state, or at a nested path that the adapter is configured to use. The getInitialState method returns the empty normalized state, and the getSelectors method returns the selectors that read from it. The sortComparer option sorts the ids array, which makes the selector return the items in the sorted order. The updateOne method accepts an object with an id and a changes object, not a partial item.
Why entity adapters exist
The manual-normalization problem.
A store that keeps an array of items must find, update, and remove items by scanning the array. The update is a .map() that returns the same item for the non-matching elements and a new object for the matching one. The remove is a .filter(). The insert is a spread. The code is repetitive, and it is easy to make a mistake—returning the same reference, forgetting the spread, or updating the wrong field. The adapter’s methods do the same operations correctly, and the slice’s reducers become one-liners .
The lookup problem.
An array requires a linear scan to find an item by ID. A normalized object gives constant-time lookup by ID, and the ids array gives the order and the iteration. The selectors that the adapter generates are memoized, so the component that reads the list re-renders only when the list changes, and the component that reads a single item re-renders only when that item changes .
The sorting problem.
A normalized object does not preserve order, and the ids array is the order. The sortComparer option sorts the ids array whenever the state changes, so the selectAll selector returns the items in the sorted order. The sorting is done once, in the reducer, not on every render . The alternative is to sort in the selector, which recomputes on every read and defeats the memoization.
a. Creating the adapter and the slice
The createEntityAdapter function is called once, at module scope, with the id selector and the optional sortComparer. The getInitialState method returns the empty normalized state, and the getSelectors method returns the selectors.
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
export interface Todo {
id: number
title: string
completed: boolean
}
// The adapter is created once at module scope
export const todosAdapter = createEntityAdapter<Todo>({
// The id selector tells the adapter which field is the ID
selectId: (todo) => todo.id,
// The sort comparer orders the ids array
sortComparer: (a, b) => a.title.localeCompare(b.title),
})
const todosSlice = createSlice({
name: 'todos',
// The initial state is the empty normalized state
initialState: todosAdapter.getInitialState(),
reducers: {
// The adapter's methods produce the new state
addTodo: todosAdapter.addOne,
addTodos: todosAdapter.addMany,
updateTodo: todosAdapter.updateOne,
removeTodo: todosAdapter.removeOne,
setTodos: todosAdapter.setAll,
},
})
export const { addTodo, addTodos, updateTodo, removeTodo, setTodos } = todosSlice.actions
export default todosSlice.reducer
The createEntityAdapter call creates the adapter. The selectId option tells the adapter which field is the ID; it defaults to id, and the option is only needed when the field has a different name . The sortComparer option sorts the ids array, and the getInitialState method returns { ids: [], entities: {} } with the adapter’s sorting applied.
The reducers object uses the adapter’s methods directly as the reducer functions. The addOne method receives the state and the action with the item as the payload, and it returns the new state. The updateOne method receives the state and the action with { id, changes } as the payload, and it returns the new state . The setAll method replaces the entire collection.
b. The adapter methods
The adapter provides the CRUD methods that cover the common operations. Each method returns a new state, and each method is a pure function of the old state and the payload.
| Method | Payload | Behavior |
|---|---|---|
addOne | The item | Adds the item |
addMany | The array of items | Adds the items |
setOne | The item | Adds or replaces the item |
setMany | The array of items | Adds or replaces the items |
setAll | The array of items | Replaces the collection |
removeOne | The ID | Removes the item |
removeMany | The array of IDs | Removes the items |
removeAll | None | Clears the collection |
updateOne | { id, changes } | Updates the item |
updateMany | [{ id, changes }] | Updates the items |
upsertOne | The item | Adds or updates the item |
upsertMany | The array of items | Adds or updates the items |
The updateOne method is the one that differs most from the manual pattern. The payload is { id, changes }, and the changes is a partial object with the fields to update. The adapter merges the changes into the existing item, and the item is a new object with the updated fields . The updateMany method accepts an array of the same shape.
// The update action takes { id, changes }
dispatch(updateTodo({ id: 1, changes: { completed: true } }))
// The upsert action takes the full item
dispatch(upsertTodo({ id: 1, title: 'Updated', completed: true }))
The upsertOne method adds the item if it does not exist and replaces it if it does. The updateOne method only updates an existing item; if the ID is not in the collection, the state is unchanged.
c. The selectors
The getSelectors method returns the selectors that read from the normalized state. The selectAll selector returns the array of items in the sorted order, and the selectById selector returns a single item.
export const {
selectAll: selectAllTodos,
selectById: selectTodoById,
selectIds: selectTodoIds,
selectTotal: selectTodoTotal,
selectEntities: selectTodoEntities,
} = todosAdapter.getSelectors()
The selectors expect the state to be the normalized shape. If the slice’s state is nested inside the root state, the getSelectors method accepts a function that extracts the slice’s state.
export const {
selectAll: selectAllTodos,
selectById: selectTodoById,
} = todosAdapter.getSelectors((state: RootState) => state.todos)
The selectAllTodos selector is memoized, so the component that reads the list re-renders only when the list changes. The selectTodoById selector is a factory that takes the ID and returns a selector, so the component reads a single item and re-renders only when that item changes . The selectTotal selector returns the number of items.
Complete Example Session
// ============================================
// PART 1: THE ENTITY TYPE
// ============================================
export interface Todo {
id: number
title: string
completed: boolean
}
// ============================================
// PART 2: THE ADAPTER
// ============================================
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
export const todosAdapter = createEntityAdapter<Todo>({
sortComparer: (a, b) => a.title.localeCompare(b.title),
})
// ============================================
// PART 3: THE SLICE
// ============================================
const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState(),
reducers: {
todoAdded: todosAdapter.addOne,
todoUpdated: todosAdapter.updateOne,
todoRemoved: todosAdapter.removeOne,
todosReceived: todosAdapter.setAll,
},
})
export const { todoAdded, todoUpdated, todoRemoved, todosReceived } = todosSlice.actions
export default todosSlice.reducer
// ============================================
// PART 4: THE SELECTORS
// ============================================
import type { RootState } from '../../app/store'
export const {
selectAll: selectAllTodos,
selectById: selectTodoById,
selectTotal: selectTodoTotal,
} = todosAdapter.getSelectors((state: RootState) => state.todos)
// ============================================
// PART 5: THE COMPONENT
// ============================================
import { useAppDispatch, useAppSelector } from '../../app/hooks'
function TodoList() {
const todos = useAppSelector(selectAllTodos)
const dispatch = useAppDispatch()
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.title}
<button onClick={() => dispatch(todoUpdated({ id: todo.id, changes: { completed: !todo.completed } }))}>
Toggle
</button>
</li>
))}
</ul>
)
}
// ============================================
// PART 6: THE ASYNC THUNK
// ============================================
import { createAsyncThunk } from '@reduxjs/toolkit'
export const fetchTodos = createAsyncThunk<Todo[]>('todos/fetch', async () => {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
// The extraReducers handles the fulfilled action
// and stores the payload with setAll
The six parts demonstrate the complete entity adapter pattern: the entity type, the adapter, the slice with the adapter’s methods, the selectors, the component, and the async thunk. The critical configuration is the sortComparer, which sorts the ids array so that the selectAll selector returns the items in the sorted order.
Quick Reference
createEntityAdapter Options
| Option | Purpose | Default |
|---|---|---|
selectId | The ID field | id |
sortComparer | Sort the ids array | None |
Adapter Methods
| Method | Payload |
|---|---|
addOne / addMany | Item / array of items |
setOne / setMany / setAll | Item / array / array |
removeOne / removeMany / removeAll | ID / array / none |
updateOne / updateMany | { id, changes } / array |
upsertOne / upsertMany | Item / array |
Selectors
| Selector | Returns |
|---|---|
selectAll | Array of items (sorted) |
selectById | Single item by ID |
selectIds | Array of IDs |
selectTotal | Number of items |
selectEntities | The entities object |
State Shape
| Field | Purpose |
|---|---|
ids | Array of IDs in order |
entities | Object mapping ID to item |
Best Practices
✅ Do This:
// Create the adapter at module scope
const adapter = createEntityAdapter<Todo>() # ✅
// Use getInitialState
initialState: adapter.getInitialState() # ✅
// Use the adapter's methods as reducers
todoAdded: adapter.addOne # ✅
// Use updateOne with { id, changes }
dispatch(todoUpdated({ id: 1, changes: { completed: true } })) # ✅
// Use selectAll for the list
const todos = useAppSelector(selectAllTodos) # ✅
// Pass the state selector to getSelectors
adapter.getSelectors((state: RootState) => state.todos) # ✅
❌ Don’t Do This:
// Write manual spread reducers for normalized state
todos: { ...state.todos, entities: { ...state.todos.entities, [id]: item } } # ❌
// Use updateOne with a partial item
dispatch(todoUpdated({ id: 1, title: 'x' })) // Wrong shape # ❌
// Sort in the selector instead of the adapter
selectAll: (state) => state.ids.map(...).sort(...) # ❌
// Create the adapter inside the component
function Component() { const adapter = createEntityAdapter() } # ❌
// Forget the state selector when the slice is nested
adapter.getSelectors() // Assumes the state is the slice # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
updateOne does not update | Wrong payload shape | Use { id, changes } |
| Items not sorted | No sortComparer | Add sortComparer |
| Selector returns undefined | Wrong state path | Pass the state selector |
| Component re-renders on every change | Non-memoized selector | Use the adapter’s selectors |
| Adapter recreated on every render | Created inside the component | Create at module scope |
Real-World Examples
1. Create the adapter
const adapter = createEntityAdapter<Todo>({ sortComparer: (a, b) => a.title.localeCompare(b.title) })
2. Initial state
initialState: adapter.getInitialState()
3. Add one
todoAdded: adapter.addOne
4. Add many
todosReceived: adapter.addMany
5. Update one
todoUpdated: adapter.updateOne
dispatch(todoUpdated({ id: 1, changes: { completed: true } }))
6. Remove one
todoRemoved: adapter.removeOne
7. Set all
todosReplaced: adapter.setAll
8. Upsert one
todoUpserted: adapter.upsertOne
9. Select all
const todos = useAppSelector(selectAllTodos)
10. Select by ID
const todo = useAppSelector((state) => selectTodoById(state, 1))
Visual
The Normalized State Shape
┌──────────────────────────────────────────────────────────────┐
│ { ids: [], entities: {} } │
│ │
│ After adding three todos: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ ids: [3, 1, 2], ← sorted by title │ │
│ │ entities: { │ │
│ │ 1: { id: 1, title: 'B', completed: false }, │ │
│ │ 2: { id: 2, title: 'C', completed: false }, │ │
│ │ 3: { id: 3, title: 'A', completed: false } │ │
│ │ } │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ The ids array is the order; the entities object is the │
│ lookup table. │
└──────────────────────────────────────────────────────────────┘
The updateOne Payload
┌──────────────────────────────────────────────────────────────┐
│ updateOne TAKES { id, changes } │
│ │
│ ❌ Wrong: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ dispatch(todoUpdated({ id: 1, title: 'x' })) │ │
│ │ → the adapter cannot find the changes object │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ✅ Correct: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ dispatch(todoUpdated({ id: 1, changes: { title: 'x' } }))│ │
│ │ → the changes are merged into the existing item │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The Selector Memoization
┌──────────────────────────────────────────────────────────────┐
│ THE ADAPTER'S SELECTORS ARE MEMOIZED │
│ │
│ selectAllTodos: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Returns the array of items │ │
│ │ → the same reference until the collection changes │ │
│ │ → the component re-renders only on change │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ selectTodoById: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Factory: takes the ID and returns a selector │ │
│ │ → the component re-renders only when that item changes │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Function | createEntityAdapter |
| State shape | { ids: [], entities: {} } |
| Initial state | adapter.getInitialState() |
| Add | addOne, addMany |
| Set | setOne, setMany, setAll |
| Remove | removeOne, removeMany, removeAll |
| Update | updateOne({ id, changes }), updateMany |
| Upsert | upsertOne, upsertMany |
| Selectors | selectAll, selectById, selectIds, selectTotal |
| Sorting | sortComparer option |
| State path | Pass the state selector to getSelectors |
Key takeaways:
- The entity adapter generates the CRUD reducers for normalized state. The state shape is
{ ids, entities }, and the adapter’s methods produce the new state from the old state and the payload. The reducers in the slice become one-liners, and the manual spread is eliminated . - The
updateOnemethod takes{ id, changes }. Thechangesis a partial object with the fields to update, and the adapter merges it into the existing item. TheupsertOnemethod takes the full item and adds or replaces it . - The
sortCompareroption sorts theidsarray. The sorting happens in the reducer, once, not in the selector on every render. TheselectAllselector returns the items in the sorted order . - The selectors are memoized. The
selectAllselector returns the same array reference until the collection changes, and theselectByIdselector returns a factory that re-renders only when that item changes. The component reads the selectors instead of the raw state. - The
getSelectorsmethod accepts a state selector. When the slice’s state is nested inside the root state, the function that extracts the slice’s state is passed togetSelectors, and the returned selectors read from the nested path. - The adapter is created at module scope. Creating the adapter inside the component recreates it on every render, which discards the memoization. The adapter is a module-level object, and the slice uses it in its definition .
Remember: An entity adapter is the tool for normalized collections. The state is { ids, entities }, the adapter’s methods produce the new state for the CRUD operations, and the selectors read from the shape with memoization. The sortComparer sorts the ids array, and the updateOne payload is { id, changes }. The adapter is created at module scope, and the selectors are passed the state path when the slice is nested. The result is a slice that manages a collection with one-line reducers and memoized selectors, without the manual spread and the linear scan.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!