The previous lesson ended with an open question. useAssigneeFilter worked because the filter lived in App and from there flowed down to the two components that needed it. But as soon as that same filter is also needed by the router, the header, the report and a button buried six levels deep, the problems start: props that cross components that do not use them, copies of the same piece of data in two places that drift apart, and a data flow that no longer fits in your head. This lesson is about that, and it does not start with Redux: it starts with the problem, moves on to the cheaper alternatives —lifting state, Context, an external store— and only reaches Redux when the previous ones fall short, because most applications do not need Redux and saying so is part of teaching it well. You will see Redux's three principles and the action → reducer → new state → render cycle, with the direct parallel to your version-cached Board from 09-02 and your CustomEvents from 06-04; you will learn Redux Toolkit, which is the correct and current way to use it, with configureStore, createSlice, Immer, useSelector/useDispatch and createSelector's memoized selectors; you will solve asynchronous logic with createAsyncThunk and the three states of a request applied to your listTasks from 07-02, and you will get to know RTK Query as the specific tool for server state; you will see the DevTools with their time travel and why traceability is the real advantage; you will normalize 600 tasks with createEntityAdapter; and you will finish with the classic mistakes and an honest table of lightweight alternatives. All of it applied to Nómada Tasks' filter and board.

Contents

  1. The problem before the tool
  2. Prop drilling: the filter that crosses six components
  3. Duplicated state and drift
  4. The alternatives in order of cost
  5. Alternative 1: lifting the state
  6. Alternative 2: React Context
  7. What Context is and what it is not
  8. Alternative 3: an external store
  9. When each one is enough
  10. Redux's three principles
  11. The action → reducer → new state → render cycle
  12. The parallel with your Board and your CustomEvents
  13. Redux Toolkit: why nobody writes Redux by hand
  14. configureStore: the store
  15. createSlice: actions and reducers together
  16. Immer: writing mutations that produce immutable state
  17. useSelector and useDispatch
  18. Memoized selectors with createSelector
  19. Asynchronous logic with createAsyncThunk
  20. The three states of a request
  21. RTK Query: the tool for server state
  22. The DevTools and time travel
  23. Normalizing data with createEntityAdapter
  24. Nómada Tasks: the complete filter and board
  25. Lightweight alternatives: Zustand, Jotai and native stores
  26. Common Mistakes and Tips
  27. Exercises
  28. Conclusion

  1. The problem before the tool

Redux has a bad reputation, and it earned it. For years it was the automatic answer to the question "how do I manage state?", even when the right question was "do I really need to manage global state?". Thousands of applications were written with three folders of boilerplateactions/, reducers/, constants/— to store whether a modal was open.

That era is over. Modern Redux, through Redux Toolkit, is far more concise, and —more importantly— the industry learned to tell when it is needed and when it is not. This lesson respects that lesson learned: the problem first, then the cheap alternatives, and Redux at the end, once it has earned its place.

The problem has three faces, and it helps to look at them through Nómada Tasks' assignee filter, which is a perfectly realistic case.

  1. Prop drilling: the filter that crosses six components

Imagine that Nómada Tasks has grown. The board screen has this component structure:

graph TD
  App --> Header
  App --> Panel
  App --> Footer
  Header --> AssigneeFilter
  Header --> HeaderSummary
  Panel --> TaskList
  Panel --> Sidebar
  TaskList --> TaskCard
  Sidebar --> WorkloadReport
  Footer --> VisibleCount

The assignee filter is changed by AssigneeFilter, and it is needed by:

  • TaskList, to filter.
  • HeaderSummary, to say "3 of 6 tasks · 25 h".
  • WorkloadReport, to highlight the filtered person.
  • VisibleCount, in the footer.
  • The router, to reflect the filter in the URL (?assignee=Ivan) as you did with the History API in 07-06.

If the state lives in App —which is the common ancestor—, it has to be passed downward:

// ❌ The filter crosses components that do not care about it
function App() {
  const [assignee, setAssignee] = useState(null);

  return (
    <>
      <Header assignee={assignee} onChange={setAssignee} />
      <Panel assignee={assignee} />
      <Footer assignee={assignee} />
    </>
  );
}

function Header({ assignee, onChange }) {
  // Header does not use `assignee` for anything of its own: it only transports it
  return (
    <header>
      <AssigneeFilter value={assignee} onChange={onChange} />
      <HeaderSummary assignee={assignee} />
    </header>
  );
}

function Panel({ assignee }) {
  // Panel does not use it either: it transports it
  return (
    <div className="panel">
      <TaskList assignee={assignee} />
      <Sidebar assignee={assignee} />
    </div>
  );
}

That is called prop drilling: boring through the component tree with a prop that only matters in the leaves. Its costs are concrete:

Cost What it means
Noise Header and Panel have a prop in their signature that they do not use. Their contract lies about what they do
Cascading changes Adding a second filter (by priority) forces you to touch all six intermediate components
Broken reuse Panel can no longer be used on another screen without inventing an assignee for it
Extra renders Changing the filter re-renders the whole of Header and Panel, even though only what is inside changes
Heavier tests Testing TaskList requires knowing where the prop reaches it from

An honest nuance that is almost never mentioned: with two or three levels, prop drilling is not a problem. It is explicit, it reads well, and it needs no tooling. The problem shows up with real depth and with several shared pieces of data at once. Do not rush to install anything over passing a prop two levels down.

  1. Duplicated state and drift

The second symptom is more serious, and it is 10-01's problem 1 reappearing. When passing the prop becomes awkward, the temptation is for each component to keep its own copy:

// ❌ Two copies of the same data
function HeaderSummary() {
  const [assignee, setAssignee] = useState(null);   // copy 1
  // …
}

function TaskList() {
  const [assignee, setAssignee] = useState(null);   // copy 2
  // …
}

Now there are two sources of truth for a single fact. As soon as one changes without the other —and it will happen—, the header says "3 of 6 tasks" while the list shows all six. The user sees a screen that contradicts itself.

This symptom has a subtler and very frequent variant: storing derived values. If in addition to assignee you store visible and visibleHours as state, you have three things to keep in sync instead of one that is computed. It is exactly the mistake from section 16 of 10-02, raised to global scale.

The rule that solves both faces: for every fact in the application there must be a single place where it lives, and everything else is computed from it. The remaining question is where that place is, and that is where you have to choose a tool.

  1. The alternatives in order of cost

This is the most important table of the lesson. Read it top to bottom and stop at the first row that solves your problem.

# Alternative Cost Solves It falls short when…
0 Nothing: keep it local Zero 70% of cases Two sibling components need the same data
1 Lifting the state to the common ancestor Zero Almost everything else The ancestor is five levels up and the props cross unrelated components
2 React Context Low Transport: it removes prop drilling There are many writes and everything consuming the context repaints
3 Lightweight external store (Zustand, Jotai) Medium Transport + selective subscription You need traceability, middleware, or team discipline
4 Redux Toolkit High All of the above + traceability, DevTools, conventions It almost never falls short; the problem is that it is overkill
Data library (TanStack Query, RTK Query) Medium Server state, which is another problem It does not replace interface state

The last row is the trap half the industry falls into. Before deciding which store to use, decide what state you have. If you take an inventory of a real application, the split usually looks like this:

Kind of state Typical share Where it should live
Server (lists, details, catalogs) ~60% A data cache (RTK Query, TanStack Query)
A component's local state (open/closed, text being typed) ~25% useState
URL state (filters, page, sorting) ~10% The URL, with the router
Genuinely global (user, theme, permissions) ~5% A shared store

That 5% is Redux's territory. If you have done the split properly, a large application's global store is surprisingly small. When somebody says "Redux is too much boilerplate for this", what is almost always happening is that they are putting the 60% that does not belong there into Redux.

An important detail for Nómada Tasks: the assignee filter belongs to the URL-state row. That Marta can send the link ?assignee=Ivan over chat and her colleague sees the same thing is a feature, not a whim. The cheapest solution for the filter is neither Redux nor Context: it is the URL, which you already know how to handle from 07-06. We will use it as an example all the same because it illustrates the mechanism well, but it is worth keeping in mind.

  1. Alternative 1: lifting the state

It is what you already did in 10-02 and in your plain-JavaScript app.js with the state object. The data moves up to the nearest common ancestor of everyone who needs it.

In favor: zero cost, explicit flow, anyone reading the code sees where each piece of data comes from, no new dependencies.

Against: when the common ancestor is the root and there are five levels in between, section 2's prop drilling appears.

Before dismissing it, there is a technique that takes it much further than people think: compose with children instead of drilling.

// Instead of passing `assignee` to Panel so it can pass it to TaskList…
function App() {
  const [assignee, setAssignee] = useState(null);

  return (
    <>
      <Header>
        <AssigneeFilter value={assignee} onChange={setAssignee} />
        <HeaderSummary assignee={assignee} />
      </Header>

      <Panel
        list={<TaskList assignee={assignee} />}
        aside={<WorkloadReport assignee={assignee} />}
      />
    </>
  );
}

function Panel({ list, aside }) {
  // Panel no longer knows anything about `assignee`: it only places what it is given
  return <div className="panel">{list}<aside>{aside}</aside></div>;
}

The element is created in App, where the state lives, and placed in Panel, which only provides the structure. Panel gets its honest signature and its reusability back. This technique eliminates an enormous amount of prop drilling with no library at all, and that is why it comes before Context in the cost list.

  1. Alternative 2: React Context

Context lets a component make a value available to its whole subtree, without passing it through the intermediate props.

// src/context/FilterContext.jsx
import { createContext, useContext, useState, useMemo } from 'react';

const FilterContext = createContext(null);

export function FilterProvider({ children }) {
  // The value is memoized: otherwise it would be a new object on every render
  // and ALL the consumers would always repaint.
  const [assignee, setAssignee] = useState(null);

  const value = useMemo(() => ({ assignee, setAssignee }), [assignee]);

  return <FilterContext.Provider value={value}>{children}</FilterContext.Provider>;
}

/** Access hook, with a check for correct usage. */
export function useFilter() {
  const context = useContext(FilterContext);
  if (context === null) {
    throw new Error('useFilter must be used inside <FilterProvider>');
  }
  return context;
}

And the consumption, from any depth:

function HeaderSummary({ tasks }) {
  const { assignee } = useFilter();     // no intermediate props
  const visible = assignee === null ? tasks : tasks.filter((t) => t.assignee === assignee);
  return <p>{visible.length} of {tasks.length} tasks</p>;
}

The intermediate components (Header, Panel) get their clean signature back. The prop drilling disappears.

  1. What Context is and what it is not

Here is the most widespread misunderstanding in all of React, and it deserves to be explicit:

Context is a transport mechanism, not a state manager.

Context solves "how do I get this value down there". It does not solve "how do I organize the changes", "how do I avoid unnecessary renders" or "how do I know who changed what". The state still lives in an ordinary useState; Context only avoids the prop staircases.

And it has a technical limitation with real consequences: when the context's value changes, every component that consumes it re-renders, regardless of which part of the value they use.

// A context with several things inside
const value = useMemo(() => ({ user, theme, filter, setFilter }), [user, theme, filter]);

If filter changes, the component that only reads theme repaints too. With a small context and infrequent changes (the signed-in user, the language, the light/dark theme) it is completely irrelevant. With a context that changes on every keystroke and forty consumers, it is a measurable performance problem.

The two usual mitigations:

  1. Split into several contexts by rate of change: one for what almost never changes (user, theme) and another for what changes a lot (filters). It is the same idea as 09-05's manualChunks by rate of change.
  2. Separate value and actions into two contexts: components that only dispatch actions do not repaint when the value changes, because the functions are stable.
Context is good for… Context is NOT good for…
Stable values: user, theme, language, configuration State that changes many times per second
Injecting dependencies (an API client, a service) Selective subscription to a slice of the state
Avoiding prop drilling Traceability of who changed what and when
Small and medium applications Complex logic with derived effects

The practical conclusion: Context + useState covers the vast majority of applications perfectly. If that combination is enough for you, you need nothing more and this is the end of the lesson for you. The following sections explain what happens when it is not enough.

  1. Alternative 3: an external store

A store is an object that lives outside the component tree, holds the state, lets you subscribe to it and notifies subscribers when it changes.

The decisive difference from Context is selective subscription: each component declares which slice of the state it cares about, and only re-renders if that specific slice changes. It is, conceptually, the difference between broadcasting to everyone and notifying the interested parties — and if that sounds familiar, it is because it is exactly the difference between the virtual DOM and 10-01's signals, applied to state instead of to the screen.

And something that is appreciated later: since the store lives outside React, it can be used from outside React. Your BoardChannel from 07-04 can dispatch actions when it receives a WebSocket message without being inside any component. A service worker can query it. A test can set it up without rendering anything.

Redux is a store of that kind, with three very specific decisions on top. Let us get to them.

  1. When each one is enough

Before getting into Redux, the decision table that avoids 90% of the mistakes:

Situation Sufficient tool
An open dropdown on a card Local useState
The filter shared by two siblings Lifting the state
The filter shared by five components across two branches Composition with children, or Context
A filter that has to be shareable by link The URL and the router (07-06)
Light/dark theme, language, signed-in user Context
The task list from the server A data cache (RTK Query, TanStack Query)
A shopping cart with rules, discounts and history External store
A collaborative editor with undo/redo Redux (time travel is literally this)
Large application, team of six, three-year lifespan Redux Toolkit, for the discipline and the traceability
Debugging "the state gets corrupted and I do not know who touches it" Redux, without a doubt: it is its real advantage

  1. Redux's three principles

Redux is defined by three rules. They are not arbitrary: each one buys a specific property.

Principle 1 · A single source of truth. All of the application's state lives in one object, inside one store.

  • What it buys: there are no copies to drift apart, the complete state can be dumped to a file, saved in localStorage (07-01), sent in a bug report or restored as it is.

Principle 2 · The state is read-only. The only way to change it is to dispatch an action: a plain object that describes what has happened.

{ type: 'filter/assigneeChanged', payload: 'Iván' }
{ type: 'tasks/statusChanged', payload: { id: 6, target: 'in-progress' } }
  • What it buys: every change goes through a single point. They can be logged, recorded, replayed and rolled back. Nobody can change the state behind your back.

Notice the vocabulary: the action describes what has happened (assigneeChanged), not what to do (changeAssignee). It is a convention with consequences: a single action can affect several parts of the state, and the action log reads like a chronicle of what the user did.

Principle 3 · Changes are made with pure functions. A reducer is a function (currentState, action) => newState. Pure: no effects, no requests, no Math.random(), no Date.now(), no mutating the input.

function filterReducer(state = { assignee: null }, action) {
  switch (action.type) {
    case 'filter/assigneeChanged':
      return { ...state, assignee: action.payload };   // NEW object (04-07)
    default:
      return state;
  }
}
  • What it buys: the same sequence of actions always produces the same state. That is what makes time travel, bug replay and trivial testing possible.

And yes, the word "reducer" is the one from 04-05: the signature (accumulated, item) => newAccumulated is identical to reduce's. Your application's state is literally the result of reducing the action history over an initial state.

  1. The action → reducer → new state → render cycle

graph TD
  U["The user changes the filter"] --> D["dispatch of the action<br/>type: filter/assigneeChanged<br/>payload: Iván"]
  D --> M["Middleware<br/>(logging, async, DevTools)"]
  M --> R["Reducers<br/>(currentState, action) =&gt; newState"]
  R --> S["Store: new state"]
  S --> N["Notification to the subscribers"]
  N --> C["The components whose slice changed<br/>re-render"]
  C --> U

Five properties of this cycle worth fixing:

  1. It is unidirectional. Data always travels in the same direction. There are no shortcuts: a component cannot write to the state directly.
  2. Its core is synchronous. dispatch → reducers → new state happens in one go. Asynchrony is handled outside, in the middleware (section 19).
  3. It is serializable. Actions and state are plain data, so they can be saved, sent and replayed.
  4. It is auditable. Every change has a name and some data. None of them is anonymous.
  5. It is interceptable. Middleware can log, delay, cancel or transform actions without touching reducers or components.

  1. The parallel with your Board and your CustomEvents

Now the part that stops all this from sounding abstract. You have already built the three pieces of this cycle, separately and under other names.

Redux piece Your equivalent in Nómada Tasks Lesson
Store with a single state The state = { board, filters, sort } object in app.js 06-06
Action (what happened) The CustomEvent with EVENTS.TASK_ADVANCED and its detail 06-04
dispatch emit(EVENTS.TASK_ADVANCED, { id }) 06-04
Subscription addEventListener on the container 06-04
Reducer The handler that applies the change and calls render() 06-06
Invalidation of derived values #version += 1 on every Board mutation 09-02
Memoized selector The { version, today, value } cache in summary() 09-02

The equivalence of the last two rows is especially exact. Your Board does this:

// js/model/board.js — 09-02
summary(today = TODAY) {
  if (this.#summaryCache?.version === this.#version && this.#summaryCache.today === today) {
    return this.#summaryCache.value;         // cache hit: zero work
  }
  const value = this.#computeSummary(today);
  this.#summaryCache = { version: this.#version, today, value };
  return value;
}

And Redux's createSelector does exactly the same, with one difference: where you compare a version counter, it compares the references of the inputs. Since the state is immutable, if the reference has not changed, neither has the content. Immutability makes the version counter unnecessary: the reference is the counter.

And there is a real difference in Redux's favor worth acknowledging. Your CustomEvents are a broadcast mechanism: anyone can listen, there is no central registry of who listens to what, and to follow the flow you have to search for the string 'task:advanced' across the whole project. Redux enforces that every change goes through a point that can be observed. That is the real advantage, and it is not about performance: it is about traceability.

  1. Redux Toolkit: why nobody writes Redux by hand

Classic Redux required writing, for every change, a constant, an action creator, a switch case and an immutable update by hand:

// Classic Redux: four places for a single change
export const ASSIGNEE_CHANGED = 'filter/assigneeChanged';

export const assigneeChanged = (name) => ({ type: ASSIGNEE_CHANGED, payload: name });

export function filterReducer(state = INITIAL, action) {
  switch (action.type) {
    case ASSIGNEE_CHANGED:
      return { ...state, assignee: action.payload };
    default:
      return state;
  }
}

Multiply that by forty actions and by nested updates of the kind {...state, tasks: {...state.tasks, [id]: {...state.tasks[id], status: 'done'}}} and you will understand the bad reputation.

Redux Toolkit (RTK) is the official answer, and today it is the correct way to use Redux. It is not an alternative or an optional layer: the official documentation advises against writing Redux without it. It brings four things:

Piece What it solves
configureStore Sets up the store with DevTools, middleware and development checks already configured
createSlice Generates actions and reducer at once, from a single object
Immer (included) Lets you write mutations that produce immutable state
createAsyncThunk / RTK Query Asynchrony and server state
npm install @reduxjs/toolkit react-redux

Two packages: @reduxjs/toolkit (the store, independent of the view) and react-redux (the hooks that connect it with React).

  1. configureStore: the store

// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import filterReducer from './filterSlice.js';
import tasksReducer from './tasksSlice.js';

export const store = configureStore({
  reducer: {
    filter: filterReducer,     // the state will live in state.filter
    tasks: tasksReducer        // and in state.tasks
  }
});

And the connection with React, once only, at the root:

// src/main.jsx
import { Provider } from 'react-redux';
import { store } from './store/index.js';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </StrictMode>
);

configureStore comes with things enabled by default that are worth knowing about, because they are among the most helpful day to day:

  • The DevTools are connected with no configuration at all.
  • A mutation checker warns in development if any reducer mutates the state outside Immer.
  • A serializability checker warns if you put something into the state that is not plain data: a Date, a Map, a promise, a class instance. This is annoying at first and appreciated later, because it is what protects principle 1.

That last point has a direct consequence for Nómada Tasks: your Task instances with their private #status cannot go into the store. In Redux, the state is plain data and the rules live in functions. It is the same trade you already accepted in 10-02 when choosing object literals for React's state, now turned into a checked norm.

  1. createSlice: actions and reducers together

A slice is a portion of the state with its actions and its reducer, defined in one go:

// src/store/filterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const filterSlice = createSlice({
  name: 'filter',
  initialState: {
    assignee: null,      // R8: null, never ''
    text: '',
    sort: 'priority'
  },
  reducers: {
    assigneeChanged(state, action) {
      state.assignee = action.payload;    // ← it looks like a mutation; it is not (section 16)
    },
    textChanged(state, action) {
      state.text = action.payload;
    },
    sortChanged(state, action) {
      state.sort = action.payload;
    },
    filtersCleared(state) {
      state.assignee = null;
      state.text = '';
    }
  }
});

export const { assigneeChanged, textChanged, sortChanged, filtersCleared } =
  filterSlice.actions;

export default filterSlice.reducer;

What createSlice generates automatically:

  • The action creators: assigneeChanged('Iván') returns { type: 'filter/assigneeChanged', payload: 'Iván' }. The type is composed from name + the reducer's name, so there are no constants to maintain and no risk of collision.
  • The combined reducer, which dispatches internally by type.

Compare with section 13's classic Redux: four places have become one.

  1. Immer: writing mutations that produce immutable state

state.assignee = action.payload looks like it violates principle 3. It does not, and understanding why avoids a lot of mistrust.

RTK includes Immer, a library that wraps the state in a Proxy (the same mechanism as Vue's reactivity in 10-04). That proxy intercepts the writes and, instead of applying them to the real object, notes down what you wanted to change. When the reducer finishes, Immer builds a new object applying those changes, reusing everything that was not touched.

graph LR
  A["Current state<br/>(frozen)"] --> B["Draft<br/>(Proxy that records)"]
  B --> C["Your reducer writes<br/>state.assignee = 'Iván'"]
  C --> D["Immer applies the changes"]
  D --> E["New state<br/>(references shared<br/>with what was not modified)"]

The value of this really shows with nested state. Without Immer:

// ❌ Immutable update of a task inside an array, by hand
return {
  ...state,
  tasks: state.tasks.map((t) =>
    t.id === action.payload.id ? { ...t, status: action.payload.target } : t
  )
};

With Immer:

// ✅ The same thing, readable
const task = state.tasks.find((t) => t.id === action.payload.id);
if (task) task.status = action.payload.target;

And a valuable property that gets overlooked: Immer produces structurally shared updates. The tasks that do not change keep their exact reference. That is precisely what 10-02's React.memo needs in order not to repaint the 599 cards that have not changed.

Three Immer rules to respect:

  1. Either you mutate the draft, or you return a new value. Never both.
// ❌ Both at once: undefined behavior
reducers: {
  wrong(state, action) {
    state.assignee = action.payload;
    return { ...state, text: '' };
  }
}
  1. To replace the whole state, return it, do not reassign the parameter:
reducers: {
  reset() {
    return { assignee: null, text: '', sort: 'priority' };   // ✅
  }
}
  1. Immer only works inside RTK's reducers. Outside —in a component, in a selector— 04-07's immutability rules still apply exactly as they were.

  1. useSelector and useDispatch

The two hooks that connect components with the store:

// src/components/AssigneeFilter.jsx
import { useSelector, useDispatch } from 'react-redux';
import { assigneeChanged } from '../store/filterSlice.js';

export function AssigneeFilter({ assignees }) {
  const assignee = useSelector((state) => state.filter.assignee);
  const dispatch = useDispatch();

  return (
    <label htmlFor="assignee-filter">
      Assignee:
      <select
        id="assignee-filter"
        value={assignee ?? ''}
        onChange={(e) => dispatch(assigneeChanged(e.target.value || null))}
      >
        <option value="">All</option>
        {assignees.map((n) => <option key={n} value={n}>{n}</option>)}
      </select>
    </label>
  );
}

Notice what has disappeared: this component receives no prop related to the filter. Neither value nor onChange. Section 2's six levels of prop drilling have evaporated, because the component talks to the store directly from wherever it is.

useSelector(fn) runs fn(state) and returns the result, subscribing to it. And here is the key difference from Context: the component only re-renders if the selector's result changes, not if anything in the store changes. Changing state.tasks does not repaint this <select>.

The comparison is done by default with Object.is, the same reference comparison from 10-02. Out of that comes the most frequent mistake with Redux:

// ❌ Returns a NEW ARRAY on every call: it repaints always
const visible = useSelector((s) => s.tasks.list.filter((t) => t.status !== 'done'));

// ❌ A new object on every call: the same thing
const { assignee, text } = useSelector((s) => ({ ...s.filter }));

Three solutions, in order of preference:

// ✅ 1 · One useSelector per primitive value
const assignee = useSelector((s) => s.filter.assignee);
const text = useSelector((s) => s.filter.text);

// ✅ 2 · Return a stable reference from the state
const filter = useSelector((s) => s.filter);       // the same object if it has not changed

// ✅ 3 · A memoized selector (section 18)
const visible = useSelector(selectVisible);

useDispatch() returns the dispatch function. It is stable between renders, so it can be used in effect dependencies with no problem — unlike the functions you create yourself.

  1. Memoized selectors with createSelector

A selector is a function that extracts or computes something from the state. Writing them separately has two advantages: they are reusable, and they decouple components from the shape of the state. If tomorrow state.filter.assignee moves to state.ui.filters.assignee, only the selector changes.

// src/store/selectors.js
import { createSelector } from '@reduxjs/toolkit';
import { WEIGHTS } from '../domain/rules.js';

// Input selectors: cheap, no computation
export const selectTasks    = (state) => state.tasks.list;
export const selectAssignee = (state) => state.filter.assignee;
export const selectText     = (state) => state.filter.text;
export const selectSort     = (state) => state.filter.sort;

// Derived and memoized selector
export const selectVisible = createSelector(
  [selectTasks, selectAssignee, selectText, selectSort],
  (tasks, assignee, text, sort) => {
    const t = text.trim().toLowerCase();
    return tasks
      .filter((x) => assignee === null || x.assignee === assignee)
      .filter((x) => t === '' || x.title.toLowerCase().includes(t))
      .toSorted(COMPARATORS[sort] ?? COMPARATORS.priority);
  }
);

export const selectVisibleSummary = createSelector(
  [selectVisible],
  (visible) => ({
    total: visible.length,
    openHours: visible.filter((t) => t.status !== 'done')
                      .reduce((s, t) => s + t.estimatedHours, 0),
    effort: visible.reduce((s, t) => s + t.estimatedHours * WEIGHTS[t.priority], 0)
  })
);

const COMPARATORS = {
  priority: (a, b) => WEIGHTS[b.priority] - WEIGHTS[a.priority] || a.id - b.id,
  date:     (a, b) => a.dueDate.localeCompare(b.dueDate),
  hours:    (a, b) => b.estimatedHours - a.estimatedHours
};

How createSelector works, which is where the parallel with 09-02 closes:

  1. It runs the input selectors, which are cheap.
  2. It compares their results with those of the previous call using Object.is.
  3. If all of them are identical, it returns the cached result without running the computation.
  4. If any of them changed, it recomputes and stores.

It is your version cache, with the immutable state's reference acting as the version number. And it has an effect that is not about performance but about correctness: since it returns the same reference as long as the inputs do not change, it solves the previous section's useSelector problem. Without memoization, filter creates a new array every time and the component always repaints.

Two warnings:

  • By default the cache has size one. If two components call the same selector with different states —typical with selectors parameterized by id—, they cancel each other out. RTK offers ways to create one instance per component or to enlarge the cache.
  • Memoizing trivial selectors contributes nothing. (s) => s.filter.assignee does not need createSelector: it computes nothing and already returns a stable reference. It is the same warning as in 09-01 and 10-02: memoizing has a cost.

  1. Asynchronous logic with createAsyncThunk

Reducers are pure: they cannot request data. Asynchrony lives in the middleware, and RTK ships the thunk already configured, which lets you dispatch a function instead of an object.

createAsyncThunk builds that function and automatically dispatches three actions per request:

// src/store/tasksSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { listTasks } from '../data/tasks-api.js';   // your module from 07-02

export const loadTasks = createAsyncThunk(
  'tasks/load',
  async ({ assignee } = {}, { signal, rejectWithValue }) => {
    try {
      // `signal` is provided by RTK: it is 07-03's AbortController, already integrated
      return await listTasks({ assignee, signal });
    } catch (error) {
      // ApiError from 07-03: we extract what is serializable (principle 1)
      return rejectWithValue({ message: error.message, status: error.status ?? null });
    }
  }
);

Three important details:

  • signal comes free. RTK creates an AbortController for each execution of the thunk and cancels it if promise.abort() is called or if the request is discarded. Your fetchJson from 07-03 accepts it as it is: zero adapters.
  • rejectWithValue exists because an Error is not serializable and would violate principle 1. You store a plain object with whatever you want to display.
  • Deduplication can be configured with the condition option, so as not to fire the same request if one is already in flight.

  1. The three states of a request

Each thunk dispatches tasks/load/pending, tasks/load/fulfilled and tasks/load/rejected. The slice picks them up in extraReducers:

const tasksSlice = createSlice({
  name: 'tasks',
  initialState: {
    list: [],
    phase: 'idle',        // 'idle' | 'loading' | 'ready' | 'error'
    error: null
  },
  reducers: {
    statusChanged(state, action) {
      const { id, target } = action.payload;
      const task = state.list.find((t) => t.id === id);
      if (!task) return;
      if (NEXT[task.status] !== target) return;    // R6: transition not allowed
      task.status = target;                        // Immer
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(loadTasks.pending, (state) => {
        state.phase = 'loading';
        state.error = null;
      })
      .addCase(loadTasks.fulfilled, (state, action) => {
        state.phase = 'ready';
        state.list = action.payload;
      })
      .addCase(loadTasks.rejected, (state, action) => {
        state.phase = 'error';
        state.error = action.payload ?? { message: action.error.message };
      });
  }
});

And the consumption:

function Board() {
  const dispatch = useDispatch();
  const phase = useSelector((s) => s.tasks.phase);

  useEffect(() => {
    const promise = dispatch(loadTasks({}));
    return () => promise.abort();      // cancellation on unmount (10-02, section 24)
  }, [dispatch]);

  if (phase === 'loading') return <p role="status">Loading tasks…</p>;
  if (phase === 'error')   return <p role="alert">The tasks could not be loaded.</p>;
  return <TaskList />;
}

Four points:

  • 10-02's race condition still exists. If two loads are dispatched, the last one to arrive wins. The fix is to abort the previous one, as here, or to use condition.
  • A single phase field instead of three booleans: 10-02's impossible-state rule.
  • statusChanged implements R6 in the reducer, with the same NEXT table from your plain-JavaScript domain. The business rules have not moved into Redux: they are consulted from it.
  • The reducer is pure and therefore trivial to test with Jest, without mounting React or the store: reducer(previousState, action) and you check the output. It is Redux's best property for 08-03's tests.

  1. RTK Query: the tool for server state

The previous section works, but repeat it for fifteen resources and 10-02's questions will appear: who caches?, who deduplicates?, who invalidates when a task is created?, who refreshes when you come back to the tab?

RTK Query is the answer RTK ships with, with the same philosophy as TanStack Query:

// src/store/tasksApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const tasksApi = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
  tagTypes: ['Task'],
  endpoints: (builder) => ({
    listTasks: builder.query({
      query: (assignee) => (assignee ? `tasks?assignee=${assignee}` : 'tasks'),
      providesTags: ['Task']             // this query depends on the 'Task' tag
    }),
    changeStatus: builder.mutation({
      query: ({ id, target }) => ({
        url: `tasks/${id}`,
        method: 'PATCH',
        body: { status: target }
      }),
      invalidatesTags: ['Task']          // on mutating, it is invalidated and the list reloads by itself
    })
  })
});

export const { useListTasksQuery, useChangeStatusMutation } = tasksApi;
function RemoteList({ assignee }) {
  const { data: tasks = [], isFetching, error } = useListTasksQuery(assignee);
  const [changeStatus] = useChangeStatusMutation();

  if (error) return <p role="alert">Error while loading</p>;

  return (
    <ul aria-busy={isFetching}>
      {tasks.map((t) => (
        <li key={t.id}>
          {t.title}
          <button onClick={() => changeStatus({ id: t.id, target: 'done' })}>Completed</button>
        </li>
      ))}
    </ul>
  );
}

What has disappeared from this code: the useEffect, the AbortController, the loading state, the error state, the manual dispatch and —most importantly— the reload after the mutation. The tag system does it by itself.

Server-state problem Who solves it here
Two components request the same thing Deduplication by query key
Going back shows a loading screen Cached data while it revalidates
Stale data after an hour Refresh on focus and on reconnect
After creating, the list is not updated invalidatesTags
Unstable network Configurable retries (your withRetries from 07-03)
Optimistic change and rollback onQueryStarted with patchResult.undo()

10-01's conclusion is confirmed: if 60% of your state is server state and RTK Query manages it, what is left for the manual slices is very little. And that is exactly what should be left.

  1. The DevTools and time travel

With the Redux DevTools extension installed and configureStore, you get this with no configuration at all:

Feature What it is really for
Action list The chronicle of what the user did, in order and by name
Diff per action What exactly changed in the state, field by field
Time travel Going back to any previous action and seeing the screen at that moment
Skip action Canceling an action from the history and recomputing everything else
Export / import state Reproducing somebody else's bug on your machine
Trace Which line of code each action was dispatched from

Time travel works because of principle 3: since reducers are pure, the state at moment n can be recomputed by replaying the first n actions over the initial state. No copies of the state are stored: the history is stored and the state is recomputed. It is the same reasoning as in 03-07 about deterministic functions, applied to a whole application.

And here is Redux's real advantage, which is neither performance nor prop drilling:

When somebody says "my filter gets cleared when I come back from the detail view and I do not know why", with Redux you open the DevTools, reproduce the journey and see the action that did it, with its name and its origin. Without Redux, you search across the project and guess.

Compare with your situation in Nómada Tasks: to debug why the board changes unexpectedly, you have console.log, breakpoints and searching for emit( across the whole codebase. It works —you did it in 08-01— but it is reconstructing by hand what here is recorded. In an application of twenty screens and six people, that difference is measured in days.

The price is just as clear: for this to work, every change has to be a named action. That discipline is the cost, and it is only paid gladly when the application is big enough to need the traceability.

  1. Normalizing data with createEntityAdapter

With the 600 tasks from 09-01's load test, storing a flat array has the problems you already measured in 09-02: looking up by id is O(n), and updating a task with map creates a new 600-position array.

Normalizing means storing the data as a dictionary by id plus a list of ids:

// Instead of this…
{ list: [ {id:1, …}, {id:2, …}, … ] }

// …this
{
  ids: [1, 2, 3, 4, 5, 6],
  entities: {
    1: { id: 1, title: 'Redesign the multipurpose room', … },
    2: { id: 2, title: 'Signage for the screen-printing workshop', … }
  }
}

It is your Map index from 09-02, expressed with plain objects because the state must be serializable. createEntityAdapter manages it for you:

import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import { WEIGHTS } from '../domain/rules.js';

const adapter = createEntityAdapter({
  sortComparer: (a, b) => WEIGHTS[b.priority] - WEIGHTS[a.priority] || a.id - b.id
});

const tasksSlice = createSlice({
  name: 'tasks',
  initialState: adapter.getInitialState({ phase: 'idle', error: null }),
  reducers: {
    taskAdded: adapter.addOne,
    tasksReceived: adapter.setAll,
    taskRemoved: adapter.removeOne,
    statusChanged(state, action) {
      const { id, target } = action.payload;
      const task = state.entities[id];               // O(1), like your Map
      if (task && NEXT[task.status] === target) task.status = target;
    }
  }
});

export const { selectAll, selectById } =
  adapter.getSelectors((state) => state.tasks);

Concrete advantages, with 09-02's numbers:

Operation Flat array (600 tasks) Normalized
Look up by id O(n) — 0.004 ms O(1) — 0.00008 ms
Update one task Walks all 600 with map Touches one entry
Batch of 600 updates after reconnecting (07-04) 34.2 ms 0.8 ms
Duplicates impossible Not guaranteed By construction

And a real disadvantage: to paint the list the array has to be rebuilt, which is what selectAll does. That is why it comes memoized. With six tasks, normalizing is complicating things for the fun of it; with six hundred and frequent updates, it is the difference between smooth and sticky — the same boundary 09-02 drew for the Map index.

  1. Nómada Tasks: the complete filter and board

Let us put the pieces together in the module's target screen. The domain stays the same, in plain JavaScript:

// src/domain/rules.js — unchanged from 10-02
export const NEXT = Object.freeze({ pending: 'in-progress', 'in-progress': 'done', done: null });
export const LABEL = Object.freeze({ pending: 'Start', 'in-progress': 'Mark done', done: 'Completed' });
export const WEIGHTS = Object.freeze({ high: 3, medium: 2, low: 1 });
export const TODAY = '2026-09-20';
export const isOverdue = (t, today = TODAY) => t.status !== 'done' && t.dueDate < today;

The tasks slice:

// src/store/tasksSlice.js
import { createSlice } from '@reduxjs/toolkit';
import { NEXT } from '../domain/rules.js';
import { BACKLOG } from '../data/backlog.js';

const tasksSlice = createSlice({
  name: 'tasks',
  initialState: { list: BACKLOG, phase: 'ready', error: null },
  reducers: {
    /** Advances a task's status, respecting R6. */
    statusAdvanced(state, action) {
      const task = state.list.find((t) => t.id === action.payload.id);
      if (!task) return;
      const target = NEXT[task.status];
      if (target === null) return;      // from 'done' there is no advancing
      task.status = target;             // Immer: apparent mutation, real new state
    }
  }
});

export const { statusAdvanced } = tasksSlice.actions;
export default tasksSlice.reducer;

The selectors, with the assignees included:

// src/store/selectors.js
import { createSelector } from '@reduxjs/toolkit';

export const selectTasks    = (s) => s.tasks.list;
export const selectAssignee = (s) => s.filter.assignee;

export const selectAssignees = createSelector(
  [selectTasks],
  (tasks) => [...new Set(tasks.map((t) => t.assignee).filter(Boolean))].sort()
);

export const selectVisible = createSelector(
  [selectTasks, selectAssignee],
  (tasks, assignee) =>
    assignee === null ? tasks : tasks.filter((t) => t.assignee === assignee)
);

export const selectSummary = createSelector(
  [selectVisible, selectTasks],
  (visible, all) => ({
    visible: visible.length,
    total: all.length,
    openHours: visible.filter((t) => t.status !== 'done')
                      .reduce((s, t) => s + t.estimatedHours, 0)
  })
);

The components, without a single filter prop:

// src/components/TaskList.jsx
import { useSelector, useDispatch } from 'react-redux';
import { selectVisible } from '../store/selectors.js';
import { statusAdvanced } from '../store/tasksSlice.js';
import { TaskCard } from './TaskCard.jsx';

export function TaskList() {
  const visible = useSelector(selectVisible);   // memoized: stable reference
  const dispatch = useDispatch();

  if (visible.length === 0) {
    return <p className="empty-list">No task matches the filter.</p>;
  }

  return (
    <ul className="task-list">
      {visible.map((task) => (
        <TaskCard
          key={task.id}
          task={task}
          onAdvance={() => dispatch(statusAdvanced({ id: task.id }))}
        />
      ))}
    </ul>
  );
}
// src/components/HeaderSummary.jsx
import { useSelector } from 'react-redux';
import { selectSummary } from '../store/selectors.js';

export function HeaderSummary() {
  const { visible, total, openHours } = useSelector(selectSummary);
  return <p className="board__summary">{visible} of {total} tasks · {openHours} h open</p>;
}
// src/App.jsx — look at what is NOT here: not one filter prop
import { AssigneeFilter } from './components/AssigneeFilter.jsx';
import { HeaderSummary } from './components/HeaderSummary.jsx';
import { TaskList } from './components/TaskList.jsx';

export default function App() {
  return (
    <main className="board">
      <header className="board__header">
        <h1>Nómada Tasks</h1>
        <AssigneeFilter />
        <HeaderSummary />
      </header>
      <TaskList />
    </main>
  );
}

Check the canonical numbers: with no filter, "6 of 6 tasks · 45 h open". With "Iván", "3 of 6 · 25 h". With "Lucía", "1 of 6 · 14 h". With "Marta", "2 of 6 · 6 h". On pressing "Start" on Carpentry workshop quote, tasks/statusAdvanced is dispatched with {id: 6}, and in the DevTools you see the action, that task's exact diff and you can go back.

And now this section's honest balance sheet, which is what 10-01 asked for:

Aspect Before (10-02, lifted state) With Redux
Filter props crossing the tree 6 components 0
Files to touch to add a priority filter 6 2 (slice + selector)
Store files 0 4 (index, 2 slices, selectors)
Dependencies 2 4
Added weight (compressed, approx.) ~13 kB
Traceability of changes console.log Complete history with time travel
Testing the logic without rendering Hard Trivial: reducers are pure functions

Does it pay off for Nómada Tasks as it stands? No. With one screen and two consumers of the filter, 10-02's lifted state —or the URL directly— is the right answer, and adding Redux is complexity with no counterpart. Would it pay off in the product version for twenty workshops? Yes, and because of the table's last row more than any other.

  1. Lightweight alternatives: Zustand, Jotai and native stores

Redux is not the only way to have an external store, and for years it has not been the most popular one for new mid-sized projects.

Zustand — a minimalist store with hooks and selective subscription:

import { create } from 'zustand';
import { NEXT } from './domain/rules.js';

export const useBoard = create((set) => ({
  tasks: BACKLOG,
  assignee: null,

  filterBy: (assignee) => set({ assignee }),

  advance: (id) => set((state) => ({
    tasks: state.tasks.map((t) => {
      if (t.id !== id) return t;
      const target = NEXT[t.status];
      return target === null ? t : { ...t, status: target };
    })
  }))
}));

// In any component, at any depth:
const assignee = useBoard((s) => s.assignee);
const advance = useBoard((s) => s.advance);

No Provider, no actions, no reducers, no boilerplate. And with the same selective subscription as useSelector.

Jotaiatomic state: instead of one big object, many small atoms that compose. Derived values are declared from other atoms, in the style of 10-01's signals:

import { atom, useAtom } from 'jotai';

export const tasksAtom = atom(BACKLOG);
export const assigneeAtom = atom(null);

export const visibleAtom = atom((get) => {
  const assignee = get(assigneeAtom);
  const tasks = get(tasksAtom);
  return assignee === null ? tasks : tasks.filter((t) => t.assignee === assignee);
});

The honest table:

Criterion Redux Toolkit Zustand Jotai Context + useState Pinia (Vue) / Signals (Angular)
Boilerplate Medium Very low Very low Low Low
Approximate weight ~13 kB ~1 kB ~3 kB 0 Included
Selective subscription Yes Yes Yes (per atom) No Yes
DevTools and time travel Excellent Basic Basic No Good
Imposed conventions Many None Few None Some
Built-in asynchrony createAsyncThunk, RTK Query By hand Async atoms By hand By hand or a library
Learning curve Steep Very shallow Medium Very shallow Shallow
Good for Large teams, traceability, long-lived applications Almost everything else Highly derived state Stable values Their own ecosystems

How to decide, without dogma:

  • Start with nothing. useState + lifting + composition with children goes much further than people think.
  • If 60% is server state, solve it with a data cache; the global problem almost disappears.
  • If you need a store and the team is small, Zustand or Jotai. Less ceremony, same result.
  • If you need traceability, conventions for six people and an application that will live for years, Redux Toolkit wins on the last row: the action history and the DevTools.
  • If you already work in Vue or Angular, their native stores —Pinia and services with signals— are integrated and are usually the right answer. You will see them in 10-04 and 10-05.

Common Mistakes and Tips

Installing Redux out of habit. It is the ecosystem's historical mistake. The right question is "what state do I have and of what kind?". If, once you take the inventory, it turns out that almost everything is server and local state, Redux is unnecessary.

Putting server state in manual slices. It is 60% of the state and it has rules of its own (staleness, refresh, invalidation, retries). It goes in RTK Query or TanStack Query. Putting it in slices is writing a worse cache by hand.

Storing derived values in the state. If openHours follows from tasks, it is not stored: it is computed with a selector. Storing it creates two sources of truth and reintroduces 10-01's problem 1. It is the same mistake as deriving state with useEffect in 10-02.

Returning new objects or arrays from useSelector. A new reference on every call, a render on every store action. Use primitive selectors or memoize with createSelector.

Mutating the state outside a reducer. Immer only works inside RTK's reducers. In a component, a thunk or a selector, mutating the state breaks change detection and corrupts time travel. configureStore's checker warns in development: listen to it.

Mixing mutation and return in the same reducer. Either you mutate the draft, or you return a new value. Both at once has undefined behavior.

Putting non-serializable things in the state. Dates, Maps, Sets, promises, functions, class instances with private fields. They break principle 1, the DevTools and persistence. Store ISO strings (like your dueDate) and plain objects.

Naming actions as commands. setAssignee describes what to do; assigneeChanged describes what happened. The second form lets several reducers react to the same fact and makes the history readable.

One giant slice. Split by domain: filter, tasks, session. A 400-line slice is as hard to maintain as any other 400-line file.

Tip: reducers are the best place to test the logic. They are pure functions: reducer(previousState, action) and you compare the output, with nothing rendered. It is the cheapest thing Redux offers for 08-03's tests.

Tip: store in the URL whatever has to be shareable. Filters, page, sorting and active tab belong in the URL, not in the store. Marta sending ?assignee=Ivan over chat is a free feature, and you have known how to do it since 07-06.

Tip: when in doubt, start with no store. Adding Redux to an application with well-modeled state is a day's work. Removing it from one where everything goes through it is a quarter.

Exercises

Exercise 1 · Diagnose before installing

For each of these five pieces of data in the product version of Nómada Tasks, decide where it should live, choosing between: local state, lifted, URL, server-data cache, or global store. Justify it with a criterion from this lesson and say what would happen if you put it in the wrong place.

  1. Whether a card's action menu is open.
  2. The task list returned by listTasks().
  3. The assignee currently being filtered by.
  4. The signed-in user and their permissions.
  5. The text Marta has typed so far in the new-task form, unsubmitted.

Exercise 2 · Write a complete slice

Write a sessionSlice for the product version, with this initial state and these three actions:

{ user: null, permissions: [], theme: 'light' }
  • signedIn: receives { user, permissions } and stores them.
  • signedOut: returns to the initial state.
  • themeToggled: switches between 'light' and 'dark'.

Then write:

  1. A selectCanEdit selector that returns true if the permissions include 'tasks:edit'.
  2. A memoized selectEditableTasks selector that, combining selectVisible and the permissions, returns the tasks the user can edit: all of them if they have the permission, and only their own (where they are the assignee) if they do not.
  3. A Jest test of the reducer, without rendering anything.

An additional question: why does themeToggled carry no payload?

Exercise 3 · From CustomEvent to action

This is a real fragment of your plain-JavaScript Nómada Tasks:

// js/view/controller.js — 06-04
container.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action]');
  if (!button) return;

  const id = Number(button.closest('[data-id]').dataset.id);

  if (button.dataset.action === 'advance') {
    state.board.changeStatus(id, NEXT[state.board.findById(id).status]);
    emit(EVENTS.TASK_ADVANCED, { id });
    render();
  }
});
  1. Translate it into the Redux cycle: identify which part is the action, which the reducer, which the dispatch and which the render.
  2. Write the complete equivalent in Redux Toolkit.
  3. Explain three concrete things that are gained with the translation and two that are lost.
  4. What happens in each version if changeStatus throws a RuleError for violating R6?

Solutions

Solution 1

# Data Where Criterion If placed wrongly
1 Open menu Local (useState in the card) Nobody else cares about it; it dies with the component In the global store: 600 junk entries, useless actions in the history and an object that grows without limit
2 Task list from the server Data cache (RTK Query) It is server state: it goes stale, it refreshes, it is invalidated, it can fail In a manual slice: nobody knows when to refresh, you have to invalidate by hand after every mutation and you reimplement a worse cache
3 Filtered assignee The URL It has to be shareable by link and survive a page reload In the store: it is lost on reload, ?assignee=Ivan does not work and you have to sync store and URL by hand
4 User and permissions Global store (or Context) Very distant parts need it, it changes very rarely, it conditions what gets painted Local or lifted: prop drilling of the worst kind, crossing the whole application
5 Unsubmitted form text Local (or uncontrolled, 10-02) It is ephemeral, it changes on every keystroke and only the form cares about it In the store: one action per keystroke, an unreadable history in the DevTools and cascading renders

Item 3 deserves a comment: it is the case most people get wrong, precisely because "several components share it" sounds like global state. But sharing it between components is a transport problem; that it survives a reload and travels in a link is a product requirement, and only the URL satisfies it. A modern router lets you read and write query parameters as if they were state, so the ergonomics are the same.

Solution 2

// src/store/sessionSlice.js
import { createSlice, createSelector } from '@reduxjs/toolkit';
import { selectVisible } from './selectors.js';

const INITIAL = { user: null, permissions: [], theme: 'light' };

const sessionSlice = createSlice({
  name: 'session',
  initialState: INITIAL,
  reducers: {
    signedIn(state, action) {
      state.user = action.payload.user;
      state.permissions = action.payload.permissions;
      // the theme is NOT touched: it is a preference that survives signing out
    },
    signedOut(state) {
      return { ...INITIAL, theme: state.theme };   // returning: full replacement (Immer's rule 2)
    },
    themeToggled(state) {
      state.theme = state.theme === 'light' ? 'dark' : 'light';
    }
  }
});

export const { signedIn, signedOut, themeToggled } = sessionSlice.actions;
export default sessionSlice.reducer;

// ── Selectors ───────────────────────────────────────────────
export const selectUser        = (s) => s.session.user;
export const selectPermissions = (s) => s.session.permissions;

/** Trivial: it does not need createSelector, it computes nothing expensive and creates no new references. */
export const selectCanEdit = (s) => s.session.permissions.includes('tasks:edit');

/** This one does need memoization: it returns a new array. */
export const selectEditableTasks = createSelector(
  [selectVisible, selectCanEdit, selectUser],
  (visible, canEdit, user) =>
    canEdit ? visible : visible.filter((t) => t.assignee === user?.name)
);

The test, with no React and no store:

// test/sessionSlice.test.js
import reducer, { signedIn, signedOut, themeToggled } from '../src/store/sessionSlice.js';

describe('sessionSlice', () => {
  const initial = { user: null, permissions: [], theme: 'light' };

  test('signedIn stores user and permissions', () => {
    const result = reducer(initial, signedIn({
      user: { name: 'Marta' },
      permissions: ['tasks:edit']
    }));
    expect(result.user).toEqual({ name: 'Marta' });
    expect(result.permissions).toEqual(['tasks:edit']);
  });

  test('signedOut clears the session but keeps the theme', () => {
    const withSession = { user: { name: 'Iván' }, permissions: ['x'], theme: 'dark' };
    expect(reducer(withSession, signedOut())).toEqual({ ...initial, theme: 'dark' });
  });

  test('themeToggled goes there and back', () => {
    const dark = reducer(initial, themeToggled());
    expect(dark.theme).toBe('dark');
    expect(reducer(dark, themeToggled()).theme).toBe('light');
  });

  test('the reducer does not mutate the state it receives', () => {
    const frozen = Object.freeze({ ...initial, permissions: Object.freeze([]) });
    expect(() => reducer(frozen, themeToggled())).not.toThrow();
    expect(frozen.theme).toBe('light');    // the original untouched
  });
});

Why themeToggled carries no payload: because the new value is derived from the current one, it is not supplied by whoever dispatches. It is the same reason 10-02 used setCount((n) => n + 1) instead of setCount(count + 1). If the component computed the new theme and sent it as a payload, two presses in quick succession could send the same value. With the decision inside the reducer, which always sees the most recent state, that is impossible.

The last test deserves attention: freezing the input state with Object.freeze is an excellent technique for verifying that a reducer really is pure, and it is the testing equivalent of the checker configureStore enables in development.

Solution 3

1 · The translation of the pieces:

Piece of the original code Redux equivalent
event.target.closest('[data-action]') Stays the same: it is DOM. In React it would be onClick on the button
state.board.changeStatus(id, …) The reducer: the pure transformation of the state
emit(EVENTS.TASK_ADVANCED, {id}) dispatch(statusAdvanced({id})): reporting what has happened
render() Disappears: useSelector triggers the render for whoever is concerned
NEXT[...findById(id).status] Moves inside the reducer, which already has the state

An important note: in the original, emit and changeStatus are two separate things — first you mutate and then you announce. In Redux they are the same thing: the dispatch is simultaneously the announcement and the cause of the change. That unification is what makes the action history complete by construction; in your version, somebody can call changeStatus without emitting the event, and then the announcement is lost.

2 · The complete equivalent:

// src/store/tasksSlice.js
import { createSlice } from '@reduxjs/toolkit';
import { NEXT } from '../domain/rules.js';

const tasksSlice = createSlice({
  name: 'tasks',
  initialState: { list: BACKLOG, lastError: null },
  reducers: {
    statusAdvanced(state, action) {
      state.lastError = null;
      const task = state.list.find((t) => t.id === action.payload.id);
      if (!task) {
        state.lastError = `Task ${action.payload.id} does not exist`;
        return;
      }
      const target = NEXT[task.status];
      if (target === null) {
        state.lastError = `Task "${task.title}" is already done`;   // R6
        return;
      }
      task.status = target;
    }
  }
});

export const { statusAdvanced } = tasksSlice.actions;
export default tasksSlice.reducer;
// src/components/TaskCard.jsx (fragment)
const dispatch = useDispatch();

<button
  type="button"
  disabled={NEXT[task.status] === null}
  onClick={() => dispatch(statusAdvanced({ id: task.id }))}
>
  {LABEL[task.status]}
</button>

3 · Three things that are gained:

  • Complete traceability. Every advance is recorded in the DevTools history with its id and its diff, and you can go back. In the original version, to find out why a task ended up as 'done' you have to set a breakpoint and reproduce the journey.
  • The logic is testable without a DOM. reducer(state, statusAdvanced({id: 6})) is a function call. The original version needs jsdom, a container, a template and a simulated click (08-05).
  • A single path for the change. It is not possible to change a task's status without dispatching the action, so no mutation stays outside the log. In the original version, board.changeStatus(6, 'done') from the console changes the model without telling anybody and without repainting.

Two things that are lost:

  • Immediacy and weight. Four new files, two dependencies, ~13 kB and a layer of indirection: to follow what the button does you have to go from the component to the action, from the action to the slice and from the slice to the selector. In the original version it is all in twenty consecutive lines.
  • The domain's rich objects. The state is plain data: you lose the Task class with its private #status, its getters and its methods, which guaranteed R6 by encapsulation (05-03). In Redux the guarantee is by convention: nothing stops another reducer from writing task.status = 'done' and skipping NEXT.

4 · What happens if R6 is violated. In the original version, changeStatus throws a RuleError, the exception propagates up through the click handler and —if nobody catches it— dies in the console: the interface never finds out and nothing is shown to the user. In the Redux version a reducer cannot throw: it must be pure and total. The invalid transition becomes state (lastError), which the interface can display as an accessible warning. It is a difference of philosophy worth understanding: in Redux, the domain's foreseeable errors are data, not exceptions; exceptions are reserved for what is genuinely unexpected. It is, by the way, exactly the same reasoning with which 02-05 distinguished between expectable errors and programming faults.

Conclusion

You have learned state management starting where you had to start: with the problem. You know its three faces —the prop drilling of the filter crossing six components that do not use it, the duplicated state that makes the header and the list contradict each other, and the stored derived values that reintroduce 10-01's problem 1 inside the framework that came to solve it. And you have the table of alternatives in order of cost, with the instruction to stop at the first row that solves your case: nothing, lifting the state, Context, a lightweight store, Redux. With the inventory almost nobody takes: in a real application, 60% of the state is server state, 25% local, 10% URL and only 5% genuinely global — and that 5% is Redux's territory.

You know that lifting the state goes much further with composition: passing already-created elements as children or as content props removes prop drilling without installing anything. You know what Context is —a transport mechanism, not a state manager— and what its real limitation is: all consumers repaint when the value changes, with the two mitigations of splitting by rate of change and separating value from actions. And you know what an external store adds: selective subscription, and the possibility of using it from outside React —from your BoardChannel of 07-04, from a test, from anywhere.

You know Redux's three principles and, more importantly, what each one buys: a single source of truth (nothing to drift apart, serializable and restorable state), read-only state through actions (every change through an observable point), and changes with pure functions (the same sequence always produces the same state, which is what makes time travel possible). You have the action → reducer → new state → render cycle with its five properties, and the exact parallel with what you had already built: your state object, your CustomEvents with emit, your handler that applied the change and called render(), and your version cache from 09-02 — where immutability makes the version counter unnecessary because the reference is the counter.

You are fluent in Redux Toolkit as the correct and current way: configureStore with its mutation and serializability checkers, createSlice that turns four places into one, Immer with its proxy that records writes and produces new state while structurally sharing what was not modified —with its three rules: do not mix mutation and return, return in order to replace entirely, and do not expect magic outside reducers—, useSelector with its selective subscription and its new-reference trap, and createSelector as memoization of derived values. You know how to solve asynchrony with createAsyncThunk and its three automatic actions, with the signal that integrates your AbortController from 07-03 and rejectWithValue so as not to put exceptions in the state; and you know that server state has a tool of its own, RTK Query, with cache by key, invalidation by tags, automatic refresh and optimistic mutations.

You are clear about where Redux's real advantage lies, which is neither performance nor transport: it is traceability. The history of named actions, the diff per action, the time travel that works because reducers are pure, and the possibility of exporting somebody else's state and reproducing their bug on your machine. And you know how to normalize with createEntityAdapter when there are 600 tasks, which is your Map index from 09-02 expressed in plain data, with the same boundary: below a certain size, it complicates without gaining anything.

You have seen Nómada Tasks' filter and board in Redux, with App carrying not one filter prop and the canonical numbers intact, and the honest balance sheet: zero props crossing the tree and complete traceability, in exchange for four files, two dependencies, 13 kB and a layer of indirection. With the conclusion 10-01 asked for: for Nómada Tasks as it stands, it does not pay off; for the product version for twenty workshops, it does. And you know the lightweight alternatives —Zustand, Jotai, and other ecosystems' native stores— with the table that says when each one is the reasonable answer.

Everything seen so far has happened inside the same model: function components, virtual DOM, immutable state, manual memoization. The next lesson changes all three. You are going to see the same screen in a framework that groups template, logic and styles in a single file, that detects dependencies automatically with proxies instead of comparing trees, and where the equivalent of useMemo is not an optimization but the natural way to write. It is 10-01's family 2 of reactivity, in its most polished form: Vue.js Basics.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved