Skip to content

Commit a1c0bb0

Browse files
Add basic createEntityAdapter usage docs (#442)
* Add basic createEntityAdapter usage docs * Fix by hand example code * Apply suggestions from code review Co-Authored-By: Mark Erikson <mark@isquaredsoftware.com> * Copy updates per feedback * Fix normalizr example and have it align with the normalizr usage demo * Show combination of normalizr and createEntityAdapter * Update docs and ts usage example for passing non-arrays on many methods * Remove redundant comment and add to section intro * Update createEntityAdapter API docs * Update codesandbox links * Assorted usage guide tweaks * Netlify tweaks, 1 * Netlify tweaks, 2 * Netlify tweaks, 3 * Fix headers and tweak phrasing * Shorten line lengths Co-authored-by: Mark Erikson <mark@isquaredsoftware.com>
1 parent c89d5e2 commit a1c0bb0

File tree

4 files changed

+369
-3
lines changed

4 files changed

+369
-3
lines changed

docs/api/createEntityAdapter.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,14 @@ export interface EntityAdapter<T> extends EntityStateAdapter<T> {
196196
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:
197197

198198
- `addOne`: accepts a single entity, and adds it
199-
- `addMany`: accepts an array of entities, and adds them
200-
- `setAll`: accepts an array of entities, and replaces the existing entity contents with the values in the array
199+
- `addMany`: accepts an array of entities or an object in the shape of `Record<EntityId, T>`, and adds them.
200+
- `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
201201
- `removeOne`: accepts a single entity ID value, and removes the entity with that ID if it exists
202202
- `removeMany`: accepts an array of entity ID values, and removes each entity with those IDs if they exist
203203
- `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
204204
- `updateMany`: accepts an array of update objects, and updates all corresponding entities
205205
- `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.
206-
- `upsertMany`: accepts an array of entities that will be upserted.
206+
- `upsertMany`: accepts an array of entities or an object in the shape of `Record<EntityId, T>` that will be upserted.
207207

208208
Each method has a signature that looks like:
209209

docs/usage/usage-guide.md

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,3 +700,313 @@ interface ThunkAPI {
700700
```
701701

702702
You can use any of these as needed inside the payload callback to determine what the final result should be.
703+
704+
## Managing Normalized Data
705+
706+
Most applications typically deal with data that is deeply nested or relational. The goal of normalizing data is to efficiently organize the data in your state. This is typically done by storing collections as objects with the key of an `id`, while storing a sorted array of those `ids`. For a more in-depth explanation and further examples, there is a great reference in the [Redux docs page on "Normalizing State Shape"](https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape).
707+
708+
### Normalizing by hand
709+
710+
Normalizing data doesn't require any special libraries. Here's a basic example of how you might normalize the response from a `fetchAll` API request that returns data in the shape of `{ users: [{id: 1, first_name: 'normalized', last_name: 'person'}] }`, using some hand-written logic:
711+
712+
```js
713+
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
714+
import userAPI from './userAPI'
715+
716+
export const fetchUsers = createAsyncThunk('users/fetchAll', async () => {
717+
const response = await userAPI.fetchAll()
718+
return response.data
719+
})
720+
721+
export const slice = createSlice({
722+
name: 'users',
723+
initialState: {
724+
ids: [],
725+
entities: {}
726+
},
727+
reducers: {},
728+
extraReducers: builder => {
729+
builder.addCase(fetchUsers.fulfilled, (state, action) => {
730+
// reduce the collection by the id property into a shape of { 1: { ...user }}
731+
const byId = action.payload.users.reduce((byId, user) => {
732+
byId[user.id] = user
733+
return byId
734+
}, {})
735+
state.entities = byId
736+
state.ids = Object.keys(byId)
737+
})
738+
}
739+
})
740+
```
741+
742+
Although we're capable of writing this code, it does become repetitive, especially if you're handling multiple types of data. In addition, this example only handles loading entries into the state, not updating them.
743+
744+
### Normalizing with `normalizr`
745+
746+
[`normalizr`](https://github.com/paularmstrong/normalizr) is a popular existing library for normalizing data. You can use it on its own without Redux, but it is very commonly used with Redux. The typical usage is to format collections from an API response and then process them in your reducers.
747+
748+
```js
749+
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
750+
import { normalize, schema } from 'normalizr'
751+
752+
import userAPI from './userAPI'
753+
754+
const userEntity = new schema.Entity('users')
755+
756+
export const fetchUsers = createAsyncThunk('users/fetchAll', async () => {
757+
const response = await userAPI.fetchAll()
758+
// Normalize the data before passing it to our reducer
759+
const normalized = normalize(response.data, [userEntity])
760+
return normalized.entities
761+
})
762+
763+
export const slice = createSlice({
764+
name: 'users',
765+
initialState: {
766+
ids: [],
767+
entities: {}
768+
},
769+
reducers: {},
770+
extraReducers: builder => {
771+
builder.addCase(fetchUsers.fulfilled, (state, action) => {
772+
state.entities = action.payload.users
773+
state.ids = Object.keys(action.payload.users)
774+
})
775+
}
776+
})
777+
```
778+
779+
As with the hand-written version, this doesn't handle adding additional entries into the state, or updating them later - it's just loading in everything that was received.
780+
781+
### Normalizing with `createEntityAdapter`
782+
783+
Redux Toolkit's `createEntityAdapter` API provides a standardized way to store your data in a slice by taking a collection and putting it into the shape of `{ ids: [], entities: {} }`. Along with this predefined state shape, it generates a set of reducer functions and selectors that know how to work with the data.
784+
785+
```js
786+
import {
787+
createSlice,
788+
createAsyncThunk,
789+
createEntityAdapter
790+
} from '@reduxjs/toolkit'
791+
import userAPI from './userAPI'
792+
793+
export const fetchUsers = createAsyncThunk('users/fetchAll', async () => {
794+
const response = await userAPI.fetchAll()
795+
// In this case, `response.data` would be:
796+
// [{id: 1, first_name: 'Example', last_name: 'User'}]
797+
return response.data
798+
})
799+
800+
export const usersAdapter = createEntityAdapter()
801+
802+
// By default, `createEntityAdapter` gives you `{ ids: [], entities: {} }`.
803+
// If you want to track 'loading' or other keys, you would initialize them here:
804+
// `getInitialState({ loading: false, activeRequestId: null })`
805+
const initialState = usersAdapter.getInitialState()
806+
807+
export const slice = createSlice({
808+
name: 'users',
809+
initialState,
810+
reducers: {
811+
removeUser: usersAdapter.removeOne
812+
},
813+
extraReducers: builder => {
814+
builder.addCase(fetchUsers.fulfilled, usersAdapter.upsertMany)
815+
}
816+
})
817+
818+
const reducer = slice.reducer
819+
export default reducer
820+
821+
export const { removeUser } = slice.actions
822+
```
823+
824+
You can [view the full code of this example usage on CodeSandbox](https://codesandbox.io/s/rtk-entities-basic-example-1xubt)
825+
826+
### Using `createEntityAdapter` with Normalization Libraries
827+
828+
If you're already using `normalizr` or another normalization library, you could consider using it along with `createEntityAdapter`. To expand on the examples above, here is a demonstration of how we could use `normalizr` to format a payload, then leverage the utilities `createEntityAdapter` provides.
829+
830+
By default, the `setAll`, `addMany`, and `upsertMany` CRUD methods expect an array of entities. However, they also allow you to pass in an object that is in the shape of `{ 1: { id: 1, ... }}` as an alternative, which makes it easier to insert pre-normalized data.
831+
832+
```js
833+
// features/articles/articlesSlice.js
834+
import {
835+
createSlice,
836+
createEntityAdapter,
837+
createAsyncThunk,
838+
createSelector
839+
} from '@reduxjs/toolkit'
840+
import fakeAPI from '../../services/fakeAPI'
841+
import { normalize, schema } from 'normalizr'
842+
843+
// Define normalizr entity schemas
844+
export const userEntity = new schema.Entity('users')
845+
export const commentEntity = new schema.Entity('comments', {
846+
commenter: userEntity
847+
})
848+
export const articleEntity = new schema.Entity('articles', {
849+
author: userEntity,
850+
comments: [commentEntity]
851+
})
852+
853+
const articlesAdapter = createEntityAdapter()
854+
855+
export const fetchArticle = createAsyncThunk(
856+
'articles/fetchArticle',
857+
async id => {
858+
const data = await fakeAPI.articles.show(id)
859+
// Normalize the data so reducers can load a predictable payload, like:
860+
// `action.payload = { users: {}, articles: {}, comments: {} }`
861+
const normalized = normalize(data, articleEntity)
862+
return normalized.entities
863+
}
864+
)
865+
866+
export const slice = createSlice({
867+
name: 'articles',
868+
initialState: articlesAdapter.getInitialState(),
869+
reducers: {},
870+
extraReducers: {
871+
[fetchArticle.fulfilled]: (state, action) => {
872+
// Handle the fetch result by inserting the articles here
873+
articlesAdapter.upsertMany(state, action.payload.articles)
874+
}
875+
}
876+
})
877+
878+
const reducer = slice.reducer
879+
export default reducer
880+
881+
// features/users/usersSlice.js
882+
883+
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit'
884+
import { fetchArticle } from '../articles/articlesSlice'
885+
886+
const usersAdapter = createEntityAdapter()
887+
888+
export const slice = createSlice({
889+
name: 'users',
890+
initialState: usersAdapter.getInitialState(),
891+
reducers: {},
892+
extraReducers: builder => {
893+
builder.addCase(fetchArticle.fulfilled, (state, action) => {
894+
// And handle the same fetch result by inserting the users here
895+
usersAdapter.upsertMany(state, action.payload.users)
896+
})
897+
}
898+
})
899+
900+
const reducer = slice.reducer
901+
export default reducer
902+
903+
// features/comments/commentsSlice.js
904+
905+
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit'
906+
import { fetchArticle } from '../articles/articlesSlice'
907+
908+
const commentsAdapter = createEntityAdapter()
909+
910+
export const slice = createSlice({
911+
name: 'comments',
912+
initialState: commentsAdapter.getInitialState(),
913+
reducers: {},
914+
extraReducers: {
915+
[fetchArticle.fulfilled]: (state, action) => {
916+
// Same for the comments
917+
commentsAdapter.upsertMany(state, action.payload.comments)
918+
}
919+
}
920+
})
921+
922+
const reducer = slice.reducer
923+
export default reducer
924+
```
925+
926+
You can [view the full code of this example `normalizr` usage on CodeSandbox](https://codesandbox.io/s/rtk-entities-basic-example-with-normalizr-bm3ie)
927+
928+
### Using selectors with `createEntityAdapter`
929+
930+
The entity adapter providers a selector factory that generates the most common selectors for you. Taking the examples above, we can add selectors to our `usersSlice` like this:
931+
932+
```js
933+
// Rename the exports for readability in component usage
934+
export const {
935+
selectById: selectUserById,
936+
selectIds: selectUserIds,
937+
selectEntities: selectUserEntities,
938+
selectAll: selectAllUsers,
939+
selectTotal: selectTotalUsers
940+
} = usersAdapter.getSelectors(state => state.users)
941+
```
942+
943+
You could then use these selectors in a component like this:
944+
945+
```js
946+
import React from 'react'
947+
import { useSelector } from 'react-redux'
948+
import { selectTotalUsers, selectAllUsers } from './usersSlice'
949+
950+
import styles from './UsersList.module.css'
951+
952+
export function UsersList() {
953+
const count = useSelector(selectTotalUsers)
954+
const users = useSelector(selectAllUsers)
955+
956+
return (
957+
<div>
958+
<div className={styles.row}>
959+
There are <span className={styles.value}>{count}</span> users.{' '}
960+
{count === 0 && `Why don't you fetch some more?`}
961+
</div>
962+
{users.map(user => (
963+
<div key={user.id}>
964+
<div>{`${user.first_name} ${user.last_name}`}</div>
965+
</div>
966+
))}
967+
</div>
968+
)
969+
}
970+
```
971+
972+
### Specifying Alternate ID Fields
973+
974+
By default, `createEntityAdapter` assumes that your data has unique IDs in an `entity.id` field. If your data set stores its ID in a different field, you can pass in a `selectId` argument that returns the appropriate field.
975+
976+
```js
977+
// In this instance, our user data always has a primary key of `idx`
978+
const userData = {
979+
users: [
980+
{ idx: 1, first_name: 'Test' },
981+
{ idx: 2, first_name: 'Two' }
982+
]
983+
}
984+
985+
// Since our primary key is `idx` and not `id`,
986+
// pass in an ID selector to return that field instead
987+
export const usersAdapter = createEntityAdapter({
988+
selectId: user => user.idx
989+
})
990+
```
991+
992+
### Sorting Entities
993+
994+
`createEntityAdapter` provides a `sortComparer` argument that you can leverage to sort the collection of `ids` in state. This can be very useful for when you want to guarantee a sort order and your data doesn't come presorted.
995+
996+
```js
997+
// In this instance, our user data always has a primary key of `idx`
998+
const userData = {
999+
users: [
1000+
{ id: 1, first_name: 'Test' },
1001+
{ id: 2, first_name: 'Banana' }
1002+
]
1003+
}
1004+
1005+
// Sort by `first_name`. `state.ids` would be ordered as
1006+
// `ids: [ 2, 1 ]`, since 'B' comes before 'T'.
1007+
// When using the provided `selectAll` selector, the result would be sorted:
1008+
// [{ id: 2, first_name: 'Banana' }, { id: 1, first_name: 'Test' }]
1009+
export const usersAdapter = createEntityAdapter({
1010+
sortComparer: (a, b) => a.first_name.localeCompare(b.first_name)
1011+
})
1012+
```

docs/usage/usage-with-typescript.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,3 +569,58 @@ const booksSlice = createSlice({
569569
}
570570
})
571571
```
572+
573+
### Using `createEntityAdapter` with `normalizr`
574+
575+
When using a library like [`normalizr`](https://github.com/paularmstrong/normalizr/), your normalized data will resemble this shape:
576+
577+
```js
578+
{
579+
result: 1,
580+
entities: {
581+
1: { id: 1, other: 'property' },
582+
2: { id: 2, other: 'property' }
583+
}
584+
}
585+
```
586+
587+
The methods `addMany`, `upsertMany`, and `setAll` all allow you to pass in the `entities` portion of this directly with no extra conversion steps. However, the `normalizr` TS typings currently do not correctly reflect that multiple data types may be included in the results, so you will need to specify that type structure yourself.
588+
589+
Here is an example of how that would look:
590+
591+
```ts
592+
type Author = { id: number; name: string }
593+
type Article = { id: number; title: string }
594+
type Comment = { id: number; commenter: number }
595+
596+
export const fetchArticle = createAsyncThunk(
597+
'articles/fetchArticle',
598+
async (id: number) => {
599+
const data = await fakeAPI.articles.show(id)
600+
// Normalize the data so reducers can responded to a predictable payload.
601+
// Note: at the time of writing, normalizr does not automatically infer the result,
602+
// so we explicitly declare the shape of the returned normalized data as a generic arg.
603+
const normalized = normalize<
604+
any,
605+
{
606+
articles: { [key: string]: Article }
607+
users: { [key: string]: Author }
608+
comments: { [key: string]: Comment }
609+
}
610+
>(data, articleEntity)
611+
return normalized.entities
612+
}
613+
)
614+
615+
export const slice = createSlice({
616+
name: 'articles',
617+
initialState: articlesAdapter.getInitialState(),
618+
reducers: {},
619+
extraReducers: builder => {
620+
builder.addCase(fetchArticle.fulfilled, (state, action) => {
621+
// The type signature on action.payload matches what we passed into the generic for `normalize`, allowing us to access specific properties on `payload.articles` if desired
622+
articlesAdapter.upsertMany(state, action.payload.articles)
623+
})
624+
}
625+
})
626+
```

netlify.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
base = "website"
33
publish = "build"
44
command = "npm run build"
5+
ignore = "git diff --quiet HEAD^ HEAD docs website"
56

67
[build.environment]
78
NODE_VERSION = "10"

0 commit comments

Comments
 (0)