Skip to content

Commit dcb4fc1

Browse files
committed
Add API docs for createEntityAdapter
1 parent bf23105 commit dcb4fc1

File tree

3 files changed

+378
-0
lines changed

3 files changed

+378
-0
lines changed

docs/api/createAsyncThunk.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ dispatch(fetchUserById(123))
5050

5151
## Parameters
5252

53+
`createAsyncThunk` accepts two parameters: a string action `type` value, and a `payloadCreator` callback.
54+
5355
### `type`
5456

5557
A string that will be used to generate additional Redux action type constants, representing the lifecycle of an async request:

docs/api/createEntityAdapter.md

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
---
2+
id: createEntityAdapter
3+
title: createEntityAdapter
4+
sidebar_label: createEntityAdapter
5+
hide_title: true
6+
---
7+
8+
# `createEntityAdapter`
9+
10+
## Overview
11+
12+
A function that generates a set of prebuilt reducers and selectors for performing CRUD operations on a [normalized state structure](https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape) 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`.
13+
14+
This API was ported from [the `@ngrx/entity` library](https://ngrx.io/guide/entity) 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.
15+
16+
> **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.
17+
>
18+
> As with all Redux logic, [_only_ plain JS objects and arrays should be passed in to the store - **no class instances!**](https://redux.js.org/style-guide/style-guide#do-not-put-non-serializable-values-in-state-or-actions)
19+
>
20+
> 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`.
21+
22+
The methods generated by `createEntityAdapter` will all manipulate an "entity state" structure that looks like:
23+
24+
```js
25+
{
26+
// The unique IDs of each item. Must be strings or numbers
27+
ids: []
28+
// A lookup table mapping entity IDs to the corresponding entity objects
29+
entities: {
30+
}
31+
}
32+
```
33+
34+
`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.
35+
36+
Sample usage:
37+
38+
```js
39+
import {
40+
createEntityAdapter,
41+
createSlice,
42+
configureStore
43+
} from '@reduxjs/toolkit'
44+
45+
const booksAdapter = createEntityAdapter({
46+
// Assume IDs are stored in a field other than `book.id`
47+
selectId: book => book.bookId,
48+
// Keep the "all IDs" array sorted based on book titles
49+
sortComparer: (a, b) => a.title.localeCompare(b.title)
50+
})
51+
52+
const booksSlice = createSlice({
53+
name: 'books',
54+
initialState: booksAdapter.getInitialState(),
55+
reducer: {
56+
// Can pass adapter functions directly as case reducers. Because we're passing this
57+
// as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
58+
bookAdded: booksAdapter.addOne,
59+
booksReceived(state, action) {
60+
// Or, call them as "mutating" helpers in a case reducer
61+
booksAdapter.setAll(state, action.payload.books)
62+
}
63+
}
64+
})
65+
66+
const store = configureStore({
67+
reducer: {
68+
books: booksSlice.reducer
69+
}
70+
})
71+
72+
console.log(store.getState().books)
73+
// {ids: [], entities: {} }
74+
75+
// Can create a set of memoized selectors based on the location of this entity state
76+
const booksSelectors = booksAdapter.getSelectors(state => state.books)
77+
78+
// And then use the selectors to retrieve values
79+
const allBooks = booksSelectors.selectAll(store.getState())
80+
```
81+
82+
## Parameters
83+
84+
`createEntityAdapter` accepts a single options object parameter, with two optional fields inside.
85+
86+
### `selectId`
87+
88+
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.
89+
90+
### `sortComparer`
91+
92+
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.
93+
94+
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.
95+
96+
If not provided, the `state.ids` array will not be sorted, and no guarantees are made about the ordering.
97+
98+
## Return Value
99+
100+
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.
101+
102+
The adapter instance will include the following methods (some TypeScript overloads removed for space):
103+
104+
```ts
105+
export type EntityId = number | string
106+
107+
export type Comparer<T> = (a: T, b: T) => EntityId
108+
export type IdSelector<T> = (model: T) => EntityId
109+
110+
export interface DictionaryNum<T> {
111+
[id: number]: T | undefined
112+
}
113+
export abstract class Dictionary<T> implements DictionaryNum<T> {
114+
[id: string]: T | undefined
115+
}
116+
117+
export type Update<T> = { id: EntityId; changes: Partial<T> }
118+
export type EntityMap<T> = (entity: T) => T
119+
120+
export type TypeOrPayloadAction<T> = T | PayloadAction<T>
121+
122+
export interface EntityState<T> {
123+
ids: EntityId[]
124+
entities: Dictionary<T>
125+
}
126+
127+
export interface EntityDefinition<T> {
128+
selectId: IdSelector<T>
129+
sortComparer: false | Comparer<T>
130+
}
131+
132+
export interface EntityStateAdapter<T> {
133+
addOne<S extends EntityState<T>>(state: S, entity: TypeOrPayloadAction<T>): S
134+
135+
addMany<S extends EntityState<T>>(
136+
state: S,
137+
entities: TypeOrPayloadAction<T[]>
138+
): S
139+
140+
setAll<S extends EntityState<T>>(
141+
state: S,
142+
entities: TypeOrPayloadAction<T[]>
143+
): S
144+
145+
removeOne<S extends EntityState<T>>(
146+
state: S,
147+
key: TypeOrPayloadAction<EntityId>
148+
): S
149+
150+
removeMany<S extends EntityState<T>>(
151+
state: S,
152+
keys: TypeOrPayloadAction<EntityId[]>
153+
): S
154+
155+
removeAll<S extends EntityState<T>>(state: S): S
156+
157+
updateOne<S extends EntityState<T>>(
158+
state: S,
159+
update: TypeOrPayloadAction<Update<T>>
160+
): S
161+
162+
updateMany<S extends EntityState<T>>(
163+
state: S,
164+
updates: TypeOrPayloadAction<Update<T>[]>
165+
): S
166+
167+
upsertOne<S extends EntityState<T>>(
168+
state: S,
169+
entity: TypeOrPayloadAction<T>
170+
): S
171+
172+
upsertMany<S extends EntityState<T>>(
173+
state: S,
174+
entities: TypeOrPayloadAction<T[]>
175+
): S
176+
177+
map<S extends EntityState<T>>(
178+
state: S,
179+
map: TypeOrPayloadAction<EntityMap<T>>
180+
): S
181+
}
182+
183+
export interface EntitySelectors<T, V> {
184+
selectIds: (state: V) => EntityId[]
185+
selectEntities: (state: V) => Dictionary<T>
186+
selectAll: (state: V) => T[]
187+
selectTotal: (state: V) => number
188+
}
189+
190+
export interface EntityAdapter<T> extends EntityStateAdapter<T> {
191+
selectId: IdSelector<T>
192+
sortComparer: false | Comparer<T>
193+
getInitialState(): EntityState<T>
194+
getInitialState<S extends object>(state: S): EntityState<T> & S
195+
getSelectors(): EntitySelectors<T, EntityState<T>>
196+
getSelectors<V>(
197+
selectState: (state: V) => EntityState<T>
198+
): EntitySelectors<T, V>
199+
}
200+
```
201+
202+
### CRUD Functions
203+
204+
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:
205+
206+
- `addOne`: accepts a single entity, and adds it
207+
- `addMany`: accepts an array of entities, and adds them
208+
- `setAll`: accepts an array of entities, and replaces the existing entity contents with the values in the array
209+
- `removeOne`: accepts a single entity ID value, and removes the entity with that ID if it exists
210+
- `removeMany`: accepts an array of entity ID values, and removes each entity with those IDs if they exist
211+
- `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 updates the corresponding entity
212+
- `updateMany`: accepts an array of update objects, and updates all corresponding entities
213+
- `upsertOne`: 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.
214+
- `upsertMany`: accepts an array of entities that will be upserted.
215+
- `map`: accepts a callback function that will be run against each existing entity, and may return a change description object. Afterwards, all changes will be merged into the corresponding existing entities.
216+
217+
Each method has a signature that looks like:
218+
219+
```ts
220+
(state: EntityState<T>, argument: TypeOrPayloadAction<Argument<T>>) => EntityState<T>
221+
```
222+
223+
In other words, they accept a state that looks like `{ids: [], entities: {}}`, and calculate and return a new state.
224+
225+
These CRUD methods may be used in multiple ways:
226+
227+
- They may be passed as case reducers directly to `createReducer` and `createSlice`.
228+
- 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
229+
- They may be used as immutable update methods when called manually, if the `state` argument is actually a plain JS object or array
230+
231+
> **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.
232+
233+
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.
234+
235+
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.
236+
237+
### `getInitialState`
238+
239+
Returns a new entity state object like `{ids: [], entities: {}}`.
240+
241+
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:
242+
243+
```js
244+
const booksSlice = createSlice({
245+
name: 'books',
246+
initialState: booksAdapter.getInitialState({
247+
loading: 'idle'
248+
}),
249+
reducers: {
250+
booksLoadingStarted(state, action) {
251+
// Can update the additional state field
252+
state.loading = 'pending'
253+
}
254+
}
255+
})
256+
```
257+
258+
### Selector Functions
259+
260+
The entity adapter will contain a `getSelectors()` function that returns a set of four selectors that know how to read the contents of an entity state object:
261+
262+
- `selectIds`: returns the `state.ids` array
263+
- `selectEntities`: returns the `state.entities` lookup table
264+
- `selectAll`: maps over the `state.ids` array, and returns an array of entities in the same order
265+
- `selectTotal`: returns the total number of entities being stored in this state
266+
267+
Each selector function will be created using the `createSelector` function from Reselect, to enable memoizing calculation of the results.
268+
269+
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:
270+
271+
- 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
272+
- It may also be called with a selector function that accepts the entire Redux state tree and returns the correct entity state object.
273+
274+
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:
275+
276+
```js
277+
const store = configureStore({
278+
reducer: {
279+
books: booksReducer
280+
}
281+
})
282+
283+
const simpleSelectors = booksAdapter.getSelectors()
284+
const globalizedSelectors = booksAdapter.getSelectors(state => state.books)
285+
286+
// Need to manually pass the correct entity state object in to this selector
287+
const bookIds = simpleSelectors.selectIds(store.getState().books)
288+
289+
// This selector already knows how to find the books entity state
290+
const allBooks = globalizedSelectors.selectAll(store.getState())
291+
```
292+
293+
## Examples
294+
295+
Exercising several of the CRUD methods and selectors:
296+
297+
```js
298+
import {
299+
createEntityAdapter,
300+
createSlice,
301+
configureStore
302+
} from '@reduxjs/toolkit'
303+
304+
// Since we don't provide `selectId`, it defaults to assuming `entity.id` is the right field
305+
const booksAdapter = createEntityAdapter({
306+
// Keep the "all IDs" array sorted based on book titles
307+
sortComparer: (a, b) => a.title.localeCompare(b.title)
308+
})
309+
310+
const booksSlice = createSlice({
311+
name: 'books',
312+
initialState: booksAdapter.getInitialState({
313+
loading: 'idle'
314+
}),
315+
reducer: {
316+
// Can pass adapter functions directly as case reducers. Because we're passing this
317+
// as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
318+
bookAdded: booksAdapter.addOne,
319+
booksLoading(state, action) {
320+
if (state.loading === 'idle') {
321+
state.loading = 'pending'
322+
}
323+
},
324+
booksReceived(state, action) {
325+
if (state.loading === 'pending') {
326+
// Or, call them as "mutating" helpers in a case reducer
327+
booksAdapter.setAll(state, action.payload.books)
328+
state.loading = 'idle'
329+
}
330+
},
331+
bookUpdated: booksAdapter.updateOne
332+
}
333+
})
334+
335+
const {
336+
bookAdded,
337+
booksLoading,
338+
booksReceived,
339+
bookUpdated
340+
} = booksSlice.actions
341+
342+
const store = configureStore({
343+
reducer: {
344+
books: booksSlice.reducer
345+
}
346+
})
347+
348+
// Check the initial state:
349+
console.log(store.getState().books)
350+
// {ids: [], entities: {}, loading: 'idle' }
351+
352+
const booksSelectors = booksAdapter.getSelectors(state => state.books)
353+
354+
store.dispatch(bookAdded({ id: 'a', title: 'First' }))
355+
console.log(store.getState().books)
356+
// {ids: ["a"], entities: {a: {id: "a", title: "First"}}, loading: 'idle' }
357+
358+
store.dispatch(bookUpdated({ id: 'a', title: 'First (altered)' }))
359+
store.dispatch(booksLoading())
360+
console.log(store.getState().books)
361+
// {ids: ["a"], entities: {a: {id: "a", title: "First (altered)"}}, loading: 'pending' }
362+
363+
store.dispatch(
364+
booksReceived([{ id: 'b', title: 'Book 3' }, { id: 'c', title: 'Book 2' }])
365+
)
366+
367+
console.log(booksSelectors.selectIds(store.getState()))
368+
// "a" was removed due to the `setAll()` call
369+
// Since they're sorted by title, "Book 2" comes before "Book 3"
370+
// ["c", "b"]
371+
372+
console.log(booksSelectors.selectAll(store.getState()))
373+
// All book entries in sorted order
374+
// [{id: "c", title: "Book 2"}, {id: "b", title: "Book 3"}]
375+
```

website/sidebars.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"api/createSlice",
1616
"api/createSelector",
1717
"api/createAsyncThunk",
18+
"api/createEntityAdapter",
1819
"api/other-exports"
1920
]
2021
}

0 commit comments

Comments
 (0)