Skip to main content

๐Ÿงฉ Store, Actions, and Reducers

You know the three principles โ€” now let's get our hands into the machinery. This lesson dissects each Redux building block in depth: the store's tiny API, how to write action creators and async thunks, the immutable update patterns reducers must follow, and two techniques that keep large apps fast: state normalization and memoized selectors.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Use the store API โ€” getState, dispatch, subscribe โ€” and know how the modern store is configured
  • Write action creators and async thunks that dispatch pending / fulfilled / rejected actions
  • Apply immutable update patterns for objects, nested objects, and arrays
  • Explain and design a normalized state shape for relational data
  • Write reusable selectors and memoize expensive ones with createSelector

Estimated Time: 35โ€“45 minutes  โ€ข  Difficulty: Intermediate

Hands-on: Normalize a nested blog-post structure and write selectors to reconstruct it.

In This Lesson

The Store in Depth

The store is the object that ties actions and reducers together. It holds the state, gives you a way to read it, a way to request changes, and a way to be notified of changes. Everything else in Redux orbits this object.

flowchart TD Store["Redux Store
getState ยท dispatch ยท subscribe"] State["Application State
(one plain object)"] -->|"read via getState()"| Store Store -->|"notifies via subscribe()"| Listeners["Subscribers
(UI components)"] Store -->|"changed via dispatch(action)"| Actions["Actions"] Actions --> Reducers["Reducers
(produce new state)"] Reducers --> State

Creating the store

Modern Redux uses configureStore from Redux Toolkit. It wraps the old createStore with sensible defaults โ€” DevTools support, the thunk middleware, and development-time checks that warn you if you accidentally mutate state.

import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';

// The common case โ€” just pass your root reducer:
const store = configureStore({ reducer: rootReducer });

// With preloaded state (e.g. hydrating from the server or localStorage):
const store2 = configureStore({
  reducer: rootReducer,
  preloadedState: { counter: { value: 0 }, todos: [] }
});

โš ๏ธ Don't use the deprecated createStore

Older material calls createStore(reducer, preloadedState, applyMiddleware(...)). That API is now deprecated. configureStore gives you the same result with less setup and safer defaults, so prefer it for all new code.

The store API

Whatever created it, every store exposes the same small set of methods:

MethodWhat it does
getState()Returns the current state tree
dispatch(action)Sends an action through the reducers to produce new state
subscribe(listener)Registers a callback fired after every dispatch; returns an unsubscribe function
replaceReducer(next)Swaps the reducer at runtime (advanced: code-splitting, hot reloading)
console.log(store.getState());               // read

store.dispatch({ type: 'counter/incremented' }); // change

const unsubscribe = store.subscribe(() => {   // observe
  console.log('state changed:', store.getState());
});
unsubscribe();                                // stop observing
๐Ÿ“ฎ Bulletin-board analogy: getState() is reading the company bulletin board; dispatch() is submitting a new notice to be posted; subscribe() is signing up for an email whenever the board changes. There is exactly one board โ€” the single source of truth.

๐Ÿ“– In React, you rarely call these directly

You almost never call getState/subscribe by hand in a React app โ€” the react-redux hooks useSelector and useDispatch do it for you (next lesson). Knowing the raw API still matters, because that's what those hooks are built on.

Actions & Action Creators

An action carries information from your app to the store. It's a plain object whose only required field is type; everything else is convention.

Action shape

// A typical action
{
  type: 'todos/added',
  payload: { id: 1, text: 'Learn Redux', completed: false }
}

// An error action (Flux Standard Action style)
{
  type: 'users/fetchRejected',
  error: true,
  payload: 'Network Error',
  meta: { userId: 123 }
}

Action types as constants

Storing type strings as named constants prevents silent typos (a mistyped type just falls through to the default case, doing nothing) and gives your editor autocomplete.

// action-types.js
export const TODO_ADDED   = 'todos/added';
export const TODO_TOGGLED = 'todos/toggled';
export const TODO_DELETED = 'todos/deleted';

Action creators

An action creator is a function that builds and returns an action, so the construction logic lives in one place.

import { TODO_ADDED, TODO_TOGGLED, TODO_DELETED } from './action-types';

export function addTodo(text) {
  return {
    type: TODO_ADDED,
    payload: { id: crypto.randomUUID(), text, completed: false }
  };
}

export const toggleTodo = (id) => ({ type: TODO_TOGGLED, payload: { id } });
export const deleteTodo = (id) => ({ type: TODO_DELETED, payload: { id } });
๐Ÿ“ Form analogy: An action is a standardized business form. The type is the form's title ("Expense Reimbursement"), the payload is the filled-in details, and an action creator is the assistant who makes sure the form is filled out correctly every time.

๐Ÿ’ก Redux Toolkit shortcut

RTK's createSlice generates the action types and action creators for you from your reducer functions โ€” you'll almost never hand-write the constant + creator pair shown above in a real project. We show the manual version so you understand what RTK is generating on your behalf.

Async Action Creators (Thunks)

Reducers must be pure, so asynchronous work like fetching from an API can't happen inside them. The answer is a thunk: with the thunk middleware (built into configureStore), an action creator may return a function that receives dispatch and getState, letting you await work and dispatch actions as results arrive.

// A hand-written thunk with the classic three-phase pattern
export function fetchUsers() {
  return async (dispatch, getState) => {
    dispatch({ type: 'users/fetchPending' });
    try {
      const res = await fetch('https://api.example.com/users');
      if (!res.ok) throw new Error('Network response was not ok');
      const users = await res.json();
      dispatch({ type: 'users/fetchFulfilled', payload: users });
      return users;
    } catch (err) {
      dispatch({ type: 'users/fetchRejected', error: true, payload: err.message });
      throw err;
    }
  };
}

The pending / fulfilled / rejected trio is so common that Redux Toolkit ships createAsyncThunk to generate all three action types and the wrapper for you:

import { createAsyncThunk } from '@reduxjs/toolkit';

// Automatically dispatches users/fetch/pending, /fulfilled, /rejected
export const fetchUsers = createAsyncThunk('users/fetch', async () => {
  const res = await fetch('https://api.example.com/users');
  if (!res.ok) throw new Error('Network response was not ok');
  return res.json(); // becomes action.payload of the fulfilled action
});

โœ… Prefer createAsyncThunk in real apps

It removes the repetitive try/catch/dispatch scaffolding, standardizes the action names, and integrates cleanly with createSlice's extraReducers to update loading and error state. The hand-written version above is worth understanding, but you'll reach for the Toolkit one in practice.

Reducers & Immutable Updates

A reducer has the signature (state = initialState, action) => newState. It must be pure: never mutate its arguments, never perform side effects, and return the existing state unchanged for actions it doesn't handle.

function reducer(state = initialState, action) {
  switch (action.type) {
    case 'SOMETHING_HAPPENED':
      return { ...state, /* changed fields */ };
    default:
      return state; // crucial: unknown actions leave state as-is
  }
}

The immutability rule

"Return new state without mutating the old" is the single most important reducer discipline. Redux detects changes by reference (oldState !== newState), so if you mutate the existing object in place, Redux โ€” and React โ€” may not notice the change at all.

Mutating versus copying state On the left, mutating the original object keeps the same reference so change detection fails. On the right, spreading into a new object creates a new reference that Redux and React can detect. โœ— Mutate in place state.value = 31 same reference โ†’ Redux sees no change UI does not update โœ“ Copy & change { ...state, value: 31 } new reference โ†’ Redux detects change UI re-renders
Figure 1 โ€” Redux compares by reference. Mutating keeps the same reference and silently breaks updates; copying into a new object is what makes the change visible.

Here are the immutable patterns you'll use constantly:

// 1. Update a field on an object
const next = { ...state, age: 31 };

// 2. Update a nested object (spread at every level you change)
const next = {
  ...state,
  user: {
    ...state.user,
    address: { ...state.user.address, city: 'Boston' }
  }
};

// 3. Add to an array
const next = [...state, newItem];

// 4. Remove from an array
const next = state.filter(item => item.id !== idToRemove);

// 5. Update one item in an array
const next = state.map(item =>
  item.id === targetId ? { ...item, completed: true } : item
);

๐Ÿ’ก Redux Toolkit lets you "mutate" safely

Inside a createSlice reducer, RTK uses the Immer library so you can write what looks like mutation โ€” state.value += 1 โ€” and Immer produces a correct immutable copy behind the scenes. That only works inside RTK reducers; plain reducers like the ones above still require manual copying.

๐Ÿข Departments analogy: Each reducer is a department (HR, Finance, IT). A company-wide memo (action) reaches all of them, but each only updates the state it owns and ignores the rest โ€” exactly how combined reducers respond only to actions they recognize.

State Normalization

When your data is relational โ€” posts that have authors and comments, comments that also have authors โ€” nesting it deeply in the store causes two problems: the same user gets duplicated in many places, and updating that user means hunting through every copy. Normalization stores data the way a database does: each entity type in its own lookup table, keyed by id, with relationships stored as ids.

// โœ— Nested (duplicated authors, painful updates)
{
  posts: [
    { id: 1, author: { id: 2, name: 'User 2' }, title: 'Post 1',
      comments: [{ id: 1, author: { id: 3, name: 'User 3' }, text: 'Hi' }] }
  ]
}

// โœ“ Normalized (each entity stored once, referenced by id)
{
  users:    { byId: { 2: { id: 2, name: 'User 2' }, 3: { id: 3, name: 'User 3' } },
              allIds: [2, 3] },
  posts:    { byId: { 1: { id: 1, author: 2, title: 'Post 1', comments: [1] } },
              allIds: [1] },
  comments: { byId: { 1: { id: 1, author: 3, text: 'Hi' } },
              allIds: [1] }
}

โœ… Why normalize

  • No duplication โ€” each user exists in exactly one place.
  • Cheap updates โ€” change a user's name once and every reference reflects it.
  • Simpler reducers โ€” updating an item is { ...byId, [id]: newItem }.
  • Fast lookups โ€” byId[id] is O(1); the allIds array preserves order.
๐Ÿ“š Library-catalog analogy: A library stores books, authors, and publishers in separate tables and lets a book reference its author by id rather than reprinting the author's full details on every book record. Update the author once and every book is instantly correct.

๐Ÿ’ก Redux Toolkit's createEntityAdapter

RTK provides createEntityAdapter, which builds the { ids, entities } normalized shape and gives you prebuilt reducers (addOne, upsertMany, removeOne) and selectors. In production you'll usually let it manage normalized collections rather than hand-rolling byId/allIds.

Selectors

A selector is a function that reads a specific piece of information out of the state. Selectors keep your components ignorant of the state's shape: if the shape changes later, you fix the selector, not every component.

// Simple selectors
const selectTodos  = state => state.todos;
const selectFilter = state => state.visibilityFilter;

// A derived selector that computes something from the state
const selectVisibleTodos = state => {
  const todos  = selectTodos(state);
  const filter = selectFilter(state);
  switch (filter) {
    case 'completed': return todos.filter(t => t.completed);
    case 'active':    return todos.filter(t => !t.completed);
    default:          return todos;
  }
};

Derived selectors that filter or map can be expensive, and returning a brand-new array every call can cause needless re-renders. The reselect library (re-exported by Redux Toolkit as createSelector) memoizes the result โ€” it only recomputes when its inputs actually change.

import { createSelector } from '@reduxjs/toolkit';

const selectTodos  = state => state.todos;
const selectFilter = state => state.visibilityFilter;

// Recomputes only when todos or filter changes; otherwise returns the cached result
const selectVisibleTodos = createSelector(
  [selectTodos, selectFilter],
  (todos, filter) => {
    switch (filter) {
      case 'completed': return todos.filter(t => t.completed);
      case 'active':    return todos.filter(t => !t.completed);
      default:          return todos;
    }
  }
);

๐Ÿ’ก Why selectors are worth the habit

  • Encapsulation โ€” components don't need to know how state is shaped.
  • Reusability โ€” one selector serves many components.
  • Derived data โ€” totals, filters, and joins live outside your components.
  • Performance โ€” memoization skips redundant recalculation.
  • Testability โ€” pure functions are trivial to unit-test.

Hands-on: Normalize a Blog

๐Ÿ‹๏ธ From nested data to a normalized store

Objective: Practice the two skills that keep large Redux apps healthy โ€” normalization and selectors.

Instructions:

  1. Start from this nested data: one post by author u1, with two comments by u2 and u1.
  2. Rewrite it into a normalized shape with users, posts, and comments, each as { byId, allIds }, using ids for relationships.
  3. Write a selector selectPostWithComments(state, postId) that reconstructs the full nested object โ€” post + its author's name + each comment with its author's name.
const nested = {
  post: {
    id: 'p1', title: 'Hello Redux',
    author: { id: 'u1', name: 'Ada' },
    comments: [
      { id: 'c1', text: 'Great post', author: { id: 'u2', name: 'Grace' } },
      { id: 'c2', text: 'Thanks!',    author: { id: 'u1', name: 'Ada'   } }
    ]
  }
};
๐Ÿ’ก Hint

Each entity appears once in its own byId map keyed by id. Ada (u1) shows up as both the post author and a comment author, but in the normalized store she exists in exactly one place: users.byId.u1. Your selector's job is to "rehydrate" โ€” look ids back up in those maps.

โœ… Solution
// Normalized state
const state = {
  users: {
    byId: { u1: { id: 'u1', name: 'Ada' }, u2: { id: 'u2', name: 'Grace' } },
    allIds: ['u1', 'u2']
  },
  posts: {
    byId: { p1: { id: 'p1', title: 'Hello Redux', author: 'u1', comments: ['c1', 'c2'] } },
    allIds: ['p1']
  },
  comments: {
    byId: {
      c1: { id: 'c1', text: 'Great post', author: 'u2' },
      c2: { id: 'c2', text: 'Thanks!',    author: 'u1' }
    },
    allIds: ['c1', 'c2']
  }
};

// Selector that reconstructs the nested object
function selectPostWithComments(state, postId) {
  const post = state.posts.byId[postId];
  if (!post) return null;
  return {
    id: post.id,
    title: post.title,
    author: state.users.byId[post.author].name,
    comments: post.comments.map(cid => {
      const c = state.comments.byId[cid];
      return { id: c.id, text: c.text, author: state.users.byId[c.author].name };
    })
  };
}

selectPostWithComments(state, 'p1');
// { id: 'p1', title: 'Hello Redux', author: 'Ada',
//   comments: [ { id: 'c1', text: 'Great post', author: 'Grace' },
//               { id: 'c2', text: 'Thanks!',    author: 'Ada'   } ] }

Notice that renaming Ada now takes a single update to users.byId.u1.name and both her post and her comment reflect it automatically. That's the whole point of normalization.

๐ŸŽฏ Quick Quiz

Question 1: Why must a reducer return a new object instead of mutating the existing state?

Question 2: What is the main benefit of a normalized state shape?

Question 3: What does wrapping a selector in createSelector give you?

Summary & Quiz

๐ŸŽ‰ Key Takeaways

  • The store exposes a tiny API โ€” getState, dispatch, subscribe โ€” and is created with configureStore today.
  • Action creators build actions in one place; thunks (and createAsyncThunk) handle async with a pending/fulfilled/rejected pattern.
  • Reducers must be pure and immutable: copy-and-change, never mutate, because Redux detects changes by reference.
  • Normalizing relational data into byId/allIds tables removes duplication and makes updates cheap.
  • Selectors decouple components from state shape; createSelector memoizes expensive derived data.
  • Redux Toolkit (createSlice, createAsyncThunk, createEntityAdapter, Immer) automates most of this in real projects.

๐Ÿ“š Further Reading

๐Ÿš€ What's Next?

You now understand the pieces in isolation. The next lesson wires them into React with the official react-redux bindings โ€” the <Provider> component and the useSelector / useDispatch hooks โ€” so your components can read state and dispatch actions.

๐ŸŽ‰ Great progress!

Store, actions, reducers, normalization, selectors โ€” that's the full toolbox. Time to connect it to a real UI.