createEntityAdapter
Overview
A function that generates a set of prebuilt reducers and selectors for performing CRUD operations on a normalized state structure containing instances of a particular type of data object. These reducer functions may be passed as case reducers to createReducer
and createSlice
. They may also be used as "mutating" helper functions inside of createReducer
and createSlice
.
This API was ported from the @ngrx/entity
library created by the NgRx maintainers, but has been significantly modified for use with Redux Toolkit. We'd like to thank the NgRx team for originally creating this API and allowing us to port and adapt it for our needs.
Note: The term "Entity" is used to refer to a unique type of data object in an application. For example, in a blogging application, you might have
User
,Post
, andComment
data objects, with many instances of each being stored in the client and persisted on the server.User
is an "entity" - a unique type of data object that the application uses. Each unique instance of an entity is assumed to have a unique ID value in a specific field.As with all Redux logic, only plain JS objects and arrays should be passed in to the store - no class instances!
For purposes of this reference, we will use
Entity
to refer to the specific data type that is being managed by a copy of the reducer logic in a specific portion of the Redux state tree, andentity
to refer to a single instance of that type. Example: instate.users
,Entity
would refer to theUser
type, andstate.users.entities[123]
would be a singleentity
.
The methods generated by createEntityAdapter
will all manipulate an "entity state" structure that looks like:
{
// The unique IDs of each item. Must be strings or numbers
ids: []
// A lookup table mapping entity IDs to the corresponding entity objects
entities: {
}
}
createEntityAdapter
may be called multiple times in an application. If you are using it with plain JavaScript, you may be able to reuse a single adapter definition with multiple entity types if they're similar enough (such as all having an entity.id
field). For TypeScript usage, you will need to call createEntityAdapter
a separate time for each distinct Entity
type, so that the type definitions are inferred correctly.
Sample usage:
- TypeScript
- JavaScript
import {
createEntityAdapter,
createSlice,
configureStore,
} from '@reduxjs/toolkit'
type Book = { bookId: string; title: string }
const booksAdapter = createEntityAdapter<Book>({
// Assume IDs are stored in a field other than `book.id`
selectId: (book) => book.bookId,
// Keep the "all IDs" array sorted based on book titles
sortComparer: (a, b) => a.title.localeCompare(b.title),
})
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(),
reducers: {
// Can pass adapter functions directly as case reducers. Because we're passing this
// as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
bookAdded: booksAdapter.addOne,
booksReceived(state, action) {
// Or, call them as "mutating" helpers in a case reducer
booksAdapter.setAll(state, action.payload.books)
},
},
})
const store = configureStore({
reducer: {
books: booksSlice.reducer,
},
})
type RootState = ReturnType<typeof store.getState>
console.log(store.getState().books)
// { ids: [], entities: {} }
// Can create a set of memoized selectors based on the location of this entity state
const booksSelectors = booksAdapter.getSelectors<RootState>(
(state) => state.books
)
// And then use the selectors to retrieve values
const allBooks = booksSelectors.selectAll(store.getState())
import {
createEntityAdapter,
createSlice,
configureStore,
} from '@reduxjs/toolkit'
const booksAdapter = createEntityAdapter({
// Assume IDs are stored in a field other than `book.id`
selectId: (book) => book.bookId,
// Keep the "all IDs" array sorted based on book titles
sortComparer: (a, b) => a.title.localeCompare(b.title),
})
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(),
reducers: {
// Can pass adapter functions directly as case reducers. Because we're passing this
// as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
bookAdded: booksAdapter.addOne,
booksReceived(state, action) {
// Or, call them as "mutating" helpers in a case reducer
booksAdapter.setAll(state, action.payload.books)
},
},
})
const store = configureStore({
reducer: {
books: booksSlice.reducer,
},
})
console.log(store.getState().books)
// { ids: [], entities: {} }
// Can create a set of memoized selectors based on the location of this entity state
const booksSelectors = booksAdapter.getSelectors((state) => state.books)
// And then use the selectors to retrieve values
const allBooks = booksSelectors.selectAll(store.getState())
Parameters
createEntityAdapter
accepts a single options object parameter, with two optional fields inside.
selectId
A function that accepts a single Entity
instance, and returns the value of whatever unique ID field is inside. If not provided, the default implementation is entity => entity.id
. If your Entity
type keeps its unique ID values in a field other than entity.id
, you must provide a selectId
function.
sortComparer
A callback function that accepts two Entity
instances, and should return a standard Array.sort()
numeric result (1, 0, -1) to indicate their relative order for sorting.
If provided, the state.ids
array will be kept in sorted order based on comparisons of the entity objects, so that mapping over the IDs array to retrieve entities by ID should result in a sorted array of entities.
If not provided, the state.ids
array will not be sorted, and no guarantees are made about the ordering. In other words, state.ids
can be expected to behave like a standard Javascript array.
Note that sorting only kicks in when state is changed via one of the CRUD functions below (for example, addOne()
, updateMany()
).
Return Value
A "entity adapter" instance. An entity adapter is a plain JS object (not a class) containing the generated reducer functions, the original provided selectId
and sortComparer
callbacks, a method to generate an initial "entity state" value, and functions to generate a set of globalized and non-globalized memoized selector functions for this entity type.
The adapter instance will include the following methods (additional referenced TypeScript types included):
export type EntityId = number | string
export type Comparer<T> = (a: T, b: T) => number
export type IdSelector<T> = (model: T) => EntityId
export interface DictionaryNum<T> {
[id: number]: T | undefined
}
export interface Dictionary<T> extends DictionaryNum<T> {
[id: string]: T | undefined
}
export type Update<T> = { id: EntityId; changes: Partial<T> }
export interface EntityState<T> {
ids: EntityId[]
entities: Dictionary<T>
}
export interface EntityDefinition<T> {
selectId: IdSelector<T>
sortComparer: false | Comparer<T>
}
export interface EntityStateAdapter<T> {
addOne<S extends EntityState<T>>(state: S, entity: T): S
addOne<S extends EntityState<T>>(state: S, action: PayloadAction<T>): S
addMany<S extends EntityState<T>>(state: S, entities: T[]): S
addMany<S extends EntityState<T>>(state: S, entities: PayloadAction<T[]>): S
setAll<S extends EntityState<T>>(state: S, entities: T[]): S
setAll<S extends EntityState<T>>(state: S, entities: PayloadAction<T[]>): S
removeOne<S extends EntityState<T>>(state: S, key: EntityId): S
removeOne<S extends EntityState<T>>(state: S, key: PayloadAction<EntityId>): S
removeMany<S extends EntityState<T>>(state: S, keys: EntityId[]): S
removeMany<S extends EntityState<T>>(
state: S,
keys: PayloadAction<EntityId[]>
): S
removeAll<S extends EntityState<T>>(state: S): S
updateOne<S extends EntityState<T>>(state: S, update: Update<T>): S
updateOne<S extends EntityState<T>>(
state: S,
update: PayloadAction<Update<T>>
): S
updateMany<S extends EntityState<T>>(state: S, updates: Update<T>[]): S
updateMany<S extends EntityState<T>>(
state: S,
updates: PayloadAction<Update<T>[]>
): S
upsertOne<S extends EntityState<T>>(state: S, entity: T): S
upsertOne<S extends EntityState<T>>(state: S, entity: PayloadAction<T>): S
upsertMany<S extends EntityState<T>>(state: S, entities: T[]): S
upsertMany<S extends EntityState<T>>(
state: S,
entities: PayloadAction<T[]>
): S
}
export interface EntitySelectors<T, V> {
selectIds: (state: V) => EntityId[]
selectEntities: (state: V) => Dictionary<T>
selectAll: (state: V) => T[]
selectTotal: (state: V) => number
selectById: (state: V, id: EntityId) => T | undefined
}
export interface EntityAdapter<T> extends EntityStateAdapter<T> {
selectId: IdSelector<T>
sortComparer: false | Comparer<T>
getInitialState(): EntityState<T>
getInitialState<S extends object>(state: S): EntityState<T> & S
getSelectors(): EntitySelectors<T, EntityState<T>>
getSelectors<V>(
selectState: (state: V) => EntityState<T>
): EntitySelectors<T, V>
}
CRUD Functions
The primary content of an entity adapter is a set of generated reducer functions for adding, updating, and removing entity instances from an entity state object:
addOne
: accepts a single entity, and adds it if it's not already present.addMany
: accepts an array of entities or an object in the shape ofRecord<EntityId, T>
, and adds them if not already present.setOne
: accepts a single entity and adds or replaces itsetMany
: accepts an array of entities or an object in the shape ofRecord<EntityId, T>
, and adds or replaces them.setAll
: accepts an array of entities or an object in the shape ofRecord<EntityId, T>
, and replaces all existing entities with the values in the array.removeOne
: accepts a single entity ID value, and removes the entity with that ID if it exists.removeMany
: accepts an array of entity ID values, and removes each entity with those IDs if they exist.removeAll
: removes all entities from the entity state object.updateOne
: accepts an "update object" containing an entity ID and an object containing one or more new field values to update inside achanges
field, and performs a shallow update on the corresponding entity.updateMany
: accepts an array of update objects, and performs shallow updates on all corresponding entities.upsertOne
: accepts a single entity. If an entity with that ID exists, it will perform a shallow update and the specified fields will be merged into the existing entity, with any matching fields overwriting the existing values. If the entity does not exist, it will be added.upsertMany
: accepts an array of entities or an object in the shape ofRecord<EntityId, T>
that will be shallowly upserted.
Should I add, set or upsert my entity?
All three options will insert new entities into the list. However they differ in how they handle entities that already exist. If an entity already exists:
addOne
andaddMany
will do nothing with the new entitysetOne
andsetMany
will completely replace the old entity with the new one. This will also get rid of any properties on the entity that are not present in the new version of said entity.upsertOne
andupsertMany
will do a shallow copy to merge the old and new entities overwriting existing values, adding any that were not there and not touching properties not provided in the new entity.
Each method has a signature that looks like:
(state: EntityState<T>, argument: TypeOrPayloadAction<Argument<T>>) => EntityState<T>
In other words, they accept a state that looks like {ids: [], entities: {}}
, and calculate and return a new state.
These CRUD methods may be used in multiple ways:
- They may be passed as case reducers directly to
createReducer
andcreateSlice
. - They may be used as "mutating" helper methods when called manually, such as a separate hand-written call to
addOne()
inside of an existing case reducer, if thestate
argument is actually an ImmerDraft
value. - They may be used as immutable update methods when called manually, if the
state
argument is actually a plain JS object or array.
Note: These methods do not have corresponding Redux actions created - they are just standalone reducers / update logic. It is entirely up to you to decide where and how to use these methods! Most of the time, you will want to pass them to
createSlice
or use them inside another reducer.
Each method will check to see if the state
argument is an Immer Draft
or not. If it is a draft, the method will assume that it's safe to continue mutating that draft further. If it is not a draft, the method will pass the plain JS value to Immer's createNextState()
, and return the immutably updated result value.
The argument
may be either a plain value (such as a single Entity
object for addOne()
or an Entity[]
array for addMany()
, or a PayloadAction
action object with that same value as action.payload
. This enables using them as both helper functions and reducers.
Note on shallow updates:
updateOne
,updateMany
,upsertOne
, andupsertMany
only perform shallow updates in a mutable manner. This means that if your update/upsert consists of an object that includes nested properties, the value of the incoming change will overwrite the entire existing nested object. This may be unintended behavior for your application. As a general rule, these methods are best used with normalized data that do not have nested properties.
getInitialState
Returns a new entity state object like {ids: [], entities: {}}
.
It accepts an optional object as an argument. The fields in that object will be merged into the returned initial state value. For example, perhaps you want your slice to also track some loading state:
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({
loading: 'idle',
}),
reducers: {
booksLoadingStarted(state, action) {
// Can update the additional state field
state.loading = 'pending'
},
},
})
Selector Functions
The entity adapter will contain a getSelectors()
function that returns a set of selectors that know how to read the contents of an entity state object:
selectIds
: returns thestate.ids
array.selectEntities
: returns thestate.entities
lookup table.selectAll
: maps over thestate.ids
array, and returns an array of entities in the same order.selectTotal
: returns the total number of entities being stored in this state.selectById
: given the state and an entity ID, returns the entity with that ID orundefined
.
Each selector function will be created using the createSelector
function from Reselect, to enable memoizing calculation of the results.
Because selector functions are dependent on knowing where in the state tree this specific entity state object is kept, getSelectors()
can be called in two ways:
- If called without any arguments, it returns an "unglobalized" set of selector functions that assume their
state
argument is the actual entity state object to read from. - It may also be called with a selector function that accepts the entire Redux state tree and returns the correct entity state object.
For example, the entity state for a Book
type might be kept in the Redux state tree as state.books
. You can use getSelectors()
to read from that state in two ways:
const store = configureStore({
reducer: {
books: booksReducer,
},
})
const simpleSelectors = booksAdapter.getSelectors()
const globalizedSelectors = booksAdapter.getSelectors((state) => state.books)
// Need to manually pass the correct entity state object in to this selector
const bookIds = simpleSelectors.selectIds(store.getState().books)
// This selector already knows how to find the books entity state
const allBooks = globalizedSelectors.selectAll(store.getState())
Notes
Applying Multiple Updates
If updateMany()
is called with multiple updates targeted to the same ID, they will be merged into a single update, with later updates overwriting the earlier ones.
For both updateOne()
and updateMany()
, changing the ID of one existing entity to match the ID of a second existing entity will cause the first to replace the second completely.
Examples
Exercising several of the CRUD methods and selectors:
import {
createEntityAdapter,
createSlice,
configureStore,
} from '@reduxjs/toolkit'
// Since we don't provide `selectId`, it defaults to assuming `entity.id` is the right field
const booksAdapter = createEntityAdapter({
// Keep the "all IDs" array sorted based on book titles
sortComparer: (a, b) => a.title.localeCompare(b.title),
})
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({
loading: 'idle',
}),
reducers: {
// Can pass adapter functions directly as case reducers. Because we're passing this
// as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
bookAdded: booksAdapter.addOne,
booksLoading(state, action) {
if (state.loading === 'idle') {
state.loading = 'pending'
}
},
booksReceived(state, action) {
if (state.loading === 'pending') {
// Or, call them as "mutating" helpers in a case reducer
booksAdapter.setAll(state, action.payload)
state.loading = 'idle'
}
},
bookUpdated: booksAdapter.updateOne,
},
})
const {
bookAdded,
booksLoading,
booksReceived,
bookUpdated,
} = booksSlice.actions
const store = configureStore({
reducer: {
books: booksSlice.reducer,
},
})
// Check the initial state:
console.log(store.getState().books)
// {ids: [], entities: {}, loading: 'idle' }
const booksSelectors = booksAdapter.getSelectors((state) => state.books)
store.dispatch(bookAdded({ id: 'a', title: 'First' }))
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "First"}}, loading: 'idle' }
store.dispatch(bookUpdated({ id: 'a', changes: { title: 'First (altered)' } }))
store.dispatch(booksLoading())
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "First (altered)"}}, loading: 'pending' }
store.dispatch(
booksReceived([
{ id: 'b', title: 'Book 3' },
{ id: 'c', title: 'Book 2' },
])
)
console.log(booksSelectors.selectIds(store.getState()))
// "a" was removed due to the `setAll()` call
// Since they're sorted by title, "Book 2" comes before "Book 3"
// ["c", "b"]
console.log(booksSelectors.selectAll(store.getState()))
// All book entries in sorted order
// [{id: "c", title: "Book 2"}, {id: "b", title: "Book 3"}]