Learn Redux in 10 DaysDay 3: Designing State
books.chapter 3Learn Redux in 10 Days

Day 3: Designing State

What You'll Learn Today

  • How state shape impacts your entire application
  • The difference between nested and normalized state
  • Using createEntityAdapter for 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:

  1. Performance: Prevents unnecessary re-renders during updates
  2. Maintainability: Improves code readability and ease of modification
  3. Bug prevention: Reduces the risk of data inconsistencies
  4. 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 ids array 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 ID
  • title: Task name
  • completed: Completion flag
  • priority: Priority ("high", "medium", "low")
  • createdAt: Creation timestamp

Implement the following features:

  1. Add a task (taskAdded)
  2. Toggle task completion (taskToggled)
  3. Clear all completed tasks (completedTasksCleared)
  4. Manage tasks sorted by priority
  5. 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.