CicloUrbano's store exists, boots up and shows up in DevTools, but it's empty: three placeholder slices with reducers: {} that don't accept a single action. This lesson fills it in. This is where the real work of Redux happens, and that work isn't configuration — it's modeling state and its transitions: deciding what shape each domain has, what events it can receive and how it changes with each one. You'll see the anatomy of an action and how to name it, createSlice in depth and everything it generates for you, Immer's apparent immutability — the mechanism that surprises people coming from classic Redux the most — and the one rule you can never break with it, preparing actions with prepare to keep reducers pure, CicloUrbano's three complete slices, selectors as the boundary between components and the shape of state, normalizing entities by identifier, and async logic with createAsyncThunk. By the end, the store will have all of CicloUrbano's client behavior modeled and tested. Wiring it up to components is still 07-05's job: there isn't a single useSelector in this lesson.

Contents

  1. Anatomy of an action, and how to name it
  2. createSlice in depth: what it takes and what it generates
  3. Immer's apparent immutability
  4. The rule you can't break
  5. Reducers with preparation: prepare
  6. catalogueSlice: filter, search and sort
  7. sessionSlice: user and sign-in
  8. bookingsSlice: creating, confirming and cancelling
  9. Selectors: the boundary with the shape of state
  10. Normalizing state by identifier
  11. createEntityAdapter in brief
  12. Async logic with createAsyncThunk
  13. extraReducers for reacting to other slices
  14. Testing reducers and selectors

  1. Anatomy of an action, and how to name it

A Redux action is a plain object with a shape fixed by convention:

{
  type: 'bookings/bookingConfirmed',   // required: what happened
  payload: 'res-01'                    // optional: the event's data
}
Field Required What it holds
type Yes A string identifying the event. It must be unique across the whole app
payload No The data needed to apply the change. A value, an object, whatever is needed
meta No Additional information that isn't part of the change (for example, a request identifier)
error No true if the action represents a failure

Naming it well is half the job. RTK's convention is domain/eventThatHappened:

✅ Good ❌ Bad Why
bookings/bookingConfirmed CONFIRM_BOOKING The action describes what happened, not a command. Past tense, as in 05-05
catalogue/typeChanged SET_TYPE SET_X describes an assignment, not a domain event
session/signedOut LOGOUT The domain prefix avoids collisions between slices
bookings/bookingCancelled updateBookingStatus A generic name forces you to read the reducer to know what it does

The domain prefix isn't decorative: it's what makes the DevTools history read like a chronicle — session/signedIn, catalogue/typeChanged, bookings/bookingCreated — and what makes type unique with no effort. With createSlice you never write it yourself: it's generated by concatenating the slice's name and the reducer's name.

This is the same discipline from 05-05 — past-tense actions describing events — with two mandatory Redux adjustments: the field is called type, not tipo, and the data goes in payload instead of loose fields. These are library APIs and, like the rest of the course, they're never translated.

  1. createSlice in depth: what it takes and what it generates

A slice is "a slice of state together with everything that governs it": its initial state, its transitions and, by convention, its selectors. createSlice takes a configuration object:

Key What it is
name The domain's name. Used as the prefix for every type
initialState This slice's starting state
reducers An object { eventName: (state, action) => … }. Each key generates an action creator
extraReducers Responses to actions defined outside this slice (sections 12 and 13)
selectors Selectors declared alongside the slice (section 9)

And it returns an object with three things:

const catalogueSlice = createSlice({
  name: 'catalogue',
  initialState: { type: 'todos' },
  reducers: {
    typeChanged(state, action) {
      state.type = action.payload;
    }
  }
});

catalogueSlice.name;             // 'catalogue'
catalogueSlice.reducer;          // the reducer function, for configureStore
catalogueSlice.actions;          // { typeChanged: actionCreator }

catalogueSlice.actions.typeChanged('electrica');
// → { type: 'catalogue/typeChanged', payload: 'electrica' }

It's worth pausing on what just happened, because it's most of the saving over classic Redux. From one nine-line definition, you got:

  • The type constant 'catalogue/typeChanged', which you never wrote and therefore can't misspell.
  • The action creator typeChanged, which packages its argument as payload.
  • The reducer's case that responds to that type.
  • The default case that returns the state untouched when the action isn't its own.

In classic Redux that used to be three files and about thirty lines.

Slice export convention — the one used throughout CicloUrbano and that fits the project's own rule ("export default for components, named exports for everything else"), with one practical exception:

// The action creators, named
export const { typeChanged, searchTermChanged, sortChanged } = catalogueSlice.actions;

// The reducer, default: it's the only thing configureStore needs
export default catalogueSlice.reducer;

The reducer goes by default because every slice file has exactly one, so the store's import catalogueReducer from '…/catalogueSlice.js' stays clean.

  1. Immer's apparent immutability

Look again at the reducer from the previous section:

typeChanged(state, action) {
  state.type = action.payload;   // mutation??
}

This flatly contradicts principle 3 from 07-03 and everything you learned in 05-01 and 05-05. And yet it's correct, it's the recommended way to write it, and it mutates nothing.

The explanation is Immer, a library RTK bundles and applies automatically inside createSlice. Here's what happens underneath:

  1. Before calling your reducer, Immer wraps the current state in a draft, a proxy object that looks like the real state.
  2. Your code writes to the draft: state.type = 'electrica', state.ids.push('res-02'), delete state.entities['res-01'].
  3. The proxy doesn't apply those changes: it records them.
  4. When the reducer finishes, Immer builds a new object by applying the recorded changes, copying only the branches that were touched and reusing the references of everything that didn't change.
flowchart LR
    A["Current state<br/>(immutable)"] --> B["Immer creates<br/>a draft (proxy)"]
    B --> C["Your reducer 'mutates'<br/>the draft"]
    C --> D["Immer records<br/>the changes"]
    D --> E["New state<br/>copying only<br/>the touched branches"]
    A -. "untouched branches<br/>share their reference" .-> E

Compare the same change written both ways:

// Without Immer: immutability by hand, three levels of spreading
function reducer(state, action) {
  return {
    ...state,
    entities: {
      ...state.entities,
      [action.payload]: {
        ...state.entities[action.payload],
        status: 'confirmada'
      }
    }
  };
}

// With Immer, inside createSlice
bookingConfirmed(state, action) {
  state.entities[action.payload].status = 'confirmada';
}

Five lines of ... spreading replaced by one that says exactly what it does. And the diagram's last row matters for performance: branches that aren't touched keep their reference, so a component reading state.catalogue won't see any change when a booking gets confirmed. That's what makes useSelector's identity comparison (07-05) work so well.

Two clarifications so the mental model stays correct:

  • Immer only works inside createSlice and createReducer. A hand-written reducer passed straight to configureStore has no draft: there, mutating is mutating, and it will trip the immutableCheck warning you saw in 07-03.
  • The draft only exists while the reducer is running. You can't stash it, pass it to a setTimeout, or return it from a promise.

  1. The rule you can't break

Immer has exactly one rule, and breaking it produces failures that are hard to understand:

Either you mutate the draft, or you return a new state. Never both in the same reducer.

// ✅ CORRECT: mutate the draft and return nothing
bookingConfirmed(state, action) {
  state.entities[action.payload].status = 'confirmada';
}

// ✅ CORRECT: return a new state and don't touch the draft
catalogueReset() {
  return CATALOGUE_INITIAL_STATE;
}

// ❌ WRONG: both
bookingConfirmed(state, action) {
  state.entities[action.payload].status = 'confirmada';
  return { ...state, lastAction: 'confirm' };   // Immer doesn't know what to do with this
}

The reset case deserves attention because it's the one people get wrong the most:

// ❌ Doesn't work: reassigning the parameter changes nothing outside
catalogueReset(state) {
  state = CATALOGUE_INITIAL_STATE;   // only changes the local variable
}

// ✅ This works
catalogueReset() {
  return CATALOGUE_INITIAL_STATE;
}

Reassigning state is basic JavaScript: you change the local variable, not the object it used to point to. Immer can't detect that.

And a third case, the subtlest of them all:

// ❌ Dangerous: a reference to the draft gets saved
let lastState;
bookingCreated(state, action) {
  state.entities[action.payload.id] = action.payload;
  lastState = state;   // the draft gets revoked when the reducer ends; using it later throws
}

Outside the reducer, lastState points to a revoked proxy and any access throws Cannot perform 'get' on a proxy that has been revoked. If you need the resulting state, read it from the store.

Safe operations on the draft, the ones you'll use every day:

Operation Example
Assign a property state.type = action.payload
Push to an array state.ids.push(newId)
Remove from an array state.ids = state.ids.filter((id) => id !== action.payload)
Add a key to an object state.entities[id] = booking
Delete a key delete state.entities[id]
Modify a nested item state.entities[id].status = 'confirmada'

  1. Reducers with preparation: prepare

Principle 3 says a reducer is pure: no random identifiers, no new Date(), nothing non-deterministic. In 05-05 you solved this by building the booking in the handler and passing it already constructed to the reducer. It works, but it just moves the problem: every place that creates a booking has to remember to build it the same way.

RTK offers a better solution: prepare notation. Instead of a function, a reducer can be an object with reducer and prepare.

bookingCreated: {
  // prepare builds the action: non-deterministic effects are allowed HERE
  prepare(bikeId, userId, startDate, hours) {
    return {
      payload: {
        id: `res-${crypto.randomUUID().slice(0, 8)}`,
        bicicletaId: bikeId,
        user: userId,
        startDate,
        hours,
        status: 'activa',
        createdAt: new Date().toISOString()
      }
    };
  },
  // reducer stays pure: it just places what it's given
  reducer(state, action) {
    const booking = action.payload;
    state.entities[booking.id] = booking;
    state.ids.push(booking.id);
  }
}
// Whoever calls this knows nothing about identifiers or dates
bookingCreated('bici-002', 'usr-01', '2026-05-04T09:00', 2);
// → { type: 'bookings/bookingCreated',
//     payload: { id: 'res-3f7a1c9e', bicicletaId: 'bici-002', user: 'usr-01',
//                startDate: '2026-05-04T09:00', hours: 2,
//                status: 'activa', createdAt: '2026-05-04T08:52:10.114Z' } }

What you gain:

  • The reducer stays pure, so it can be tested with a fixed input and time travel still works: replaying the history, the action already has its identifier and date baked in, and the result is identical.
  • Construction lives in one single place. The form, a "repeat booking" button and a test all create bookings exactly the same way.
  • The signature is convenient. bookingCreated('bici-002', 'usr-01', …) instead of forcing every caller to assemble the whole object.
  • Dates are stored as ISO strings, honoring the serializability rule from 07-03.

prepare can also return meta and error besides payload, though CicloUrbano won't need that.

  1. catalogueSlice: filter, search and sort

We start with the simplest one, which also lays the groundwork for derived selectors.

// src/features/catalogue/catalogueSlice.js
import { createSlice } from '@reduxjs/toolkit';

const INITIAL_STATE = {
  type: 'todos',            // 'todos' | 'urbana' | 'electrica' | 'carga'
  searchTerm: '',            // search box text, already debounced
  sort: 'model',              // 'model' | 'price'
  bikes: [],                 // catalogue loaded from the API (temporary: see note)
  loadState: 'idle',         // 'idle' | 'loading' | 'success' | 'error'
  error: null                // message from the last load failure
};

const catalogueSlice = createSlice({
  name: 'catalogue',
  initialState: INITIAL_STATE,
  reducers: {
    typeChanged(state, action) {
      state.type = action.payload;
    },
    searchTermChanged(state, action) {
      state.searchTerm = action.payload;
    },
    sortChanged(state, action) {
      state.sort = action.payload;
    },
    filtersReset(state) {
      // The criteria get reset, NOT the bikes already loaded
      state.type = 'todos';
      state.searchTerm = '';
      state.sort = 'model';
    }
  },
  selectors: {
    selectBikes: (state) => state.bikes,
    selectType: (state) => state.type,
    selectSearchTerm: (state) => state.searchTerm,
    selectSortOrder: (state) => state.sort,
    selectCatalogueLoadState: (state) => state.loadState,
    selectCatalogueError: (state) => state.error
  }
});

export const { typeChanged, searchTermChanged, sortChanged, filtersReset } =
  catalogueSlice.actions;

export const {
  selectBikes, selectType, selectSearchTerm,
  selectSortOrder, selectCatalogueLoadState, selectCatalogueError
} = catalogueSlice.selectors;

export default catalogueSlice.reducer;

Two comments on specific decisions:

  • filtersReset doesn't return INITIAL_STATE. If it did, it would also wipe out the bikes already loaded and the load state. It modifies just the three fields that belong to it and leaves the rest untouched: it's the rule from section 4, applied with judgment.
  • bikes is here provisionally, with an expiration date. Under 07-01's taxonomy, it's server state and shouldn't live in a client store. It's placed here so you can learn createAsyncThunk with a real case, and in 07-06 it will move out of the slice into a query cache. It's fine to watch it get done and then undone: it's exactly what happens on a real project when a server-state library gets introduced.

And a warning that gets fully resolved in 07-05: just because type exists in the slice doesn't mean the filter stops living in the URL. The catalogue filter is still URL state (06-02, 07-01) and can't have two sources of truth. How the two get reconciled is a decision made when wiring up the components.

  1. sessionSlice: user and sign-in

// src/features/session/sessionSlice.js
import { createSlice } from '@reduxjs/toolkit';
import { users } from '../../data/domain.js';

const INITIAL_STATE = {
  user: null,      // { id, name, email, role } or null if there's no session
  loading: true,   // true while checking whether a saved session exists
  error: null       // message if sign-in fails
};

const sessionSlice = createSlice({
  name: 'session',
  initialState: INITIAL_STATE,
  reducers: {
    signedIn(state, action) {
      state.user = action.payload;   // the full user, already resolved
      state.loading = false;
      state.error = null;
    },
    signedOut(state) {
      state.user = null;
      state.loading = false;
      state.error = null;
    },
    signInFailed(state, action) {
      state.user = null;
      state.loading = false;
      state.error = action.payload;
    },
    checkFinished(state) {
      // Local storage has been checked and there was no session
      state.loading = false;
    }
  },
  selectors: {
    selectUser: (state) => state.user,
    selectIsOperator: (state) => state.user?.role === 'operario',
    selectSessionLoading: (state) => state.loading,
    selectSessionError: (state) => state.error
  }
});

export const { signedIn, signedOut, signInFailed, checkFinished } =
  sessionSlice.actions;

export const {
  selectUser,
  selectIsOperator,
  selectSessionLoading,
  selectSessionError
} = sessionSlice.selectors;

export default sessionSlice.reducer;

Decisions worth justifying:

  • signedIn receives the full user, not their identifier. Looking the user up in users is a lookup that can fail and that, once there's a real API, will be asynchronous: that's not the reducer's job. If you'd rather call it with just the identifier, that's a perfect case for prepare.
  • loading: true as the initial value is 06-05's sessionLoading, and it's still essential: starting at false would make ProtectedRoute kick out a user with a valid session during the first render.
  • isOperator isn't in state, it's a selector. It's the derived state from 07-01 and 05-01 applied to Redux: it gets computed, not stored. If it were stored, you'd have to remember to update it across all four transitions.

  1. bookingsSlice: creating, confirming and cancelling

The central slice, already in normalized form (the reasoning is in section 10).

// src/features/bookings/bookingsSlice.js
import { createSlice } from '@reduxjs/toolkit';

const INITIAL_STATE = {
  entities: {},            // { 'res-01': { id, bicicletaId, user, startDate, hours, status }, … }
  ids: [],                  // display order: ['res-01', …]
  loadState: 'idle',        // 'idle' | 'loading' | 'success' | 'error'
  error: null,               // message from the last failure
  submitState: 'idle'       // 'idle' | 'submitting' | 'success' | 'error'
};

const bookingsSlice = createSlice({
  name: 'bookings',
  initialState: INITIAL_STATE,
  reducers: {
    bookingCreated: {
      prepare(bikeId, userId, startDate, hours) {
        return {
          payload: {
            id: `res-${crypto.randomUUID().slice(0, 8)}`,
            bicicletaId: bikeId,
            user: userId,
            startDate,
            hours,
            status: 'activa',
            createdAt: new Date().toISOString()
          }
        };
      },
      reducer(state, action) {
        const booking = action.payload;
        state.entities[booking.id] = booking;
        state.ids.push(booking.id);
        state.submitState = 'success';
        state.error = null;
      }
    },

    bookingConfirmed(state, action) {
      const booking = state.entities[action.payload];
      // Defensive guard: the action could arrive with an id that no longer exists
      if (!booking) return;
      // Only an active booking can be confirmed
      if (booking.status !== 'activa') return;
      booking.status = 'confirmada';
    },

    bookingCancelled: {
      prepare(bookingId) {
        return { payload: { bookingId, cancelledAt: new Date().toISOString() } };
      },
      reducer(state, action) {
        const { bookingId, cancelledAt } = action.payload;
        const booking = state.entities[bookingId];
        if (!booking) return;
        if (booking.status === 'cancelada') return;
        booking.status = 'cancelada';
        booking.cancelledAt = cancelledAt;
      }
    },

    submitStarted(state) {
      state.submitState = 'submitting';
      state.error = null;
    },

    submitFailed(state, action) {
      state.submitState = 'error';
      state.error = action.payload;
    }
  },

  selectors: {
    selectBookingIds: (state) => state.ids,
    selectBookingEntities: (state) => state.entities,
    selectBookingById: (state, id) => state.entities[id],
    selectSubmitState: (state) => state.submitState,
    selectBookingsError: (state) => state.error
  }
});

export const {
  bookingCreated, bookingConfirmed, bookingCancelled, submitStarted, submitFailed
} = bookingsSlice.actions;

export const {
  selectBookingIds, selectBookingEntities,
  selectBookingById, selectSubmitState, selectBookingsError
} = bookingsSlice.selectors;

export default bookingsSlice.reducer;

A booking's lifecycle is modeled like this:

stateDiagram-v2
    [*] --> active: bookingCreated
    active --> confirmed: bookingConfirmed
    active --> cancelled: bookingCancelled
    confirmed --> cancelled: bookingCancelled
    cancelled --> [*]

Two details that make the difference between a correct reducer and a fragile one:

  • Guards return without doing anything (if (!booking) return;). Inside createSlice, a bare return means "nothing changes," and Immer returns the same state. Don't confuse this with an explicit return undefined in a hand-written reducer, where it would be a bug.
  • if (booking.status !== 'activa') return; encodes a business rule in the one place where it can always be enforced. Confirming an already-cancelled booking becomes impossible from anywhere in the app, and that guarantee is exactly what you buy with principle 2.

Notice also that the form's draft isn't in this slice, unlike 05-05's bookingsReducer. That's a deliberate decision from the 07-01 audit: the draft is form state, it changes on every keystroke, and there's no reason it should trigger a comparison cycle across every store subscriber. It stays next to the form.

  1. Selectors: the boundary with the shape of state

A selector is a function that takes state and returns a slice or a derivative of it.

const selectUser = (state) => state.session.user;

It looks trivial, and yet it solves a very expensive problem. Without selectors, components know the exact shape of the state:

// ❌ Without selectors: twenty components coupled to the store's shape
const user = useSelector((state) => state.session.user);
const bookings = useSelector((state) => state.bookings.ids.map((id) => state.bookings.entities[id]));

The day session gets renamed to auth, or bookings stop being normalized, you have to find and change twenty components, with the certainty of missing two. With selectors, only one file knows the shape of the state: the slice's.

Why they're declared next to the slice: the slice is the only module allowed to change the shape of state, so it's the only one that should know it. If it changes, its selectors get updated and nobody else notices. It's classic encapsulation, applied to state.

RTK offers the selectors key inside createSlice, and it has a very useful quirk: selectors declared there receive the slice's own state, not the global state, and RTK automatically adapts them so they can be called from outside with the whole state.

// Inside the slice, 'state' is session's own state
selectors: {
  selectUser: (state) => state.user   // not state.session.user
}

// From outside they're used with the global state, and RTK does the translation
selectUser(store.getState());   // works

This works because createSlice knows under which key the slice will be mounted... as long as the reducer's key in configureStore matches the slice's name. That's yet another reason to keep them matching.

Derived selectors

The interesting ones are the ones that compute instead of just reading. This is the one that governs CicloUrbano's catalogue:

// src/features/catalogue/selectors.js
import { selectBikes, selectType, selectSearchTerm, selectSortOrder }
  from './catalogueSlice.js';

/**
 * Bikes that should be visible, applying type, search term and sort.
 * This is DERIVED state: it isn't stored, it's computed.
 */
export function selectVisibleBikes(state) {
  const bikes = selectBikes(state);
  const type = selectType(state);
  const searchTerm = selectSearchTerm(state).trim().toLowerCase();
  const sort = selectSortOrder(state);

  const filtered = bikes.filter((bike) => {
    const matchesType = type === 'todos' || bike.type === type;
    const matchesTerm = searchTerm === '' || bike.model.toLowerCase().includes(searchTerm);
    return matchesType && matchesTerm;
  });

  return [...filtered].sort((a, b) =>
    sort === 'price' ? a.pricePerHour - b.pricePerHour : a.model.localeCompare(b.model)
  );
}

This is 07-01 carried over into Redux: visible isn't stored, it's computed. There's no visibleUpdated action and no effect syncing anything, so it's impossible for the filter and the list to contradict each other.

But this selector has a problem that needs flagging here and fixing in the next lesson: it returns a new array on every call, because filter and sort create new arrays. When you use it with useSelector, the identity comparison will always say "it changed" and the component will re-render on every action dispatched to the store, even ones with nothing to do with the catalogue. The fix is createSelector, which memoizes the result and only recomputes if its inputs change. It's explained in depth in 07-05, where the failure gets reproduced and fixed.

Selectors with an argument

selectBookingById takes an argument besides state:

selectBookingById: (state, id) => state.entities[id]

This is perfectly valid and very useful. Keep in mind a selector like this can't be memoized with createSelector as-is, because memoization stores a single result and switching identifiers would invalidate it every time. That's what selector factories are for, a pattern also covered in 07-05.

  1. Normalizing state by identifier

The two slices with entities — bookings now, and bikes while it's still here — use the { entities, ids } shape instead of an array. This is normalization, and the reason is clearer with a comparison.

// NOT normalized: an array
{
  bookings: [
    { id: 'res-01', bicicletaId: 'bici-002', user: 'usr-01', hours: 2, status: 'activa' }
  ]
}

// NORMALIZED: entities by id + order kept separately
{
  entities: {
    'res-01': { id: 'res-01', bicicletaId: 'bici-002', user: 'usr-01', hours: 2, status: 'activa' }
  },
  ids: ['res-01']
}
Operation With an array Normalized
Look up by id bookings.find(r => r.id === id) — walks the list entities[id] — direct access
Update one map that builds a whole new array entities[id].status = … — touches one branch
Insert [...bookings, newOne] Two point writes
Remove filter over the whole array delete + a filter over the ids (strings)
Re-renders triggered Everyone reading the list Only whoever reads that entity

The last row is the decisive one. With an array, confirming a booking creates a new array and every component reading the list re-renders. Normalized, one branch of entities changes and the rest keep their reference (section 3), so a component reading entities['res-07'] sees no change at all.

And the deeper reason: no nested data. Notice that a booking stores bicicletaId: 'bici-002' and user: 'usr-01', not the full bike or the full user. If it stored the whole object instead:

  • The bici-002 model would be duplicated across every booking that uses it, and updating the price would mean walking through every single one.
  • Two bookings could show different data for the same bike.
  • There would no longer be a single source of truth, contradicting principle 1.

The rule is the same as in a relational database: each entity once, and relationships by identifier. Rebuilding the full object for display is a derived selector's job, not the state's.

ids on its own serves a purpose that isn't obvious at first: it holds the order. An object's keys don't guarantee any useful order, so the display sequence is represented explicitly as an array of strings, cheap to copy and to compare.

  1. createEntityAdapter in brief

Hand-writing entities[id] = x; ids.push(id) in every reducer is repetitive and easy to get out of sync. RTK ships createEntityAdapter, which generates those operations for you.

import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';

const bookingsAdapter = createEntityAdapter({
  // How ids are sorted. Without this option, insertion order.
  sortComparer: (a, b) => b.startDate.localeCompare(a.startDate)
});

const bookingsSlice = createSlice({
  name: 'bookings',
  // getInitialState generates { ids: [], entities: {} } and adds yours
  initialState: bookingsAdapter.getInitialState({
    loadState: 'idle',
    error: null
  }),
  reducers: {
    // The adapter's methods ARE valid reducers
    bookingAdded: bookingsAdapter.addOne,
    bookingsReceived: bookingsAdapter.setAll,
    bookingUpdated: bookingsAdapter.updateOne,   // { id, changes: { status: 'confirmada' } }
    bookingRemoved: bookingsAdapter.removeOne
  }
});

// And it generates the basic selectors, already memoized
export const {
  selectAll: selectAllBookings,
  selectById: selectBookingById,
  selectIds: selectBookingIds
} = bookingsAdapter.getSelectors((state) => state.bookings);
Method What it does
addOne / addMany Adds without touching existing entries
setAll Replaces the whole set: typical after loading from the server
upsertOne / upsertMany Adds, or merges if it already exists
updateOne Applies partial changes: { id, changes }
removeOne / removeAll Removes

One naming note: the adapter's own fields are entities and ids — RTK's own convention, not something translated. It matches the field names already used in this lesson's hand-written slices, so adopting the adapter later would require no renaming here. If your own state used different field names, adopting createEntityAdapter would mean switching to entities/ids to match it.

In CicloUrbano, with five bikes and a handful of bookings, the adapter is more machinery than needed, which is why the slices above are hand-written: it's easier to see what's going on. In an app with thousands of entities and half a dozen entity types, createEntityAdapter saves a lot of code and eliminates a whole family of bugs from ids and entities drifting out of sync.

  1. Async logic with createAsyncThunk

Reducers are pure: they can't make requests. Requests happen in a thunk, a function dispatched instead of an object, which receives dispatch and getState. RTK wraps the pattern in createAsyncThunk.

// src/features/catalogue/catalogueSlice.js (continued)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

/**
 * Loads the bike catalogue from the fake API.
 * (The json-server API is set up in 07-06; its URL is already used here.)
 */
export const fetchBikes = createAsyncThunk(
  'catalogue/fetchBikes',            // prefix for the three generated types
  async (stationId, thunkAPI) => {   // function that returns a promise
    const { signal, rejectWithValue } = thunkAPI;

    const url = stationId
      ? `http://localhost:3001/bicicletas?stationId=${stationId}`
      : 'http://localhost:3001/bicicletas';

    const response = await fetch(url, { signal });   // cancelable: see below
    if (!response.ok) {
      return rejectWithValue(`The server responded with ${response.status}`);
    }
    return response.json();   // becomes the 'fulfilled' payload
  }
);

createAsyncThunk generates three action types from the prefix:

Generated action When it's dispatched payload
catalogue/fetchBikes/pending Right when it starts The argument, in meta.arg
catalogue/fetchBikes/fulfilled When the promise resolves Whatever the function returns
catalogue/fetchBikes/rejected When it throws or rejects The error, or whatever was passed to rejectWithValue
sequenceDiagram
    participant C as Component
    participant T as fetchBikes
    participant A as Store
    participant S as API :3001
    C->>T: dispatch(fetchBikes('est-01'))
    T->>A: /pending
    A->>A: loadState = 'loading'
    T->>S: GET /bicicletas?stationId=est-01
    alt Successful response
        S-->>T: 200 + JSON
        T->>A: /fulfilled with payload
        A->>A: bikes = payload · loadState = 'success'
    else Failure
        S-->>T: 500 or network error
        T->>A: /rejected with the message
        A->>A: loadState = 'error' · error = message
    end

Those three actions aren't in reducers because this slice doesn't define them: the thunk does. They're handled in extraReducers.

const catalogueSlice = createSlice({
  name: 'catalogue',
  initialState: INITIAL_STATE,
  reducers: { /* … typeChanged, searchTermChanged, sortChanged … */ },

  extraReducers: (builder) => {
    builder
      .addCase(fetchBikes.pending, (state) => {
        state.loadState = 'loading';
        state.error = null;
      })
      .addCase(fetchBikes.fulfilled, (state, action) => {
        state.bikes = action.payload;
        state.loadState = 'success';
      })
      .addCase(fetchBikes.rejected, (state, action) => {
        state.loadState = 'error';
        // payload if rejectWithValue was used; otherwise the error's message
        state.error = action.payload ?? action.error.message;
      });
  }
});

This loadState + error pattern is the same one from useFetchBikes (05-06), with 'idle' | 'loading' | 'success' | 'error' as the single phase variable, and for the same reason: a single variable makes contradictory states impossible, the kind where you're "loading and failed at the same time."

Details about the thunk worth pinning down:

  • thunkAPI (the second parameter) carries a lot more than signal: dispatch for dispatching other actions, getState for reading the current state, rejectWithValue for returning a controlled error, and requestId for identifying this specific run.
  • signal is an AbortSignal: passing it to fetch, the request gets cancelled if the thunk is aborted. It's the AbortController from 05-02, already wired up.
  • condition avoids duplicate requests, a problem you solved by hand in 05-02:
export const fetchBikes = createAsyncThunk(
  'catalogue/fetchBikes',
  async (stationId, { signal }) => { /* … */ },
  {
    condition(stationId, { getState }) {
      // If it's already loading, don't fire another request
      if (getState().catalogue.loadState === 'loading') return false;
    }
  }
);
  • The function returns the data, it doesn't store it. The thunk fetches; the reducer places. Keeping that separation is what lets you test each part on its own.

An honest heads-up, which is the thread leading to the end of this module: all of this — loading, errors, cancellation, avoiding duplicates — is infrastructure you're hand-writing for every resource. And revalidation, invalidation after a write, retries and caching are all still missing. That whole list is exactly what justifies lesson 07-06.

  1. extraReducers for reacting to other slices

extraReducers isn't just for thunks: it lets a slice respond to any action, including ones from another domain. It's the answer to signal 4 from 07-02 ("several providers need to react to the same event").

Concrete case: when signing out, the user's bookings should disappear from the store.

// src/features/bookings/bookingsSlice.js
import { signedOut } from '../session/sessionSlice.js';

const bookingsSlice = createSlice({
  name: 'bookings',
  initialState: INITIAL_STATE,
  reducers: { /* … */ },
  extraReducers: (builder) => {
    builder
      .addCase(signedOut, () => INITIAL_STATE)   // returning a new state: correct
      .addMatcher(
        (action) => action.type.endsWith('/rejected'),
        (state, action) => {
          state.error = action.payload ?? action.error?.message ?? 'Unknown failure';
        }
      )
      .addDefaultCase((state) => state);
  }
});
Builder method What it does
addCase(action, reducer) Responds to one specific type. Must come before any addMatcher
addMatcher(predicate, reducer) Responds to every action that satisfies a condition
addDefaultCase(reducer) Responds to whatever didn't match anything above

The conceptually important part: an action is dispatched once, and every reducer sees it. sessionSlice defines signedOut, and bookingsSlice consumes it too, with neither of them knowing about the other beyond the import of the action creator. It's the decoupling the context API couldn't give you — there, you'd have had to pass a function from one provider to another.

Watch out for a circular dependency: bookingsSlice imports signedOut from sessionSlice. If sessionSlice imported anything from bookingsSlice, you'd have a cycle. The usual convention is that "cross-cutting" actions live in the slice that originates them, and everyone else imports them, never the other way around.

  1. Testing reducers and selectors

This is where principle 3's promise gets cashed in: reducers and selectors are pure functions, so testing them means calling them and comparing the output. No React, no DOM, no store, no simulations.

// src/features/bookings/bookingsSlice.test.js
import reducer, { bookingConfirmed, bookingCancelled, bookingCreated } from './bookingsSlice.js';

const STATE_WITH_ONE = {
  entities: {
    'res-01': {
      id: 'res-01', bicicletaId: 'bici-002', user: 'usr-01',
      startDate: '2026-05-04T09:00', hours: 2, status: 'activa'
    }
  },
  ids: ['res-01'],
  loadState: 'success',
  error: null,
  submitState: 'idle'
};

test('confirms an active booking', () => {
  const next = reducer(STATE_WITH_ONE, bookingConfirmed('res-01'));
  expect(next.entities['res-01'].status).toBe('confirmada');
});

test('does not confirm an already cancelled booking', () => {
  const cancelled = reducer(STATE_WITH_ONE, bookingCancelled('res-01'));
  const attempt = reducer(cancelled, bookingConfirmed('res-01'));
  expect(attempt.entities['res-01'].status).toBe('cancelada');
});

test('does not mutate the state it receives', () => {
  reducer(STATE_WITH_ONE, bookingConfirmed('res-01'));
  expect(STATE_WITH_ONE.entities['res-01'].status).toBe('activa');   // untouched
});

test('ignores a non-existent booking', () => {
  const next = reducer(STATE_WITH_ONE, bookingConfirmed('res-99'));
  expect(next).toBe(STATE_WITH_ONE);   // same reference: nothing changed
});

test('creates a booking in active status', () => {
  const next = reducer(STATE_WITH_ONE, bookingCreated('bici-004', 'usr-01', '2026-05-06T10:00', 3));
  expect(next.ids).toHaveLength(2);
  const created = next.entities[next.ids[1]];
  expect(created.status).toBe('activa');
  expect(created.bicicletaId).toBe('bici-004');
});

Notice two especially valuable tests:

  • "does not mutate the state it receives" verifies principle 3 directly. It's the safety net against some future refactor that pulls the reducer out of createSlice and loses Immer.
  • "ignores a non-existent booking" checks with toBe that the reference is the same. It's proof the guard works and that no unnecessary new state gets generated, which in turn avoids re-renders.

Derived selectors are just as easy to test:

import { selectVisibleBikes } from './selectors.js';

test('filters by type and sorts by price', () => {
  const state = {
    catalogue: {
      type: 'electrica', searchTerm: '', sort: 'price',
      bikes: [
        { id: 'bici-002', model: 'Electric Pro', type: 'electrica', pricePerHour: 4.0 },
        { id: 'bici-001', model: 'Classic Urban', type: 'urbana', pricePerHour: 2.5 },
        { id: 'bici-005', model: 'Electric Pro', type: 'electrica', pricePerHour: 4.0 }
      ],
      loadState: 'success', error: null
    }
  };
  const visible = selectVisibleBikes(state);
  expect(visible).toHaveLength(2);
  expect(visible.every((b) => b.type === 'electrica')).toBe(true);
});

This is one of Redux's most underrated advantages: all the business logic lives in pure functions that get tested without mounting a single component. The concrete tools — Jest, assertions, coverage — are Module 9's territory; what matters today is that the design makes it possible.

Common Mistakes and Tips

Mistake 1: mutating and returning at the same time. state.x = 1; return {…state} breaks Immer's one rule. Either one thing, or the other.

Mistake 2: reassigning the state parameter. state = INITIAL_STATE does nothing. To reset, return INITIAL_STATE.

Mistake 3: generating identifiers or dates inside the reducer. It breaks purity, and with it time travel and testing. Belongs in prepare.

Mistake 4: storing derived state. A visible, total or isOperator field in the state is a field that will someday contradict its source. Selector.

Mistake 5: nesting whole entities. Storing the full bike inside every booking duplicates data and breaks the single source of truth. Store bicicletaId and rebuild it in a selector.

Mistake 6: naming actions like commands. catalogue/setType describes an assignment; catalogue/typeChanged describes an event. With the second form, DevTools reads like a chronicle of what the user did.

Mistake 7: putting addMatcher before addCase. The extraReducers builder evaluates them in order, and RTK warns you if you mix them up. Specific cases first, then matchers, and addDefaultCase last.

Mistake 8: initialState shared by reference. If several slices share the same object literal, and one of them returns it as-is on a reset, a later change could affect both. Define a constant per slice.

Tip 1: one file per slice, and the slice owns its shape. State, reducers and selectors together. Nobody outside it should ever write state.bookings.entities.

Tip 2: write the initial state first, commented. Deciding the shape before the transitions avoids redoing the reducers three times. Type comments ('activa' | 'confirmada' | 'cancelada') are worth their weight in gold.

Tip 3: test every new reducer the same day. It costs two minutes because they're pure functions, and those tests stay valid even after the component that uses them gets rewritten from scratch.

Tip 4: guard reducers that look up by id. if (!entity) return; turns a crash into a no-op. Without the guard, an action with a stale identifier takes the whole app down.

Exercises

Exercise 1. Add a bookingExtended action to bookingsSlice that adds hours to a booking. Requirements: only an 'activa' booking can be extended; the total is capped at 24 hours; the moment of the extension must be recorded; and if the booking doesn't exist or doesn't meet the conditions, the state must not change. Write two tests as well.

Exercise 2. This slice has five problems. Find them and rewrite it.

import { createSlice } from '@reduxjs/toolkit';

const stationsSlice = createSlice({
  name: 'stations',
  initialState: {
    list: [],
    selected: null,
    totalDocks: 0,
    lastFetch: new Date()
  },
  reducers: {
    SET_STATIONS(state, action) {
      state.list = action.payload;
      state.totalDocks = action.payload.reduce((s, e) => s + e.docks, 0);
      return { ...state, lastFetch: new Date() };
    },
    stationSelected(state, action) {
      const found = state.list.find((e) => e.id === action.payload);
      state.selected = found;
    },
    reset(state) {
      state = { list: [], selected: null, totalDocks: 0, lastFetch: new Date() };
    }
  }
});

Exercise 3. Write a createAsyncThunk called submitBooking that sends a new booking to POST http://localhost:3001/reservas and handles its three states in bookingsSlice using the submitState field. It must: read the current user from the store instead of receiving it as an argument, return a controlled error if the response isn't successful, and add the booking to the normalized state once the server confirms it.

Solutions

Solution 1.

// Inside reducers, in bookingsSlice
bookingExtended: {
  prepare(bookingId, extraHours) {
    return { payload: { bookingId, extraHours, extendedAt: new Date().toISOString() } };
  },
  reducer(state, action) {
    const { bookingId, extraHours, extendedAt } = action.payload;
    const booking = state.entities[bookingId];

    if (!booking) return;                       // doesn't exist
    if (booking.status !== 'activa') return;    // only active ones
    if (extraHours <= 0) return;                // extending by zero or less makes no sense

    const total = booking.hours + extraHours;
    if (total > 24) return;                      // domain limit

    booking.hours = total;
    booking.extendedAt = extendedAt;
  }
}
test('extends an active booking within the limit', () => {
  const next = reducer(STATE_WITH_ONE, bookingExtended('res-01', 3));
  expect(next.entities['res-01'].hours).toBe(5);   // 2 + 3
  expect(next.entities['res-01'].extendedAt).toBeDefined();
});

test('does not extend past 24 hours', () => {
  const next = reducer(STATE_WITH_ONE, bookingExtended('res-01', 30));
  expect(next).toBe(STATE_WITH_ONE);   // same reference: nothing changed
});

extendedAt goes in prepare because new Date() can't be in the reducer, and the 24-hour limit is encoded in the reducer — not the component — so no future screen can ever bypass it.

Solution 2. The five problems:

  1. SET_STATIONS: SCREAMING_CASE name shaped like a command. It should be stationsReceived, a past-tense event.
  2. totalDocks is derived state stored in the store. It's a sum over list: belongs in a selector.
  3. SET_STATIONS mutates and returns in the same reducer: breaks Immer's rule.
  4. new Date() in two places: in initialState and in the reducers. Not serializable and not pure. An ISO string, generated in prepare.
  5. reset reassigns the parameter, so it does nothing. It must return the initial state.

And a sixth for good measure: selected stores the whole object, duplicating the entity. It should store the identifier and get rebuilt in a selector.

import { createSlice } from '@reduxjs/toolkit';

const INITIAL_STATE = {
  list: [],
  selectedId: null,          // 6) only the id
  lastFetch: null            // 4) ISO string or null
};

const stationsSlice = createSlice({
  name: 'stations',
  initialState: INITIAL_STATE,
  reducers: {
    stationsReceived: {   // 1) past-tense event
      prepare(stations) {  // 4) the moment is generated outside the reducer
        return { payload: { stations, fetchedAt: new Date().toISOString() } };
      },
      reducer(state, action) {   // 3) only mutates the draft, doesn't return
        state.list = action.payload.stations;
        state.lastFetch = action.payload.fetchedAt;
      }
    },
    stationSelected(state, action) {
      state.selectedId = action.payload;
    },
    stationsReset() {
      return INITIAL_STATE;   // 5) return, don't reassign
    }
  },
  selectors: {
    selectStations: (state) => state.list,
    // 2) derived: computed, not stored
    selectTotalDocks: (state) =>
      state.list.reduce((sum, station) => sum + station.docks, 0),
    // 6) the full entity is rebuilt here
    selectSelectedStation: (state) =>
      state.list.find((station) => station.id === state.selectedId) ?? null
  }
});

export const { stationsReceived, stationSelected, stationsReset } =
  stationsSlice.actions;
export const { selectStations, selectTotalDocks, selectSelectedStation } =
  stationsSlice.selectors;
export default stationsSlice.reducer;

Solution 3.

// src/features/bookings/bookingsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const submitBooking = createAsyncThunk(
  'bookings/submitBooking',
  async (draft, { getState, rejectWithValue, signal }) => {
    // The user is read from the store: the component doesn't need to pass it
    const user = getState().session.user;
    if (!user) {
      return rejectWithValue('You need to sign in to book.');
    }

    const body = {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,
      bicicletaId: draft.bicicletaId,
      user: user.id,
      startDate: draft.startDate,
      hours: Number(draft.hours),
      status: 'activa'
    };

    try {
      const response = await fetch('http://localhost:3001/reservas', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
        signal
      });
      if (!response.ok) {
        return rejectWithValue(`The booking couldn't be created (${response.status}).`);
      }
      return response.json();   // the server returns the created booking
    } catch (failure) {
      if (failure.name === 'AbortError') throw failure;   // cancellation isn't a business error
      return rejectWithValue('There is no connection to the server.');
    }
  },
  {
    // Avoids a double submit from a double click
    condition(draft, { getState }) {
      if (getState().bookings.submitState === 'submitting') return false;
    }
  }
);

// And in bookingsSlice's extraReducers:
extraReducers: (builder) => {
  builder
    .addCase(submitBooking.pending, (state) => {
      state.submitState = 'submitting';
      state.error = null;
    })
    .addCase(submitBooking.fulfilled, (state, action) => {
      const booking = action.payload;
      state.entities[booking.id] = booking;   // normalized state
      state.ids.push(booking.id);
      state.submitState = 'success';
      state.error = null;
    })
    .addCase(submitBooking.rejected, (state, action) => {
      state.submitState = 'error';
      state.error = action.payload ?? action.error.message;
    });
}

Three decisions worth a comment:

  • getState() inside the thunk keeps every component from having to read the user and pass it along. The rule behind it: the thunk is where logic that needs to read state belongs; the reducer just applies the result.
  • rejectWithValue versus throwing. Throwing, action.error.message carries the technical message from the error (Failed to fetch); with rejectWithValue you control exactly what text the user sees. For UI messages, always rejectWithValue.
  • The booking is built by the thunk, not the reducer or prepare, because the server's response is authoritative: fulfilled stores what the API returned, not what was sent. If the server assigned its own identifier, this would be essential.

Conclusion

CicloUrbano's store now has behavior. You know an action is { type, payload }, named domain/eventThatHappened in the past tense — bookings/bookingConfirmed, catalogue/typeChanged, session/signedOut — and that this naming is what turns the DevTools history into a readable chronicle. createSlice takes name, initialState and reducers and generates the types, the action creators, the reducer and the default case all at once: what used to be three or four files in classic Redux becomes one. Immer lets you write state.entities[id].status = 'confirmada' inside a slice because what you're touching is a draft that records changes and produces a new object by copying only the affected branches — everything else keeps its reference, which is what will make useSelector efficient — with one rule you can never break: either you mutate the draft or you return a new state, never both. And prepare keeps things pure by pulling everything non-deterministic — identifiers, ISO dates — out of the reducer.

CicloUrbano ends up with three complete slices: catalogueSlice with type, searchTerm and sort plus loading the catalogue, sessionSlice with the user, the loading: true starting value that's essential for ProtectedRoute, and isOperator as a selector rather than a field, and bookingsSlice with the activa → confirmada / cancelada cycle, guards that encode business rules in the one place they can't be bypassed, and normalized { entities, ids } state: each entity once, relationships by identifier — bicicletaId, not the whole bike — and explicit order in an array of strings. Selectors live next to their slice because the slice is the only thing that should know the shape of the state, and derived ones like selectVisibleBikes compute instead of storing, with the pending warning that they return a new array on every call. createAsyncThunk resolves asynchrony with its three pending/fulfilled/rejected actions handled in extraReducers using the loadState/error pattern, and extraReducers also lets one slice react to another's actions without coupling to it. All of it is pure functions, so testing them means calling them and comparing, with no React and no DOM.

What's still missing is the part you can see. In the next lesson you'll wire the store up to components with useSelector and useDispatch, and that's where the golden rule shows up that decides whether Redux re-renders your whole app or only what's needed: identity comparison. You'll see the bug reproduced with selectVisibleBikes, its four fixes including memoization with createSelector, you'll rewrite CataloguePage, BookingsPage, BookingsPanel and UserMenu, and you'll decide with real arguments what stays out of the store. The next lesson is Redux: Connecting to React.

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