CicloUrbano's store already has shape and behavior: three complete slices, business rules in the reducers, normalized entities, selectors living next to their domain, and async loading with createAsyncThunk. All of that exists today without a single component knowing about it. This lesson closes the loop with react-redux, and it does so by giving disproportionate attention to one single issue, because it's the one that separates an integration that works from one that re-renders the whole app on every action: the golden rule of identity comparison in useSelector. You'll see how a component subscribes and when it re-runs, reproduce the bug from a selector that returns a new array and fix it four different ways, rewrite CataloguePage, BookingsPage, BookingsPanel and UserMenu on top of the store, decide with real arguments what stays out of Redux, dispatch thunks while showing loading and error states, learn to read connect in legacy code, debug with the DevTools history, and organize code by feature. It closes with an honest comparison between context + reducer, Redux Toolkit and Zustand for an app like this one.

Contents

  1. useSelector: how a component subscribes
  2. The golden rule: identity comparison
  3. The bug, reproduced
  4. The four fixes
  5. createSelector in practice
  6. useDispatch, and why dispatch is stable
  7. UserMenu on top of sessionSlice
  8. CataloguePage: the URL filter and the store coexisting
  9. BookingsPage and BookingsPanel on top of bookingsSlice
  10. Dispatching thunks from a component
  11. What stays out of Redux
  12. connect and mapStateToProps: only for reading legacy code
  13. Debugging with Redux DevTools
  14. Code organization: folder per feature
  15. An honest final comparison

  1. useSelector: how a component subscribes

import { useSelector } from 'react-redux';

function BookingsBadge() {
  const count = useSelector((state) => state.bookings.ids.length);
  return <span className="badge">{count}</span>;
}

What useSelector does under the hood, step by step:

  1. It subscribes to the store via store.subscribe(...), using the Provider you placed in main.jsx (07-03).
  2. It runs the selector function with the current state and returns the result.
  3. After every dispatched action, it runs the selector function again with the new state.
  4. It compares the new result with the previous one using Object.is (reference identity comparison).
  5. If they differ, it triggers a re-render of the component. If they're equal, it does nothing.

That step 5 is the granular selection that context couldn't give you (07-02). A component subscribed to state.bookings.ids.length doesn't re-render when the theme changes, when someone types in the search box, or when a booking gets confirmed without changing the count.

And step 3 is the one to always keep in mind: the selector function runs after every action, no matter which domain it belongs to. session/signedIn, catalogue/searchTermChanged, bookings/bookingConfirmed: all of them make every selector in every mounted component run. Selectors must therefore be cheap.

flowchart TD
    A["dispatch(anyAction)"] --> B["The reducer produces<br/>the new state"]
    B --> C["The store notifies<br/>ALL subscribers"]
    C --> D["Each useSelector runs<br/>its selector function"]
    D --> E{"Object.is(new, previous)"}
    E -- "true" --> F["No re-render"]
    E -- "false" --> G["Component re-renders"]

  1. The golden rule: identity comparison

useSelector compares with Object.is. If your selector builds a new object or array on every call, the comparison will always say "it changed," and the component will re-render on every action dispatched to the store, no matter where it came from.

It's the same identity trap you saw in 07-02 with a provider's value, in a different place and under a different name. And here it's worse, because a badly subscribed component re-renders on actions that have nothing to do with it.

What the selector returns Does identity repeat? Consequence
A number, a string, a boolean, null Yes, if the value is equal ✅ Correct
A reference stored in state (state.session.user) Yes, as long as the reducer doesn't replace it ✅ Correct
An object literal { a, b } No, never ❌ Re-renders on every action
The result of .map(), .filter(), .sort(), .slice() No, never ❌ Re-renders on every action
Object.values(...), [...something] No, never ❌ Re-renders on every action

The "a reference stored in state" row explains why 07-04's normalization is such a good idea: since Immer reuses the references of the branches that don't change, state.bookings.entities['res-01'] is literally the same object as long as nobody touches that booking, and identity comparison works on its own.

  1. The bug, reproduced

Let's look at the problem with the derived selector from 07-04, which is exactly the dangerous case.

// ❌ BUGGY VERSION
import { useSelector } from 'react-redux';
import { selectVisibleBikes } from '../features/catalogue/selectors.js';

function CataloguePage() {
  const visible = useSelector(selectVisibleBikes);
  console.log('CataloguePage ran');
  return <BikeList bikes={visible} />;
}

Remember the selector: it ends with [...filtered].sort(...). It returns a new array every time.

Try this in the browser with DevTools open:

  1. Type a letter in the search box → catalogue/searchTermChanged → the component runs. Correct: the catalogue changed.
  2. Click the theme button... and nothing happens here, because the theme isn't in Redux. Good call.
  3. Sign in → session/signedIn → CataloguePage runs. The catalogue hasn't changed at all.
  4. Confirm a booking → bookings/bookingConfirmed → CataloguePage runs again.

With console logging you'll see the line appear on every action in the app. And it's not just a render: since visible is a new array, BikeList gets a new prop and re-renders too, with every BikeCard inside it.

In development, react-redux warns you in the console:

Selector unknown returned a different result when called with the same parameters.
This can lead to unnecessary rerenders.

It's a warning that's very easy to ignore, and very expensive to ignore.

  1. The four fixes

Fix 1: select primitive values

The simplest one, the fastest one, and the one people forget most often.

// ❌ Returns a new object
const { name, role } = useSelector((state) => ({
  name: state.session.user?.name,
  role: state.session.user?.role
}));

// ✅ Two primitives, each comparable by value
const name = useSelector((state) => state.session.user?.name);
const role = useSelector((state) => state.session.user?.role);

When what you need is two or three loose values, several useSelectors over primitives always win. There's no memoization to maintain, no dependencies to declare, and each one only re-renders when its own value actually changes.

Fix 2: split into several useSelectors

The generalization of the previous one. A component can have as many useSelectors as it needs; the cost of each subscription is negligible.

function BookingsPanel() {
  const ids = useSelector(selectBookingIds);          // array stored in state
  const submitState = useSelector(selectSubmitState);  // string
  const error = useSelector(selectBookingsError);      // string or null
  // …
}

Here ids is safe because it's the array that lives in state, not one built on the fly. Immer only replaces it when a booking is added or removed.

Fix 3: memoize with createSelector

When the selector actually has to compute something — filter, sort, aggregate — the answer is memoizing it. That's the whole of section 5.

Fix 4: a custom equality function

useSelector accepts a second argument: a function that decides whether two results are equal.

import { useSelector, shallowEqual } from 'react-redux';

// Compares top-level properties instead of the reference
const { name, role } = useSelector(
  (state) => ({ name: state.session.user?.name, role: state.session.user?.role }),
  shallowEqual
);

shallowEqual compares key by key with Object.is. The object is still new on every call, but since its surface content is equal, useSelector doesn't re-render.

RTK also offers a useSelector with createSelector already built in, and react-redux exports useDebugValue-friendly helpers, but in practice these four options are enough.

Which to pick:

Situation Fix
You need one or a few loose values 1 and 2: primitives, one useSelector per value
You need a reference that's already in state Select it directly: it's safe
You need a computation (filter, sort, aggregate) 3: createSelector
You need a grouped object and don't want to memoize 4: shallowEqual

Order of preference: 1 → 2 → 3 → 4. The fourth comes last because shallowEqual walks the object on every comparison and doesn't save the selector's own work, only the re-render.

  1. createSelector in practice

createSelector comes from Reselect, and RTK re-exports it. It takes a list of input selectors and a combiner function, and it memoizes: if the inputs are identical to the previous call's, it returns the previous result without recomputing, and with the same reference.

// src/features/catalogue/selectors.js
import { createSelector } from '@reduxjs/toolkit';
import {
  selectBikes, selectSearchTerm, selectSortOrder
} from './catalogueSlice.js';

/**
 * Visible bikes in the catalogue.
 * TYPE doesn't come from the store: it arrives as an argument because it lives in the URL.
 */
export const selectVisibleBikes = createSelector(
  [
    selectBikes,             // input 1: state.catalogue.bikes
    selectSearchTerm,        // input 2: state.catalogue.searchTerm
    selectSortOrder,         // input 3: state.catalogue.sort
    (state, type) => type    // input 4: the URL filter
  ],
  (bikes, searchTerm, sort, type) => {
    const query = searchTerm.trim().toLowerCase();

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

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

What happens now in section 3's scenario:

Dispatched action Do the inputs change? Result
session/signedIn No Returns the same reference. Object.is gives true. No re-render
bookings/bookingConfirmed No Same. No re-render
catalogue/searchTermChanged Yes, searchTerm Recomputes, new array, re-renders. Correct
Change of ?tipo= in the URL Yes, the fourth input Recomputes. Correct

Three important warnings about createSelector:

1. Input selectors must be cheap and stable. They always run; what you save is the combiner function. If an input returned a new object every time, memoization would never hit and the selector would be worse than not having one.

2. Memoization has a size. Historically, Reselect kept only one cached result, so a selector with an argument used from two components with different arguments would keep invalidating each other on every call. In Reselect 5 — the one RTK 2 ships — the default memoization (weakMapMemoize) handles several arguments well, and that problem disappears in most cases. If you need the classic behavior with several consumers, the pattern is a selector factory:

// Factory: each component creates its own memoized instance
export const createSelectUserBookings = () =>
  createSelector(
    [selectBookingEntities, selectBookingIds, (state, userId) => userId],
    (entities, ids, userId) => ids.map((id) => entities[id]).filter((b) => b.user === userId)
  );

// In the component
function UserBookings({ userId }) {
  // useMemo keeps the SAME instance across renders (08-03)
  const selector = useMemo(createSelectUserBookings, []);
  const bookings = useSelector((state) => selector(state, userId));
  // …
}

3. Don't memoize what costs nothing. createSelector wrapped around (state) => state.session.user is noise: there's no computation to save, and the reference is already stable. Memoize when the selector builds something.

createSelector and useMemo solve the same problem at different layers: one memoizes over the store's state and serves every component; the other memoizes inside a single component. The details of when memoizing pays off are in 08-03.

  1. useDispatch, and why dispatch is stable

import { useDispatch } from 'react-redux';
import { bookingConfirmed } from '../features/bookings/bookingsSlice.js';

function ConfirmButton({ bookingId }) {
  const dispatch = useDispatch();

  function handleClick() {
    dispatch(bookingConfirmed(bookingId));
  }

  return <button type="button" onClick={handleClick}>Confirm</button>;
}

useDispatch returns the store's dispatch function, which is always the same one: the store is created once, in store.js, and its dispatch never changes for the life of the app.

Practical consequences:

  • A component that only dispatches never re-renders because of Redux. It isn't subscribed to anything. It's the automatic equivalent of the state/actions split that in 07-02 had to be set up by hand with two contexts.
  • dispatch can go in a useEffect's dependencies without causing loops. In fact, the hooks linter will ask you to include it, and doing so is correct.
  • There's no need to memoize handlers that only dispatch, out of fear for identity: the function that does the work is stable.

Notice the project's naming pattern applied to Redux: handleClick is the component's internal handler and bookingConfirmed is the action creator imported from the slice. The "handlers handleX inside, props onX outward" convention still holds; what changes is that now, instead of calling an onConfirm prop, many components dispatch directly.

A design nuance worth thinking about: not every component should dispatch. A BikeCard that dispatches actions stops being reusable and stops being testable without a store. The sensible rule is that pages and panels connect; presentational components receive props. BikeList and BikeCard keep receiving bikes and onSelect just as before.

  1. UserMenu on top of sessionSlice

The simplest case, and the one that teaches fix 1.

// src/components/UserMenu.jsx
import { useSelector, useDispatch } from 'react-redux';
import { useNavigate } from 'react-router';
import { signedOut } from '../features/session/sessionSlice.js';
import { selectUser, selectIsOperator }
  from '../features/session/sessionSlice.js';
import { useToggle } from '../hooks/useToggle.js';
import styles from './UserMenu.module.css';

function UserMenu() {
  // Primitives and state references: identity comparison is safe
  const user = useSelector(selectUser);
  const isOperator = useSelector(selectIsOperator);
  const dispatch = useDispatch();
  const navigate = useNavigate();

  // Local UI state: does NOT go in the store
  const [open, toggleOpen] = useToggle(false);

  function handleSignOut() {
    dispatch(signedOut());
    toggleOpen();
    navigate('/', { replace: true });
  }

  if (!user) {
    return <Link to="/acceso" className={styles.signIn}>Sign in</Link>;
  }

  return (
    <div className={styles.menu}>
      <button type="button" onClick={toggleOpen} aria-expanded={open}>
        {user.name}
      </button>
      {open && (
        <ul className={styles.dropdown}>
          {isOperator && <li><Link to="/taller">Workshop</Link></li>}
          <li><Link to="/reservas">My bookings</Link></li>
          <li><button type="button" onClick={handleSignOut}>Sign out</button></li>
        </ul>
      )}
    </div>
  );
}

export default UserMenu;

Three points that sum up half the lesson:

  • selectIsOperator returns a boolean. It's derived state computed in the selector (07-04), and being a primitive, identity comparison works perfectly.
  • open stays in useToggle. It's exactly the example from section 11.
  • signedOut() is called with no arguments and returns { type: 'session/signedOut' }. And remember from 07-04 that bookingsSlice reacts to that very action in its extraReducers, clearing out the bookings: one dispatch, two domains updated, no coupling between components.

  1. CataloguePage: the URL filter and the store coexisting

This is the most interesting design decision of the lesson, and it's been pending since 07-04.

The problem: ?tipo= has lived in the URL since 06-02, and catalogueSlice has a type field. Two sources of truth for the same piece of data is exactly what 07-01 forbids.

The decision: the type stays in the URL and disappears from the slice. The URL is the source of truth because the filter needs to be shareable by link and survive a reload, and no client store gives you that. The slice keeps searchTerm and sort, which aren't link-shareable by product decision, and selectVisibleBikes receives the type as an argument, exactly as written in section 5.

// src/features/catalogue/CataloguePage.jsx — with Redux and the URL coexisting
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useSelector, useDispatch } from 'react-redux';
import BikeSearch from '../../components/BikeSearch.jsx';
import TypeSelector from './TypeSelector.jsx';
import BikeList from './BikeList.jsx';
import LoadingIndicator from '../../components/LoadingIndicator.jsx';
import Notice from '../../components/Notice.jsx';
import { fetchBikes, searchTermChanged, selectSearchTerm,
         selectCatalogueLoadState, selectCatalogueError }
  from './catalogueSlice.js';
import { selectVisibleBikes } from './selectors.js';

function CataloguePage() {
  const [params, setParams] = useSearchParams();
  const dispatch = useDispatch();

  // FILTER'S SOURCE OF TRUTH: the URL, as in 06-02
  const chosenType = params.get('tipo') ?? 'todos';

  // From the store: primitives and a memoized selector with an argument
  const searchTerm = useSelector(selectSearchTerm);
  const loadState = useSelector(selectCatalogueLoadState);
  const error = useSelector(selectCatalogueError);
  const visible = useSelector((state) => selectVisibleBikes(state, chosenType));

  // State shared between just a few: stays local (07-01)
  const [selectedId, setSelectedId] = useState(null);

  useEffect(() => {
    dispatch(fetchBikes());
  }, [dispatch]);

  function handleTypeChange(newType) {
    const next = new URLSearchParams(params);
    if (newType === 'todos') next.delete('tipo');
    else next.set('tipo', newType);
    setParams(next, { replace: true });
  }

  function handleSearch(text) {
    dispatch(searchTermChanged(text));
  }

  if (loadState === 'loading') {
    return <LoadingIndicator message="Loading the catalogue…" />;
  }

  return (
    <section>
      <h1>Bike catalogue</h1>
      {error && <Notice tone="error" text={error} />}

      <BikeSearch value={searchTerm} onSearch={handleSearch} />
      <TypeSelector value={chosenType} onChange={handleTypeChange} />

      <BikeList
        bikes={visible}
        selectedId={selectedId}
        onSelect={setSelectedId}
      />
    </section>
  );
}

export default CataloguePage;

What's worth remembering from this component:

  • Three sources of state coexist without duplicating anything: the filter in the URL, the search term and the catalogue in the store, and the selection in a local useState. Each one where it belongs, following 07-01's taxonomy.
  • useEffect with [dispatch] as its only dependency works precisely because dispatch is stable. And the thunk's condition (07-04) keeps StrictMode's double render from firing two requests.
  • BikeList and TypeSelector don't know about Redux. They still receive props. They're presentational components, and staying that way keeps them testable and reusable.
  • The alternative that was NOT chosen: keeping type in the slice and syncing it with the URL through a useEffect. It would have worked almost all the time, and would have failed on the hard cases — pasting a URL, the "back" button, opening in a new tab — on top of adding an extra render on every filter change. When there are two candidates for source of truth, you pick one and the other stops existing.

  1. BookingsPage and BookingsPanel on top of bookingsSlice

// src/features/bookings/BookingsPage.jsx
import { useSelector } from 'react-redux';
import { selectBookingIds } from './bookingsSlice.js';
import BookingPanel from './BookingPanel.jsx';

function BookingsPage() {
  // A reference from state, not a built array: safe
  const ids = useSelector(selectBookingIds);

  if (ids.length === 0) {
    return <p>You don't have any bookings yet. <Link to="/reservas/nueva">Create one</Link></p>;
  }

  return (
    <section>
      <h1>My bookings</h1>
      <ul>
        {ids.map((id) => <BookingPanel key={id} bookingId={id} />)}
      </ul>
    </section>
  );
}

export default BookingsPage;
// src/features/bookings/BookingPanel.jsx — each row subscribes to ITS booking
import { useSelector, useDispatch } from 'react-redux';
import { bookingConfirmed, bookingCancelled, selectBookingById }
  from './bookingsSlice.js';
import StatusBadge from '../../components/StatusBadge.jsx';

function BookingPanel({ bookingId }) {
  const booking = useSelector((state) => selectBookingById(state, bookingId));
  const dispatch = useDispatch();

  if (!booking) return null;

  return (
    <li>
      <h2>{booking.bicicletaId}</h2>
      <StatusBadge status={booking.status} />
      <p>{booking.hours} h from {booking.startDate.replace('T', ' ')}</p>

      {booking.status === 'activa' && (
        <>
          <button type="button" onClick={() => dispatch(bookingConfirmed(bookingId))}>
            Confirm
          </button>
          <button type="button" onClick={() => dispatch(bookingCancelled(bookingId))}>
            Cancel
          </button>
        </>
      )}
    </li>
  );
}

export default BookingPanel;

This pair of components illustrates Redux's most efficient pattern with lists, and it's worth explaining well:

  • The parent selects only the identifiers. ids is the array that lives in state; it only changes reference when a booking is added or removed. Confirming a booking doesn't re-render BookingsPage.
  • Each child selects its own entity by identifier. Thanks to Immer, entities['res-01'] keeps its reference as long as nobody touches that booking, so confirming res-01 re-renders only the res-01 row.

With a list of two hundred bookings, the difference between this pattern and selecting the full array in the parent is two hundred renders versus one. It's the practical payoff of why state was normalized in 07-04.

  1. Dispatching thunks from a component

A thunk is dispatched just like an action: dispatch(fetchBikes()). The difference is that it returns a promise with useful methods.

// src/features/bookings/NewBookingPage.jsx (excerpt)
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useNavigate } from 'react-router';
import { submitBooking, selectSubmitState, selectBookingsError }
  from './bookingsSlice.js';
import { useNoticesActions } from '../../contexts/NoticesContext.jsx';

function NewBookingPage() {
  // The draft is FORM state: stays local (07-01)
  const [draft, setDraft] = useState({ bicicletaId: '', startDate: '', hours: 1 });

  const submitState = useSelector(selectSubmitState);
  const error = useSelector(selectBookingsError);
  const dispatch = useDispatch();
  const navigate = useNavigate();
  const { showNotice } = useNoticesActions();

  const submitting = submitState === 'submitting';

  async function handleSubmit(event) {
    event.preventDefault();

    // unwrap() throws if the thunk ended up rejected, and returns the payload otherwise
    try {
      const booking = await dispatch(submitBooking(draft)).unwrap();
      showNotice('success', `Booking ${booking.id} created.`);
      navigate('/reservas', { replace: true });
    } catch (failure) {
      // The store's state already holds the error; here we only decide the UI
      showNotice('error', String(failure));
    }
  }

  return (
    <form onSubmit={handleSubmit} aria-busy={submitting}>
      {/* … form fields … */}

      {error && <p role="alert">{error}</p>}

      <button type="submit" disabled={submitting}>
        {submitting ? 'Submitting…' : 'Book'}
      </button>
    </form>
  );
}

Important points:

  • .unwrap() turns the thunk's result into something you can use try/catch with: it returns the fulfilled payload, or throws the rejected error. Without it, await dispatch(...) always resolves, even when the request failed, and the catch never runs. It's the most common mistake when dispatching thunks.
  • Loading and error are read from the store, not from a parallel useState. A single source of truth, and one visible in DevTools too.
  • Navigation and the notice happen in the component, not in the thunk. A reducer doesn't navigate, and a thunk shouldn't know about the router: UI logic stays in the UI.
  • disabled={submitting} and aria-busy (03-06) prevent a double submit from the UI; the thunk's condition (07-04) prevents it from the store too. Both things, not just one.

  1. What stays out of Redux

Having a store doesn't mean putting everything in it. Here's the reasoned list for CicloUrbano.

Data In Redux? Reasoning
Visual theme No: stays in ThemeContext Changes once per session, is read by a button and the document's data-theme attribute. There's no logic to audit and no actions worth tracing. Adding it would only add noise to the history
open in UserMenu, Modal, BookingDialog No: local useToggle Local UI state. In the store it would clutter DevTools with dozens of irrelevant actions and would force checking who else reads that field before touching it
?tipo= filter No: lives in the URL Needs to be shareable by link and survive a reload. Two sources of truth is worse than none
Catalogue's selectedId No: useState in CataloguePage Shared between two siblings and dies with the screen. Lifting it to the store would be premature textbook globalization
Form's draft No: useState in the page Changes on every keystroke. Every keypress would be an action, a comparison cycle across every subscriber, and an entry in the history
Notices No: stay in NoticesContext They work well, are already split into state and actions (07-02), and their lifespan is purely visual
Session Yes, sessionSlice Several screens read it, it has rule-governed transitions, and other domains react to it ending
Bookings Yes, bookingsSlice Business rules that deserve a reducer and tests, entities shared across screens
searchTerm and sort Yes, catalogueSlice Shared between the search box and the list, and must survive going to a detail page and back
Bike catalogue Today yes, not in 07-06 It's server state. It's in the slice provisionally, exactly as announced in 07-04

The rule that sums up the table: something belongs in Redux when its change is a domain event worth recording. "The user opened a menu" isn't one. "The user cancelled a booking" is.

  1. connect and mapStateToProps: only for reading legacy code

⚠️ This section exists solely so you can read projects written before 2019. connect still works and react-redux still maintains it, but it isn't used in new code. It won't appear again in the course.

Before hooks, connecting was done with a higher-order component:

// ⚠️ LEGACY CODE — DO NOT WRITE LIKE THIS TODAY
import { connect } from 'react-redux';
import { bookingConfirmed, bookingCancelled } from './actions.js';

function BookingPanel({ booking, onConfirm, onCancel }) {
  return (
    <li>
      <h2>{booking.bicicletaId}</h2>
      <button onClick={() => onConfirm(booking.id)}>Confirm</button>
      <button onClick={() => onCancel(booking.id)}>Cancel</button>
    </li>
  );
}

// What from the state becomes props
function mapStateToProps(state, ownProps) {
  return { booking: state.bookings.entities[ownProps.bookingId] };
}

// What actions become already-dispatched props
const mapDispatchToProps = {
  onConfirm: bookingConfirmed,
  onCancel: bookingCancelled
};

export default connect(mapStateToProps, mapDispatchToProps)(BookingPanel);

The hooks equivalent:

Legacy API Hooks equivalent Note
mapStateToProps(state) useSelector(selector) One useSelector per value, instead of one object with everything
mapStateToProps(state, ownProps) useSelector((s) => selector(s, props.id)) Props are used directly, no second parameter
mapDispatchToProps as an object const dispatch = useDispatch() + dispatch(action()) The creators are no longer wrapped
mapDispatchToProps as a function Same: dispatch(...) in the handler
mergeProps Combine inside the component itself Rarely used
connect()(Component) Nothing: the component just uses the hooks A wrapping layer disappears
ownProps The component's normal props

Why it was abandoned: connect adds a wrapping component for every connection — which clutters the tree in DevTools —, forces you to split the "dumb" component from the "connected" one, has an API with four different ways of writing mapDispatchToProps, and is notoriously hard to type. Hooks do the same thing with less indirection.

What is worth keeping from that era is the conceptual distinction between presentational and connected components. connect enforced it; with hooks you have to keep it up by discipline, and it's the rule from section 6: pages and panels connect, presentational components receive props.

  1. Debugging with Redux DevTools

Now that the app dispatches real actions, the tool from 07-03 is worth what it promised. A concrete example of how the way you search for a bug changes.

The symptom: "sometimes, when confirming two bookings in a row, the second one shows up cancelled."

Without Redux, the investigation would be: put a console.log in three handlers, try to reproduce it, suspect a closure problem, suspect a race condition, add more logging.

With Redux DevTools:

  1. Reproduce the bug once.
  2. Look at the action list. There's the whole real sequence:
    session/signedIn
    catalogue/fetchBikes/pending
    catalogue/fetchBikes/fulfilled
    bookings/bookingConfirmed     ← payload: 'res-01'
    bookings/bookingCancelled     ← payload: 'res-02'   ⚠️ where does this come from?
    bookings/bookingConfirmed     ← payload: 'res-02'
    
  3. The extra action is identified in twenty seconds. The bug isn't in the reducer or the state: there's an onClick dispatching bookingCancelled when it shouldn't, probably a mis-wired handler on the second row's button.
  4. Hit skip on that action and check that without it the result is correct: confirms the diagnosis without touching any code.
  5. Look at the Diff panel for the suspicious action to see exactly what changed.

And if a user reported the bug, they can export the history as JSON and you can import it: you reproduce their exact session on your own machine.

What really changes isn't the speed, it's the nature of the question. Without Redux you ask "why is the state wrong?" With Redux you ask "which action left it like this?", and that question always has an answer, because it's right there in the list.

Three functions you'll use daily:

Function When
Diff The first thing to look at: what changed with that action and nothing else
Jump / jump to an action Go back to the previous state and see the UI at that instant
Skip Test a hypothesis without changing any code

  1. Code organization: folder per feature

So far CicloUrbano is organized by type: components/, pages/, hooks/, contexts/, reducers/. With Redux, the community recommends something else.

By type By feature
Groups Files that do the same thing Files that talk about the same thing
Adding a feature Touch 5 folders Create 1 folder
Removing a feature Search the whole project Delete the folder
Finding "everything about bookings" Hard Trivial
Scales well up to Small projects Any size
src/
├── main.jsx
├── routes.jsx
├── index.css
├── store/
│   └── store.js                      # configureStore with the reducer map
├── features/
│   ├── bookings/
│   │   ├── bookingsSlice.js          # state, reducers, thunks, selectors
│   │   ├── bookingsSlice.test.js
│   │   ├── BookingsPage.jsx
│   │   ├── NewBookingPage.jsx
│   │   ├── BookingPanel.jsx
│   │   ├── BookingPanel.module.css
│   │   └── BookingForm.jsx
│   ├── catalogue/
│   │   ├── catalogueSlice.js
│   │   ├── selectors.js              # derived selectors with createSelector
│   │   ├── CataloguePage.jsx
│   │   ├── BikeDetailPage.jsx
│   │   ├── BikeList.jsx
│   │   ├── BikeCard.jsx
│   │   └── TypeSelector.jsx
│   ├── session/
│   │   ├── sessionSlice.js
│   │   ├── SignInPage.jsx
│   │   ├── UserMenu.jsx
│   │   ├── ProtectedRoute.jsx
│   │   └── RequireRole.jsx
│   └── stations/
│       ├── StationsPage.jsx
│       ├── StationDetailPage.jsx
│       ├── FleetTab.jsx
│       └── IncidentsTab.jsx
├── components/                        # SHARED: don't belong to a single domain
│   ├── Layout.jsx
│   ├── Header.jsx
│   ├── Footer.jsx
│   ├── Panel.jsx
│   ├── Modal.jsx
│   ├── Notice.jsx
│   ├── StatusBadge.jsx
│   ├── LoadingIndicator.jsx
│   ├── Breadcrumbs.jsx
│   └── ErrorBoundary.jsx
├── contexts/                          # theme and notices: still no Redux
│   ├── ThemeContext.jsx
│   ├── NoticesContext.jsx
│   └── Providers.jsx
├── hooks/                             # generic, domain-free
│   ├── useToggle.js
│   ├── useLocalStorage.js
│   ├── useDebounce.js
│   └── useWindowWidth.js
├── utils/
└── data/

Rules that make this work:

  1. components/ holds only what's genuinely shared. If only one domain uses something, it lives in that domain's folder.
  2. A domain never imports from inside another one. If bookings needs something from catalogue, it's imported from that folder's root, and if that starts happening a lot, the boundary between domains is drawn wrong.
  3. routes.jsx stays at the root, and it does import from several domains: it is, by definition, the map that ties them together.
  4. The migration is gradual. You move one domain at a time, and the app keeps working at every step.

In a project the size of CicloUrbano, organizing by type still holds up. The reason to switch isn't aesthetic: it's that folder-per-feature makes coupling visible. When bookings/ needs five things from catalogue/, it shows up right in the imports, and it's a design signal, not a filing detail.

  1. An honest final comparison

With the whole module behind you, here's the comparison for an app like CicloUrbano.

Context + useReducer Redux Toolkit Zustand
Dependencies None 2 packages, ~15-20 kB 1 package, ~1 kB
Code to get started One context file Store + one slice per domain One file
Granular selection No: re-renders the whole consumer Yes, with useSelector Yes, with a selector
Debugging tools No Excellent Redux DevTools via a plugin
Time travel No Yes Limited
Middleware No Yes Yes, simpler
Async Manual createAsyncThunk Regular async functions
Ceremony Medium High Very low
State outside React No Yes Yes
Team convention Has to be invented Imposed and documented Has to be invented
Learning curve You already know it Medium Low
Job market presence — Very common Growing

And the recommendation, plainly stated:

Situation Choice
Personal project, 1 person, small app Context + useReducer. You already have it and it works
Team of 2-4, medium app, no complex state logic Zustand, or context if global state is small
Team of 5+, large app, business logic worth auditing Redux Toolkit. The convention and the tools pay for themselves
App whose state is almost all server data TanStack Query and little else for client state (07-06)
Project already on classic Redux Migrate to RTK gradually, slice by slice

For CicloUrbano, honestly: it's a small app with a small team, and context + useReducer would still be enough. Redux got introduced because it's what you'll run into on the job, because the mental model — actions, pure reducers, selectors, normalized state — transfers whole to Zustand and to Jotai, and because DevTools are a tool worth having used at least once. What we're not going to do is pretend it was essential.

One last warning, the most important one in the module and the thread leading into the next lesson: once 07-06 pulls the bikes, stations and bookings out of the store into a server-state cache, what's left in Redux will be very little. In many real apps, after that move client state fits in two contexts, and that's a legitimate conclusion to reach — after understanding Redux, not by avoiding it.

Common Mistakes and Tips

Mistake 1: a selector that returns a built object or array. The number one mistake, by far. Causes re-renders on every action in the app. Primitives, state references, or createSelector.

Mistake 2: grouping several values into an object "for convenience." useSelector((s) => ({ a: s.x, b: s.y })) without shallowEqual is mistake 1 in disguise. Two useSelectors are simpler and faster.

Mistake 3: forgetting .unwrap() when dispatching a thunk. await dispatch(thunk()) always resolves, so the catch never runs and failures get swallowed silently.

Mistake 4: selecting the whole array in a list's parent. Select the identifiers in the parent and the entity in each child: from N re-renders down to one.

Mistake 5: connecting presentational components. A BikeCard with useSelector stops being reusable and stops being testable without a store. Pages and panels connect; everything else receives props.

Mistake 6: duplicating in Redux something that already lives in the URL and syncing it with an effect. Pick one source of truth and delete the other.

Mistake 7: reading state with store.getState() inside a component. It doesn't subscribe to anything, so the component doesn't re-render when it changes. getState() is for thunks and middleware.

Tip 1: turn on the unstable-selector warning. react-redux already emits it in development. Don't ignore it: every one of those warnings is a component re-rendering more than it should.

Tip 2: name selectors selectX and export them from the slice. A component should never write state.bookings.entities directly.

Tip 3: measure with the Profiler before memoizing. createSelector on a trivial selector is cost with no benefit. Module 8 teaches you how to measure it (08-05).

Tip 4: keep the store small on purpose. Every field you add is a field someone will have to understand. The table in section 11 is a decision-making tool, not a closed list.

Exercises

Exercise 1. This component causes a re-render on every action in the store, has three distinct problems, and also fails on submit. Find them and rewrite it.

function FleetSummary({ stationId }) {
  const { bikes, stations } = useSelector((state) => ({
    bikes: state.catalogue.bikes,
    stations: state.stations.list
  }));

  const atStation = useSelector((state) =>
    state.catalogue.bikes.filter((b) => b.stationId === stationId)
  );

  const dispatch = useDispatch();

  async function handleReload() {
    try {
      await dispatch(fetchBikes(stationId));
      showNotice('success', 'Fleet updated.');
    } catch (failure) {
      showNotice('error', 'Could not update.');
    }
  }

  return <Panel title={`Fleet (${atStation.length})`}>{/* … */}</Panel>;
}

Exercise 2. Write a memoized selector selectBookingsSummary that returns { active, confirmed, cancelled, totalHours } from bookingsSlice's normalized state, and a BookingsBadge component that consumes it, showing only the number of active bookings. Explain why the component shouldn't use that selector as-is, and what you'd do instead.

Exercise 3. Translate this legacy component to hooks, keeping its behavior.

class StationList extends React.Component {
  componentDidMount() {
    this.props.load();
  }
  render() {
    const { stations, loading, onSelect } = this.props;
    if (loading) return <LoadingIndicator />;
    return (
      <ul>
        {stations.map((s) => (
          <li key={s.id} onClick={() => onSelect(s.id)}>{s.nombre}</li>
        ))}
      </ul>
    );
  }
}

const mapStateToProps = (state) => ({
  stations: state.stations.list,
  loading: state.stations.estadoCarga === 'cargando'
});

const mapDispatchToProps = {
  load: fetchStations,
  onSelect: stationSelected
};

export default connect(mapStateToProps, mapDispatchToProps)(StationList);

Solutions

Solution 1. The three performance problems and the submit one:

  1. The first useSelector returns an object literal. New reference on every call: re-render on every action.
  2. The second useSelector returns the result of a filter. New array on every call: the same problem, a second time.
  3. bikes and stations are selected and never used. A free subscription to two branches of state, for nothing.
  4. .unwrap() is missing: await dispatch(fetchBikes(...)) resolves even when the thunk ends up rejected, so the catch never runs and a network failure would show "Fleet updated."
// src/features/catalogue/selectors.js
export const createSelectBikesAtStation = () =>
  createSelector(
    [selectBikes, (state, stationId) => stationId],
    (bikes, stationId) => bikes.filter((b) => b.stationId === stationId)
  );
// src/features/stations/FleetSummary.jsx
import { useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { createSelectBikesAtStation } from '../catalogue/selectors.js';
import { fetchBikes } from '../catalogue/catalogueSlice.js';
import { useNoticesActions } from '../../contexts/NoticesContext.jsx';

function FleetSummary({ stationId }) {
  const dispatch = useDispatch();
  const { showNotice } = useNoticesActions();

  // One memoized instance per mounted component
  const selector = useMemo(createSelectBikesAtStation, []);
  const atStation = useSelector((state) => selector(state, stationId));

  async function handleReload() {
    try {
      await dispatch(fetchBikes(stationId)).unwrap();   // 4)
      showNotice('success', 'Fleet updated.');
    } catch (failure) {
      showNotice('error', 'Could not update.');
    }
  }

  return (
    <Panel title={`Fleet (${atStation.length})`}>
      <button type="button" onClick={handleReload}>Reload</button>
      {/* … */}
    </Panel>
  );
}

The two useless subscriptions are gone, the filter is memoized with a factory — so two FleetSummarys for different stations don't invalidate each other — and the submit correctly reports the failure.

Solution 2.

// src/features/bookings/selectors.js
import { createSelector } from '@reduxjs/toolkit';
import { selectBookingEntities, selectBookingIds } from './bookingsSlice.js';

export const selectBookingsSummary = createSelector(
  [selectBookingEntities, selectBookingIds],
  (entities, ids) => {
    const summary = { active: 0, confirmed: 0, cancelled: 0, totalHours: 0 };
    for (const id of ids) {
      const booking = entities[id];
      if (booking.status === 'activa') summary.active += 1;
      if (booking.status === 'confirmada') summary.confirmed += 1;
      if (booking.status === 'cancelada') summary.cancelled += 1;
      if (booking.status !== 'cancelada') summary.totalHours += booking.hours;
    }
    return summary;
  }
);

Why BookingsBadge shouldn't use it as-is: the selector returns an object, and even though createSelector memoizes it, it recomputes — and returns a new object — every time any booking changes. If the badge only shows the number of active bookings, it would re-render when a booking gets cancelled, when another gets confirmed, or when a third one's hours change, even though the number of active bookings stays the same.

The fix is to select the primitive:

// ✅ Only re-renders when the number of active bookings changes
function BookingsBadge() {
  const active = useSelector((state) => selectBookingsSummary(state).active);
  if (active === 0) return null;
  return <span className="badge" aria-label={`${active} active bookings`}>{active}</span>;
}

The memoized selector does the computation once and shares it across every consumer; each component pulls out just the primitive it needs, and useSelector compares that number. It's fixes 1 and 3 combined, and it's the recommended pattern for selectors that return aggregates: memoize the aggregate, select the field.

Solution 3.

// src/features/stations/StationList.jsx
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchStations, stationSelected, selectStations,
         selectStationsLoadState } from './stationsSlice.js';
import LoadingIndicator from '../../components/LoadingIndicator.jsx';

function StationList() {
  // mapStateToProps → one useSelector per value
  const stations = useSelector(selectStations);
  const loading = useSelector((state) => selectStationsLoadState(state) === 'cargando');

  // mapDispatchToProps → useDispatch
  const dispatch = useDispatch();

  // componentDidMount → useEffect with [] (plus dispatch, which is stable)
  useEffect(() => {
    dispatch(fetchStations());
  }, [dispatch]);

  if (loading) return <LoadingIndicator message="Loading stations…" />;

  return (
    <ul>
      {stations.map((station) => (
        <li key={station.id}>
          <button type="button" onClick={() => dispatch(stationSelected(station.id))}>
            {station.name}
          </button>
        </li>
      ))}
    </ul>
  );
}

export default StationList;

Four improvements the translation brings for free:

  • The wrapping component that connect inserted into the tree is gone.
  • loading is a boolean derived in the selector, not an object: perfect identity comparison.
  • <li onClick> becomes a <button> inside the <li>. The original wasn't accessible: an <li> doesn't receive focus or respond to the keyboard (03-06). Migrating legacy code is a good time to fix this.
  • dispatch in the effect's dependencies is correct and doesn't cause loops, precisely because it's stable.

Conclusion

The loop is closed. useSelector subscribes a component to the store, runs its selector function after every action, and compares the result with Object.is, which is where the golden rule comes from: a selector that builds a new object or array on every call causes re-renders on every action in the app, no matter which domain it comes from. The four fixes, in order of preference: select primitives, split into several useSelectors, memoize with createSelector — remembering that input selectors must be cheap and that memoization only pays off when there's real computation —, and, as a last resort, an equality function like shallowEqual. useDispatch returns the store's dispatch function, which is forever stable: a component that only dispatches never re-renders because of Redux, and dispatch can go in an effect's dependencies without any worry.

CicloUrbano ends up connected with judgment. UserMenu reads two primitives from sessionSlice and keeps its dropdown in useToggle. CataloguePage makes three sources of state coexist without duplicating any of them: the filter in the URL — still the source of truth, with the type passed as an argument to the memoized selector —, the search term and the catalogue in the store, and the selection in a local useState. BookingsPage selects only the identifiers, and BookingPanel selects its own entity, so confirming a booking re-renders exactly one row: the direct payoff of having normalized the state. Thunks are dispatched with .unwrap() so try/catch works, and loading and error are read from the store, not from a parallel useState. And the table of what stays out — theme, modals, URL filter, catalogue selection, form draft, notices — sums up the whole module's rule: something belongs in Redux when its change is a domain event worth recording.

You also know how to read connect, mapStateToProps and mapDispatchToProps without ever writing them, how to use the DevTools history to turn "why is the state wrong?" into "which action left it like this?", and how to organize code by feature instead of by type, with components/ reserved for what's genuinely shared. The final comparison is an honest one: for an app the size of CicloUrbano, context + useReducer would have been enough; Redux Toolkit pays for itself once there's a team, scale and logic worth auditing; and Zustand sits at a very reasonable middle ground.

What's left is the biggest piece, and the one that's going to shift the balance of everything above. Today the store has a bikes field loaded with a createAsyncThunk, and it's already been said twice that it was there provisionally: data coming from a server isn't application state. It doesn't belong to you, it goes stale on its own, it's shared across tabs and users, and it arrives late. Treating it as client state forces you to hand-write loading, error, cancellation, race conditions, deduplication, revalidation, invalidation, retries and pagination — for every single resource. In the next lesson you'll set up a fake API with json-server, get to know TanStack Query, understand the lifecycle of a cached piece of data, perform mutations with optimistic updates and their rollback, rewrite useFetchBikes as useBikes, and settle CicloUrbano's final architecture: Redux for client state, Query for server state. The next lesson is Server State: Requests, Caching and Synchronization.

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