The previous lesson ended with a precise list of what context doesn't solve: debugging tools, a single point to intercept changes, time travel, granular selection, and a shared convention for async logic. Redux exists for exactly that list. And it's worth saying from the first line, to clear up the most common misunderstanding: Redux isn't a faster way to share state, it's a more disciplined way to change it. What you're buying with it isn't speed, it's predictability and diagnostic power. In this lesson you'll understand what problem Redux solves, its three principles and why they make the application debuggable, its direct relationship to the useReducer you already wrote in 05-05, why Redux Toolkit is today the official way to use Redux, and what changes compared to classic Redux, which you'll still run into in older projects. Then you'll set up CicloUrbano's store with configureStore, provide it in main.jsx without breaking the provider order or the router, and install Redux DevTools to see the action history. By the end, the store will exist and work, but barely be consumed: modeling it is 07-04's job, and connecting it to components is 07-05's.

Contents

  1. What Redux is and what problem it solves
  2. The three principles
  3. Why those principles make the application predictable and debuggable
  4. Relationship with useReducer: same model, different scale
  5. The unidirectional flow
  6. Redux Toolkit: the official way to use Redux today
  7. What classic Redux looked like (legacy code you need to know how to read)
  8. Installation
  9. CicloUrbano's store: src/store/store.js
  10. The default middleware and its development checks
  11. Providing the store in main.jsx
  12. Redux DevTools: the strongest argument
  13. When NOT to use Redux

  1. What Redux is and what problem it solves

Redux is a predictable state container: a JavaScript object that holds all of the application's client state, and that can only be changed by dispatching actions, objects that describe what happened. There is no other way to modify it.

The problem it solves isn't "sharing data between distant components" — context already does that, and more cheaply. The problem is this other one, which shows up as an application grows:

When something goes wrong on screen, you don't know what changed the state, when it changed, or who caused it.

In an application with fifteen components calling setSomething from handlers, effects, and request callbacks, reconstructing the sequence of events that led to the bug is archaeology. Redux attacks that by turning every change into an explicit, ordered record: a list of actions, with their payload and timestamp, that you can step back through.

The second thing it solves, more mundane but just as real: it gives you a convention. On a team of six, "where does this data live and how does it change" stops being a per-feature argument.

  1. The three principles

Redux rests on three rules. They're not recommendations: they're what makes everything else possible.

Principle 1: single source of truth

All of the application's state lives in a single object, inside a single store.

// Rough shape of CicloUrbano's state (defined in 07-04)
{
  bookings: { entities: {…}, ids: […], loadState: 'idle', error: null },
  catalogue: { type: 'todos', searchTerm: '', sort: 'modelo' },
  session:   { user: null, loading: true }
}

Practical consequences: the complete state can be serialized and attached to a bug report; it can be persisted to localStorage and restored; you can write tests starting from a specific initial state; and there's no place where the same piece of data can say two different things.

Principle 2: the state is read-only

The only way to change the state is to dispatch an action, a plain object describing what happened.

store.dispatch({ type: 'bookings/bookingConfirmed', payload: 'res-01' });

Nobody writes state.bookings[0].status = 'confirmada'. The consequence is that there's a deliberate bottleneck: every change goes through the same place, so that place can log it, measure it, intercept it, or replay it.

Principle 3: changes are made with pure functions

A reducer is a pure function (state, action) => newState. It doesn't mutate its arguments, doesn't make requests, doesn't read the clock or generate random ids, and given the same inputs it always returns the same output.

It's literally the definition you learned in 05-05 for bookingsReducer.

  1. Why those principles make the application predictable and debuggable

The three principles together produce one very specific property:

Initial state + list of actions = current state. Always, no exceptions.

Everything else follows from that:

Property How the principles enable it
Reproducing a bug If you save the initial state and the actions, you can reproduce the exact session of the user who hit the bug
Time travel Since reducers are pure, replaying the first N actions gives you the state at moment N
Automatic logging Principle 2's bottleneck lets you log every change without touching any component
Trivial tests Testing a reducer means calling a function with two arguments and comparing the output (Module 9)
Undo / redo With immutable states, saving the previous ones is just saving references
Diff-based debugging DevTools shows the "before and after" of each action, so the unexpected change is pinpointed in seconds

And the cost, which needs to be said just as clearly: more ceremony. Changing a piece of data stops being a single line and becomes an action, a case in a reducer, and a selector. That ceremony is what you pay for the table above. If your application doesn't need anything from that table, don't pay it.

  1. Relationship with useReducer: same model, different scale

This was already announced in 05-05: Redux's model is exactly useReducer's model. Compare them.

// What you wrote in 05-05
const [state, dispatch] = useReducer(bookingsReducer, INITIAL_BOOKINGS_STATE);
dispatch({ type: 'booking_confirmed', bookingId: 'res-01' });

// The same thing in Redux
const state = store.getState();
store.dispatch({ type: 'bookings/bookingConfirmed', payload: 'res-01' });

A Redux reducer and your bookingsReducer are the same kind of function. The differences are about scope, not concept:

useReducer Redux
Where the state lives Inside the React tree, in a component In an independent object, outside React
Scope A subtree The whole application
Access useContext from the provider's descendants useSelector from any component
Name of the type field Free choice — it happened to be type in this course too, but nothing enforces it Mandatorily type
Payload Free-form fields on the action payload convention
Intercepting changes No Middleware
Tooling None DevTools
Combining domains One reducer per provider Several reducers combined in one store

Pay attention to two rows: in Redux the field is called type, but unlike your own useReducer actions — where nothing stops you from calling it kind or tag — here it's non-negotiable, because the library and its tooling (DevTools, middleware) depend on that exact name. And the payload goes by convention in payload, a single field, where your own reducer actions were free to spread the data across whatever fields made sense (bookingId, field, value…). It's the same rule as always: APIs and keywords don't get translated.

The fact that the state lives outside React has consequences already hinted at in 07-02: the store can be read from a utility, from a request interceptor, or from a test without mounting any component, and it survives the unmounting of any part of the tree.

  1. The unidirectional flow

flowchart LR
    A["View<br/>BookingsPage"] -- "1. dispatch(action)" --> B["Store"]
    B -- "2. (state, action)" --> C["Reducer<br/>pure function"]
    C -- "3. new state" --> B
    B -- "4. notifies" --> D["Subscribers<br/>useSelector"]
    D -- "5. re-renders if its slice changed" --> A
    E["Middleware<br/>logging · async · DevTools"] -.- B

The five steps, with concrete CicloUrbano names:

  1. The user clicks "Confirm" in BookingsPanel, and the handler dispatches { type: 'bookings/bookingConfirmed', payload: 'res-01' }.
  2. The store passes the action through the middleware — which can log it, delay it, or transform it — and then calls the root reducer with the current state and the action.
  3. The reducer returns a new state; it never mutates the one it received.
  4. The store saves the new state and notifies its subscribers.
  5. Each subscribed component checks whether its slice changed and re-renders only if it did.

"Unidirectional" means the arrows never go backwards: a view never modifies the state directly, a reducer never triggers a navigation, and the store never calls a component. That discipline is what makes step 5 the only place to look when the screen doesn't show what you expect.

Notice step 5 and the phrase "its slice": that's where the granular selection context couldn't give you lives. The exact mechanism is useSelector, and that's the subject of 07-05.

  1. Redux Toolkit: the official way to use Redux today

Redux is over ten years old and carries a well-earned reputation for verbosity... referring to how it was written back in 2016. Since 2019, Redux Toolkit (RTK) has been the official, recommended way to use Redux, and the project's own documentation says so. This course teaches Redux exclusively through RTK.

What the package includes:

Utility What it's for Where you'll see it
configureStore Creates the store with good defaults and DevTools already wired up This lesson
createSlice Generates a reducer and action creators from a description 07-04
createAsyncThunk Async logic with the three states (pending/fulfilled/rejected) 07-04
createSelector Memoized derived selectors (comes from Reselect) 07-04 and 07-05
createEntityAdapter State normalized by id, with the operations already written 07-04
Immer, included Lets you write code "that mutates" without actually mutating 07-04
RTK Query, included Server-data cache, an alternative to TanStack Query 07-06

What changes compared to classic Redux:

Classic Redux (2016) With Redux Toolkit
Boilerplate Type constants, action creators, a switch, and combineReducers, each in its own file A single createSlice generates actions and reducer together
Immutability By hand, with nested ... spreads; one slip is a silent bug Immer: you write state.bookings.push(x) and it's still immutable
Async logic redux-thunk installed and configured separately, or redux-saga createAsyncThunk, included, with a defined pattern
DevTools setup window.__REDUX_DEVTOOLS_EXTENSION__ by hand in createStore Wired up by default in development
Checks None: mutating the state by mistake goes unnoticed Development warnings if you mutate or store something non-serializable
Size redux itself is tiny, but 3-4 packages end up piling on RTK is heavier, and in exchange it replaces all of them
Files per feature 3 or 4 (types.js, actions.js, reducer.js, selectors.js) 1 (xSlice.js)

The only row that counts against RTK is size: RTK plus react-redux weigh in around 15-20 kB compressed, versus roughly 2 kB for plain redux. In exchange you save Reselect, redux-thunk, DevTools configuration, and a fair amount of your own code. In any real application, the trade-off is worth it.

  1. What classic Redux looked like (legacy code you need to know how to read)

⚠️ This section exists solely so you can read old projects. The code you'll see here should never be written in new code. It's included because you'll run into it in applications that have been in production for years, and because it explains where the concepts RTK automates came from. After this section, it won't show up again in the course.

// ⚠️ LEGACY CODE — DO NOT WRITE LIKE THIS TODAY
// types.js
export const BOOKING_CONFIRMED = 'BOOKING_CONFIRMED';

// actions.js
export function confirmBooking(bookingId) {
  return { type: BOOKING_CONFIRMED, payload: bookingId };
}

// reducer.js
import { BOOKING_CONFIRMED } from './types.js';

const initialState = { bookings: [] };

function bookingsReducer(state = initialState, action) {
  switch (action.type) {
    case BOOKING_CONFIRMED:
      return {
        ...state,
        bookings: state.bookings.map((booking) =>
          booking.id === action.payload ? { ...booking, status: 'confirmada' } : booking
        )
      };
    default:
      return state;
  }
}

// store.js
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const rootReducer = combineReducers({ bookings: bookingsReducer, catalogue: catalogueReducer });

const store = createStore(
  rootReducer,
  applyMiddleware(thunk)
);

What to look for in this fragment when you run into it:

  • The type constants existed to avoid typos in string literals scattered across several files. RTK eliminates them: createSlice generates the type from the name.
  • The switch with default: return state is mandatory in classic Redux, because every action passes through every reducer. Notice the difference from the bookingsReducer in 05-05, where the default threw an error: that was correct there because only that reducer's own actions ever arrived.
  • The nested ... spreads are hand-rolled immutability. With two levels they're already hard to read, and with three they're a bug magnet.
  • Explicit combineReducers and applyMiddleware(thunk): both disappear with configureStore, which does the same thing internally.
  • It's common to find this alongside connect, mapStateToProps, and mapDispatchToProps, the API that came before hooks. It's covered in 07-05, also just so you can read it.

Everything that code does, RTK does with a fraction of the text, with no chance of forgetting a default or mutating by accident.

  1. Installation

npm install @reduxjs/toolkit react-redux

Two packages, and what each one gives you:

Package What it provides
@reduxjs/toolkit Redux itself, plus configureStore, createSlice, createAsyncThunk, createSelector, createEntityAdapter, Immer, and RTK Query
react-redux The glue with React: <Provider>, useSelector, useDispatch

Don't install redux, redux-thunk, or reselect separately. They're already bundled in RTK, and adding them on their own can end up with two copies of Redux in the same final bundle.

  1. CicloUrbano's store: src/store/store.js

configureStore is the entry point. It takes a configuration object whose only required key is reducer.

// src/store/store.js
import { configureStore } from '@reduxjs/toolkit';
import bookingsReducer from '../features/bookings/bookingsSlice.js';
import catalogueReducer from '../features/catalogue/catalogueSlice.js';
import sessionReducer from '../features/session/sessionSlice.js';

export const store = configureStore({
  // The reducer map defines the SHAPE of the global state:
  //   state.bookings · state.catalogue · state.session
  reducer: {
    bookings: bookingsReducer,
    catalogue: catalogueReducer,
    session: sessionReducer
  }
});

Three things configureStore does without you having to ask:

  1. Combines the reducers. The object you pass in reducer gets turned internally into a combineReducers, so state.bookings is whatever bookingsReducer returns.
  2. Sets up the default middleware, including what's needed for thunks and the development checks from section 10.
  3. Connects Redux DevTools in development, and disconnects them in production.

The keys of the reducer object are the shape of the state. Choosing them is a design decision: bookings, catalogue, and session are the three client-state domains that came out of the 07-01 audit.

Provisional slices

Since the full slices are 07-04's job, for now bare skeletons that let the application boot are enough. Here's one:

// src/features/session/sessionSlice.js — PROVISIONAL VERSION from 07-03
import { createSlice } from '@reduxjs/toolkit';

const sessionSlice = createSlice({
  name: 'session',
  initialState: { user: null, loading: true },
  reducers: {}   // filled in in 07-04
});

export default sessionSlice.reducer;

The only thing that matters today: createSlice returns an object whose .reducer is the function configureStore expects. With reducers: {} the slice doesn't accept any actions yet, but the store already has the right shape, and DevTools can already show it to you. The other two — catalogueSlice and bookingsSlice — are identical, each with its own initialState.

Checking that the store exists

Without connecting anything to React yet, you can check it from the browser console or from a test file:

import { store } from './store/store.js';

console.log(store.getState());
// { bookings: {…}, catalogue: { type: 'todos', searchTerm: '', sort: 'modelo' }, session: { user: null, loading: true } }

store.dispatch({ type: 'test/madeUpAction' });
// It doesn't fail: no reducer recognizes it, and each one returns its state unchanged.
// But it DOES show up in DevTools, which is what matters right now.

That last detail marks a fundamental difference from the bookingsReducer in 05-05: there, an unknown action deliberately threw an error. In Redux, every action passes through every reducer, so an action a given reducer doesn't recognize is the normal situation, and it must return the state untouched. RTK does that for you.

The store's API, for completeness, even though in practice you'll rarely use it directly from components:

Method What it does
store.getState() Returns the complete current state
store.dispatch(action) Sends an action
store.subscribe(fn) Registers a listener called after every action; returns the function to unsubscribe

react-redux uses these three methods under the hood. From components you'll use useSelector and useDispatch (07-05), not these.

  1. The default middleware and its development checks

A middleware is a function that sits between dispatch and the reducer. It's the single interception point that context didn't have: every action passes through it, so that's where you log, measure, transform, or stop things.

configureStore installs three by default:

Middleware What it does Active in
thunk Lets you dispatch functions as well as objects: the foundation of async logic (07-04) Development and production
serializableCheck Warns if you put something non-serializable into the state or an action: Date, Map, Set, functions, promises, class instances Development only
immutableCheck Warns if a reducer mutates the state instead of returning a new one Development only

The two checks are the safety net for principles 2 and 3, and each one deserves an example.

// ⚠️ Triggers the serializableCheck warning
dispatch({ type: 'bookings/bookingCreated', payload: { startDate: new Date() } });
// A non-serializable value was detected in an action, in the path: `payload.startDate`

// ✅ Correct: ISO strings, not Date objects
dispatch({ type: 'bookings/bookingCreated', payload: { startDate: '2026-05-04T09:00' } });

Why it matters: if the state is serializable, it can be dumped to JSON, attached to a bug report, persisted to localStorage, and replayed in DevTools. A Date inside the state breaks all four. Store ISO strings — which is exactly what the Booking shape does, with startDate: '2026-05-04T09:00' — and convert to Date only at the moment you format it.

// ⚠️ Triggers the immutableCheck warning: mutates the state OUTSIDE a slice
function badReducer(state, action) {
  state.bookings.push(action.payload);   // a real mutation
  return state;
}
// A state mutation was detected between dispatches, in the path: `bookings.bookings`

One important warning that gets fully resolved in 07-04: inside a createSlice, writing state.bookings.push(...) is correct and doesn't trigger this warning, because Immer hands you a draft, not the real state. Outside a slice, it's a real mutation. The difference is explained in full in the next lesson.

Both checks cost time: they walk the entire state after every action. With large states you'll notice it in development, which is why RTK disables them in production automatically. If one of them bothers you in development over a specific piece of data:

export const store = configureStore({
  reducer: { bookings: bookingsReducer, catalogue: catalogueReducer, session: sessionReducer },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: { ignoredPaths: ['bookings.abortController'] }
    })
});

Notice the signature: middleware receives a function that returns the default list, and you adjust it. If you write middleware: [myMiddleware] you wipe out thunk and both checks at once, which is a common and hard-to-diagnose mistake.

Adding your own middleware is done by concatenating:

const logger = (store) => (next) => (action) => {
  console.groupCollapsed(action.type);
  console.log('before:', store.getState());
  const result = next(action);
  console.log('after:', store.getState());
  console.groupEnd();
  return result;
};

export const store = configureStore({
  reducer: { /* … */ },
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger)
});

Those three nested arrows are the canonical signature of Redux middleware: it receives the store, returns a function that receives the next link in the chain, and returns the one that receives the action. In practice you won't write many of these, but it's useful to recognize the shape. And this particular example is unnecessary: DevTools already gives you the same thing, and better.

  1. Providing the store in main.jsx

react-redux exposes <Provider store={…}>, which makes the store available to the whole tree. The interesting question is where to place it relative to what's already there.

// src/main.jsx — with the Redux store
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { RouterProvider } from 'react-router';
import { store } from './store/store.js';
import { router } from './routes.jsx';
import ErrorBoundary from './components/ErrorBoundary.jsx';
import { Providers } from './contexts/Providers.jsx';
import { reportError } from './utils/monitoring.js';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <ErrorBoundary title="CicloUrbano isn't available right now" onLog={reportError}>
      <Provider store={store}>
        <Providers>
          <RouterProvider router={router} />
        </Providers>
      </Provider>
    </ErrorBoundary>
  </StrictMode>
);
flowchart TD
    A["StrictMode"] --> B["ErrorBoundary"]
    B --> C["Provider store={store}"]
    C --> D["Providers<br/>Theme + User"]
    D --> E["RouterProvider"]
    E --> F["Layout · routes"]

Justifying the order, because every level has a reason:

  • ErrorBoundary outside everything (04-05): it must also catch failures that happen while creating the store or initializing the providers.
  • Provider above Providers and the router. This is the most important part: useSelector only works inside Provider, so any component that ends up using Redux — including route-error pages — must be inside it. And since the store is an external object that never changes identity, placing it at the very top costs nothing: <Provider> never publishes anything new.
  • Providers keeps existing. Redux doesn't replace context: the theme still lives in ThemeContext (07-01 explained why). And if a provider ever needed to read from the store, it would have to sit inside Provider, which is the case here.
  • RouterProvider goes last, as in 06-02: the providers that must survive everything, including the root route's errorElement, go on the outside.

A detail about StrictMode: in development it renders components twice to catch impure effects. With Redux this does not duplicate actions dispatched from event handlers, but it can duplicate ones dispatched from a useEffect with incorrect dependencies. If you see every loading action doubled in DevTools during development, that's almost always the cause, and it's usually a sign the effect is written wrong.

  1. Redux DevTools: the strongest argument

You already have a store, and it doesn't consume anything yet. Even so, today you can already see the tool that justifies half of this lesson.

Installation: the Redux DevTools browser extension (Chrome, Firefox, or Edge). Nothing else needs configuring: configureStore connects it on its own in development. Run npm run dev, open the app, open your browser's dev tools, and look for the Redux tab.

What it shows you:

Panel What it displays
Action list Every action dispatched, in order, with its type
Action The selected action in full, with its payload
State The complete state right after that action, as a navigable tree
Diff Only what changed with that action. The most useful panel of all
Trace The call stack from where the action was dispatched, if enabled

And what you can do, not just look at:

  • Step through history. Clicking a previous action takes the app back to the state it had at that moment. The interface actually updates.
  • Playback slider. Replays the whole session like a movie, action by action.
  • Skip actions. Disable an action from the history and recompute the state without it: "what would have happened if this action hadn't been dispatched?"
  • Dispatch actions by hand. Write an action and send it to the store to test a transition without touching the UI.
  • Export and import. Save the history to a JSON file and load it on another machine. A bug report stops being "it doesn't work for me" and becomes a file that reproduces the exact problem.
flowchart LR
    A["session/signedIn"] --> B["catalogue/typeChanged"]
    B --> C["bookings/bookingCreated"]
    C --> D["bookings/bookingConfirmed"]
    D -. "jump to the state after B" .-> B

This is what context can't give you, and why many teams choose Redux. And it's an honest argument: when the bug is "sometimes, after confirming two bookings in a row, the second one shows up as cancelled," having the exact list of actions and each one's diff turns an afternoon of debugging into five minutes.

None of this works in production, and that's deliberate: DevTools disconnect in the production build so a real user's state and history are never exposed.

  1. When NOT to use Redux

An essential section, because half of Redux's problems come from using it where it doesn't belong.

Don't use Redux if:

Situation Use instead
Your "global state" is actually API data TanStack Query or RTK Query (07-06)
You just need to avoid passing props through for session, theme, or language Context (07-02)
The state is local to one component useState
The state is complex but belongs to a single domain and subtree useReducer + context
The data needs to be shareable by link The URL, with useSearchParams (06-02)
It's a small project with one or two people Context, or Zustand if you want a store with no ceremony
You're just starting and don't know yet what state you'll have Start with useState and scale up as needed

It's a good choice when several of these hold at once: the application is large and going to keep growing; several people touch the same state and need a shared convention; the state logic has business rules worth auditing and testing; you need to debug hard-to-reproduce bugs; or you want to intercept changes at a single point to log, persist, or measure them.

In CicloUrbano, honestly: an application of this size would work perfectly well with context + useReducer, and that's how it's worked so far. Redux gets introduced because it's what you'll run into on the job, because the mental model transfers to Zustand and Jotai, and because DevTools is a tool worth having in your hands at least once. What we're not going to do is pretend it was indispensable.

Common Mistakes and Tips

Mistake 1: installing redux and redux-thunk on top of RTK. They're already bundled in. Installing them separately can put two copies of Redux in the final bundle and cause baffling "two different stores" bugs.

Mistake 2: replacing the middleware array instead of concatenating. middleware: [myMiddleware] removes thunk and both checks. The correct form is always (getDefaultMiddleware) => getDefaultMiddleware().concat(myMiddleware).

Mistake 3: storing Date, Map, Set objects, or class instances in the state. They break serializability, and with it time travel, history export, and persistence. ISO strings and plain objects.

Mistake 4: placing Provider inside RouterProvider. Any component outside it — starting with the root route's errorElement — will fail when using useSelector, with a poorly descriptive error. The store goes on top.

Mistake 5: creating more than one store. Redux is designed for exactly one per application. If you feel the need to create a second one, what you actually want is another slice.

Mistake 6: writing a reducer's default case to throw an error, by analogy with the bookingsReducer from 05-05. In Redux every action passes through every reducer, so throwing would break the app on the very first action from another domain.

Tip 1: create the store before the slices. Start with empty skeletons, boot the app, open DevTools, and check that you see the initial state. That way the first real slice gets written on top of already-verified scaffolding.

Tip 2: use the extension from day one. Don't install it once you hit a bug — by then you'll have already lost the history of how you got there.

Tip 3: export the store as a named constant (export const store), not as a default export. It's a single object for the whole application, and it's worth it being named the same way in every file.

Exercises

Exercise 1. Build CicloUrbano's store from scratch: install the packages, write the three provisional slices with the initial state that fits each domain according to the 07-01 audit, set up src/store/store.js, place Provider in main.jsx, and check in Redux DevTools that the initial state has the expected shape. Write down the initial state you chose for each one and justify it.

Exercise 2. This configureStore has three problems. Find them and fix it.

import { configureStore } from '@reduxjs/toolkit';
import thunk from 'redux-thunk';
import { logger } from './middleware/logger.js';

export const store = configureStore({
  reducer: bookingsReducer,
  middleware: [thunk, logger],
  preloadedState: {
    session: { user: { id: 'usr-01' }, signedInAt: new Date() }
  }
});

Exercise 3. For each of these five pieces of data from a future CicloUrbano expansion, decide whether it belongs in the Redux store or not, and justify it with the 07-01 decision tree and section 13.

  1. The list of open workshop incidents, coming from GET /incidencias.
  2. The interface language, chosen by the user and used across the whole application.
  3. The position of the catalogue's maximum-price slider, while the user is dragging it.
  4. The history of the last five bikes viewed, shown in the header and persisted across sessions.
  5. Whether BookingDialog is open.

Solutions

Solution 1.

npm install @reduxjs/toolkit react-redux
// src/features/catalogue/catalogueSlice.js — provisional
import { createSlice } from '@reduxjs/toolkit';

const catalogueSlice = createSlice({
  name: 'catalogue',
  initialState: {
    type: 'todos',         // 'todos' | 'urbana' | 'electrica' | 'carga'
    searchTerm: '',        // search box text
    sort: 'modelo'         // 'modelo' | 'precio'
  },
  reducers: {}
});

export default catalogueSlice.reducer;
// src/features/bookings/bookingsSlice.js — provisional
import { createSlice } from '@reduxjs/toolkit';

const bookingsSlice = createSlice({
  name: 'bookings',
  initialState: {
    entities: {},          // bookings by id
    ids: [],                // display order
    loadState: 'idle',     // 'idle' | 'loading' | 'success' | 'error'
    error: null
  },
  reducers: {}
});

export default bookingsSlice.reducer;
// src/features/session/sessionSlice.js — provisional
import { createSlice } from '@reduxjs/toolkit';

const sessionSlice = createSlice({
  name: 'session',
  initialState: { user: null, loading: true },
  reducers: {}
});

export default sessionSlice.reducer;

Justification for each initial state:

  • catalogue: the three filter criteria, all at a neutral value. type starts at 'todos' to match what CataloguePage already did when there's no ?tipo= in the URL. Note: type existing here doesn't mean the filter stops living in the URL; how the two relate gets resolved in 07-05.
  • bookings: normalized shape (entities + ids), because bookings get looked up by id constantly. Detailed in 07-04. Plus a loadState/error pair for the async fetch, starting at 'idle' because nothing has been requested yet.
  • session: user: null because nobody has signed in yet, and loading: true because on boot you need to check whether a saved session exists — exactly the loadingSession from 06-05. Starting at false would make ProtectedRoute kick out a user with a valid session during the first render.

In DevTools, with the store mounted, the State panel should show all three keys and no action other than Redux's own initialization action (@@INIT).

Solution 2. The three problems:

  1. reducer: bookingsReducer passes a single reducer as the root reducer. The state would just be the bookings' state directly, without the bookings, catalogue, and session keys. It needs to be an object with the reducer map.
  2. middleware: [thunk, logger] replaces the default list, so serializableCheck and immutableCheck are lost. And thunk is redundant: RTK already includes it, and the line import thunk from 'redux-thunk' is unnecessary altogether.
  3. signedInAt: new Date() puts a non-serializable Date object into the preloaded state. It needs to be an ISO string.
import { configureStore } from '@reduxjs/toolkit';
import { logger } from './middleware/logger.js';
import bookingsReducer from '../features/bookings/bookingsSlice.js';
import catalogueReducer from '../features/catalogue/catalogueSlice.js';
import sessionReducer from '../features/session/sessionSlice.js';

export const store = configureStore({
  reducer: {
    bookings: bookingsReducer,
    catalogue: catalogueReducer,
    session: sessionReducer
  },
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
  preloadedState: {
    session: { user: { id: 'usr-01' }, signedInAt: '2026-05-04T09:00:00.000Z', loading: false }
  }
});

preloadedState is used to restore a saved state or to set a starting point in tests, and it must respect the same shape as the reducer map.

Solution 3.

Data Into Redux? Justification
1. Workshop incidents No It's server state. The first question in the 07-01 tree gets answered yes, and the search ends there: it goes into a query cache (07-06). Putting it in a slice forces you to hand-write loading, error, cancellation, revalidation, and invalidation
2. Interface language Not needed Many readers, extremely infrequent changes: exactly the theme's profile. If the project is already using Redux for other things, putting it in a preferencesSlice isn't wrong either; it simply adds nothing
3. Price slider, while dragging No Changes dozens of times a second. Every move would be an action in the DevTools history, making it unusable, and a comparison cycle for every subscriber. Local state with useDebounce; and once the user releases it, the final value can go into the URL alongside ?tipo=
4. History of viewed bikes Yes Genuine client state: read by a distant component (the header), written by another (BikeDetailPage), with its own rule ("the last five, no duplicates") worth a reducer and a test, and its persistence is cleanly handled by a middleware that writes to localStorage after every action
5. BookingDialog open No Local UI state, the textbook example from section 13 and from 07-01's table. It goes in a useToggle inside the component that opens it

Case 4 is the most interesting, because it's the only one that genuinely benefits from Redux, and for a reason that isn't "several components use it" — context would cover that too — but the combination of having its own business rule and needing persistence at a single point: exactly the two things section 13 puts in the "yes" column.

Conclusion

Redux is a predictable state container, and what you're buying with it isn't speed but predictability and diagnostic power. Its three principles — a single source of truth in one object, read-only state that only changes by dispatching actions, and changes made through pure functions — together produce one very specific property: initial state plus a list of actions equals the current state, always. From that come time travel, bug reproduction, automatic logging, and tests that amount to calling a function and comparing the output. The model is exactly the useReducer from 05-05, at a different scale: the differences are that the state lives outside the React tree, that the field is called type and the payload payload, that there's a single interception point — the middleware — and that there are tools.

You've set up the store with Redux Toolkit, the official way to use Redux today: configureStore combines the reducers, installs thunk and the development checks for serializability and mutation, and connects DevTools without you having to ask. CicloUrbano's store lives in src/store/store.js with three domains — bookings, catalogue, and session — today with provisional slices, and it's provided with <Provider store={store}> placed above Providers and RouterProvider, because useSelector only works inside it, and because the store, being an external object with a fixed identity, costs nothing at the very top. From classic Redux — createStore, type constants, a hand-written switch, explicit combineReducers, applyMiddleware(thunk) — all you take away is the ability to read it in older projects; it won't show up again as suggested code. And you know when not to use Redux, which takes up a whole table in this lesson: server data, environmental preferences, local state, values that need to travel in a link, and small projects all have better answers.

Right now the store is empty. What's missing is what gives it value: modeling the state and its transitions. In the next lesson you'll write the real slices with createSlice, understand why Immer lets you write state.bookings.push(...) without mutating anything, generate ids and dates outside the reducer with prepare, normalize entities by id, declare selectors alongside their slice, and resolve async loading with createAsyncThunk and its three states. The next lesson is Redux: Actions and Reducers.

React Course

Module 1: Getting Started with React

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved