skip to content

In Redux Toolkit's createEntityAdapter, how do addOne, setOne, upsertOne and updateOne differ when an entity with that id already exists?

level: middleimportance: should knowfreq 36%

answer

  1. ids array plus entities lookup
  2. add refuses, set replaces
  3. upsert merges or inserts
  4. update needs an existing id

basics

~20 s

For an existing id, addOne does nothing, setOne replaces the whole entity, upsertOne shallow-merges the new fields into it, and updateOne shallow-merges an { id, changes } object. For a missing id, updateOne is ignored while the other three insert the entity.

solid answer

~30 s

`createEntityAdapter()` manages a **normalised** shape, `{ ids: [], entities: {} }`, and generates CRUD functions for it. When the id is already present: `addOne` is a **no-op**; `setOne` **replaces** the stored entity completely; `upsertOne` **shallow-merges** the incoming object into the stored one; and `updateOne({ id, changes })` shallow-merges `changes`. When the id is absent, `addOne`, `setOne` and `upsertOne` insert it, while `updateOne` is **ignored**. The functions work both as standalone case reducers (`todoAdded: adapter.addOne`, reading `action.payload`) and as helpers called inside a case reducer with the draft. `adapter.getSelectors(selectSlice)` returns memoised `selectAll`, `selectById`, `selectIds`, `selectEntities` and `selectTotal`.

code

ts · 25 lines
ts
import { createEntityAdapter, createSlice, type PayloadAction } from '@reduxjs/toolkit'

interface Book { id: string; title: string; rating?: number }

const booksAdapter = createEntityAdapter<Book>({
  sortComparer: (a, b) => a.title.localeCompare(b.title),
})

const booksSlice = createSlice({
  name: 'books',
  initialState: booksAdapter.getInitialState({ status: 'idle' }),
  reducers: {
    bookAdded: booksAdapter.addOne,
    booksLoaded: booksAdapter.setAll,
    bookRated(state, action: PayloadAction<{ id: string; rating: number }>) {
      booksAdapter.updateOne(state, {
        id: action.payload.id,
        changes: { rating: action.payload.rating },
      })
    },
  },
})

export const { selectAll: selectAllBooks, selectById: selectBookById } =
  booksAdapter.getSelectors((state: { books: ReturnType<typeof booksSlice.reducer> }) => state.books)

go deeper

for a junior

Recall the ids plus entities shape and that the adapter generates add, set, upsert, update and remove functions plus selectAll and selectById.

for a middle

Explain how each CRUD function behaves for an existing and a missing id, including that upsert and update merge only one level deep.

for a senior

Pick the right function for each server event, full refresh versus partial patch, and avoid silent no-ops such as updateOne on an id that was never loaded.

for a principal

Decide where normalised entity state earns its complexity versus plain arrays, and keep one normalisation convention across features so selectors stay predictable.

## The normalised shape Storing a collection as an array makes lookups by id linear and updates awkward. Redux's recommended shape is **normalised**: an `ids` array for order and an `entities` object for lookup by id. `createEntityAdapter` from `@reduxjs/toolkit` generates that shape and every routine operation on it. - `adapter.getInitialState()` returns `{ ids: [], entities: {} }`; `getInitialState({ status: 'idle' })` adds your own fields beside them. - `selectId` tells the adapter where the id lives; the default is `entity.id`. - `sortComparer`, if given, keeps `ids` sorted by that comparison whenever entities are added or updated; without it, `ids` stays in insertion order. ## Why normalise at all - **Lookup by id is direct**: `entities[id]` instead of scanning an array with `find`. - **One copy per entity**: several lists can hold ids that point at the same stored object, so an edit shows up everywhere at once. - **Cheaper change detection**: in an unsorted adapter, editing one entity's fields gives that entity and the `entities` map new references while `ids` keeps its old reference, so a list component that selects only `ids` is not handed a new array. ## The four functions interviewers compare | Function | Id already present | Id absent | |---|---|---| | `addOne(entity)` | ignored; the stored entity is kept | inserted | | `setOne(entity)` | **replaced** wholesale | inserted | | `upsertOne(entity)` | **shallow-merged** into the stored entity | inserted | | `updateOne({ id, changes })` | `changes` shallow-merged | **ignored** | Each has a `Many` counterpart (`addMany`, `setMany`, `upsertMany`, `updateMany`), and there are `setAll` (replace the whole collection), `removeOne`, `removeMany` and `removeAll`. The differences matter in practice: 1. **addOne for a refresh loses data.** If the server returns a newer version of an entity already in state, `addOne` keeps the stale copy. 2. **setOne drops fields.** If the incoming object is a partial summary, `setOne` deletes the fields it lacks. Use it when the server sends the complete entity and removed fields should disappear. 3. **upsertOne keeps fields.** It merges shallowly, so fields missing from the incoming object stay. A nested object in the incoming data still replaces the nested object wholesale; the merge is one level deep. 4. **updateOne needs the id to exist.** Updating an id that has not been loaded yet silently does nothing, which surprises people who use it after an optimistic create. `updateOne` can also change an entity's id if `changes` contains a new id; the adapter moves the entry and rewrites `ids`. ## Two ways to call them The CRUD functions accept either a plain value or a whole action, and they work on either a draft or plain state: ```ts reducers: { todoAdded: todosAdapter.addOne, // reads action.payload todosReceived(state, action) { todosAdapter.setAll(state, action.payload) // called with the draft state.status = 'idle' }, } ``` Inside a `createSlice` case reducer, the state is an Immer draft and the adapter writes to it. Called outside a reducer with plain state, the same function returns a new state object instead of mutating. ## Selectors `adapter.getSelectors()` returns selectors that take the entity state directly; `adapter.getSelectors((state: RootState) => state.todos)` returns versions that take the root state: - `selectIds`, `selectEntities`, `selectTotal`; - `selectAll` — the entities in `ids` order, memoised so it returns the same array until `ids` or `entities` change; - `selectById(state, id)`. These are built with RTK's draft-safe variant of `createSelector`, so `selectAll` can be passed to a component hook without producing a new array on every call. ## When to reach for it - a collection fetched from a server and then edited item by item; - several views that need lookup by id and ordered lists of the same data; - lists that receive both full refreshes (`setAll`, `setMany`) and partial updates (`updateOne`, `upsertMany`). For a small list that is only ever replaced wholesale, a plain array is simpler. A strong answer gives the table above from memory and explains which function fits a full refresh versus a partial patch.

  • A Redux Toolkit list refresh uses adapter.addMany and edited titles from the server never appear. Why?
    `addMany` skips every entity whose id is already present, so existing rows keep their old values. Use `setMany` to replace them or `upsertMany` to merge, or `setAll` when the response is the complete list and missing entities should be removed.
  • How does sortComparer affect a Redux Toolkit entity adapter?
    With a `sortComparer`, the adapter keeps `ids` ordered by that comparison after every insert and update, so `selectAll` returns sorted entities without sorting in a selector. Without it, `ids` stays in insertion order.

saying these in an interview costs you the question

  • addOne overwrites an existing entity with the same id
  • upsertOne replaces the whole stored entity
  • updateOne inserts the entity when the id is missing
  • Adapter state is an array of entities
  • Adapter CRUD functions only work outside createSlice reducers