Day 3: Designing State
What You'll Learn Today
- How state shape impacts your entire application
- The difference between nested and normalized state
- Using
createEntityAdapterfor efficient CRUD operations - Slice separation strategies (by feature vs by data type)
- Computing derived data from state
- State design best practices and common anti-patterns
Why State Shape Matters
Redux state serves as the "Single Source of Truth" for your entire application. Designing the state shape properly provides the following benefits:
- Performance: Prevents unnecessary re-renders during updates
- Maintainability: Improves code readability and ease of modification
- Bug prevention: Reduces the risk of data inconsistencies
- Scalability: Easier to adapt as the application grows
Conversely, poor state design leads to problems such as:
- Data inconsistencies from duplicated data
- Complex updates due to deep nesting
- Unnecessary recalculations and re-renders
Nested State vs Normalized State
Nested State (A Common Mistake)
Let's consider a blog application. Here's what happens when you store API responses directly in state:
// Bad: Nested state
const state = {
posts: [
{
id: 1,
title: "Redux Basics",
author: {
id: 101,
name: "Alice Johnson",
avatar: "/avatars/alice.png"
},
comments: [
{
id: 1001,
text: "Great article!",
author: {
id: 102,
name: "Bob Smith",
avatar: "/avatars/bob.png"
}
},
{
id: 1002,
text: "Very helpful, thanks!",
author: {
id: 101,
name: "Alice Johnson",
avatar: "/avatars/alice.png"
}
}
]
}
]
};
This design has several problems:
- Data duplication: "Alice Johnson" appears in multiple places
- Difficult updates: Changing a user's avatar requires updating every occurrence
- Inefficient lookups: Finding a specific comment requires traversing nested structures
Normalized State (Recommended)
// Good: Normalized state
const state = {
users: {
ids: [101, 102],
entities: {
101: { id: 101, name: "Alice Johnson", avatar: "/avatars/alice.png" },
102: { id: 102, name: "Bob Smith", avatar: "/avatars/bob.png" }
}
},
posts: {
ids: [1],
entities: {
1: {
id: 1,
title: "Redux Basics",
authorId: 101,
commentIds: [1001, 1002]
}
}
},
comments: {
ids: [1001, 1002],
entities: {
1001: { id: 1001, text: "Great article!", authorId: 102, postId: 1 },
1002: { id: 1002, text: "Very helpful, thanks!", authorId: 101, postId: 1 }
}
}
};
flowchart LR
subgraph Nested["Nested State"]
direction TB
P1["Post"]
A1["Author (embedded)"]
C1["Comment"]
C1A["Author (duplicated!)"]
P1 --> A1
P1 --> C1
C1 --> C1A
end
subgraph Normalized["Normalized State"]
direction TB
NP["Posts\n{ids, entities}"]
NU["Users\n{ids, entities}"]
NC["Comments\n{ids, entities}"]
NP -->|authorId| NU
NP -->|commentIds| NC
NC -->|authorId| NU
end
style Nested fill:#ef4444,color:#fff
style Normalized fill:#22c55e,color:#fff
The benefits of normalized design:
- No data duplication: Each entity exists in exactly one place
- Easy updates: Changing one place reflects everywhere
- O(1) lookups: Direct access to objects by ID
- Order management: The
idsarray maintains display order
The Entity Pattern: { ids: [], entities: {} }
The standard data structure for normalized state follows this format:
{
ids: [1, 2, 3], // Array of IDs (maintains order)
entities: { // Object keyed by ID
1: { id: 1, name: "..." },
2: { id: 2, name: "..." },
3: { id: 3, name: "..." }
}
}
Here's why this structure excels:
| Operation | Array Only | Entity Pattern |
|---|---|---|
| Find by ID | O(n) | O(1) |
| Add | O(1) | O(1) |
| Update | O(n) | O(1) |
| Delete | O(n) | O(1) |
| Maintain order | Natural | Managed via ids array |
| Get list | Natural | ids.map(id => entities[id]) |
createEntityAdapter
Redux Toolkit provides createEntityAdapter to easily implement the entity pattern.
Basic Usage
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
// 1. Create an adapter
const usersAdapter = createEntityAdapter();
// 2. Generate initial state
const initialState = usersAdapter.getInitialState({
// Additional state fields
loading: false,
error: null
});
// => { ids: [], entities: {}, loading: false, error: null }
// 3. Use in a slice
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {
userAdded: usersAdapter.addOne,
userUpdated: usersAdapter.updateOne,
userRemoved: usersAdapter.removeOne,
usersReceived: usersAdapter.setAll
}
});
export const { userAdded, userUpdated, userRemoved, usersReceived } =
usersSlice.actions;
export default usersSlice.reducer;
TypeScript version
import {
createSlice,
createEntityAdapter,
PayloadAction,
EntityState
} from "@reduxjs/toolkit";
interface User {
id: number;
name: string;
email: string;
role: "admin" | "user";
}
interface UsersState extends EntityState<User, number> {
loading: boolean;
error: string | null;
}
const usersAdapter = createEntityAdapter<User>();
const initialState: UsersState = usersAdapter.getInitialState({
loading: false,
error: null
});
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {
userAdded: usersAdapter.addOne,
userUpdated: usersAdapter.updateOne,
userRemoved: usersAdapter.removeOne,
usersReceived: usersAdapter.setAll
}
});
export const { userAdded, userUpdated, userRemoved, usersReceived } =
usersSlice.actions;
export default usersSlice.reducer;
CRUD Operations Reference
createEntityAdapter provides the following CRUD methods:
const adapter = createEntityAdapter();
// === Add ===
adapter.addOne(state, entity); // Add a single entity
adapter.addMany(state, entities); // Add multiple entities
// === Update ===
adapter.updateOne(state, { id, changes }); // Partially update one
adapter.updateMany(state, updates); // Partially update many
adapter.upsertOne(state, entity); // Update if exists, add if not
adapter.upsertMany(state, entities); // Upsert multiple
// === Remove ===
adapter.removeOne(state, id); // Remove one
adapter.removeMany(state, ids); // Remove multiple
adapter.removeAll(state); // Remove all
// === Set ===
adapter.setOne(state, entity); // Completely replace one
adapter.setMany(state, entities); // Completely replace multiple
adapter.setAll(state, entities); // Replace all
Practical Usage Example
import { useDispatch, useSelector } from "react-redux";
import { userAdded, userUpdated, userRemoved, usersReceived } from "./usersSlice";
function UserManager() {
const dispatch = useDispatch();
// Add a user
const handleAddUser = () => {
dispatch(userAdded({
id: Date.now(),
name: "New User",
email: "new@example.com",
role: "user"
}));
};
// Update a user (partial update)
const handleUpdateUser = (id) => {
dispatch(userUpdated({
id,
changes: { name: "Updated Name" }
}));
};
// Remove a user
const handleRemoveUser = (id) => {
dispatch(userRemoved(id));
};
// Set all users (e.g., from API response)
const handleSetAll = (users) => {
dispatch(usersReceived(users));
};
return (
<div>
<button onClick={handleAddUser}>Add User</button>
</div>
);
}
Generated Selectors
createEntityAdapter also auto-generates selectors:
// Generate selectors
const usersSelectors = usersAdapter.getSelectors(
(state) => state.users
);
// Available selectors
usersSelectors.selectAll(state); // Returns array of all entities
usersSelectors.selectById(state, id); // Returns one entity by ID
usersSelectors.selectIds(state); // Returns array of all IDs
usersSelectors.selectEntities(state); // Returns the entities object
usersSelectors.selectTotal(state); // Returns total count
import { useSelector } from "react-redux";
function UserList() {
const allUsers = useSelector(usersSelectors.selectAll);
const totalUsers = useSelector(usersSelectors.selectTotal);
const specificUser = useSelector((state) =>
usersSelectors.selectById(state, 101)
);
return (
<div>
<h2>Users ({totalUsers})</h2>
<ul>
{allUsers.map((user) => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
</div>
);
}
Customizing Sort Order
const usersAdapter = createEntityAdapter({
// Custom ID field (default is "id")
selectId: (user) => user.userId,
// Specify sort order
sortComparer: (a, b) => a.name.localeCompare(b.name)
});
Slice Separation Strategies
As your application grows, how you split state into slices becomes important.
Strategy 1: By Data Type (Recommended)
// store.js
import { configureStore } from "@reduxjs/toolkit";
import usersReducer from "./features/users/usersSlice";
import postsReducer from "./features/posts/postsSlice";
import commentsReducer from "./features/comments/commentsSlice";
const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
comments: commentsReducer
}
});
flowchart TB
subgraph Store["Redux Store"]
direction LR
subgraph Users["users"]
U["ids, entities"]
end
subgraph Posts["posts"]
P["ids, entities"]
end
subgraph Comments["comments"]
C["ids, entities"]
end
end
style Store fill:#3b82f6,color:#fff
style Users fill:#8b5cf6,color:#fff
style Posts fill:#8b5cf6,color:#fff
style Comments fill:#8b5cf6,color:#fff
Benefit: Clear management per entity, highly reusable.
Strategy 2: By Feature
const store = configureStore({
reducer: {
auth: authReducer, // Login, user info
blog: blogReducer, // Posts, comments
dashboard: dashboardReducer // Dashboard-specific state
}
});
Benefit: Easier to understand by feature boundary.
Which to Choose
| Criteria | By Data Type | By Feature |
|---|---|---|
| Data sharing | Easy | Prone to duplication across features |
| Scalability | High | Moderate |
| Comprehension | Clear data model | Clear feature boundaries |
| Recommended for | Most apps | Highly independent features |
In practice, a hybrid approach works best: use data-type-based slices as the foundation, with feature-based slices for UI-specific state.
const store = configureStore({
reducer: {
// By data type (entities)
users: usersReducer,
posts: postsReducer,
comments: commentsReducer,
// By feature (UI state)
auth: authReducer,
ui: uiReducer
}
});
Blog App State Design Example
Let's look at a normalized state design for a real blog application.
Slice Definitions
// features/posts/postsSlice.js
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt)
});
const initialState = postsAdapter.getInitialState({
status: "idle",
error: null,
currentPostId: null
});
const postsSlice = createSlice({
name: "posts",
initialState,
reducers: {
postAdded: postsAdapter.addOne,
postUpdated: postsAdapter.updateOne,
postRemoved: postsAdapter.removeOne,
postsLoaded: postsAdapter.setAll,
currentPostSet(state, action) {
state.currentPostId = action.payload;
}
}
});
export const postsSelectors = postsAdapter.getSelectors(
(state) => state.posts
);
export const selectCurrentPost = (state) =>
postsSelectors.selectById(state, state.posts.currentPostId);
export const { postAdded, postUpdated, postRemoved, postsLoaded, currentPostSet } =
postsSlice.actions;
export default postsSlice.reducer;
// features/comments/commentsSlice.js
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
const commentsAdapter = createEntityAdapter({
sortComparer: (a, b) => a.createdAt.localeCompare(b.createdAt)
});
const initialState = commentsAdapter.getInitialState();
const commentsSlice = createSlice({
name: "comments",
initialState,
reducers: {
commentAdded: commentsAdapter.addOne,
commentRemoved: commentsAdapter.removeOne,
commentsForPostLoaded: commentsAdapter.upsertMany
}
});
// Selector for comments belonging to a specific post
export const commentsSelectors = commentsAdapter.getSelectors(
(state) => state.comments
);
export const selectCommentsByPostId = (state, postId) => {
const allComments = commentsSelectors.selectAll(state);
return allComments.filter((comment) => comment.postId === postId);
};
export const { commentAdded, commentRemoved, commentsForPostLoaded } =
commentsSlice.actions;
export default commentsSlice.reducer;
// features/users/usersSlice.js
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
const usersAdapter = createEntityAdapter();
const initialState = usersAdapter.getInitialState({
currentUserId: null
});
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {
userLoggedIn(state, action) {
usersAdapter.upsertOne(state, action.payload);
state.currentUserId = action.payload.id;
},
userLoggedOut(state) {
state.currentUserId = null;
},
usersLoaded: usersAdapter.setAll
}
});
export const usersSelectors = usersAdapter.getSelectors(
(state) => state.users
);
export const selectCurrentUser = (state) =>
usersSelectors.selectById(state, state.users.currentUserId);
export const { userLoggedIn, userLoggedOut, usersLoaded } =
usersSlice.actions;
export default usersSlice.reducer;
Store Configuration
// app/store.js
import { configureStore } from "@reduxjs/toolkit";
import postsReducer from "../features/posts/postsSlice";
import commentsReducer from "../features/comments/commentsSlice";
import usersReducer from "../features/users/usersSlice";
const store = configureStore({
reducer: {
posts: postsReducer,
comments: commentsReducer,
users: usersReducer
}
});
export default store;
Computing Derived Data
Store only "base data" in state, and compute any values needed for display.
Bad Example: Storing Derived Data
// Bad: Derived data stored in state
const state = {
items: [
{ id: 1, name: "Product A", price: 1000, quantity: 2 },
{ id: 2, name: "Product B", price: 500, quantity: 3 }
],
totalItems: 5, // Computable from items
totalPrice: 3500, // Computable from items
isEmpty: false // Computable from items.length
};
Good Example: Computing with Selectors
// Good: Store only base data, compute derived data with selectors
const cartSlice = createSlice({
name: "cart",
initialState: {
ids: [],
entities: {}
},
reducers: {
itemAdded: cartAdapter.addOne,
itemRemoved: cartAdapter.removeOne,
quantityUpdated: cartAdapter.updateOne
}
});
// Derived data computed via selectors
const cartSelectors = cartAdapter.getSelectors((state) => state.cart);
const selectCartItems = cartSelectors.selectAll;
const selectTotalItems = (state) => {
const items = selectCartItems(state);
return items.reduce((sum, item) => sum + item.quantity, 0);
};
const selectTotalPrice = (state) => {
const items = selectCartItems(state);
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
};
const selectIsCartEmpty = (state) => {
return cartSelectors.selectTotal(state) === 0;
};
Tip: Using
createSelector(Reselect), which you'll learn on Day 6, enables memoization (caching) of derived data.
State Design Best Practices
Summary
| Principle | Recommended | Avoid |
|---|---|---|
| Data structure | Normalized {ids, entities} |
Deep nesting |
| Data storage | Each entity in exactly one place | Same data in multiple places |
| Derived data | Compute with selectors | Store in state |
| ID references | authorId: 101 |
Embedded objects |
| CRUD operations | createEntityAdapter |
Manual immutable updates |
| Slice separation | By data type + UI state | Everything in one slice |
| Sort/filter | Execute in selectors | Store computed results in state |
Common Anti-Patterns
1. Data Duplication
// Bad: User info embedded in both posts and comments
posts: [{ id: 1, author: { id: 101, name: "Alice" } }]
comments: [{ id: 1, author: { id: 101, name: "Alice" } }]
// Good: Reference by ID
posts: { entities: { 1: { id: 1, authorId: 101 } } }
users: { entities: { 101: { id: 101, name: "Alice" } } }
2. Deep Nesting
// Bad: 3+ levels of nesting
state.departments[0].teams[1].members[2].tasks[0].status = "done";
// Good: Flat structure
state.tasks.entities[taskId].status = "done";
3. Storing Derived Data
// Bad: Storing computed values in state
{ items: [...], totalCount: 5, totalPrice: 3500 }
// Good: Compute with selectors
const selectTotalPrice = (state) =>
selectAllItems(state).reduce((sum, item) => sum + item.price * item.quantity, 0);
Exercises
Exercise 1: Normalize State
Normalize the following nested state:
const state = {
departments: [
{
id: "d1",
name: "Engineering",
manager: { id: "u1", name: "Alice", email: "alice@example.com" },
members: [
{ id: "u2", name: "Bob", email: "bob@example.com" },
{ id: "u3", name: "Charlie", email: "charlie@example.com" }
]
},
{
id: "d2",
name: "Design",
manager: { id: "u4", name: "Diana", email: "diana@example.com" },
members: [
{ id: "u5", name: "Eve", email: "eve@example.com" }
]
}
]
};
Exercise 2: Implement createEntityAdapter
Build a task management slice using createEntityAdapter. Each task has the following fields:
id: Unique IDtitle: Task namecompleted: Completion flagpriority: Priority ("high", "medium", "low")createdAt: Creation timestamp
Implement the following features:
- Add a task (
taskAdded) - Toggle task completion (
taskToggled) - Clear all completed tasks (
completedTasksCleared) - Manage tasks sorted by priority
- A selector that returns only incomplete tasks
Exercise 3: Blog App State Design
Design a normalized state for a blog application with the following requirements:
- Posts: title, body, author, tags, creation date
- Tags: name, post count
- Authors: name, profile image, bio
- Comments: body, author, post, creation date
- Likes: user, post
Think about how to represent relationships between entities using IDs and how to divide the state into slices.