The official, opinionated, batteries-included toolset for efficient Redux development
—
Redux Toolkit's entity adapter provides a standardized way to manage normalized entity state with prebuilt CRUD operations and selectors.
Creates an adapter for managing normalized entity collections with automatic CRUD operations.
/**
* Creates adapter for managing normalized entity state
* @param options - Configuration options for entity management
* @returns EntityAdapter with state methods and selectors
*/
function createEntityAdapter<T, Id extends EntityId = EntityId>(options?: {
selectId?: IdSelector<T, Id>;
sortComparer?: false | Comparer<T>;
}): EntityAdapter<T, Id>;
type EntityId = number | string;
type IdSelector<T, Id extends EntityId> = (entity: T) => Id;
type Comparer<T> = (a: T, b: T) => number;
interface EntityAdapter<T, Id extends EntityId> {
// State manipulation methods
addOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
addMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
setOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
setMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
setAll<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
removeOne<S extends EntityState<T, Id>>(state: S, key: Id): void;
removeMany<S extends EntityState<T, Id>>(state: S, keys: readonly Id[]): void;
removeAll<S extends EntityState<T, Id>>(state: S): void;
updateOne<S extends EntityState<T, Id>>(state: S, update: Update<T, Id>): void;
updateMany<S extends EntityState<T, Id>>(state: S, updates: ReadonlyArray<Update<T, Id>>): void;
upsertOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
upsertMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
// State creation and selector methods
getInitialState(): EntityState<T, Id>;
getInitialState<S extends Record<string, any>>(state: S): EntityState<T, Id> & S;
getSelectors(): EntitySelectors<T, EntityState<T, Id>, Id>;
getSelectors<V>(selectState: (state: V) => EntityState<T, Id>): EntitySelectors<T, V, Id>;
}
interface EntityState<T, Id extends EntityId> {
/** Array of entity IDs in order */
ids: Id[];
/** Normalized entity lookup table */
entities: Record<Id, T>;
}
interface Update<T, Id extends EntityId> {
id: Id;
changes: Partial<T>;
}Usage Examples:
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
interface User {
id: string;
name: string;
email: string;
createdAt: number;
}
// Create entity adapter
const usersAdapter = createEntityAdapter<User>({
// Optional: custom ID selection (defaults to entity.id)
selectId: (user) => user.id,
// Optional: sorting function for IDs array
sortComparer: (a, b) => a.name.localeCompare(b.name)
});
// Create slice with entity adapter
const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState({
// Additional state beyond entities and ids
loading: false,
error: null as string | null
}),
reducers: {
userAdded: usersAdapter.addOne,
usersReceived: usersAdapter.setAll,
userUpdated: usersAdapter.updateOne,
userRemoved: usersAdapter.removeOne,
// Custom reducers can use adapter methods
userUpserted: (state, action) => {
usersAdapter.upsertOne(state, action.payload);
// Can also modify additional state
state.loading = false;
}
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
usersAdapter.setAll(state, action.payload);
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || null;
});
}
});
export const { userAdded, usersReceived, userUpdated, userRemoved } = usersSlice.actions;
export default usersSlice.reducer;The normalized state structure used by entity adapters.
/**
* Standard entity state structure with normalized data
*/
interface EntityState<T, Id extends EntityId = EntityId> {
/** Array of entity IDs maintaining order */
ids: Id[];
/** Lookup table mapping ID to entity */
entities: Record<Id, T>;
}
/**
* Valid entity ID types
*/
type EntityId = number | string;
/**
* Update object for partial entity updates
*/
interface Update<T, Id extends EntityId = EntityId> {
/** ID of the entity to update */
id: Id;
/** Partial changes to apply */
changes: Partial<T>;
}Usage Examples:
// Example state structure
const initialState = {
ids: ['user1', 'user2', 'user3'],
entities: {
'user1': { id: 'user1', name: 'Alice', email: 'alice@example.com' },
'user2': { id: 'user2', name: 'Bob', email: 'bob@example.com' },
'user3': { id: 'user3', name: 'Charlie', email: 'charlie@example.com' }
}
};
// Update object example
const update: Update<User, string> = {
id: 'user1',
changes: { name: 'Alice Smith' }
};Prebuilt selectors for accessing entity data with memoization.
/**
* Generated selectors for entity data access
*/
interface EntitySelectors<T, V, Id extends EntityId> {
/** Select the array of entity IDs */
selectIds(state: V): Id[];
/** Select the entities lookup table */
selectEntities(state: V): Record<Id, T>;
/** Select all entities as an array */
selectAll(state: V): T[];
/** Select the total number of entities */
selectTotal(state: V): number;
/** Select entity by ID */
selectById(state: V, id: Id): T | undefined;
}Usage Examples:
// Get selectors for the entity adapter
const {
selectIds: selectUserIds,
selectEntities: selectUserEntities,
selectAll: selectAllUsers,
selectTotal: selectUsersTotal,
selectById: selectUserById
} = usersAdapter.getSelectors();
// Use with state selector
const userSelectors = usersAdapter.getSelectors((state: RootState) => state.users);
// In components
const UsersList = () => {
const users = useSelector(userSelectors.selectAll);
const totalUsers = useSelector(userSelectors.selectTotal);
return (
<div>
<h2>Users ({totalUsers})</h2>
{users.map(user => (
<UserItem key={user.id} user={user} />
))}
</div>
);
};
const UserProfile = ({ userId }: { userId: string }) => {
const user = useSelector(state => userSelectors.selectById(state, userId));
if (!user) {
return <div>User not found</div>;
}
return <div>{user.name} - {user.email}</div>;
};
// Custom selectors built on entity selectors
const selectActiveUsers = createSelector(
[userSelectors.selectAll],
(users) => users.filter(user => user.isActive)
);
const selectUsersByRole = createSelector(
[userSelectors.selectAll, (state, role: string) => role],
(users, role) => users.filter(user => user.role === role)
);The entity adapter provides all standard CRUD operations with Immer integration.
/**
* Add single entity to the collection
* @param state - Entity state
* @param entity - Entity to add
*/
addOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
/**
* Add multiple entities to the collection
* @param state - Entity state
* @param entities - Array of entities or entities object
*/
addMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
/**
* Add or replace single entity
* @param state - Entity state
* @param entity - Entity to set
*/
setOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
/**
* Add or replace multiple entities
* @param state - Entity state
* @param entities - Array of entities or entities object
*/
setMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
/**
* Replace all entities in the collection
* @param state - Entity state
* @param entities - Array of entities or entities object
*/
setAll<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
/**
* Remove entity by ID
* @param state - Entity state
* @param key - Entity ID to remove
*/
removeOne<S extends EntityState<T, Id>>(state: S, key: Id): void;
/**
* Remove multiple entities by ID
* @param state - Entity state
* @param keys - Array of entity IDs to remove
*/
removeMany<S extends EntityState<T, Id>>(state: S, keys: readonly Id[]): void;
/**
* Remove all entities from the collection
* @param state - Entity state
*/
removeAll<S extends EntityState<T, Id>>(state: S): void;
/**
* Update single entity with partial changes
* @param state - Entity state
* @param update - Update object with id and changes
*/
updateOne<S extends EntityState<T, Id>>(state: S, update: Update<T, Id>): void;
/**
* Update multiple entities with partial changes
* @param state - Entity state
* @param updates - Array of update objects
*/
updateMany<S extends EntityState<T, Id>>(state: S, updates: ReadonlyArray<Update<T, Id>>): void;
/**
* Add or update single entity (insert if new, update if exists)
* @param state - Entity state
* @param entity - Entity to upsert
*/
upsertOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
/**
* Add or update multiple entities
* @param state - Entity state
* @param entities - Array of entities or entities object
*/
upsertMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;Usage Examples:
const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState(),
reducers: {
// Direct adapter method usage
userAdded: usersAdapter.addOne,
usersLoaded: usersAdapter.setAll,
userUpdated: usersAdapter.updateOne,
userRemoved: usersAdapter.removeOne,
// Custom reducers using adapter methods
bulkUpdateUsers: (state, action) => {
const updates = action.payload.map(user => ({
id: user.id,
changes: { lastUpdated: Date.now(), ...user.changes }
}));
usersAdapter.updateMany(state, updates);
},
toggleUserActive: (state, action) => {
const user = state.entities[action.payload.id];
if (user) {
usersAdapter.updateOne(state, {
id: action.payload.id,
changes: { isActive: !user.isActive }
});
}
},
syncUsers: (state, action) => {
// Replace all users and maintain sort order
usersAdapter.setAll(state, action.payload);
}
}
});
// Usage in async thunks
const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async () => {
const response = await api.getUsers();
return response.data;
}
);
const updateUser = createAsyncThunk(
'users/updateUser',
async ({ id, changes }: { id: string; changes: Partial<User> }) => {
const response = await api.updateUser(id, changes);
return { id, changes: response.data };
}
);
// In extraReducers
const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState({ loading: false }),
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.fulfilled, (state, action) => {
usersAdapter.setAll(state, action.payload);
})
.addCase(updateUser.fulfilled, (state, action) => {
usersAdapter.updateOne(state, action.payload);
});
}
});interface Product {
sku: string;
name: string;
category: string;
price: number;
}
// Use custom field as ID
const productsAdapter = createEntityAdapter<Product, string>({
selectId: (product) => product.sku,
sortComparer: (a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)
});interface Comment {
id: string;
postId: string;
text: string;
authorId: string;
}
const commentsAdapter = createEntityAdapter<Comment>({
sortComparer: (a, b) => a.createdAt - b.createdAt
});
const commentsSlice = createSlice({
name: 'comments',
initialState: commentsAdapter.getInitialState(),
reducers: {
commentsLoaded: commentsAdapter.setAll,
commentAdded: commentsAdapter.addOne,
// Remove all comments for a post
postCommentsRemoved: (state, action) => {
const commentIds = state.ids.filter(id =>
state.entities[id]?.postId === action.payload.postId
);
commentsAdapter.removeMany(state, commentIds);
}
}
});
// Selectors for nested data
const commentSelectors = commentsAdapter.getSelectors((state: RootState) => state.comments);
const selectCommentsByPost = createSelector(
[commentSelectors.selectAll, (state, postId: string) => postId],
(comments, postId) => comments.filter(comment => comment.postId === postId)
);const optimisticUpdateUser = createAsyncThunk(
'users/optimisticUpdate',
async (
{ id, changes }: { id: string; changes: Partial<User> },
{ dispatch, rejectWithValue }
) => {
// Apply optimistic update immediately
dispatch(userUpdatedOptimistically({ id, changes }));
try {
const response = await api.updateUser(id, changes);
return { id, changes: response.data };
} catch (error) {
// Revert on failure
dispatch(revertOptimisticUpdate(id));
return rejectWithValue(error.message);
}
}
);
const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState({
optimisticUpdates: {} as Record<string, Partial<User>>
}),
reducers: {
userUpdatedOptimistically: (state, action) => {
const { id, changes } = action.payload;
state.optimisticUpdates[id] = changes;
usersAdapter.updateOne(state, { id, changes });
},
revertOptimisticUpdate: (state, action) => {
const id = action.payload;
const originalChanges = state.optimisticUpdates[id];
if (originalChanges) {
// Revert the changes
const revertedChanges = Object.keys(originalChanges).reduce((acc, key) => {
// This would need original values stored somewhere
return acc;
}, {});
usersAdapter.updateOne(state, { id, changes: revertedChanges });
delete state.optimisticUpdates[id];
}
}
},
extraReducers: (builder) => {
builder.addCase(optimisticUpdateUser.fulfilled, (state, action) => {
const { id, changes } = action.payload;
delete state.optimisticUpdates[id];
usersAdapter.updateOne(state, { id, changes });
});
}
});interface PaginatedUsersState extends EntityState<User> {
currentPage: number;
totalPages: number;
pageSize: number;
totalItems: number;
loading: boolean;
}
const usersAdapter = createEntityAdapter<User>();
const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState({
currentPage: 1,
totalPages: 0,
pageSize: 20,
totalItems: 0,
loading: false
} as PaginatedUsersState),
reducers: {
pageChanged: (state, action) => {
state.currentPage = action.payload;
}
},
extraReducers: (builder) => {
builder
.addCase(fetchUsersPage.pending, (state) => {
state.loading = true;
})
.addCase(fetchUsersPage.fulfilled, (state, action) => {
state.loading = false;
const { data, pagination } = action.payload;
if (action.meta.arg.replace) {
// Replace all users (new search/filter)
usersAdapter.setAll(state, data);
} else {
// Append users (load more)
usersAdapter.addMany(state, data);
}
state.currentPage = pagination.page;
state.totalPages = pagination.totalPages;
state.totalItems = pagination.totalItems;
});
}
});
// Selectors for paginated data
const selectPaginatedUsers = createSelector(
[usersAdapter.getSelectors().selectAll, (state: RootState) => state.users],
(users, usersState) => ({
users,
currentPage: usersState.currentPage,
totalPages: usersState.totalPages,
totalItems: usersState.totalItems,
hasMore: usersState.currentPage < usersState.totalPages
})
);Install with Tessl CLI
npx tessl i tessl/npm-reduxjs--toolkit