0
# Entity Management
1
2
Redux Toolkit's entity adapter provides a standardized way to manage normalized entity state with prebuilt CRUD operations and selectors.
3
4
## Capabilities
5
6
### Create Entity Adapter
7
8
Creates an adapter for managing normalized entity collections with automatic CRUD operations.
9
10
```typescript { .api }
11
/**
12
* Creates adapter for managing normalized entity state
13
* @param options - Configuration options for entity management
14
* @returns EntityAdapter with state methods and selectors
15
*/
16
function createEntityAdapter<T, Id extends EntityId = EntityId>(options?: {
17
selectId?: IdSelector<T, Id>;
18
sortComparer?: false | Comparer<T>;
19
}): EntityAdapter<T, Id>;
20
21
type EntityId = number | string;
22
23
type IdSelector<T, Id extends EntityId> = (entity: T) => Id;
24
25
type Comparer<T> = (a: T, b: T) => number;
26
27
interface EntityAdapter<T, Id extends EntityId> {
28
// State manipulation methods
29
addOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
30
addMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
31
setOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
32
setMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
33
setAll<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
34
removeOne<S extends EntityState<T, Id>>(state: S, key: Id): void;
35
removeMany<S extends EntityState<T, Id>>(state: S, keys: readonly Id[]): void;
36
removeAll<S extends EntityState<T, Id>>(state: S): void;
37
updateOne<S extends EntityState<T, Id>>(state: S, update: Update<T, Id>): void;
38
updateMany<S extends EntityState<T, Id>>(state: S, updates: ReadonlyArray<Update<T, Id>>): void;
39
upsertOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
40
upsertMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
41
42
// State creation and selector methods
43
getInitialState(): EntityState<T, Id>;
44
getInitialState<S extends Record<string, any>>(state: S): EntityState<T, Id> & S;
45
getSelectors(): EntitySelectors<T, EntityState<T, Id>, Id>;
46
getSelectors<V>(selectState: (state: V) => EntityState<T, Id>): EntitySelectors<T, V, Id>;
47
}
48
49
interface EntityState<T, Id extends EntityId> {
50
/** Array of entity IDs in order */
51
ids: Id[];
52
/** Normalized entity lookup table */
53
entities: Record<Id, T>;
54
}
55
56
interface Update<T, Id extends EntityId> {
57
id: Id;
58
changes: Partial<T>;
59
}
60
```
61
62
**Usage Examples:**
63
64
```typescript
65
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
66
67
interface User {
68
id: string;
69
name: string;
70
email: string;
71
createdAt: number;
72
}
73
74
// Create entity adapter
75
const usersAdapter = createEntityAdapter<User>({
76
// Optional: custom ID selection (defaults to entity.id)
77
selectId: (user) => user.id,
78
79
// Optional: sorting function for IDs array
80
sortComparer: (a, b) => a.name.localeCompare(b.name)
81
});
82
83
// Create slice with entity adapter
84
const usersSlice = createSlice({
85
name: 'users',
86
initialState: usersAdapter.getInitialState({
87
// Additional state beyond entities and ids
88
loading: false,
89
error: null as string | null
90
}),
91
reducers: {
92
userAdded: usersAdapter.addOne,
93
usersReceived: usersAdapter.setAll,
94
userUpdated: usersAdapter.updateOne,
95
userRemoved: usersAdapter.removeOne,
96
97
// Custom reducers can use adapter methods
98
userUpserted: (state, action) => {
99
usersAdapter.upsertOne(state, action.payload);
100
// Can also modify additional state
101
state.loading = false;
102
}
103
},
104
extraReducers: (builder) => {
105
builder
106
.addCase(fetchUsers.pending, (state) => {
107
state.loading = true;
108
})
109
.addCase(fetchUsers.fulfilled, (state, action) => {
110
state.loading = false;
111
usersAdapter.setAll(state, action.payload);
112
})
113
.addCase(fetchUsers.rejected, (state, action) => {
114
state.loading = false;
115
state.error = action.error.message || null;
116
});
117
}
118
});
119
120
export const { userAdded, usersReceived, userUpdated, userRemoved } = usersSlice.actions;
121
export default usersSlice.reducer;
122
```
123
124
### Entity State Structure
125
126
The normalized state structure used by entity adapters.
127
128
```typescript { .api }
129
/**
130
* Standard entity state structure with normalized data
131
*/
132
interface EntityState<T, Id extends EntityId = EntityId> {
133
/** Array of entity IDs maintaining order */
134
ids: Id[];
135
/** Lookup table mapping ID to entity */
136
entities: Record<Id, T>;
137
}
138
139
/**
140
* Valid entity ID types
141
*/
142
type EntityId = number | string;
143
144
/**
145
* Update object for partial entity updates
146
*/
147
interface Update<T, Id extends EntityId = EntityId> {
148
/** ID of the entity to update */
149
id: Id;
150
/** Partial changes to apply */
151
changes: Partial<T>;
152
}
153
```
154
155
**Usage Examples:**
156
157
```typescript
158
// Example state structure
159
const initialState = {
160
ids: ['user1', 'user2', 'user3'],
161
entities: {
162
'user1': { id: 'user1', name: 'Alice', email: 'alice@example.com' },
163
'user2': { id: 'user2', name: 'Bob', email: 'bob@example.com' },
164
'user3': { id: 'user3', name: 'Charlie', email: 'charlie@example.com' }
165
}
166
};
167
168
// Update object example
169
const update: Update<User, string> = {
170
id: 'user1',
171
changes: { name: 'Alice Smith' }
172
};
173
```
174
175
### Entity Selectors
176
177
Prebuilt selectors for accessing entity data with memoization.
178
179
```typescript { .api }
180
/**
181
* Generated selectors for entity data access
182
*/
183
interface EntitySelectors<T, V, Id extends EntityId> {
184
/** Select the array of entity IDs */
185
selectIds(state: V): Id[];
186
187
/** Select the entities lookup table */
188
selectEntities(state: V): Record<Id, T>;
189
190
/** Select all entities as an array */
191
selectAll(state: V): T[];
192
193
/** Select the total number of entities */
194
selectTotal(state: V): number;
195
196
/** Select entity by ID */
197
selectById(state: V, id: Id): T | undefined;
198
}
199
```
200
201
**Usage Examples:**
202
203
```typescript
204
// Get selectors for the entity adapter
205
const {
206
selectIds: selectUserIds,
207
selectEntities: selectUserEntities,
208
selectAll: selectAllUsers,
209
selectTotal: selectUsersTotal,
210
selectById: selectUserById
211
} = usersAdapter.getSelectors();
212
213
// Use with state selector
214
const userSelectors = usersAdapter.getSelectors((state: RootState) => state.users);
215
216
// In components
217
const UsersList = () => {
218
const users = useSelector(userSelectors.selectAll);
219
const totalUsers = useSelector(userSelectors.selectTotal);
220
221
return (
222
<div>
223
<h2>Users ({totalUsers})</h2>
224
{users.map(user => (
225
<UserItem key={user.id} user={user} />
226
))}
227
</div>
228
);
229
};
230
231
const UserProfile = ({ userId }: { userId: string }) => {
232
const user = useSelector(state => userSelectors.selectById(state, userId));
233
234
if (!user) {
235
return <div>User not found</div>;
236
}
237
238
return <div>{user.name} - {user.email}</div>;
239
};
240
241
// Custom selectors built on entity selectors
242
const selectActiveUsers = createSelector(
243
[userSelectors.selectAll],
244
(users) => users.filter(user => user.isActive)
245
);
246
247
const selectUsersByRole = createSelector(
248
[userSelectors.selectAll, (state, role: string) => role],
249
(users, role) => users.filter(user => user.role === role)
250
);
251
```
252
253
### CRUD Operations
254
255
The entity adapter provides all standard CRUD operations with Immer integration.
256
257
```typescript { .api }
258
/**
259
* Add single entity to the collection
260
* @param state - Entity state
261
* @param entity - Entity to add
262
*/
263
addOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
264
265
/**
266
* Add multiple entities to the collection
267
* @param state - Entity state
268
* @param entities - Array of entities or entities object
269
*/
270
addMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
271
272
/**
273
* Add or replace single entity
274
* @param state - Entity state
275
* @param entity - Entity to set
276
*/
277
setOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
278
279
/**
280
* Add or replace multiple entities
281
* @param state - Entity state
282
* @param entities - Array of entities or entities object
283
*/
284
setMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
285
286
/**
287
* Replace all entities in the collection
288
* @param state - Entity state
289
* @param entities - Array of entities or entities object
290
*/
291
setAll<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
292
293
/**
294
* Remove entity by ID
295
* @param state - Entity state
296
* @param key - Entity ID to remove
297
*/
298
removeOne<S extends EntityState<T, Id>>(state: S, key: Id): void;
299
300
/**
301
* Remove multiple entities by ID
302
* @param state - Entity state
303
* @param keys - Array of entity IDs to remove
304
*/
305
removeMany<S extends EntityState<T, Id>>(state: S, keys: readonly Id[]): void;
306
307
/**
308
* Remove all entities from the collection
309
* @param state - Entity state
310
*/
311
removeAll<S extends EntityState<T, Id>>(state: S): void;
312
313
/**
314
* Update single entity with partial changes
315
* @param state - Entity state
316
* @param update - Update object with id and changes
317
*/
318
updateOne<S extends EntityState<T, Id>>(state: S, update: Update<T, Id>): void;
319
320
/**
321
* Update multiple entities with partial changes
322
* @param state - Entity state
323
* @param updates - Array of update objects
324
*/
325
updateMany<S extends EntityState<T, Id>>(state: S, updates: ReadonlyArray<Update<T, Id>>): void;
326
327
/**
328
* Add or update single entity (insert if new, update if exists)
329
* @param state - Entity state
330
* @param entity - Entity to upsert
331
*/
332
upsertOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
333
334
/**
335
* Add or update multiple entities
336
* @param state - Entity state
337
* @param entities - Array of entities or entities object
338
*/
339
upsertMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
340
```
341
342
**Usage Examples:**
343
344
```typescript
345
const usersSlice = createSlice({
346
name: 'users',
347
initialState: usersAdapter.getInitialState(),
348
reducers: {
349
// Direct adapter method usage
350
userAdded: usersAdapter.addOne,
351
usersLoaded: usersAdapter.setAll,
352
userUpdated: usersAdapter.updateOne,
353
userRemoved: usersAdapter.removeOne,
354
355
// Custom reducers using adapter methods
356
bulkUpdateUsers: (state, action) => {
357
const updates = action.payload.map(user => ({
358
id: user.id,
359
changes: { lastUpdated: Date.now(), ...user.changes }
360
}));
361
usersAdapter.updateMany(state, updates);
362
},
363
364
toggleUserActive: (state, action) => {
365
const user = state.entities[action.payload.id];
366
if (user) {
367
usersAdapter.updateOne(state, {
368
id: action.payload.id,
369
changes: { isActive: !user.isActive }
370
});
371
}
372
},
373
374
syncUsers: (state, action) => {
375
// Replace all users and maintain sort order
376
usersAdapter.setAll(state, action.payload);
377
}
378
}
379
});
380
381
// Usage in async thunks
382
const fetchUsers = createAsyncThunk(
383
'users/fetchUsers',
384
async () => {
385
const response = await api.getUsers();
386
return response.data;
387
}
388
);
389
390
const updateUser = createAsyncThunk(
391
'users/updateUser',
392
async ({ id, changes }: { id: string; changes: Partial<User> }) => {
393
const response = await api.updateUser(id, changes);
394
return { id, changes: response.data };
395
}
396
);
397
398
// In extraReducers
399
const usersSlice = createSlice({
400
name: 'users',
401
initialState: usersAdapter.getInitialState({ loading: false }),
402
reducers: {},
403
extraReducers: (builder) => {
404
builder
405
.addCase(fetchUsers.fulfilled, (state, action) => {
406
usersAdapter.setAll(state, action.payload);
407
})
408
.addCase(updateUser.fulfilled, (state, action) => {
409
usersAdapter.updateOne(state, action.payload);
410
});
411
}
412
});
413
```
414
415
## Advanced Patterns
416
417
### Custom ID Selection
418
419
```typescript
420
interface Product {
421
sku: string;
422
name: string;
423
category: string;
424
price: number;
425
}
426
427
// Use custom field as ID
428
const productsAdapter = createEntityAdapter<Product, string>({
429
selectId: (product) => product.sku,
430
sortComparer: (a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)
431
});
432
```
433
434
### Nested Entity Management
435
436
```typescript
437
interface Comment {
438
id: string;
439
postId: string;
440
text: string;
441
authorId: string;
442
}
443
444
const commentsAdapter = createEntityAdapter<Comment>({
445
sortComparer: (a, b) => a.createdAt - b.createdAt
446
});
447
448
const commentsSlice = createSlice({
449
name: 'comments',
450
initialState: commentsAdapter.getInitialState(),
451
reducers: {
452
commentsLoaded: commentsAdapter.setAll,
453
commentAdded: commentsAdapter.addOne,
454
455
// Remove all comments for a post
456
postCommentsRemoved: (state, action) => {
457
const commentIds = state.ids.filter(id =>
458
state.entities[id]?.postId === action.payload.postId
459
);
460
commentsAdapter.removeMany(state, commentIds);
461
}
462
}
463
});
464
465
// Selectors for nested data
466
const commentSelectors = commentsAdapter.getSelectors((state: RootState) => state.comments);
467
468
const selectCommentsByPost = createSelector(
469
[commentSelectors.selectAll, (state, postId: string) => postId],
470
(comments, postId) => comments.filter(comment => comment.postId === postId)
471
);
472
```
473
474
### Optimistic Updates with Entities
475
476
```typescript
477
const optimisticUpdateUser = createAsyncThunk(
478
'users/optimisticUpdate',
479
async (
480
{ id, changes }: { id: string; changes: Partial<User> },
481
{ dispatch, rejectWithValue }
482
) => {
483
// Apply optimistic update immediately
484
dispatch(userUpdatedOptimistically({ id, changes }));
485
486
try {
487
const response = await api.updateUser(id, changes);
488
return { id, changes: response.data };
489
} catch (error) {
490
// Revert on failure
491
dispatch(revertOptimisticUpdate(id));
492
return rejectWithValue(error.message);
493
}
494
}
495
);
496
497
const usersSlice = createSlice({
498
name: 'users',
499
initialState: usersAdapter.getInitialState({
500
optimisticUpdates: {} as Record<string, Partial<User>>
501
}),
502
reducers: {
503
userUpdatedOptimistically: (state, action) => {
504
const { id, changes } = action.payload;
505
state.optimisticUpdates[id] = changes;
506
usersAdapter.updateOne(state, { id, changes });
507
},
508
509
revertOptimisticUpdate: (state, action) => {
510
const id = action.payload;
511
const originalChanges = state.optimisticUpdates[id];
512
if (originalChanges) {
513
// Revert the changes
514
const revertedChanges = Object.keys(originalChanges).reduce((acc, key) => {
515
// This would need original values stored somewhere
516
return acc;
517
}, {});
518
usersAdapter.updateOne(state, { id, changes: revertedChanges });
519
delete state.optimisticUpdates[id];
520
}
521
}
522
},
523
extraReducers: (builder) => {
524
builder.addCase(optimisticUpdateUser.fulfilled, (state, action) => {
525
const { id, changes } = action.payload;
526
delete state.optimisticUpdates[id];
527
usersAdapter.updateOne(state, { id, changes });
528
});
529
}
530
});
531
```
532
533
### Pagination with Entities
534
535
```typescript
536
interface PaginatedUsersState extends EntityState<User> {
537
currentPage: number;
538
totalPages: number;
539
pageSize: number;
540
totalItems: number;
541
loading: boolean;
542
}
543
544
const usersAdapter = createEntityAdapter<User>();
545
546
const usersSlice = createSlice({
547
name: 'users',
548
initialState: usersAdapter.getInitialState({
549
currentPage: 1,
550
totalPages: 0,
551
pageSize: 20,
552
totalItems: 0,
553
loading: false
554
} as PaginatedUsersState),
555
reducers: {
556
pageChanged: (state, action) => {
557
state.currentPage = action.payload;
558
}
559
},
560
extraReducers: (builder) => {
561
builder
562
.addCase(fetchUsersPage.pending, (state) => {
563
state.loading = true;
564
})
565
.addCase(fetchUsersPage.fulfilled, (state, action) => {
566
state.loading = false;
567
const { data, pagination } = action.payload;
568
569
if (action.meta.arg.replace) {
570
// Replace all users (new search/filter)
571
usersAdapter.setAll(state, data);
572
} else {
573
// Append users (load more)
574
usersAdapter.addMany(state, data);
575
}
576
577
state.currentPage = pagination.page;
578
state.totalPages = pagination.totalPages;
579
state.totalItems = pagination.totalItems;
580
});
581
}
582
});
583
584
// Selectors for paginated data
585
const selectPaginatedUsers = createSelector(
586
[usersAdapter.getSelectors().selectAll, (state: RootState) => state.users],
587
(users, usersState) => ({
588
users,
589
currentPage: usersState.currentPage,
590
totalPages: usersState.totalPages,
591
totalItems: usersState.totalItems,
592
hasMore: usersState.currentPage < usersState.totalPages
593
})
594
);
595
```