Redux Toolkit Normalization
createEntityAdapter, normalized state, and CRUD operations
Core Patterns
- When to Read This
- Why Normalize?
- createEntityAdapter
- CRUD Operations
When to Read This
- Managing collections of items (users, posts, products)
- Handling relational data (posts with authors, comments)
- Optimizing lookup performance by ID
- Implementing CRUD operations
- Avoiding nested/duplicated data
Why Normalize?
❌ Nested State (Problems)
interface AppState {
posts: {
id: string;
title: string;
author: {
id: string;
name: string;
posts: Post[]; // Circular!
};
comments: Comment[];
}[];
}
// Problems:
// - Duplicated author data across posts
// - Hard to update author (need to find all posts)
// - Circular references
// - Slow lookups (must iterate array)
✅ Normalized State (Solution)
interface AppState {
users: {
ids: string[];
entities: Record<string, User>;
};
posts: {
ids: string[];
entities: Record<string, Post>;
};
comments: {
ids: string[];
entities: Record<string, Comment>;
};
}
// Benefits:
// - Single source of truth for each entity
// - Fast O(1) lookup by ID
// - Easy updates (one place)
// - No duplication
createEntityAdapter
✅ Basic Setup
import { createEntityAdapter, createSlice } from "@reduxjs/toolkit";
interface Todo {
id: string;
text: string;
completed: boolean;
}
const todosAdapter = createEntityAdapter<Todo>({
// Optional: custom ID selector
selectId: (todo) => todo.id,
// Optional: sorting
sortComparer: (a, b) => a.text.localeCompare(b.text),
});
const todosSlice = createSlice({
name: "todos",
initialState: todosAdapter.getInitialState({
// Additional state
loading: false,
error: null,
}),
reducers: {
todoAdded: todosAdapter.addOne,
todosReceived: todosAdapter.setAll,
todoUpdated: todosAdapter.updateOne,
todoRemoved: todosAdapter.removeOne,
},
});
CRUD Operations
✅ Adding Entities
reducers: {
// Add single
addTodo: todosAdapter.addOne,
// Add multiple
addTodos: todosAdapter.addMany,
// Set all (replaces existing)
setTodos: todosAdapter.setAll,
// Upsert (add or update)
upsertTodo: todosAdapter.upsertOne,
upsertTodos: todosAdapter.upsertMany,
}
dispatch(addTodo({ id: '1', text: 'Buy milk', completed: false }));
dispatch(addTodos([todo1, todo2, todo3]));
dispatch(setTodos(apiResponse)); // Replace all
dispatch(upsertTodo(updatedTodo)); // Add if new, update if exists
✅ Updating Entities
reducers: {
updateTodo: todosAdapter.updateOne,
updateTodos: todosAdapter.updateMany,
}
dispatch(updateTodo({
id: '1',
changes: { completed: true },
}));
dispatch(updateTodos([
{ id: '1', changes: { completed: true } },
{ id: '2', changes: { text: 'Updated text' } },
]));
✅ Removing Entities
reducers: {
removeTodo: todosAdapter.removeOne,
removeTodos: todosAdapter.removeMany,
removeAllTodos: todosAdapter.removeAll,
}
dispatch(removeTodo('1'));
dispatch(removeTodos(['1', '2', '3']));
dispatch(removeAllTodos());
Selectors
✅ Generated Selectors
const todosSelectors = todosAdapter.getSelectors<RootState>(
(state) => state.todos,
);
export const {
selectAll, // Returns all entities as array
selectById, // Returns entity by ID
selectIds, // Returns all IDs as array
selectEntities, // Returns entities object
selectTotal, // Returns count of entities
} = todosSelectors;
const allTodos = useAppSelector(selectAll);
const todo = useAppSelector((state) => selectById(state, "1"));
const todoIds = useAppSelector(selectIds);
const todosCount = useAppSelector(selectTotal);
✅ Custom Selectors with Normalization
// Memoized selector for filtered todos
export const selectCompletedTodos = createSelector(
[todosSelectors.selectAll],
(todos) => todos.filter((t) => t.completed),
);
// Select by multiple IDs
export const selectTodosByIds = createSelector(
[todosSelectors.selectEntities, (_: RootState, ids: string[]) => ids],
(entities, ids) => ids.map((id) => entities[id]).filter(Boolean),
);
Sorting
✅ Sort Comparer
const todosAdapter = createEntityAdapter<Todo>({
sortComparer: (a, b) => {
// Sort by completed, then by text
if (a.completed !== b.completed) {
return a.completed ? 1 : -1; // Incomplete first
}
return a.text.localeCompare(b.text);
},
});
// Updates automatically maintain sorted order
✅ Dynamic Sorting
// Store unsorted, sort in selector
const todosAdapter = createEntityAdapter<Todo>({
sortComparer: false, // No sorting in adapter
});
export const selectSortedTodos = createSelector(
[selectAll, (state: RootState) => state.todos.sortBy],
(todos, sortBy) => {
return [...todos].sort((a, b) => {
switch (sortBy) {
case "text":
return a.text.localeCompare(b.text);
case "date":
return b.createdAt - a.createdAt;
default:
return 0;
}
});
},
);
Relationships
✅ One-to-Many (Posts → Comments)
interface Post {
id: string;
title: string;
commentIds: string[];
}
const commentsAdapter = createEntityAdapter<Comment>();
const postsAdapter = createEntityAdapter<Post>();
// Selector: Post with populated comments
export const selectPostWithComments = createSelector(
[postsSelectors.selectById, commentsSelectors.selectEntities],
(post, commentsById) => {
if (!post) return null;
return {
...post,
comments: post.commentIds.map((id) => commentsById[id]).filter(Boolean),
};
},
);
✅ Many-to-One (Posts → Author)
interface Post {
id: string;
title: string;
authorId: string;
}
export const selectPostWithAuthor = createSelector(
[postsSelectors.selectById, usersSelectors.selectEntities],
(post, usersById) => {
if (!post) return null;
return {
...post,
author: usersById[post.authorId],
};
},
);
// Select all posts by author
export const selectPostsByAuthor = createSelector(
[postsSelectors.selectAll, (_: RootState, authorId: string) => authorId],
(posts, authorId) => posts.filter((p) => p.authorId === authorId),
);
With Async Thunks
✅ Fetching and Normalizing
export const fetchTodos = createAsyncThunk("todos/fetchAll", async () => {
const response = await fetch("/api/todos");
return response.json();
});
const todosSlice = createSlice({
name: "todos",
initialState: todosAdapter.getInitialState({
loading: false,
}),
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.loading = true;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.loading = false;
// setAll replaces all entities
todosAdapter.setAll(state, action.payload);
});
},
});
✅ Optimistic Updates
export const deleteTodo = createAsyncThunk(
"todos/delete",
async (todoId: string, { dispatch, rejectWithValue }) => {
// Optimistic: remove immediately
dispatch(todosSlice.actions.removeTodo(todoId));
try {
await fetch(`/api/todos/${todoId}`, { method: "DELETE" });
} catch (error) {
// Rollback on error (re-fetch or restore from cache)
dispatch(fetchTodos());
return rejectWithValue("Failed to delete");
}
},
);
Advanced Patterns
✅ Pagination
interface PaginatedState {
ids: string[];
entities: Record<string, Todo>;
currentPage: number;
totalPages: number;
hasMore: boolean;
}
const todosSlice = createSlice({
name: "todos",
initialState: todosAdapter.getInitialState<PaginatedState>({
currentPage: 1,
totalPages: 1,
hasMore: false,
}),
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchTodosPage.fulfilled, (state, action) => {
const { todos, page, totalPages } = action.payload;
// Add new page (don't replace)
todosAdapter.addMany(state, todos);
state.currentPage = page;
state.totalPages = totalPages;
state.hasMore = page < totalPages;
});
},
});
✅ Filtering with Metadata
interface TodoState {
ids: string[];
entities: Record<string, Todo>;
filteredIds: string[]; // Separate filtered view
}
reducers: {
filterTodos: (state, action: PayloadAction<'all' | 'completed' | 'active'>) => {
const filter = action.payload;
const allTodos = Object.values(state.entities);
state.filteredIds = allTodos
.filter(todo => {
if (filter === 'completed') return todo.completed;
if (filter === 'active') return !todo.completed;
return true;
})
.map(todo => todo.id);
},
}
Best Practices
- Use for collections — EntityAdapter is ideal for arrays of entities with IDs
- Normalize relationships — Store IDs, populate in selectors
- Use upsert — For adding or updating (idempotent operations)
- Sort in adapter — Use sortComparer for consistent ordering
- Generate selectors — Use
getSelectors()for standard operations - Single source of truth — Each entity type in one adapter
- Type IDs consistently — Use string or number, be consistent
Edge Cases
Missing IDs: Entities without id field need selectId function.
Sorting updates: When updating, sort order recalculates automatically if sortComparer is set.
Removing non-existent: removeOne/removeMany silently ignore missing IDs.
Initial state: getInitialState() accepts additional state properties (loading, error, etc.).
Custom ID selector: Use when entity uses different property name (_id, uuid, etc.).