createEntityAdapter

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, and Comment 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, and entity to refer to a single instance of that type. Example: in state.users, Entity would refer to the User type, and state.users.entities[123] would be a single entity.

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'

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())

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.

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.

  • addMany: accepts an array of entities or an object in the shape of Record<EntityId, T>, and adds them.

  • setAll: accepts an array of entities or an object in the shape of Record<EntityId, T>, and replaces the existing entity contents 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 a changes 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 of Record<EntityId, T> that will be shallowly 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 and createSlice.

  • 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 the state argument is actually an Immer Draft 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, and upsertMany 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 the state.ids array.

  • selectEntities: returns the state.entities lookup table.

  • selectAll: maps over the state.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 or undefined.

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"}]

Last updated