or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

actions-reducers.mdasync-thunks.mdcore-store.mdentity-adapters.mdindex.mdmiddleware.mdreact-integration.mdrtk-query-react.mdrtk-query.mdutilities.md

entity-adapters.mddocs/

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

```