| | |

Redux Toolkit 12 ⚛️ Entity Adapter — Managing normalized state collections

⏱️ Estimated read: 12 min | |

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.

MethodPayloadBehavior
addOneThe itemAdds the item
addManyThe array of itemsAdds the items
setOneThe itemAdds or replaces the item
setManyThe array of itemsAdds or replaces the items
setAllThe array of itemsReplaces the collection
removeOneThe IDRemoves the item
removeManyThe array of IDsRemoves the items
removeAllNoneClears the collection
updateOne{ id, changes }Updates the item
updateMany[{ id, changes }]Updates the items
upsertOneThe itemAdds or updates the item
upsertManyThe array of itemsAdds 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

OptionPurposeDefault
selectIdThe ID fieldid
sortComparerSort the ids arrayNone

Adapter Methods

MethodPayload
addOne / addManyItem / array of items
setOne / setMany / setAllItem / array / array
removeOne / removeMany / removeAllID / array / none
updateOne / updateMany{ id, changes } / array
upsertOne / upsertManyItem / array

Selectors

SelectorReturns
selectAllArray of items (sorted)
selectByIdSingle item by ID
selectIdsArray of IDs
selectTotalNumber of items
selectEntitiesThe entities object

State Shape

FieldPurpose
idsArray of IDs in order
entitiesObject 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

PitfallWhy It HappensFix
updateOne does not updateWrong payload shapeUse { id, changes }
Items not sortedNo sortComparerAdd sortComparer
Selector returns undefinedWrong state pathPass the state selector
Component re-renders on every changeNon-memoized selectorUse the adapter’s selectors
Adapter recreated on every renderCreated inside the componentCreate 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

ItemValue
FunctioncreateEntityAdapter
State shape{ ids: [], entities: {} }
Initial stateadapter.getInitialState()
AddaddOne, addMany
SetsetOne, setMany, setAll
RemoveremoveOne, removeMany, removeAll
UpdateupdateOne({ id, changes }), updateMany
UpsertupsertOne, upsertMany
SelectorsselectAll, selectById, selectIds, selectTotal
SortingsortComparer option
State pathPass 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 updateOne method takes { id, changes }. The changes is a partial object with the fields to update, and the adapter merges it into the existing item. The upsertOne method takes the full item and adds or replaces it .
  • The sortComparer option sorts the ids array. The sorting happens in the reducer, once, not in the selector on every render. The selectAll selector returns the items in the sorted order .
  • The selectors are memoized. The selectAll selector returns the same array reference until the collection changes, and the selectById selector returns a factory that re-renders only when that item changes. The component reads the selectors instead of the raw state.
  • The getSelectors method 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 to getSelectors, 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.


Was this helpful?


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!