← Back to Redux
Lesson 9 · State Management Fundamentals

Redux Architecture Overview

The whole Redux loop on one page - UI, action, dispatch, middleware, reducers, store, selectors - traced step by step through a real click and a real server request. Then how to draw a feature on paper before writing code.

Beginner35 min

What you will be able to do

  • Name every part of the Redux loop and what each one does
  • Follow one click through the loop, in the real order, from a logged trace
  • Explain why every reducer is called for every action
  • Describe the shape of the state: one root object made of slices
  • Follow the async path: a thunk, the server, and the pending and fulfilled actions
  • Plan a feature on paper - actions, state, selectors, components - before coding it
  • Know which module of this course covers each part

The idea, in plain English

Lessons 1 to 8 were about the problem: why state gets hard, and when Redux is worth it. From here on the course is about Redux itself. Before learning each part in detail (Modules 2 to 10), it helps to see the whole machine once, from far away - "the shape before the detail", as the outline says.

Redux has one store that holds the app’s shared state, and one direction in which data moves. The UI never changes the state directly. It dispatches an action - a plain object that says what happened. The action passes through middleware, then the reducers calculate the new state, the store saves it and tells its subscribers, and selectors give each component the piece it shows. Then the user does something again, and the loop repeats.

In this lesson we do not just draw that loop - we run it. Every part writes a line to a log, so you can see the real order of events for one click and for one server request. All code was run with Redux Toolkit 2.13.0, react-redux 9.3.0 and React 19.3, rendered and clicked in a test browser (jsdom).

Worked example: Drawing the loop on paper first: a "Save lesson" feature planned in a table, then written in 30 lines.

workflowThe Redux loopstep 1 / 5

1 - The user clicks, the UI dispatches

The click handler does not change any state. It describes what happened - an action, { type: "cart/added", payload: "Docker" } - and hands it to dispatch.

log
1. user clicks
log
2. dispatch(cart/added)
state changed?
not yet
action
a plain object

One click on "Cart", step by step, in the order our logged trace printed it. After the last box, the loop starts again at the top with the next click.

ArchitectureThe async pathtap a node to trace it

Reducers must be pure, so they cannot call a server. A thunk does the waiting, then dispatches ordinary actions with the result.

An everyday picture: the bank counter

In a bank you cannot walk into the vault and change your balance. You fill in a slip - "deposit 500" - and hand it in at the counter. A security guard looks at every slip first. Then a clerk, following the bank’s rules, updates the ledger. The ledger is the one true record. Later, when you look at your passbook or the app, you see only your own account, not the whole ledger.

Redux is the same. The slip is the action. Handing it in is dispatch. The guard is middleware. The clerk following rules is the reducer. The ledger is the store. Looking at only your account is a selector. And just like in the bank, you never write in the ledger yourself.

Bank -> Redux
The deposit slipAn action: a description of what happened.
Handing it indispatch(action).
The security guardMiddleware: sees every slip before the clerk.
The clerk and the rulesReducers: calculate the new state from the old state and the action.
The ledgerThe store: the one true record.
Your passbook viewA selector: only the part you need.

The parts, on one page

These are all the parts of the loop. Lesson 10 defines each term carefully; here, just see where each one sits.

Who does what
UI (components)Shows state, and dispatches actions when something happens.
ActionA plain object: { type, payload }. Describes what happened.
dispatchThe only way to send an action to the store.
MiddlewareSees every action before the reducers: logging, async, analytics.
ReducersPure functions: (old state, action) -> new state.
StoreHolds the state, runs the reducers, notifies subscribers.
SelectorsFunctions that read a piece of state for a component.
ThunkAn async function you can dispatch; it talks to servers and dispatches actions.

One direction

Follow the arrows in the first diagram: they go round in one direction only. The UI never writes to the store. The reducers never touch the UI. Middleware never changes state. Each part has one job, and talks only to the next part.

This is what makes Redux predictable. If the state is wrong, there are only two questions: which action was dispatched, and what did the reducer do with it? Lesson 8’s replay example answered both from a list of actions. You never have to search the whole app for "who changed this".

Example 1: trace one click

This app has one button and a store with two parts: a cart slice and a small user reducer. Every part of the loop writes a numbered line to a log. We clicked the button once and printed the log.

Three things in the output surprise most people. First, the user reducer was called, although the action was about the cart. The store calls every reducer for every action; each reducer decides whether the action concerns it, and if not, returns its state unchanged. Second, the selector ran twice: once when the store notified react-redux (to check whether the value changed), and once while CartButton rendered. Third, nothing in the UI code changed the cart - the click only described what happened.

Trace.jsx
import { configureStore, createAsyncThunk, createSlice } from "@reduxjs/toolkit"; import { Provider, useDispatch, useSelector } from "react-redux"; export const log = []; const step = (text) => log.push(text); // ---- a slice: owns one part of the state ---- const cartSlice = createSlice({ name: "cart", initialState: { items: [] }, reducers: { added(state, action) { step("4. cart reducer handles " + action.type); state.items.push(action.payload); }, }, extraReducers: (builder) => { builder.addCase(loadCart.fulfilled, (state, action) => { step("4. cart reducer handles " + action.type); state.items = action.payload; }); }, }); // ---- another part of the state, written as a plain reducer function ---- function userReducer(state = { name: "Ravi" }, action) { step("4. user reducer is called too - returns the same state"); return state; } // ---- async: a thunk talks to the server, then dispatches the result ---- const fakeServer = async () => ["Python", "Redux"]; export const loadCart = createAsyncThunk("cart/load", async () => { step("A. thunk calls the server"); const items = await fakeServer(); step("B. server answered"); return items; }); // ---- middleware: sees every action before the reducers ---- const logger = () => (next) => (action) => { step("3. middleware sees " + action.type); return next(action); }; // ---- the store: ONE root reducer made from the parts ---- export const store = configureStore({ reducer: { cart: cartSlice.reducer, user: userReducer }, middleware: (getDefault) => getDefault().concat(logger), }); store.subscribe(() => step("5. store notifies subscribers")); // ---- the UI: selects what it shows, dispatches what happened ---- function CartButton() { const count = useSelector((state) => { step("6. selector runs"); return state.cart.items.length; }); const dispatch = useDispatch(); step("7. CartButton renders: " + count + " items"); return ( <button onClick={() => { step("1. user clicks"); step("2. dispatch(cart/added)"); dispatch(cartSlice.actions.added("Docker")); }} > Cart ({count}) </button> ); } export default function App() { return ( <Provider store={store}> <CartButton /> </Provider> ); }
The log after one click - measured
1. user clicks 2. dispatch(cart/added) 3. middleware sees cart/added 4. cart reducer handles cart/added 4. user reducer is called too - returns the same state 5. store notifies subscribers 6. selector runs <- react-redux checks: did my value change? 6. selector runs <- again, while CartButton renders 7. CartButton renders: 1 items screen: Cart (1)

The shape of the state

There is one store, so there is one state object. configureStore({ reducer: { cart, user } }) builds it: each key gets its value from its own reducer. After the click and the load below, store.getState() printed {"cart":{"items":["Python","Redux"]},"user":{"name":"Ravi"}}.

Each slice owns one key and nothing else. The cart reducer only ever receives state.cart; it cannot see or change state.user. This keeps each part small and lets a team work on different slices without stepping on each other. Module 12 (Redux State Architecture) is about designing this shape well.

Example 2: the async path

Reducers must be pure functions: same input, same output, no waiting, no server calls. So where does fetching data go? Into a thunk - an async function you dispatch like an action. createAsyncThunk builds one, and it dispatches ordinary actions for you as the request moves along.

We dispatched loadCart() and printed the log. The middleware first saw cart/load/pending. No reducer handles pending here, so the state did not change - and react-redux did not even run the selector. Then the thunk called the server, waited, and when the answer came, cart/load/fulfilled went through the normal loop: middleware, reducers, notify, selectors, render. The screen changed to Cart (2). The thunk itself never reaches a reducer; only the plain actions it dispatches do. Module 8 (Async Redux) covers this path in depth.

The log after dispatch(loadCart()) - measured
3. middleware sees cart/load/pending 4. user reducer is called too - returns the same state 5. store notifies subscribers (state unchanged: no selector, no render) A. thunk calls the server B. server answered 3. middleware sees cart/load/fulfilled 4. cart reducer handles cart/load/fulfilled 4. user reducer is called too - returns the same state 5. store notifies subscribers 6. selector runs 6. selector runs 7. CartButton renders: 2 items screen: Cart (2) store.getState() -> {"cart":{"items":["Python","Redux"]},"user":{"name":"Ravi"}}

Draw the loop on paper first

This is the example from the course outline, and a habit worth building now. Before writing any Redux code for a feature, fill in a small table: what happens (actions), what the state looks like (shape), how each action changes it, what the UI needs to ask (selectors), and which components dispatch and select.

Here is the plan for a "Save lesson" feature, like the bookmark on this site. Notice that the plan already answers the hard questions - for example, "what if the user saves the same lesson twice?" - before any code exists. Writing the code afterwards is mostly copying the table.

saved.mjs - the code that follows from the plan
import { configureStore, createSlice } from "@reduxjs/toolkit"; // From the paper plan: state shape, two actions, two selectors const savedSlice = createSlice({ name: "saved", initialState: { ids: [] }, // shape: { ids: ["redux-1", ...] } reducers: { lessonSaved(state, action) { // payload: a lesson id if (!state.ids.includes(action.payload)) state.ids.push(action.payload); }, lessonUnsaved(state, action) { state.ids = state.ids.filter((id) => id !== action.payload); }, }, }); export const { lessonSaved, lessonUnsaved } = savedSlice.actions; // selectors: the questions the UI asks export const selectSavedCount = (state) => state.saved.ids.length; export const selectIsSaved = (state, id) => state.saved.ids.includes(id); const store = configureStore({ reducer: { saved: savedSlice.reducer } }); store.dispatch(lessonSaved("redux-1")); store.dispatch(lessonSaved("redux-2")); store.dispatch(lessonSaved("redux-1")); // saving twice changes nothing store.dispatch(lessonUnsaved("redux-2")); const state = store.getState(); console.log("state:", JSON.stringify(state)); console.log("header badge -> selectSavedCount:", selectSavedCount(state)); console.log("save button -> selectIsSaved('redux-1'):", selectIsSaved(state, "redux-1")); console.log("save button -> selectIsSaved('redux-2'):", selectIsSaved(state, "redux-2"));
Output - node saved.mjs
state: {"saved":{"ids":["redux-1"]}} header badge -> selectSavedCount: 1 save button -> selectIsSaved('redux-1'): true save button -> selectIsSaved('redux-2'): false
Paper plan: "Save lesson"
State shapesaved: { ids: ["redux-1", ...] } - only ids, the lessons themselves are server data.
Action: lessonSaved(id)Adds the id - but only if it is not already there.
Action: lessonUnsaved(id)Removes the id.
Selector: selectSavedCountThe header badge asks: how many are saved?
Selector: selectIsSaved(id)Each Save button asks: is this lesson saved?
ComponentsSaveButton dispatches lessonSaved / lessonUnsaved; HeaderBadge and SaveButton select.

Where the files go

The Redux documentation recommends organising code by feature, not by type: all the Redux logic for one feature in one slice file, next to that feature’s components. One store file creates the store from all the slices. You will build exactly this structure in Module 4.

A typical folder layout
src/ app/ store.js configureStore({ reducer: { cart, saved, user } }) features/ cart/ cartSlice.js state, reducers, actions, selectors for the cart CartButton.jsx saved/ savedSlice.js SaveButton.jsx main.jsx <Provider store={store}><App /></Provider>

The map of this course

Every part of the loop has its own module. When a later lesson feels detailed, come back to the first diagram and find where that part sits.

Loop part -> module
Store, actions, reducersModule 2 (Redux Fundamentals), Module 3 (without Toolkit), Module 4 (Redux Toolkit)
UI connection (Provider, useSelector, useDispatch)Module 5 (Redux with React)
Actions and payloadsModule 6
SelectorsModule 7
The async path (thunks)Module 8 (Async Redux)
MiddlewareModule 9
Server dataModule 10 (RTK Query)
The shape of the stateModule 12 (Redux State Architecture)

Common questions

Why call every reducer for every action? Because an action describes something that happened, and more than one part of the state may care. "User logged out" might clear the cart, the saved lessons and the notifications at once. Each slice decides for itself; reducers that do not care return their state unchanged, which is very fast.

Can a component change the store without dispatch? It should never try. Redux Toolkit freezes the state (Lesson 1), so a direct write throws in an ES module - and even if it did not, nothing would be notified.

Is middleware required? configureStore adds a few useful ones by default, including the thunk middleware that makes dispatch(loadCart()) work, and development checks. You add your own - like our logger - only when you need them.

Why is the selector run twice? Once to decide whether the component must re-render, once during the render itself. Selectors should therefore be fast and have no side effects. Module 7 shows how to make expensive ones cheap.

The loop at a glance

1. UI dispatches

Describe what happened.

dispatch(cartSlice.actions.added("Docker"))
2. Middleware

Sees every action first.

(store) => (next) => (action) => next(action)
3. Reducers

All are called; each returns its part of the new state.

(state, action) => newState
4. Store

Saves the new state, notifies subscribers.

store.subscribe(listener)
5. Selectors

Each component reads its piece.

useSelector((state) => state.cart.items.length)
Async

A thunk waits for the server, then dispatches actions.

createAsyncThunk("cart/load", async () => ...)

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Read the trace

“Run Trace.jsx and click twice. Find the line where the state actually changes.”

Handle pending

“Add a builder.addCase(loadCart.pending, ...) that sets a loading flag. What new lines appear in the log after dispatch(loadCart())?”

Block an action

“Change the logger so it does not call next(action) for cart/added. What happens to the screen?”

Plan a feature

“Write a paper plan for "dark mode toggle": state shape, actions, selectors, components. Then check Lesson 8 - does it even belong in Redux?”

What usually goes wrong

Changing state from the UI

The UI only describes what happened. The reducers decide the new state.

✗ onClick={() => { state.cart.items.push("Docker"); }}
✓ onClick={() => dispatch(added("Docker"))}
Calling a server inside a reducer

Reducers must be pure and synchronous. Put async work in a thunk and dispatch the result.

✗ added(state) { fetch("/api/cart"); ... }
✓ createAsyncThunk("cart/load", async () => fetchCart())
Coding before planning

Without a plan, actions get named after buttons and state gets duplicated. Fill in the paper plan first.

Expecting only "its own" reducer to run

Every reducer is called for every action. A reducer must return its state unchanged for actions it does not handle - never undefined.

✗ function userReducer(state, action) { if (action.type === "user/renamed") return {...}; }
✓ function userReducer(state = initial, action) { ...; return state; }

Practice

Write these yourself before opening anything. Getting them wrong first is most of how this sticks.

1.

Put these steps in the right order: selector runs, reducers calculate the new state, user clicks, store notifies subscribers, middleware sees the action, component re-renders, dispatch(action).

Show hint

Follow the arrows in the first diagram.

Show solution
Answer
1. user clicks 2. dispatch(action) 3. middleware sees the action 4. reducers calculate the new state 5. store notifies subscribers 6. selector runs 7. component re-renders
2.

Why was the user reducer called when the action was cart/added? What must it return?

Show hint

Who decides which actions a reducer cares about?

Show solution
Answer
The store calls every reducer for every action; each reducer decides whether the action concerns it. The user reducer does not handle cart/added, so it must return its current state unchanged.
3.

Write a paper plan for a "theme toggle" feature: state shape, actions, selectors and components.

Show hint

Two values, one action, one selector - and ask whether it belongs in Redux at all.

Show solution
One possible plan
State shape: theme: { mode: "day" | "night" } Action: themeToggled() day <-> night Selector: selectThemeMode(state) -> "day" | "night" Components: ThemeButton dispatches themeToggled; App reads the mode But (Lesson 8): the theme changes rarely and needs no tracing - React Context or a simple useState at the top is usually enough.
Coding challenge

Plan it, then build it: notifications

A notification bell shows the number of unread notifications; a dropdown lists them. Users can read one, or mark all as read. Write the paper plan first, then the slice and selectors, and test them in Node with a few dispatches.

It should
  • A paper plan: state shape, three actions, two selectors, components
  • A slice implementing the three actions
  • selectUnreadCount for the bell, selectNotifications for the dropdown
  • A short script that dispatches and prints the unread count after each step
Show one solution
Solution - checked: unread 2 -> 1 -> 0
import { configureStore, createSlice } from "@reduxjs/toolkit"; // Paper plan -> code // state: { list: [{ id, text, read }] } // actions: notificationReceived({ id, text }), notificationRead(id), allRead() // selectors: selectUnreadCount (bell badge), selectNotifications (dropdown) const notificationsSlice = createSlice({ name: "notifications", initialState: { list: [] }, reducers: { notificationReceived(state, action) { state.list.unshift({ ...action.payload, read: false }); }, notificationRead(state, action) { const item = state.list.find((n) => n.id === action.payload); if (item) item.read = true; }, allRead(state) { state.list.forEach((n) => { n.read = true; }); }, }, }); const { notificationReceived, notificationRead, allRead } = notificationsSlice.actions; const selectUnreadCount = (state) => state.notifications.list.filter((n) => !n.read).length; const selectNotifications = (state) => state.notifications.list; const store = configureStore({ reducer: { notifications: notificationsSlice.reducer } }); store.dispatch(notificationReceived({ id: 1, text: "New lesson: Redux 9" })); store.dispatch(notificationReceived({ id: 2, text: "Someone replied to you" })); console.log("unread:", selectUnreadCount(store.getState())); store.dispatch(notificationRead(2)); console.log("unread after reading #2:", selectUnreadCount(store.getState())); store.dispatch(allRead()); console.log("unread after allRead:", selectUnreadCount(store.getState())); console.log("dropdown:", selectNotifications(store.getState()).map((n) => n.text)); // unread: 2 // unread after reading #2: 1 // unread after allRead: 0 // dropdown: [ 'Someone replied to you', 'New lesson: Redux 9' ]

Key points

  • One store holds the shared state; data moves in one direction around a loop.
  • UI -> dispatch(action) -> middleware -> reducers -> store -> notify -> selectors -> UI.
  • The UI never writes state; it describes what happened with an action.
  • Every reducer is called for every action; reducers that do not care return their state unchanged.
  • The state is one object with one key per slice; each slice owns only its key.
  • Async work lives in thunks, which dispatch plain pending and fulfilled actions.
  • Plan a feature on paper - shape, actions, selectors, components - before writing code.

Quick check before you move on

What is the only way to change state in Redux?
Dispatch an action. The reducers then calculate the new state.
What sees every action before the reducers?
Middleware.
When an action is dispatched, which reducers are called?
All of them. Each one returns its part of the new state - unchanged if the action does not concern it.
Where does a server request go in Redux?
In a thunk (for example createAsyncThunk), which dispatches plain actions with the result. Never in a reducer.
What goes in a paper plan for a feature?
The state shape, the actions and how each changes the state, the selectors the UI needs, and which components dispatch and select.

Interview questions

Describe the Redux data flow.

A component dispatches an action describing what happened. It passes through middleware, then the store calls the root reducer, which delegates to each slice reducer to produce the new state. The store saves it and notifies subscribers; react-redux runs each component’s selector and re-renders those whose selected value changed.

Why does Redux use unidirectional data flow?

Because every change follows the same path - dispatch, reducers, new state - so state changes are predictable, traceable and replayable. Views never mutate state, and reducers never touch views.

Where do side effects like API calls belong in Redux?

Outside reducers - in thunks, listener middleware or RTK Query. They perform the side effect and dispatch plain actions describing the results, which reducers then handle purely.

How would you structure a Redux codebase?

By feature: one slice file per feature containing its state, reducers, actions and selectors, next to its components, plus one store file that combines the slices.

Quiz

  1. 1.

    In the click trace, what came right after "middleware sees cart/added"?

  2. 2.

    Why did the selector run twice after one click?

  3. 3.

    After cart/load/pending, why did the component not re-render?

  4. 4.

    What did store.getState() look like after the load?

  5. 5.

    In the "Save lesson" plan, what happens when the same lesson is saved twice?

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...