๐งฉ 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.
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:
| Method | What 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. Thetypeis the form's title ("Expense Reimbursement"), thepayloadis 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.
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); theallIdsarray 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:
- Start from this nested data: one post by author
u1, with two comments byu2andu1. - Rewrite it into a normalized shape with
users,posts, andcomments, each as{ byId, allIds }, using ids for relationships. - 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 withconfigureStoretoday. - 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/allIdstables removes duplication and makes updates cheap. - Selectors decouple components from state shape;
createSelectormemoizes expensive derived data. - Redux Toolkit (
createSlice,createAsyncThunk,createEntityAdapter, Immer) automates most of this in real projects.
๐ Further Reading
- Redux Fundamentals โ State, Actions & Reducers
- Normalizing State Shape
- Deriving Data with Selectors (Reselect)
- Redux Toolkit โ createAsyncThunk
๐ 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.