🗃️ Redux Core Concepts
As a React app grows, state stops being a local convenience and starts becoming a coordination problem. Redux answers that problem with a simple, strict idea: keep all your state in one place and only change it through a predictable, replayable pipeline. This lesson builds that mental model piece by piece.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why shared state gets hard to manage and when Redux is worth its cost
- State the three principles of Redux and why each one matters
- Describe the roles of actions, action creators, reducers, and the store
- Trace Redux's unidirectional data flow from a UI event to a re-render
- Build a small store by hand with action types, action creators, and a pure reducer
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Wire up a counter store from scratch, then extend the pattern to a todo list.
In This Lesson
The State Management Problem
React's useState is perfect for state that belongs to a single component — a toggle, an input value, a hover flag. The trouble starts when a piece of state needs to be:
- Shared across components that live far apart in the tree
- Updated from many different places
- Read consistently so every view agrees on the current value
The usual first fix is prop drilling — passing state down through layers of components that don't care about it just to reach the one that does. It works, but it couples unrelated components and turns a small change into a wide edit.
💡 An analogy: Think of state management like inventory in a store. A single market stall keeps stock on a notepad (local state) and that's fine. But a chain of warehouses needs one shared, authoritative system (global state) so every branch sees the same numbers. Redux is that central inventory system.
The ecosystem offers a spectrum of solutions, and Redux sits toward the structured end:
- Component state —
useState/useReducerfor local data - Context API — React's built-in way to avoid prop drilling for low-frequency values (theme, current user)
- State libraries — Redux, Zustand, Jotai, Recoil, MobX for larger, frequently-updated shared state
⚠️ Redux is not always the answer
Redux adds structure, and structure has a cost. For a small app, Context plus useReducer is often enough. Reach for Redux when state is large, changes often, is touched from many places, or you genuinely benefit from time-travel debugging and a strict audit trail.
What Is Redux?
Redux is a predictable state container for JavaScript applications. "Predictable" is the operative word: given the same starting state and the same sequence of actions, you always get the same result. That determinism is what makes Redux apps easy to test, debug, and reason about.
Redux was created by Dan Abramov and Andrew Clark in 2015, inspired by the Flux architecture and ideas from functional programming. It is framework-agnostic — you'll most often see it with React (via react-redux), but the core library has no React dependency at all.
📖 Key Terms
Store: the single object that holds your entire application state.
Action: a plain object describing what happened.
Reducer: a pure function that takes the current state and an action and returns the next state.
Dispatch: the only way to send an action into the store.
Where Redux earns its keep
Real applications that benefit from Redux tend to share a shape: lots of interconnected state read in many places. Examples include e-commerce apps (cart, filters, user preferences, product cache), collaborative tools with undo/redo, dashboards pulling from multiple data sources, and social platforms with rich, interdependent UI state.
The Three Principles
Everything in Redux follows from three rules. Learn these and the rest of the API is just plumbing.
1. Single source of truth
The whole state of your app lives in one object tree inside a single store. There is exactly one place to look, which makes debugging, persistence, and server-side rendering far simpler.
// A single state tree describes the whole app
const state = {
user: {
id: 'u123',
name: 'Jane Doe',
preferences: { theme: 'dark', notifications: true }
},
products: { items: [], isLoading: false, error: null },
cart: { items: [], totalAmount: 0 }
};
2. State is read-only
You never mutate the state directly. The only way to change it is to dispatch an action — a plain object describing the change. This guarantees that nothing can quietly modify state behind the scenes; every change is an explicit, recorded event.
// Actions describe what happened — they don't change anything themselves
store.dispatch({
type: 'cart/itemAdded',
payload: { productId: 'p123', name: 'Wireless Headphones', price: 79.99, quantity: 1 }
});
💡 An analogy: Picture a library where books can't be edited in place. To change one, you fill out a request form (an action) and hand it to the librarian (the store), who applies the change according to fixed rules. Every change leaves a paper trail.
3. Changes are made with pure functions
To describe how the state tree transforms in response to actions, you write reducers: pure functions of (state, action) => newState. Pure means no side effects, no mutation of the arguments, and the same inputs always produce the same output.
const initialState = { items: [], totalAmount: 0 };
function cartReducer(state = initialState, action) {
switch (action.type) {
case 'cart/itemAdded': {
const item = action.payload;
const existing = state.items.find(i => i.productId === item.productId);
const items = existing
? state.items.map(i =>
i.productId === item.productId
? { ...i, quantity: i.quantity + item.quantity }
: i
)
: [...state.items, item];
return {
...state,
items,
totalAmount: state.totalAmount + item.price * item.quantity
};
}
case 'cart/itemRemoved': {
const target = state.items.find(i => i.productId === action.payload);
if (!target) return state;
return {
...state,
items: state.items.filter(i => i.productId !== action.payload),
totalAmount: state.totalAmount - target.price * target.quantity
};
}
default:
return state;
}
}
⚠️ Never mutate state in a reducer
state.items.push(item) is a bug even though it looks convenient. Redux relies on reference equality to detect changes — if you mutate the existing object, the reference stays the same and your UI may not re-render. Always return a new object/array. (Redux Toolkit lifts this restriction with Immer, which you'll meet in the next lesson.)
Actions, Reducers & the Store
Actions
Actions are plain objects that carry information into the store. They are the only source of data for the store. Every action must have a type; most also carry a payload.
- Plain JavaScript objects
- A required
type— a descriptive string, e.g.'user/loggedIn' - Optional data, conventionally under
payload
Action creators
Writing action objects by hand everywhere is repetitive and error-prone. Action creators are small functions that build actions for you, keeping the shape consistent in one place.
// Action creator — one authoritative place to build this action
function addToCart(product, quantity = 1) {
return {
type: 'cart/itemAdded',
payload: {
productId: product.id,
name: product.name,
price: product.price,
quantity
}
};
}
// Usage
dispatch(addToCart(product, 2));
Reducers
Reducers specify how state changes for each action. They must be pure, must never mutate their arguments, and must return the untouched state for any action they don't recognize (that default case is essential — many reducers may see the same action).
The store
The store ties actions and reducers together. It holds state, exposes it, and lets you dispatch changes and subscribe to updates.
import { createStore } from 'redux';
import rootReducer from './reducers';
const store = createStore(rootReducer);
// Read the current state
console.log(store.getState());
// React to every change
const unsubscribe = store.subscribe(() =>
console.log('State updated:', store.getState())
);
// The only way to change state
store.dispatch(addToCart(product));
// Stop listening
unsubscribe();
💡 A note on the modern API
The bare createStore shown here is the classic API and the clearest way to learn the concepts. In real projects today you should use Redux Toolkit's configureStore instead — createStore is even marked deprecated to nudge you toward it. You'll switch to that in the next lesson; the concepts you're learning now carry over unchanged.
Unidirectional Data Flow
Redux enforces a strict one-way flow. Data always travels in the same direction, which means you can always answer "how did the state get here?" by replaying the actions.
- An event happens in the UI (a click, a form submit, an API response arriving).
- An action is dispatched describing what happened.
- The reducer receives the current state and the action and computes the next state.
- The store saves the new state and notifies subscribers.
- The UI re-renders from the new state.
💡 An analogy: It's a well-run restaurant. The diner (UI) places an order (action), the waiter (dispatch) carries it to the kitchen, the chef (reducer) follows the recipe to produce the dish (new state), it's delivered back to the table (store notifies), and the diner enjoys the result (re-render). Orders only ever flow one way through the pass.
This discipline buys you predictable updates, clean separation of responsibilities, and the ability to log or replay every state transition — the foundation of Redux DevTools' famous time-travel debugging.
Building a Store by Hand
Let's assemble a complete counter store the "classic" way so every moving part is visible. In practice you'd fold most of this into a single Redux Toolkit slice, but seeing it laid out explicitly makes the abstraction click.
Step 1 — Action types
// actionTypes.js
export const INCREMENT = 'counter/increment';
export const DECREMENT = 'counter/decrement';
export const RESET = 'counter/reset';
export const SET_VALUE = 'counter/valueSet';
Step 2 — Action creators
// actions.js
import { INCREMENT, DECREMENT, RESET, SET_VALUE } from './actionTypes';
export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });
export const reset = () => ({ type: RESET });
export const setValue = (value) => ({ type: SET_VALUE, payload: value });
Step 3 — The reducer
// reducer.js
import { INCREMENT, DECREMENT, RESET, SET_VALUE } from './actionTypes';
const initialState = { count: 0 };
export default function counterReducer(state = initialState, action) {
switch (action.type) {
case INCREMENT:
return { ...state, count: state.count + 1 };
case DECREMENT:
return { ...state, count: state.count - 1 };
case RESET:
return { ...state, count: 0 };
case SET_VALUE:
return { ...state, count: action.payload };
default:
return state;
}
}
Step 4 — Create the store
// store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './reducer';
// configureStore wires up DevTools and sensible middleware for you
const store = configureStore({ reducer: counterReducer });
export default store;
Step 5 — Use it
// index.js
import store from './store';
import { increment, decrement, reset, setValue } from './actions';
const unsubscribe = store.subscribe(() =>
console.log('Updated state:', store.getState())
);
store.dispatch(increment()); // { count: 1 }
store.dispatch(increment()); // { count: 2 }
store.dispatch(decrement()); // { count: 1 }
store.dispatch(setValue(100)); // { count: 100 }
store.dispatch(reset()); // { count: 0 }
unsubscribe();
Console output
Updated state: { count: 1 }
Updated state: { count: 2 }
Updated state: { count: 1 }
Updated state: { count: 100 }
Updated state: { count: 0 }
When you have several reducers, you combine them into one root reducer. Redux Toolkit's configureStore accepts an object of slice reducers and combines them automatically:
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counter/reducer';
import cartReducer from './cart/reducer';
const store = configureStore({
reducer: {
counter: counterReducer,
cart: cartReducer
}
});
// state shape: { counter: {...}, cart: {...} }
Best Practices & Pitfalls
| ✅ Do | ❌ Don't |
|---|---|
| Return brand-new objects/arrays from reducers | Mutate state with push, =, splice |
Keep reducers pure — no fetch, no Date.now(), no random | Put side effects or API calls inside a reducer |
Name action types after events: 'cart/itemAdded' | Name them as commands or vague verbs |
| Store the minimal state and derive the rest with selectors | Duplicate derived values that can drift out of sync |
Always handle the default case | Forget it — the reducer would return undefined |
✅ Rule of thumb
Actions describe what happened, reducers describe how state responds, and the store is where state lives. Keep those responsibilities separate and Redux stays easy to reason about.
Hands-on Exercise
🏋️ Build a Todo Store with Classic Redux
Objective: Apply actions, reducers, and the store to a small todo app supporting add, toggle, delete, and filter.
Instructions:
- Define action types:
todos/added,todos/toggled,todos/deleted,filter/set. - Write an action creator for each.
- Write a
todosReducer(array of{ id, text, completed }) and afilterReducer(a string). - Combine them under
configureStore({ reducer: { todos, filter } }). - Dispatch a sequence of actions and log
store.getState()after each to verify.
💡 Hint
For todos/toggled, don't mutate — map over the array and return a new object only for the matching id: todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t). For todos/deleted, use filter. Give new todos a unique id (a counter or crypto.randomUUID() created in the action creator, not the reducer).
✅ Sample solution
// actions.js
let nextId = 1;
export const addTodo = (text) => ({ type: 'todos/added', payload: { id: nextId++, text, completed: false } });
export const toggleTodo = (id) => ({ type: 'todos/toggled', payload: id });
export const deleteTodo = (id) => ({ type: 'todos/deleted', payload: id });
export const setFilter = (f) => ({ type: 'filter/set', payload: f });
// reducers.js
export function todosReducer(state = [], action) {
switch (action.type) {
case 'todos/added':
return [...state, action.payload];
case 'todos/toggled':
return state.map(t =>
t.id === action.payload ? { ...t, completed: !t.completed } : t
);
case 'todos/deleted':
return state.filter(t => t.id !== action.payload);
default:
return state;
}
}
export function filterReducer(state = 'ALL', action) {
return action.type === 'filter/set' ? action.payload : state;
}
// store.js
import { configureStore } from '@reduxjs/toolkit';
import { todosReducer, filterReducer } from './reducers';
const store = configureStore({
reducer: { todos: todosReducer, filter: filterReducer }
});
store.dispatch(addTodo('Learn Redux'));
store.dispatch(addTodo('Build a store'));
store.dispatch(toggleTodo(1));
store.dispatch(setFilter('ACTIVE'));
console.log(store.getState());
// { todos: [ {id:1,...completed:true}, {id:2,...} ], filter: 'ACTIVE' }
🎯 Quick Quiz
Question 1: According to Redux's principles, what is the only way to change the state in the store?
Question 2: Why must a reducer return the original state in its default case?
Question 3: Which line is a bug inside a reducer?
Summary & Quiz
🎉 Key Takeaways
- Redux is a predictable state container — same state + same actions always give the same result.
- The three principles: single source of truth, state is read-only, changes via pure reducers.
- The building blocks are actions (what happened), reducers (how state responds), and the store (where state lives).
- Data flows one way: event → dispatch → reducer → store → re-render.
- Never mutate state; always return new objects. Use Redux when shared state is large and changes often.
📚 Further Reading
- Redux Essentials — Overview & Concepts
- Redux Fundamentals Tutorial
- The Three Principles (official docs)
- Redux DevTools
🚀 What's Next?
You've seen the concepts and the boilerplate that comes with them. Next you'll learn Redux Toolkit — the official, modern way to write Redux that collapses all of this into a fraction of the code with createSlice and configureStore.
🎉 Well done!
You understand the machinery. Now let's make it far less to type.