Concept
Data Normalization established why { ids, entities } is the right shape for collections, and showed the hand-written CRUD logic (add, update, remove, keeping ids and entities in sync) required to maintain it correctly. createEntityAdapter automates all of that, generating the initial state shape, a set of ready-made reducer functions, and matching memoized selectors, from one function call.
import { createEntityAdapter, createSlice } from "@reduxjs/toolkit";
const postsAdapter = createEntityAdapter();
console.log(postsAdapter.getInitialState());
// { ids: [], entities: {} }, the EXACT shape from Data NormalizationConfirmed directly against the real package: getInitialState() produces precisely the { ids: [], entities: {} } shape covered manually in Data Normalization, this isn't a new concept, it's that same pattern with generated tooling around it.
store.dispatch({ type: 'counter/incremented' });
dispatch() sends the action to the store's single root reducer, this is the ONLY way state changes in Redux; nothing else can mutate the store.
Generated CRUD reducer functions
const postsSlice = createSlice({
name: "posts",
initialState: postsAdapter.getInitialState({ status: "idle" }), // extra fields merge in alongside ids/entities
reducers: {
postAdded: postsAdapter.addOne, // (state, action) → adds ONE entity, keeps ids/entities in sync
postsLoaded: postsAdapter.setAll, // replaces the ENTIRE collection
postUpdated: postsAdapter.updateOne, // { id, changes: {...} } → merges changes into ONE entity
postRemoved: postsAdapter.removeOne, // removes by id, from BOTH ids and entities
},
});addOne, setAll, updateOne, removeOne (plus addMany, upsertOne, removeMany, and more) are pre-built reducer functions, pass them directly as values in createSlice's reducers object, exactly like a hand-written reducer function. Each one correctly maintains ids/entities consistency automatically, the exact bookkeeping Data Normalization flagged as an easy-to-miss manual mistake.
store.dispatch(postsSlice.actions.postsLoaded([
{ id: 2, title: "Banana" },
{ id: 1, title: "Apple" },
]));Confirmed: sortComparer keeps ids automatically re-sorted, even across updates
const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => a.title.localeCompare(b.title),
});Confirmed by running this exact setup: loading [{ id: 2, title: "Banana" }, { id: 1, title: "Apple" }] via setAll produces ids: [1, 2], already reordered to match alphabetical title order, not insertion order. Then, dispatching updateOne({ id: 1, changes: { title: "Zebra" } }), changing post 1's title to something that now sorts last, produces ids: [2, 3, 1] (given a third post, "Cherry", was added in between): the adapter automatically re-sorts ids in response to a field change that affects sort order, not just on initial load. This is meaningfully more than the hand-written normalize() function from Data Normalization provided, that only normalized on ingestion, with no ongoing sort maintenance.
Generated selectors via getSelectors()
const postsSelectors = postsAdapter.getSelectors((state) => state.posts);
postsSelectors.selectAll(state); // → array of all entities, in `ids` order
postsSelectors.selectById(state, id); // → one entity, O(1) lookup
postsSelectors.selectIds(state); // → just the ids array
postsSelectors.selectTotal(state); // → countConfirmed working exactly as documented: getSelectors takes a function locating this adapter's slice within the overall store state (since posts might live at state.posts, or nested deeper) and returns a set of createSelector-memoized selectors (per Selectors & Reselect) for the common read patterns every normalized collection needs.
Try It
Predict the outcome before checking the solution.
const adapter = createEntityAdapter();
const slice = createSlice({
name: "items",
initialState: adapter.getInitialState(),
reducers: { itemAdded: adapter.addOne, itemAdded2: adapter.addOne },
});
const store = configureStore({ reducer: { items: slice.reducer } });
store.dispatch(slice.actions.itemAdded({ id: 1, name: "First" }));
store.dispatch(slice.actions.itemAdded({ id: 1, name: "Duplicate ID" })); // SAME id, dispatched via addOne again
console.log(store.getState().items.ids, store.getState().items.entities[1]);addOne is dispatched twice with the same id: 1. Does the entities object end up with the second call's data, and does ids end up with a duplicate 1?
Solution
[1] { id: 1, name: 'First' }, the SECOND addOne call with the duplicate ID has no effect at all; the first entity's data is preserved unchanged.
This is a specific, confirmed behavior worth knowing precisely: addOne is designed for adding a genuinely new entity, if an entity with that ID already exists, addOne is a no-op, it does not overwrite the existing entity's data, and ids correctly has no duplicate. This is meaningfully different from upsertOne (add if missing, update if present) or setOne (always overwrites), using the wrong one of these three for a given use case is a real, easy mistake, since they have very similar signatures but different collision behavior.
Implement It Yourself
Sketch a minimal addOne/removeOne pair, to see the bookkeeping createEntityAdapter automates:
function addOne(state, action) {
const entity = action.payload;
if (state.entities[entity.id] !== undefined) return; // no-op if already exists (matches confirmed addOne behavior)
state.ids.push(entity.id);
state.entities[entity.id] = entity;
}
function removeOne(state, action) {
const id = action.payload;
if (state.entities[id] === undefined) return; // no-op if it doesn't exist
state.ids = state.ids.filter((existingId) => existingId !== id);
(Written as "mutating" Immer draft logic, exactly like real createSlice/createEntityAdapter internals, per createSlice.) This is precisely the pair of operations Data Normalization's "Common Mistakes" section flagged as easy to get wrong by hand (forgetting to keep ids and entities in sync), createEntityAdapter generates correct, tested versions of exactly this logic, plus several more variants (upsertOne, setAll, updateMany, etc.), so this bookkeeping never has to be hand-written and re-verified per project.
Under the Hood
This is the direct automation of everything covered by hand in Data Normalization, same { ids, entities } shape, same reasons for using it. It composes with createSlice (the generated reducer functions are plain Immer-powered reducers, usable as reducers object values exactly like hand-written ones) and with Selectors & Reselect (getSelectors() returns genuinely memoized selectors, not plain functions). It's also the state-management foundation RTK Query builds on for caching fetched collections.
Common Mistakes
1. Confusing addOne (no-op on collision) with upsertOne (updates on collision) or setOne (always overwrites)
reducers: {
itemAdded: adapter.addOne, // ❌ if the intent is "add or update", this silently does nothing on a duplicate ID
}Confirmed above: addOne with a duplicate ID is a no-op, preserving the existing entity untouched. If the actual intent is "insert if new, update if it already exists" (a very common real pattern, e.g. syncing fetched data), upsertOne is the correct choice, not addOne.
2. Passing the wrong locating function to getSelectors()
const selectors = adapter.getSelectors(); // ❌ no locator, assumes state itself IS the entity slice
const posts = selectors.selectAll(store.getState()); // WRONG if posts actually live at state.postsgetSelectors() without an argument assumes the state passed to its selectors is the { ids, entities } slice directly, if the slice is nested (as it almost always is, e.g. state.posts), the locator function (state) => state.posts must be passed, or every selector call site will need to manually pre-extract state.posts itself.
3. Manually maintaining ids/entities bookkeeping alongside adapter-generated reducers
reducers: {
postAdded(state, action) {
postsAdapter.addOne(state, action); // uses the adapter...
state.ids.push(action.payload.id); // ❌ ...but ALSO manually pushes, now id is duplicated in ids
},
}Once a reducer case is using an adapter function, it already handles all ids/entities bookkeeping correctly, adding manual bookkeeping on top of it reintroduces exactly the inconsistency bugs the adapter exists to prevent.
Best Practices
- Use
createEntityAdapterfor any collection of same-shaped entities, rather than hand-rolling the{ ids, entities }pattern and its CRUD logic, it's the same normalization from Data Normalization, tested and maintained. - Choose the specific adapter method matching your actual intent precisely,
addOne(new only),upsertOne(new-or-update),setOne/setAll(always overwrite) have meaningfully different collision behavior. - Always provide a locator function to
getSelectors()matching where the entity slice actually lives in the store, unless it's genuinely the entire top-level state.
Performance Tips
getSelectors()'s generated selectors are memoized viacreateSelectorinternally,selectAllwon't reconstruct its array on every call, only when the underlyingids/entitiesactually changed, consistent with Selectors & Reselect's memoization guarantees.- A
sortComparerre-sortsidson every add/update that could affect order, for very large collections with frequent updates, this is a real, non-zero cost worth being aware of; omittingsortComparer(accepting insertion order, or sorting only at the selector level for occasional reads) is a reasonable tradeoff when updates are frequent but sorted display is rare.
