🗄️ Redux Principles & Architecture
As a React app grows, state stops being a component's private business and becomes a shared, cross-cutting concern — the same user, the same cart, the same theme needed in a dozen places at once. Redux gives that shared state a single, predictable home and a strict set of rules for changing it. In this lesson you'll learn why Redux exists, its three governing principles, and the one-way data flow that ties store, actions, and reducers together.
Week 6 · Day 1 (Monday: Redux Fundamentals) · Lecture 1
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain the state-management problems Redux solves — prop drilling, sync, and unpredictable updates
- State Redux's three core principles and why each one matters
- Identify the four building blocks — store, actions, reducers, dispatch — and how they relate
- Trace the one-way data flow from UI event → action → reducer → store → UI
- Build a minimal counter store with
createStore,dispatch, andsubscribe - Recognize why Redux Toolkit (RTK) is the modern standard, and how it maps onto these fundamentals
Estimated Time: 70 minutes
Practice: Wire up a working counter store, then sketch the state shape for a todo app.
In This Lesson
What Is Redux?
Redux is a predictable state container for JavaScript applications. That phrase is worth unpacking: it's a container (one place that holds your app's data), and it's predictable (the same actions applied to the same state always produce the same result). Although it's most famous as React's companion, Redux is framework-agnostic — it works with Angular, Vue, or plain vanilla JavaScript, because at its heart it's just a tiny library for managing a JavaScript object over time.
🏛️ The Bank Vault Analogy
Think of Redux as a bank:
- Store — the vault holding all the money (your application's state)
- Actions — deposit and withdrawal slips describing what you want to happen
- Reducers — the tellers who read a slip and produce the new balance
- Dispatch — handing your slip across the counter
- Subscribers — account holders who get notified the moment the balance changes
You can't walk into the vault and grab cash directly, and you can't reach into the Redux store and change state directly. You go through the proper channel — an action, processed by a reducer — every single time. That discipline is exactly what makes the system auditable and predictable.
You've already met React's built-in state tools — useState and useContext. Redux doesn't replace them; it complements them for the slice of state that is genuinely global and changes in complex ways. Knowing when not to reach for Redux is as important as knowing how to use it, and we'll return to that at the end.
The Problem Redux Solves
Imagine a shopping app. The header shows a cart badge, a sidebar shows a mini-cart, and a checkout page shows the full cart. All three need the same cart data, and all three must update the instant an item is added. With component-local state, you'd lift the cart up to a common ancestor and thread it down through props — a pattern that quickly turns painful.
Prop drilling
"Prop drilling" is passing data through layers of components that don't use it themselves, just so a deeply-nested child can receive it. Each intermediate component becomes a courier for props it doesn't care about — fragile, noisy, and hard to refactor.
Every dotted arrow is a component forwarding cart it never reads. Redux cuts the cord: any component can read from the store directly, no matter how deep it sits.
Problems Redux addresses
| Problem | Without Redux | With Redux |
|---|---|---|
| Prop drilling | Thread props through every layer | Read from the store anywhere |
| State sync | Multiple copies drift out of sync | One source of truth |
| Predictability | Any component can mutate state | Changes only via dispatched actions |
| Debugging | Hard to trace "who changed this?" | Time-travel & action logs in DevTools |
| Testing | Logic tangled with UI | Pure reducers test in isolation |
⚠️ Redux is not always the answer
Redux adds structure — and structure has a cost. For small apps, or state that's local to one screen, React's useState and Context are simpler and enough. Reach for Redux when several distant parts of the app share complex, frequently-changing state, or when you need serious debugging and time-travel tooling.
The Three Principles
Everything Redux does follows from three rules. Memorize these — they're the constitution the whole architecture obeys.
1. Single source of truth
The entire state of your application lives in one object tree, inside one store. There's exactly one place to look, one thing to serialize for persistence, one snapshot to inspect while debugging.
// The whole app state is one plain object:
{
user: {
name: 'Ada Lovelace',
email: 'ada@example.com',
preferences: { theme: 'dark', notifications: true }
},
posts: [
{ id: 1, title: 'Redux is Predictable', likes: 5 },
{ id: 2, title: 'Learn Redux', likes: 3 }
],
ui: { isLoading: false, error: null }
}
2. State is read-only
The only way to change state is to dispatch an action — a plain object describing what happened. Nothing, not even the app itself, writes to the store directly. This is the rule that makes every change intentional and traceable.
// ❌ Never mutate the store directly:
store.getState().user.name = 'Grace Hopper';
// ✅ Describe the change as an action and dispatch it:
store.dispatch({
type: 'user/nameUpdated',
payload: 'Grace Hopper'
});
3. Changes are made with pure functions
To specify how the state tree transforms in response to actions, you write reducers — pure functions of the form (state, action) => newState. Same inputs, same output, no side effects, no mutation.
// A pure reducer: no mutation, no side effects, fully deterministic.
function counterReducer(state = 0, action) {
switch (action.type) {
case 'counter/incremented':
return state + 1; // returns a NEW value
case 'counter/decremented':
return state - 1;
default:
return state; // unknown action? return state unchanged
}
}
// ❌ NOT pure — mutates its argument:
function badReducer(state, action) {
if (action.type === 'counter/incremented') {
state.count++; // mutation! Redux can't detect this change
return state;
}
return state;
}
💡 Why "pure"?
Because reducers never reach outside themselves — no timers, no fetch, no Math.random(), no mutation — Redux can replay them, undo them, and reproduce any bug from a saved action log. Purity is what powers time-travel debugging.
The Building Blocks
Four pieces make up every Redux setup. Get comfortable with the vocabulary now; the next two lessons drill into actions and reducers individually.
dispatch is the doorway between your UI and the store.- Store — created once, it holds state and exposes
getState(),dispatch(action), andsubscribe(listener). - Action — a plain object with a required
typestring and, usually, apayloadcarrying data. - Reducer — a pure function that takes the current state and an action and returns the next state.
- Dispatch — the store method you call to send an action in; it's the only way to trigger a state change.
// The store's public API in one glance:
import { createStore } from 'redux';
const store = createStore(counterReducer);
store.getState(); // read the current state
store.dispatch(action); // send an action → runs the reducer
const unsub = store.subscribe(listener); // run listener after every dispatch
unsub(); // stop listening
One-Way Data Flow
This is the single most important diagram in Redux. Data travels in one direction, always, in a loop. A user does something, an action is dispatched, the reducer produces new state, the store updates, and the UI re-renders from that new state — ready for the next interaction.
Walking the loop
- Trigger — a user clicks, types, or an event fires.
- Dispatch — an action object is sent to the store via
dispatch. - Reduce — the store calls the reducer with the current state and the action; the reducer returns the next state.
- Store update — the store saves the new state and notifies every subscriber.
- Render — subscribed UI reads the fresh state and re-renders.
✅ Why one direction matters
Because data can only flow this way — never sideways, never backward — you can always answer "how did the state get like this?" by replaying the ordered list of dispatched actions. Two-way binding makes that question nearly unanswerable; Redux makes it trivial.
A Complete Counter Store
Let's tie every piece together with the smallest useful example: a counter. This is plain Redux (great for understanding the mechanics); further down we'll rewrite it the modern Redux Toolkit way.
import { createStore } from 'redux';
// 1. The initial state (also the reducer's default parameter)
const initialState = { count: 0, lastAction: null };
// 2. The reducer — pure, immutable, with a default case
function counterReducer(state = initialState, action) {
switch (action.type) {
case 'counter/incremented':
return { ...state, count: state.count + 1, lastAction: 'increment' };
case 'counter/decremented':
return { ...state, count: state.count - 1, lastAction: 'decrement' };
case 'counter/reset':
return { ...state, count: 0, lastAction: 'reset' };
default:
return state;
}
}
// 3. Create the store
const store = createStore(counterReducer);
// 4. Subscribe — this listener runs after every dispatch
store.subscribe(() => {
console.log('State changed:', store.getState());
});
// 5. Dispatch actions to drive the state forward
store.dispatch({ type: 'counter/incremented' });
store.dispatch({ type: 'counter/incremented' });
store.dispatch({ type: 'counter/decremented' });
Console Output
State changed: { count: 1, lastAction: 'increment' }
State changed: { count: 2, lastAction: 'increment' }
State changed: { count: 1, lastAction: 'decrement' }
Notice the spread ...state in each case: we build a new object rather than editing the old one. That immutability is what lets Redux detect the change by reference and tell the UI to re-render. We'll go deep on immutable updates in the Reducers lesson.
Redux Toolkit: The Modern Way
Everything above teaches you how Redux works — and you should understand it. But in real projects today, you should write Redux with Redux Toolkit (RTK), the official, recommended toolset. RTK removes boilerplate: it configures the store, generates action creators, and lets you write "mutating" reducer logic that's safely converted to immutable updates under the hood (via the Immer library).
import { configureStore, createSlice } from '@reduxjs/toolkit';
// A "slice" bundles the reducer AND its action creators together.
const counterSlice = createSlice({
name: 'counter',
initialState: { count: 0, lastAction: null },
reducers: {
// Looks like mutation, but Immer makes it immutable & safe:
incremented(state) { state.count += 1; state.lastAction = 'increment'; },
decremented(state) { state.count -= 1; state.lastAction = 'decrement'; },
reset(state) { state.count = 0; state.lastAction = 'reset'; }
}
});
// createSlice auto-generates matching action creators:
export const { incremented, decremented, reset } = counterSlice.actions;
// configureStore sets up the store + DevTools + good defaults:
const store = configureStore({ reducer: counterSlice.reducer });
store.dispatch(incremented()); // same result, far less code
console.log(store.getState()); // { count: 1, lastAction: 'increment' }
💡 Learn the plumbing, ship the toolkit
We teach plain Redux first because RTK is a convenience layer on top of these exact principles — store, actions, pure reducers, one-way flow. Once you can see those underneath, RTK stops being magic and becomes a well-earned shortcut. Every concept in the next two lessons maps directly onto RTK's createSlice.
Practice & Quiz
🏋️ Exercise 1: Build a counter store
Goal: Using plain Redux, create a store whose state is a single number, supporting increment, decrement, and an incrementBy action that reads action.payload.
import { createStore } from 'redux';
function counterReducer(state = 0, action) {
// TODO: handle 'increment', 'decrement', and 'incrementBy'
}
const store = createStore(counterReducer);
store.dispatch({ type: 'increment' });
store.dispatch({ type: 'incrementBy', payload: 5 });
console.log(store.getState()); // should log: 6
💡 Hint
Each case returns a new number — never modify state. For incrementBy, add action.payload to the current state. Don't forget the default case that returns state unchanged.
✅ Solution
function counterReducer(state = 0, action) {
switch (action.type) {
case 'increment': return state + 1;
case 'decrement': return state - 1;
case 'incrementBy': return state + action.payload;
default: return state;
}
}
🏋️ Exercise 2: Design a state shape
Goal: Sketch (as a plain object) the initial state tree for a todo app that tracks a list of todos, a current filter (all/active/completed), and a loading flag. Follow principle #1: everything in one tree.
✅ Solution
const initialState = {
todos: [], // [{ id, text, completed }]
filter: 'all', // 'all' | 'active' | 'completed'
ui: { isLoading: false, error: null }
};
Separating ui state from data is a common, healthy pattern — you'll see it again when we normalize state.
🎯 Quick Quiz
Question 1: What is the only way to change state in a Redux app?
Question 2: A reducer must be a pure function. Which of these breaks that rule?
Question 3: In the one-way data flow, what comes immediately after an action is dispatched?
Best Practices & Pitfalls
✅ Do
- Use Redux Toolkit for new projects — it's the official standard and removes boilerplate
- Keep the whole app state in one store (single source of truth)
- Change state only by dispatching actions; keep reducers pure
- Name action types
domain/eventName, e.g.cart/itemAdded - Reach for Redux when state is truly shared and complex — not for every piece of local UI state
❌ Don't
- Mutate
stateinside a reducer (in plain Redux) — build a new object instead - Put non-serializable values (functions, DOM nodes, class instances) in state or actions
- Perform side effects —
fetch, timers, logging — inside reducers - Reach for Redux to solve a problem that
useStateor Context already handles cleanly
⚠️ Reference equality is the whole game
Redux (and React) decide "did this change?" by comparing object references, not deep-comparing contents. Mutating state.count++ keeps the same reference, so the change goes undetected and the UI won't update. Always return a new object/array from a reducer.
Summary
🎉 Key Takeaways
- Redux is a predictable state container — one store, changed only through actions
- It solves prop drilling, state sync, predictability, debugging, and testing for shared state
- Three principles: single source of truth, state is read-only, changes via pure reducers
- The four blocks are store, action, reducer, dispatch, tied together by one-way data flow
- Redux Toolkit is the modern standard; it sits on top of these exact fundamentals
📚 Additional Resources
- Redux — Core Concepts
- Redux Essentials — Overview and Concepts
- Redux — Three Principles
- Redux Toolkit — Getting Started
🚀 What's Next?
You now understand the architecture and the vocabulary. Next we zoom into the first building block in detail: Actions and Action Creators — how to design action objects, name their types, and wrap them in reusable functions.
🎉 Great start!
The mental model you just built — store, action, reducer, one-way flow — is the foundation everything else in this module stands on.