🗃️ Redux Core Concepts and Architecture
Redux has a reputation for being complicated, but the whole idea rests on three short rules and one direction of data flow. Understand those, and every Redux app you ever read will suddenly make sense. This lesson builds that mental model before you touch any store code.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what Redux is and the problem it solves for shared application state
- State and apply the three core principles: single source of truth, read-only state, changes via pure functions
- Trace the unidirectional data flow from UI event to action to reducer to new state
- Describe the roles of actions, reducers, and the store and how middleware extends them
- Decide when Redux is the right tool versus React's built-in Context and local state
Estimated Time: 30–40 minutes • Difficulty: Intermediate
Hands-on: Build a tiny pure-JavaScript store from scratch to see the data flow with your own eyes.
In This Lesson
What Problem Does Redux Solve?
Redux is a predictable state container for JavaScript apps. In plain terms: it is one central object that holds the state your whole app cares about, plus a strict, well-documented set of rules for how that object is allowed to change. Because the rules are strict, the changes are predictable — you can always answer "why did the state become this?" by pointing at the exact event that changed it.
The pain it addresses is shared state that lives in many places. Imagine a shopping app where the cart count shows in the header, the cart page lists items, and a checkout button reads the total. Passing that data down through props ("prop drilling") gets tangled fast, and letting each component keep its own copy leads to screens that disagree with each other. Redux gives every component one agreed-upon place to read from and one disciplined way to write to it.
💡 A useful analogy: Think of Redux as a library's central catalog database. No matter which librarian you ask or which terminal you use, you get the same answer about whether a book is available. The chaos Redux prevents is the version where every terminal shows a different, out-of-date answer for the same book.
⚠️ Redux is a tool, not a requirement
Redux earns its keep when your state is large, updated often, updated by complex logic, and needed in many places — especially on a bigger team. For a small app or mostly-local state, React's own useState and Context are usually the better, lighter choice. We'll weigh this explicitly later in the lesson.
📖 A note on "modern Redux"
Everything in this lesson is the conceptual foundation. In real projects today you write Redux with Redux Toolkit (RTK), the official, recommended package that removes almost all of the hand-written boilerplate you'll see here. We show the underlying mechanics first so the Toolkit shortcuts make sense later in the module.
The Three Core Principles
The entire architecture follows from three rules. Memorize these — every Redux concept is just a consequence of one of them.
1. Single source of truth
The global state of your app lives in a single object tree inside one store. One place to look means easier debugging, easier persistence (save the tree, reload it later), and a straightforward path to features like server-side rendering.
// The whole app's state is one plain object
const state = {
user: {
currentUser: { id: 'u123', name: 'Alice Johnson', email: 'alice@example.com' },
isLoading: false,
error: null
},
products: {
items: [
{ id: 'p1', name: 'Laptop', price: 999 },
{ id: 'p2', name: 'Phone', price: 699 }
],
filter: 'all'
},
cart: {
items: [{ productId: 'p2', quantity: 1 }],
isCheckingOut: false
},
ui: { menuOpen: false, currentPage: 'home' }
};
2. State is read-only
The only way to change state is to dispatch an action — a plain object that describes what happened. Nothing writes to the state directly: not a view, not a network callback. Every change flows through the same narrow door, which is exactly what makes changes traceable.
// An action is a plain object describing an event
const addToCart = {
type: 'cart/itemAdded',
payload: { productId: 'p1', quantity: 1 }
};
// Dispatching is the ONLY way to request a change
store.dispatch(addToCart);
🏦 Bank analogy: You can't reach into the bank's computer and change your balance. You submit a transaction — a deposit or withdrawal slip — which documents the intended change, is processed by authorized code, and leaves an audit trail. Actions are those transaction slips.
3. Changes are made with pure functions
To say how the state tree changes in response to actions, you write reducers: pure functions of the form (state, action) => newState. Pure means: given the same inputs they always return the same output, they never mutate their arguments, and they perform no side effects (no API calls, no random values, no writing to the DOM).
// A pure reducer — returns a NEW state, never mutates the old one
function cartReducer(state = { items: [] }, action) {
switch (action.type) {
case 'cart/itemAdded':
return {
...state,
items: [...state.items, {
productId: action.payload.productId,
quantity: action.payload.quantity
}]
};
case 'cart/itemRemoved':
return {
...state,
items: state.items.filter(
item => item.productId !== action.payload.productId
)
};
default:
return state; // unknown action? return state unchanged
}
}
📖 Recipe analogy: A reducer is a recipe. Same ingredients (previous state + action) always yield the same dish (next state). It never alters the original ingredients, and it depends on nothing outside the recipe. That determinism is what makes Redux debuggable and testable.
✅ Why "pure" matters so much
Because reducers are pure, Redux DevTools can replay every action to reconstruct any past state ("time-travel debugging"), your reducers are trivial to unit-test, and you never hit the class of bug where two parts of the app fight over the same mutated object.
The Unidirectional Data Flow
Redux data moves in exactly one direction around a loop. Learn this loop and you've learned Redux's runtime behavior.
Step by step, when a user clicks a button:
- Interaction — the user does something (clicks, submits, types).
- Dispatch — the handler dispatches an action describing what happened.
- Reduce — the store calls the reducer with the current state and the action.
- New state — the reducer returns a brand-new state object.
- Store update — the store swaps in the new state.
- Notify — the store tells every subscriber the state changed.
- Re-render — connected components read the new state and update.
🍽️ Restaurant analogy: A customer (UI) places an order (dispatch). The waiter carries it to the kitchen. The chef (reducer) cooks from the order without altering the pantry's originals. The kitchen manager (store) updates inventory and signals the waiter, who delivers the finished plate back to the customer. Every order follows the same path — no customer wanders into the kitchen.
Actions, Reducers & the Store
Three building blocks cooperate. Here's each one's job and a best-practice example.
Actions & action creators
An action is a plain object that must have a type. Anything else — usually a payload — is up to you. An action creator is just a function that returns an action, so you don't hand-write the object everywhere.
// A named type constant avoids typos and centralizes the string
const USER_UPDATED = 'user/updated';
// Action creator: a function that returns an action
function updateUser(userData) {
return { type: USER_UPDATED, payload: userData };
}
dispatch(updateUser({ name: 'Alice', email: 'alice@example.com' }));
💡 Action conventions worth adopting
- Name types
'domain/eventName'(e.g.'cart/itemAdded') — read them as events that happened, not commands. - Keep the shape flat and small:
{ type, payload }, plus optionalerror/meta. - Prefer past-tense event names (
itemAdded) over imperative ones (addItem) — it keeps the mental model "actions describe history."
Reducers
A reducer decides how state changes for each action type. It reads the current slice of state, matches on action.type, and returns a new state — always falling back to returning the existing state unchanged for actions it doesn't recognize.
const initialUserState = { profile: {}, lastUpdated: null };
function userReducer(state = initialUserState, action) {
switch (action.type) {
case 'user/updated':
return {
...state,
profile: { ...state.profile, ...action.payload },
lastUpdated: new Date().toISOString()
};
case 'user/loggedOut':
return initialUserState;
default:
return state;
}
}
The store
The store ties it all together. It holds state, lets you read it, lets you dispatch to change it, and lets you subscribe to be told when it changes. Historically you created it with createStore; the modern equivalent is Redux Toolkit's configureStore, which we use going forward.
// Modern Redux Toolkit setup (recommended)
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';
const store = configureStore({ reducer: rootReducer });
// The store's small, four-method API:
store.getState(); // read the current state tree
store.dispatch(action); // request a change
const unsub = store.subscribe(() => {
console.log('changed:', store.getState());
});
unsub(); // stop listening
⚠️ Legacy vs. modern
You'll still find tutorials using import { createStore } from 'redux'. That function is now deprecated in favor of configureStore from @reduxjs/toolkit, which wires up good defaults (DevTools, the thunk middleware, and safety checks) for you. Prefer configureStore in new code.
Combining reducers
As the state tree grows, you split it into slices, each owned by its own reducer, then compose them. Each slice reducer only sees and returns its own branch of the tree.
import { combineReducers } from 'redux';
const rootReducer = combineReducers({
user: userReducer, // owns state.user
products: productReducer, // owns state.products
cart: cartReducer, // owns state.cart
ui: uiReducer // owns state.ui
});
// The resulting state shape mirrors the keys above.
🏛️ Government analogy: Combined reducers are like separate departments — Treasury, Justice, Education. A single national memo (an action) goes to all of them, but each only acts on the parts relevant to its domain. Together they form one government (the complete state).
Middleware & the Modern Toolkit
Reducers must stay pure — so where do side effects like API calls live? In middleware: code that sits between dispatch and the reducer, able to inspect, delay, transform, or act on actions as they pass through.
A middleware is a small function with a distinctive triple-arrow shape. Here's a logger that records each action and the state after it:
const loggerMiddleware = store => next => action => {
console.log('dispatching:', action);
const result = next(action); // pass the action along the chain
console.log('next state:', store.getState());
return result;
};
The most important use is asynchronous logic. The redux-thunk middleware lets an action creator return a function instead of a plain object, so you can await an API call and dispatch results as they arrive:
// A "thunk" — an async action creator (thunk is bundled with RTK)
function fetchUsers() {
return async (dispatch) => {
dispatch({ type: 'users/fetchPending' });
try {
const res = await fetch('https://api.example.com/users');
if (!res.ok) throw new Error('Network error');
const users = await res.json();
dispatch({ type: 'users/fetchFulfilled', payload: users });
} catch (err) {
dispatch({ type: 'users/fetchRejected', error: err.message });
}
};
}
💡 Common middleware you'll meet
- redux-thunk — async actions via functions (included and enabled by RTK's
configureStoreby default). - Redux Toolkit's
createAsyncThunk— the modern, structured way to handle async requests with automatic pending/fulfilled/rejected actions. - redux-saga / redux-observable — heavier tools for very complex async flows.
- redux-persist — save and rehydrate the store to
localStorage.
For nearly all apps today, RTK's built-in thunk plus createAsyncThunk is all you need.
Redux vs. Context API
Since you just learned React's Context API, the natural question is "when do I need Redux at all?" They solve overlapping but different problems. Context is a transport mechanism (get a value to deep components without prop drilling); Redux is a full state-management architecture (a disciplined update model plus tooling).
| Concern | Redux (with Toolkit) | Context API + useReducer |
|---|---|---|
| Primary job | State management architecture | Passing a value down the tree |
| Boilerplate | Low with RTK (was high with legacy Redux) | Minimal |
| Async / side effects | First-class via thunks & middleware | Roll your own |
| DevTools | Excellent time-travel debugging | Limited |
| Re-render control | Fine-grained via selectors | All consumers re-render on any change |
| Best for | Large, frequently-updated, shared state | Low-frequency global values (theme, auth user, locale) |
✅ A practical rule of thumb
Reach for Context when the value changes rarely and many components just need to read it (theme, current user, language). Reach for Redux Toolkit when state changes often, the update logic is non-trivial, several features share it, or you want the debugging tools. It's completely normal to use both in one app.
Hands-on: A Store in 30 Lines
🏋️ Build your own mini-store
Objective: The best way to trust Redux is to see how small its core really is. You'll write a working store — getState, dispatch, subscribe — in plain JavaScript, then drive a counter through it.
Instructions:
- Write a
counterReducer(state, action)that handles'counter/incremented','counter/decremented', and'counter/reset', defaultingstateto{ value: 0 }. - Write a
createStore(reducer)factory that keeps a privatestate, a list of listeners, and returnsgetState,dispatch, andsubscribe. - Subscribe a listener that logs the state, then dispatch a few actions and watch the loop run.
💡 Hint
dispatch should do exactly three things: set state = reducer(state, action), then call every listener, then return the action. subscribe should push the listener onto the array and return an unsubscribe function that removes it. Never mutate state inside the reducer — return a new object.
✅ Solution
function counterReducer(state = { value: 0 }, action) {
switch (action.type) {
case 'counter/incremented': return { value: state.value + 1 };
case 'counter/decremented': return { value: state.value - 1 };
case 'counter/reset': return { value: 0 };
default: return state;
}
}
function createStore(reducer) {
let state = reducer(undefined, { type: '@@INIT' });
let listeners = [];
return {
getState: () => state,
dispatch(action) {
state = reducer(state, action); // pure update
listeners.forEach(fn => fn()); // notify everyone
return action;
},
subscribe(listener) {
listeners.push(listener);
return () => { listeners = listeners.filter(l => l !== listener); };
}
};
}
const store = createStore(counterReducer);
const unsub = store.subscribe(() => console.log(store.getState()));
store.dispatch({ type: 'counter/incremented' }); // { value: 1 }
store.dispatch({ type: 'counter/incremented' }); // { value: 2 }
store.dispatch({ type: 'counter/decremented' }); // { value: 1 }
store.dispatch({ type: 'counter/reset' }); // { value: 0 }
unsub();
That's the real heart of Redux. Everything else — combineReducers, middleware, Redux Toolkit, the React bindings — is convenience layered on top of these three methods.
🎯 Quick Quiz
Question 1: According to Redux's core principles, what is the only way to change the state?
Question 2: Which statement about reducers is true?
Question 3: In a modern Redux project, how should you create the store?
Summary & Quiz
🎉 Key Takeaways
- Redux is a predictable state container: one central state, plus strict rules for changing it.
- The three principles — single source of truth, read-only state, pure-function reducers — explain every other Redux concept.
- Data flows one way: UI → action → reducer → new state → store → UI.
- Actions describe events, reducers compute new state, the store holds it, and middleware handles side effects like async.
- Use Context for rarely-changing shared values; reach for Redux Toolkit for large, frequently-updated shared state.
- In modern code, use
configureStorefrom Redux Toolkit — not the deprecatedcreateStore.
📚 Further Reading
- Redux Essentials — Overview & Concepts
- The Three Principles of Redux
- Redux Toolkit — the modern, recommended way
- Redux FAQ — When should I use Redux?
🚀 What's Next?
Now that the philosophy is clear, we'll zoom into the machinery: the next lesson takes a deep dive into the store, actions, and reducers — including immutable update patterns, state normalization, and selectors — before we wire Redux up to React.
🎉 Well done!
You can now read any Redux codebase and trace exactly how a click becomes a state change. That mental model is the hard part — the rest is practice.