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:
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 stateconst booksSelectors = booksAdapter.getSelectors(state => state.books)
// And then use the selectors to retrieve valuesconst 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.
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 itaddMany
: accepts an array of entities, and adds themsetAll
: accepts an array of entities, and replaces the existing entity contents with the values in the arrayremoveOne
: accepts a single entity ID value, and removes the entity with that ID if it existsremoveMany
: accepts an array of entity ID values, and removes each entity with those IDs if they existupdateOne
: accepts an "update object" containing an entity ID and an object containing one or more new field values to update inside achanges
field, and updates the corresponding entityupdateMany
: accepts an array of update objects, and updates all corresponding entitiesupsertOne
: accepts a single entity. If an entity with that ID exists, the fields in the update 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 that will be upserted.
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 asaction.payload
. This enables using them as both helper functions and reducers.
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
arrayselectEntities
: returns thestate.entities
lookup tableselectAll
: maps over thestate.ids
array, and returns an array of entities in the same orderselectTotal
: returns the total number of entities being stored in this stateselectById
: 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 selectorconst bookIds = simpleSelectors.selectIds(store.getState().books)
// This selector already knows how to find the books entity stateconst 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 fieldconst 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' }), reducer: { // 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.books) 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', 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"}]