In Redux Toolkit's createEntityAdapter, how do addOne, setOne, upsertOne and updateOne differ when an entity with that id already exists?
answer
- ids array plus entities lookup
- add refuses, set replaces
- upsert merges or inserts
- update needs an existing id
basics
~20 sFor 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 linesimport { 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
Recall the ids plus entities shape and that the adapter generates add, set, upsert, update and remove functions plus selectAll and selectById.
Explain how each CRUD function behaves for an existing and a missing id, including that upsert and update merge only one level deep.
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.
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