El magatzem de CicloUrbano ja té forma i comportament: tres slices complets, regles de negoci als reductors, entitats normalitzades, selectors junt al seu domini i càrrega asíncrona amb createAsyncThunk. Tot això existeix avui sense que cap component ho sàpiga. Aquesta lliçó tanca el circuit amb react-redux, i ho fa prestant una atenció desproporcionada a un sol assumpte, perquè és el que separa una integració que funciona d'una que repinta l'aplicació sencera amb cada acció: la regla d'or de la comparació per identitat a useSelector. Veuràs com se subscriu un component i quan es torna a executar, reproduiràs la fallada del selector que retorna un array nou i la corregiràs de quatre formes diferents, reescriuràs PaginaCataleg, PaginaReserves, PanellReserves i MenuUsuari sobre el magatzem, decidiràs amb arguments què es queda fora de Redux, despatxaràs thunks mostrant càrrega i error, aprendràs a llegir connect en codi heretat, depuraràs amb l'historial de DevTools i organitzaràs el codi per funcionalitat. Es tanca amb una comparació honesta entre context + reductor, Redux Toolkit i Zustand per a una aplicació com aquesta.

Contingut

  1. useSelector: com se subscriu un component
  2. La regla d'or: comparació per identitat
  3. La fallada reproduïda
  4. Les quatre solucions
  5. createSelector a la pràctica
  6. useDispatch i per què despatxar és estable
  7. MenuUsuari sobre sliceSessio
  8. PaginaCataleg: el filtre de la URL i el magatzem convivint
  9. PaginaReserves i PanellReserves sobre sliceReserves
  10. Despatxar thunks des d'un component
  11. Què es queda fora de Redux
  12. connect i mapStateToProps: només per llegir codi heretat
  13. Depuració amb Redux DevTools
  14. Organització del codi: carpeta per funcionalitat
  15. Comparativa final honesta

  1. useSelector: com se subscriu un component

import { useSelector } from 'react-redux';

function DistintiuReserves() {
  const quantitat = useSelector((estat) => estat.reserves.ids.length);
  return <span className="distintiu">{quantitat}</span>;
}

El que fa useSelector per dins, pas a pas:

  1. Se subscriu al magatzem mitjançant magatzem.subscribe(...), usant el Provider que vas col·locar a main.jsx (07-03).
  2. Executa la funció selectora amb l'estat actual i retorna el resultat.
  3. Després de cada acció despatxada, torna a executar la funció selectora amb l'estat nou.
  4. Compara el resultat nou amb l'anterior usant Object.is (comparació per identitat de referència).
  5. Si són diferents, provoca un repintat del component. Si són iguals, no fa res.

Aquest pas 5 és la selecció granular que el context no podia donar (07-02). Un component subscrit a estat.reserves.ids.length no es repinta quan canvia el tema, quan s'escriu al cercador o quan es confirma una reserva sense alterar el número.

I el pas 3 és el que cal tenir sempre present: la funció selectora s'executa després de cada acció, sigui del domini que sigui. sessio/sessioIniciada, cataleg/termeCanviat, reserves/reservaConfirmada: totes fan que s'executin tots els selectors de tots els components muntats. Els selectors han de ser, per tant, barats.

flowchart TD
    A["dispatch(qualsevolAccio)"] --> B["El reductor produeix<br/>l'estat nou"]
    B --> C["El magatzem notifica<br/>a TOTS els subscriptors"]
    C --> D["Cada useSelector executa<br/>la seva funció selectora"]
    D --> E{"Object.is(nou, anterior)"}
    E -- "true" --> F["No hi ha repintat"]
    E -- "false" --> G["Repintat del component"]

  1. La regla d'or: comparació per identitat

useSelector compara amb Object.is. Si el teu selector construeix un objecte o un array nou a cada crida, la comparació sempre donarà «ha canviat» i el component es repintarà amb cada acció del magatzem, vingui d'on vingui.

És el mateix parany d'identitat que vas veure a 07-02 amb el value d'un proveïdor, en un altre lloc i amb un altre nom. I aquí és pitjor, perquè un component mal subscrit es repinta amb accions que no tenen res a veure amb ell.

El que retorna el selector Es repeteix la identitat? Conseqüència
Un número, una cadena, un booleà, null Sí, si el valor és igual ✅ Correcte
Una referència guardada a l'estat (estat.sessio.usuari) Sí, mentre el reductor no la substitueixi ✅ Correcte
Un objecte literal { a, b } No, mai ❌ Repintat a cada acció
El resultat de .map(), .filter(), .sort(), .slice() No, mai ❌ Repintat a cada acció
Object.values(...), [...alguna cosa] No, mai ❌ Repintat a cada acció

La fila de «una referència guardada a l'estat» explica per què la normalització de 07-04 és tan bona idea: com que Immer reutilitza les referències de les branques que no canvien, estat.reserves.entitats['res-01'] és literalment el mateix objecte mentre ningú toqui aquesta reserva, i la comparació per identitat funciona sola.

  1. La fallada reproduïda

Anem a veure el problema amb el selector derivat de 07-04, que és exactament el cas perillós.

// ❌ VERSIÓ AMB FALLADA
import { useSelector } from 'react-redux';
import { seleccionarBicicletesVisibles } from '../funcionalitats/cataleg/selectors.js';

function PaginaCataleg() {
  const visibles = useSelector(seleccionarBicicletesVisibles);
  console.log('PaginaCataleg s\'ha executat');
  return <LlistaBicicletes bicicletes={visibles} />;
}

Recorda el selector: acaba amb [...filtrades].sort(...). Retorna un array nou cada vegada.

Prova a fer això al navegador amb DevTools obert:

  1. Escriu una lletra al cercador → cataleg/termeCanviat → s'executa el component. Correcte: el catàleg ha canviat.
  2. Prem el botó de tema... i aquí no passa res, perquè el tema no és a Redux. Ben pensat.
  3. Inicia sessió → sessio/sessioIniciadas'executa PaginaCataleg. El catàleg no ha canviat en absolut.
  4. Confirma una reserva → reserves/reservaConfirmadas'executa PaginaCataleg una altra vegada.

Amb el registre a la consola veuràs la línia aparèixer amb cada acció de l'aplicació. I no és només un render: com que visibles és un array nou, LlistaBicicletes rep una prop nova i també es repinta, amb totes les seves TargetaBicicleta a dins.

En desenvolupament, react-redux t'avisa per consola:

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

És un avís molt fàcil d'ignorar i molt car d'ignorar.

  1. Les quatre solucions

Solució 1: seleccionar valors primitius

La més simple, la més ràpida i la que s'oblida més sovint.

// ❌ Retorna un objecte nou
const { nom, rol } = useSelector((estat) => ({
  nom: estat.sessio.usuari?.nom,
  rol: estat.sessio.usuari?.rol
}));

// ✅ Dues primitives, cada una comparable per valor
const nom = useSelector((estat) => estat.sessio.usuari?.nom);
const rol = useSelector((estat) => estat.sessio.usuari?.rol);

Quan el que necessites són dos o tres valors solts, diversos useSelector de primitives sempre guanyen. No hi ha memorització que mantenir, no hi ha dependències que declarar i cadascun només repinta quan el seu valor canvia de veritat.

Solució 2: dividir en diversos useSelector

La generalització de l'anterior. Un component pot tenir tants useSelector com necessiti; el cost de cada subscripció és menyspreable.

function PanellReserves() {
  const ids = useSelector(seleccionarIdsReserves);              // array guardat a l'estat
  const estatEnviament = useSelector(seleccionarEstatEnviament); // cadena
  const error = useSelector(seleccionarErrorReserves);          // cadena o null
  // …
}

Aquí ids és segur perquè és l'array que viu a l'estat, no un de construït al vol. Immer només el substitueix quan s'afegeix o es treu una reserva.

Solució 3: memoritzar amb createSelector

Quan el selector ha de calcular de veritat —filtrar, ordenar, agregar—, la resposta és memoritzar-lo. És l'apartat 5 sencer.

Solució 4: funció d'igualtat personalitzada

useSelector accepta un segon argument: una funció que decideix si dos resultats són iguals.

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

// Compara les propietats de primer nivell en lloc de la referència
const { nom, rol } = useSelector(
  (estat) => ({ nom: estat.sessio.usuari?.nom, rol: estat.sessio.usuari?.rol }),
  shallowEqual
);

shallowEqual compara clau a clau amb Object.is. L'objecte continua sent nou a cada crida, però com que el seu contingut superficial és igual, useSelector no repinta.

RTK ofereix a més useSelector amb createSelector ja integrat, i react-redux exporta helpers amistosos amb useDebugValue, però a la pràctica n'hi ha prou amb aquestes quatre opcions.

Quina triar:

Situació Solució
Necessites un o diversos valors solts 1 i 2: primitives, un useSelector per valor
Necessites una referència que ja és a l'estat Selecciona-la directament: és segura
Necessites un càlcul (filtrar, ordenar, agregar) 3: createSelector
Necessites un objecte agrupat i no vols memoritzar 4: shallowEqual

Ordre de preferència: 1 → 2 → 3 → 4. La quarta és l'última perquè shallowEqual recorre l'objecte a cada comparació i no evita la feina del selector, només el repintat.

  1. createSelector a la pràctica

createSelector ve de Reselect i RTK el reexporta. Rep una llista de selectors d'entrada i una funció combinadora, i memoritza: si les entrades són idèntiques a les de la crida anterior, retorna el resultat anterior sense recalcular i amb la mateixa referència.

// src/funcionalitats/cataleg/selectors.js
import { createSelector } from '@reduxjs/toolkit';
import {
  seleccionarBicicletes, seleccionarTerme, seleccionarOrdre
} from './sliceCataleg.js';

/**
 * Bicicletes visibles del catàleg.
 * El TIPUS no surt del magatzem: arriba com a argument perquè viu a la URL.
 */
export const seleccionarBicicletesVisibles = createSelector(
  [
    seleccionarBicicletes,          // entrada 1: estat.cataleg.bicicletes
    seleccionarTerme,               // entrada 2: estat.cataleg.terme
    seleccionarOrdre,               // entrada 3: estat.cataleg.ordre
    (estat, tipus) => tipus         // entrada 4: el filtre de la URL
  ],
  (bicicletes, terme, ordre, tipus) => {
    const cercat = terme.trim().toLowerCase();

    const filtrades = bicicletes.filter((bici) => {
      const coincideixTipus = tipus === 'todos' || bici.tipus === tipus;
      const coincideixTerme = cercat === '' || bici.model.toLowerCase().includes(cercat);
      return coincideixTipus && coincideixTerme;
    });

    return [...filtrades].sort((a, b) =>
      ordre === 'preu' ? a.preuHora - b.preuHora : a.model.localeCompare(b.model)
    );
  }
);

Què passa ara en l'escenari de l'apartat 3:

Acció despatxada Canvien les entrades? Resultat
sessio/sessioIniciada No Retorna la mateixa referència. Object.is dona true. Sense repintat
reserves/reservaConfirmada No Igual. Sense repintat
cataleg/termeCanviat Sí, terme Recalcula, array nou, repintat. Correcte
Canvi de ?tipo= a la URL Sí, la quarta entrada Recalcula. Correcte

Tres advertències importants sobre createSelector:

1. Els selectors d'entrada han de ser barats i estables. S'executen sempre; el que s'estalvia és la funció combinadora. Si una entrada retornés un objecte nou cada vegada, la memorització mai encertaria i el selector seria pitjor que no tenir-lo.

2. La memorització té mida. Històricament Reselect guardava un sol resultat, així que un selector amb argument usat des de dos components amb arguments diferents s'invalidava mútuament a cada crida. A Reselect 5 —el que porta RTK 2— la memorització per defecte (weakMapMemoize) gestiona bé diversos arguments i aquest problema desapareix en la majoria de casos. Si necessites el comportament clàssic amb diversos consumidors, el patró és una fàbrica de selectors:

// Fàbrica: cada component crea la seva pròpia instància memoritzada
export const crearSelectorReservesDeUsuari = () =>
  createSelector(
    [seleccionarEntitatsReserves, seleccionarIdsReserves, (estat, usuariId) => usuariId],
    (entitats, ids, usuariId) => ids.map((id) => entitats[id]).filter((r) => r.usuari === usuariId)
  );

// Al component
function ReservesDeUsuari({ usuariId }) {
  // useMemo manté la MATEIXA instància entre renders (08-03)
  const selector = useMemo(crearSelectorReservesDeUsuari, []);
  const reserves = useSelector((estat) => selector(estat, usuariId));
  // …
}

3. No memoritzis el que no costa. createSelector al voltant de (estat) => estat.sessio.usuari és soroll: no hi ha cap càlcul a estalviar i la referència ja és estable. Memoritza quan el selector construeixi alguna cosa.

createSelector i useMemo resolen el mateix problema en capes diferents: un memoritza sobre l'estat del magatzem i serveix per a tots els components; l'altre memoritza dins d'un component. El detall de quan compensa memoritzar és a 08-03.

  1. useDispatch i per què despatxar és estable

import { useDispatch } from 'react-redux';
import { reservaConfirmada } from '../funcionalitats/reserves/sliceReserves.js';

function BotoConfirmar({ idReserva }) {
  const despatxar = useDispatch();

  function gestionarClic() {
    despatxar(reservaConfirmada(idReserva));
  }

  return <button type="button" onClick={gestionarClic}>Confirmar</button>;
}

useDispatch retorna la funció dispatch del magatzem, que és sempre la mateixa: el magatzem es crea una vegada, a magatzem.js, i el seu dispatch no canvia mai durant la vida de l'aplicació.

Conseqüències pràctiques:

  • Un component que només despatxa mai es repinta per Redux. No està subscrit a res. És l'equivalent automàtic de la divisió estat/accions que a 07-02 va caler muntar a mà amb dos contextos.
  • despatxar pot anar a les dependències d'un useEffect sense causar bucles. De fet, el linter de hooks et demanarà que l'incloguis, i és correcte fer-ho.
  • No cal memoritzar els gestors que només despatxen per por de la identitat: la funció que provoca la feina és estable.

Fixa't en el patró de noms del projecte aplicat a Redux: gestionarClic és el gestor intern del component i reservaConfirmada és el creador d'acció importat del slice. La convenció de «gestors gestionarX a dins, props alX cap a fora» continua vigent; el que canvia és que ara, en lloc d'invocar una prop alConfirmar, molts components despatxen directament.

Un matís de disseny que val la pena pensar: no tots els components haurien de despatxar. Una TargetaBicicleta que despatxa accions deixa de ser reutilitzable i deixa de poder-se provar sense un magatzem. La regla raonable és que les pàgines i els panells es connecten; els components de presentació reben props. LlistaBicicletes i TargetaBicicleta continuen rebent bicicletes i alSeleccionar com fins ara.

  1. MenuUsuari sobre sliceSessio

El cas més senzill, i el que ensenya la solució 1.

// src/components/MenuUsuari.jsx
import { useSelector, useDispatch } from 'react-redux';
import { useNavigate } from 'react-router';
import { sessioTancada } from '../funcionalitats/sessio/sliceSessio.js';
import { seleccionarUsuari, seleccionarEsOperari }
  from '../funcionalitats/sessio/sliceSessio.js';
import { useAlternar } from '../hooks/useAlternar.js';
import estils from './MenuUsuari.module.css';

function MenuUsuari() {
  // Primitives i referències de l'estat: comparació per identitat segura
  const usuari = useSelector(seleccionarUsuari);
  const esOperari = useSelector(seleccionarEsOperari);
  const despatxar = useDispatch();
  const navegar = useNavigate();

  // Estat local d'interfície: NO va al magatzem
  const [obert, alternarObert] = useAlternar(false);

  function gestionarTancarSessio() {
    despatxar(sessioTancada());
    alternarObert();
    navegar('/', { replace: true });
  }

  if (!usuari) {
    return <Link to="/acceso" className={estils.acces}>Identifica't</Link>;
  }

  return (
    <div className={estils.menu}>
      <button type="button" onClick={alternarObert} aria-expanded={obert}>
        {usuari.nom}
      </button>
      {obert && (
        <ul className={estils.desplegable}>
          {esOperari && <li><Link to="/taller">Taller</Link></li>}
          <li><Link to="/reservas">Les meves reserves</Link></li>
          <li><button type="button" onClick={gestionarTancarSessio}>Tancar sessió</button></li>
        </ul>
      )}
    </div>
  );
}

export default MenuUsuari;

Tres punts que resumeixen mitja lliçó:

  • seleccionarEsOperari retorna un booleà. És estat derivat calculat al selector (07-04), i en ser primitiu la comparació per identitat funciona perfectament.
  • obert es queda a useAlternar. És exactament l'exemple de l'apartat 11.
  • sessioTancada() es crida sense arguments i retorna { type: 'sessio/sessioTancada' }. I recorda de 07-04 que sliceReserves reacciona a aquesta mateixa acció al seu extraReducers netejant les reserves: un sol despatx, dos dominis actualitzats, sense acoblament entre components.

  1. PaginaCataleg: el filtre de la URL i el magatzem convivint

Aquesta és la decisió de disseny més interessant de la lliçó, i estava pendent des de 07-04.

El problema: ?tipo= viu a la URL des de 06-02 i sliceCataleg té un camp tipus. Dues fonts de veritat per a la mateixa dada és exactament el que 07-01 prohibeix.

La decisió: el tipus es queda a la URL i desapareix del slice. La URL és la font de veritat perquè el filtre ha de poder compartir-se per enllaç i sobreviure a un recarregat, i cap magatzem de client dona això. El slice conserva terme i ordre, que no són enllaçables per decisió de producte, i seleccionarBicicletesVisibles rep el tipus com a argument, tal com es va escriure a l'apartat 5.

// src/pagines/PaginaCataleg.jsx — amb Redux i la URL convivint
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useSelector, useDispatch } from 'react-redux';
import CercadorBicicletes from '../components/CercadorBicicletes.jsx';
import SelectorTipus from '../components/SelectorTipus.jsx';
import LlistaBicicletes from '../components/LlistaBicicletes.jsx';
import IndicadorDeCarrega from '../components/IndicadorDeCarrega.jsx';
import Avis from '../components/Avis.jsx';
import { carregarBicicletes, termeCanviat, seleccionarTerme,
         seleccionarEstatCarregaCataleg, seleccionarErrorCataleg }
  from '../funcionalitats/cataleg/sliceCataleg.js';
import { seleccionarBicicletesVisibles } from '../funcionalitats/cataleg/selectors.js';

function PaginaCataleg() {
  const [parametres, establirParametres] = useSearchParams();
  const despatxar = useDispatch();

  // FONT DE VERITAT DEL FILTRE: la URL, com a 06-02
  const tipusTriat = parametres.get('tipo') ?? 'todos';

  // Del magatzem: primitives i un selector memoritzat amb argument
  const terme = useSelector(seleccionarTerme);
  const estatCarrega = useSelector(seleccionarEstatCarregaCataleg);
  const error = useSelector(seleccionarErrorCataleg);
  const visibles = useSelector((estat) => seleccionarBicicletesVisibles(estat, tipusTriat));

  // Estat compartit entre pocs: es queda local (07-01)
  const [idSeleccionada, setIdSeleccionada] = useState(null);

  useEffect(() => {
    despatxar(carregarBicicletes());
  }, [despatxar]);

  function gestionarCanviDeTipus(tipusNou) {
    const seguents = new URLSearchParams(parametres);
    if (tipusNou === 'todos') seguents.delete('tipo');
    else seguents.set('tipo', tipusNou);
    establirParametres(seguents, { replace: true });
  }

  function gestionarCercar(text) {
    despatxar(termeCanviat(text));
  }

  if (estatCarrega === 'carregant') {
    return <IndicadorDeCarrega missatge="Carregant el catàleg…" />;
  }

  return (
    <section>
      <h1>Catàleg de bicicletes</h1>
      {error && <Avis to="error" text={error} />}

      <CercadorBicicletes valor={terme} alCercar={gestionarCercar} />
      <SelectorTipus valor={tipusTriat} alCanviar={gestionarCanviDeTipus} />

      <LlistaBicicletes
        bicicletes={visibles}
        idSeleccionada={idSeleccionada}
        alSeleccionar={setIdSeleccionada}
      />
    </section>
  );
}

export default PaginaCataleg;

El que cal retenir d'aquest component:

  • Tres fonts d'estat coexisteixen sense duplicar-se: el filtre a la URL, el terme i el catàleg al magatzem, i la selecció en un useState local. Cadascuna on li correspon segons la taxonomia de 07-01.
  • useEffect amb [despatxar] com a única dependència funciona precisament perquè despatxar és estable. I condition del thunk (07-04) evita que el doble render de StrictMode provoqui dues peticions.
  • LlistaBicicletes i SelectorTipus no coneixen Redux. Continuen rebent props. Són components de presentació i així es mantenen provables i reutilitzables.
  • L'alternativa que NO s'ha triat: mantenir tipus al slice i sincronitzar-lo amb la URL mitjançant un useEffect. Hauria funcionat gairebé sempre, i hauria fallat en els casos difícils —enganxar una URL, el botó «enrere», obrir en una pestanya nova—, a més de ficar un render extra a cada canvi de filtre. Quan hi ha dos candidats a font de veritat, se'n tria un i l'altre deixa d'existir.

  1. PaginaReserves i PanellReserves sobre sliceReserves

// src/pagines/PaginaReserves.jsx
import { useSelector } from 'react-redux';
import { seleccionarIdsReserves } from '../funcionalitats/reserves/sliceReserves.js';
import PanellReserva from '../components/PanellReserva.jsx';

function PaginaReserves() {
  // Referència de l'estat, no un array construït: segur
  const ids = useSelector(seleccionarIdsReserves);

  if (ids.length === 0) {
    return <p>Encara no tens cap reserva. <Link to="/reservas/nueva">Crear-ne una</Link></p>;
  }

  return (
    <section>
      <h1>Les meves reserves</h1>
      <ul>
        {ids.map((id) => <PanellReserva key={id} idReserva={id} />)}
      </ul>
    </section>
  );
}

export default PaginaReserves;
// src/components/PanellReserva.jsx — cada fila se subscriu a LA SEVA reserva
import { useSelector, useDispatch } from 'react-redux';
import { reservaConfirmada, reservaCancellada, seleccionarReservaPerId }
  from '../funcionalitats/reserves/sliceReserves.js';
import EtiquetaEstat from './EtiquetaEstat.jsx';

function PanellReserva({ idReserva }) {
  const reserva = useSelector((estat) => seleccionarReservaPerId(estat, idReserva));
  const despatxar = useDispatch();

  if (!reserva) return null;

  return (
    <li>
      <h2>{reserva.bicicletaId}</h2>
      <EtiquetaEstat estat={reserva.estat} />
      <p>{reserva.hores} h des de {reserva.dataInici.replace('T', ' ')}</p>

      {reserva.estat === 'activa' && (
        <>
          <button type="button" onClick={() => despatxar(reservaConfirmada(idReserva))}>
            Confirmar
          </button>
          <button type="button" onClick={() => despatxar(reservaCancellada(idReserva))}>
            Cancel·lar
          </button>
        </>
      )}
    </li>
  );
}

export default PanellReserva;

Aquest parell de components il·lustra el patró més eficient de Redux amb llistes, i val la pena explicar-lo bé:

  • El pare selecciona només els identificadors. ids és l'array que viu a l'estat; només canvia de referència en afegir o treure una reserva. Confirmar una reserva no repinta PaginaReserves.
  • Cada fill selecciona la seva pròpia entitat per identificador. Gràcies a Immer, entitats['res-01'] conserva la seva referència mentre ningú toqui aquesta reserva, així que confirmar res-01 repinta exclusivament la fila de res-01.

Amb una llista de dues-centes reserves, la diferència entre aquest patró i seleccionar l'array complet al pare és de dos-cents renders a un. És l'aplicació pràctica de per què es va normalitzar l'estat a 07-04.

  1. Despatxar thunks des d'un component

Un thunk es despatxa igual que una acció: despatxar(carregarBicicletes()). La diferència és que retorna una promesa amb mètodes útils.

// src/pagines/PaginaNovaReserva.jsx (fragment)
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useNavigate } from 'react-router';
import { enviarReserva, seleccionarEstatEnviament, seleccionarErrorReserves }
  from '../funcionalitats/reserves/sliceReserves.js';
import { useAvisosAccions } from '../contextos/ContextAvisos.jsx';

function PaginaNovaReserva() {
  // L'esborrany és estat de FORMULARI: es queda local (07-01)
  const [esborrany, setEsborrany] = useState({ bicicletaId: '', dataInici: '', hores: 1 });

  const estatEnviament = useSelector(seleccionarEstatEnviament);
  const error = useSelector(seleccionarErrorReserves);
  const despatxar = useDispatch();
  const navegar = useNavigate();
  const { mostrarAvis } = useAvisosAccions();

  const enviant = estatEnviament === 'enviant';

  async function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();

    // unwrap() llança si el thunk ha acabat en rejected, i retorna el payload si no
    try {
      const reserva = await despatxar(enviarReserva(esborrany)).unwrap();
      mostrarAvis('exit', `Reserva ${reserva.id} creada.`);
      navegar('/reservas', { replace: true });
    } catch (fallada) {
      // L'estat del magatzem ja recull l'error; aquí només decidim la interfície
      mostrarAvis('error', String(fallada));
    }
  }

  return (
    <form onSubmit={gestionarEnviament} aria-busy={enviant}>
      {/* … camps del formulari … */}

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

      <button type="submit" disabled={enviant}>
        {enviant ? 'Enviant…' : 'Reservar'}
      </button>
    </form>
  );
}

Punts importants:

  • .unwrap() converteix el resultat del thunk en alguna cosa amb què es pot usar try/catch: retorna el payload del fulfilled o llança l'error del rejected. Sense ell, await despatxar(...) es resol sempre, fins i tot quan la petició ha fallat, i el catch no s'executaria mai. És la fallada més comuna en despatxar thunks.
  • La càrrega i l'error es llegeixen del magatzem, no d'un useState paral·lel. Una única font de veritat, i a més visible a DevTools.
  • La navegació i l'avís passen al component, no al thunk. Un reductor no navega i un thunk no hauria de conèixer l'enrutador: la lògica d'interfície es queda a la interfície.
  • disabled={enviant} i aria-busy (03-06) eviten el doble enviament des de la interfície; el condition del thunk (07-04) també l'evita des del magatzem. Les dues coses, no només una.

  1. Què es queda fora de Redux

Tenir un magatzem no vol dir ficar-ho tot a dins. Aquesta és la llista raonada per a CicloUrbano.

Dada A Redux? Justificació
Tema visual No: continua a ContextTema Canvia una vegada per sessió, el llegeix un botó i l'atribut data-tema del document. No hi ha lògica a auditar ni accions a traçar. Ficar-lo només afegiria soroll a l'historial
obert de MenuUsuari, Modal, DialegReserva No: useAlternar local Estat local d'interfície. Al magatzem embrutaria DevTools amb desenes d'accions irrellevants i obligaria a comprovar qui més llegeix aquest camp abans de tocar-lo
Filtre ?tipo= No: viu a la URL Ha de poder compartir-se per enllaç i sobreviure a un recarregat. Dues fonts de veritat és pitjor que cap
idSeleccionada del catàleg No: useState a PaginaCataleg Compartit entre dos germans i mor amb la pantalla. Elevar-lo al magatzem seria globalització prematura de manual
esborrany del formulari No: useState a la pàgina Canvia amb cada tecla. Cada tecla seria una acció, un cicle de comparació a tots els subscriptors i una entrada a l'historial
Avisos No: continuen a ContextAvisos Funcionen bé, estan dividits en estat i accions (07-02) i la seva vida és purament visual
Sessió , sliceSessio Diverses pantalles la llegeixen, té transicions amb regles i altres dominis reaccionen al seu tancament
Reserves , sliceReserves Regles de negoci que mereixen reductor i prova, entitats compartides entre pantalles
terme i ordre , sliceCataleg Es comparteixen entre el cercador i la llista, i han de sobreviure a anar a una fitxa i tornar
Catàleg de bicicletes Avui sí, a 07-06 no És estat del servidor. És al slice de forma provisional, tal com es va anunciar a 07-04

El criteri que resumeix la taula: alguna cosa entra a Redux quan el seu canvi és un succés del domini que mereix quedar registrat. «L'usuari ha obert un menú» no ho és. «L'usuari ha cancel·lat una reserva» sí.

  1. connect i mapStateToProps: només per llegir codi heretat

⚠️ Aquest apartat existeix únicament perquè sàpigues llegir projectes anteriors al 2019. connect continua funcionant i react-redux el manté, però no s'usa en codi nou. No tornarà a aparèixer al curs.

Abans dels hooks, la connexió es feia amb un component d'ordre superior:

// ⚠️ CODI HERETAT — NO ESCRIURE AIXÍ AVUI
import { connect } from 'react-redux';
import { reservaConfirmada, reservaCancellada } from './accions.js';

function PanellReserva({ reserva, alConfirmar, alCancellar }) {
  return (
    <li>
      <h2>{reserva.bicicletaId}</h2>
      <button onClick={() => alConfirmar(reserva.id)}>Confirmar</button>
      <button onClick={() => alCancellar(reserva.id)}>Cancel·lar</button>
    </li>
  );
}

// Què de l'estat es converteix en props
function mapStateToProps(estat, propsPropies) {
  return { reserva: estat.reserves.entitats[propsPropies.idReserva] };
}

// Quines accions es converteixen en props ja despatxades
const mapDispatchToProps = {
  alConfirmar: reservaConfirmada,
  alCancellar: reservaCancellada
};

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

Equivalència amb hooks:

API heretada Equivalent amb hooks Nota
mapStateToProps(estat) useSelector(selector) Un useSelector per valor, en lloc d'un objecte amb tot
mapStateToProps(estat, propsPropies) useSelector((e) => selector(e, props.id)) Les props s'usen directament, sense segon paràmetre
mapDispatchToProps com a objecte const despatxar = useDispatch() + despatxar(accio()) Ja no s'embolcallen els creadors
mapDispatchToProps com a funció Igual: despatxar(...) al gestor
mergeProps Combinar al mateix component Es feia servir poques vegades
connect()(Component) Res: el component usa els hooks Desapareix un nivell d'embolcall
ownProps Les props normals del component

Per què es va abandonar: connect afegeix un component embolcallador per cada connexió —cosa que embruta l'arbre a DevTools—, obliga a separar el component «beneit» del «connectat», té una API amb quatre formes diferents d'escriure mapDispatchToProps i és notablement difícil de tipar. Els hooks fan el mateix amb menys indirecció.

El que convé conservar d'aquella època és la distinció conceptual entre components de presentació i connectats. connect la imposava; amb hooks cal mantenir-la per disciplina, i és la regla de l'apartat 6: pàgines i panells es connecten, els components de presentació reben props.

  1. Depuració amb Redux DevTools

Ara que l'aplicació despatxa accions de veritat, l'eina de 07-03 val el que prometia. Un exemple concret de com canvia la manera de buscar una fallada.

El símptoma: «de vegades, en confirmar dues reserves seguides, la segona apareix cancel·lada».

Sense Redux, la investigació seria: posar console.log a tres gestors, intentar reproduir-ho, sospitar d'un problema de tancament (closure), sospitar d'una condició de cursa, afegir més registres.

Amb Redux DevTools:

  1. Reprodueix la fallada una vegada.
  2. Mira la llista d'accions. Aquí hi ha tota la seqüència real:
    sessio/sessioIniciada
    cataleg/carregarBicicletes/pending
    cataleg/carregarBicicletes/fulfilled
    reserves/reservaConfirmada     ← payload: 'res-01'
    reserves/reservaCancellada     ← payload: 'res-02'   ⚠️ d'on surt això?
    reserves/reservaConfirmada     ← payload: 'res-02'
    
  3. L'acció hi sobra i està identificada en vint segons. La fallada no és al reductor ni a l'estat: hi ha un onClick que despatxa reservaCancellada quan no hauria de fer-ho, probablement un gestor mal cablejat al botó de la segona fila.
  4. Prem skip sobre aquesta acció i comprova que sense ella el resultat és el correcte: confirma el diagnòstic sense tocar el codi.
  5. Mira el panell Diff de l'acció sospitosa per veure exactament què ha canviat.

I si la fallada la va reportar un usuari, pot exportar l'historial en JSON i tu importar-lo: reprodueixes la seva sessió exacta a la teva màquina.

El que canvia de fons no és la velocitat, és la naturalesa de la pregunta. Sense Redux et preguntes «per què l'estat està malament?». Amb Redux et preguntes «quina acció l'ha deixat així?», i aquesta pregunta sempre té resposta, perquè és a la llista.

Tres funcions que s'usen cada dia:

Funció Quan
Diff El primer que cal mirar: què ha canviat amb aquesta acció i res més
Jump / saltar a una acció Tornar a l'estat anterior i veure la interfície d'aquell instant
Skip Comprovar una hipòtesi sense modificar el codi

  1. Organització del codi: carpeta per funcionalitat

Fins ara CicloUrbano s'organitza per tipus: components/, pagines/, hooks/, contextos/, reductors/. Amb Redux, la comunitat en recomana una altra.

Per tipus Per funcionalitat
Agrupa Fitxers que fan el mateix Fitxers que parlen del mateix
Afegir una funcionalitat Tocar 5 carpetes Crear 1 carpeta
Esborrar una funcionalitat Buscar per tot el projecte Esborrar la carpeta
Trobar «tot el que és de reserves» Difícil Trivial
Escala bé fins a Projectes petits Qualsevol mida
src/
├── main.jsx
├── rutes.jsx
├── index.css
├── magatzem/
│   └── magatzem.js                   # configureStore amb el mapa de reductors
├── funcionalitats/
│   ├── reserves/
│   │   ├── sliceReserves.js          # estat, reductors, thunks, selectors
│   │   ├── sliceReserves.test.js
│   │   ├── PaginaReserves.jsx
│   │   ├── PaginaNovaReserva.jsx
│   │   ├── PanellReserva.jsx
│   │   ├── PanellReserva.module.css
│   │   └── FormulariReserva.jsx
│   ├── cataleg/
│   │   ├── sliceCataleg.js
│   │   ├── selectors.js              # selectors derivats amb createSelector
│   │   ├── PaginaCataleg.jsx
│   │   ├── PaginaFitxaBicicleta.jsx
│   │   ├── LlistaBicicletes.jsx
│   │   ├── TargetaBicicleta.jsx
│   │   └── SelectorTipus.jsx
│   ├── sessio/
│   │   ├── sliceSessio.js
│   │   ├── PaginaAcces.jsx
│   │   ├── MenuUsuari.jsx
│   │   ├── RutaProtegida.jsx
│   │   └── RequereixRol.jsx
│   └── estacions/
│       ├── PaginaEstacions.jsx
│       ├── PaginaDetallEstacio.jsx
│       ├── PestanyaFlota.jsx
│       └── PestanyaIncidencies.jsx
├── components/                        # COMPARTITS: no pertanyen a un domini
│   ├── Disseny.jsx
│   ├── Capcalera.jsx
│   ├── PeuDePagina.jsx
│   ├── Panell.jsx
│   ├── Modal.jsx
│   ├── Avis.jsx
│   ├── EtiquetaEstat.jsx
│   ├── IndicadorDeCarrega.jsx
│   ├── MollesDePa.jsx
│   └── LimitError.jsx
├── contextos/                         # tema i avisos: continuen sense Redux
│   ├── ContextTema.jsx
│   ├── ContextAvisos.jsx
│   └── Proveidors.jsx
├── hooks/                             # genèrics, sense domini
│   ├── useAlternar.js
│   ├── useMagatzemLocal.js
│   ├── useDebounce.js
│   └── useAmpladaFinestra.js
├── utilitats/
└── dades/

Regles que fan que això funcioni:

  1. components/ guarda només el genuïnament compartit. Si alguna cosa la usa un únic domini, viu a la seva carpeta.
  2. Un domini no importa de l'interior d'un altre. Si reserves necessita alguna cosa de cataleg, s'importa de l'arrel d'aquesta carpeta, i si comença a passar molt, la frontera entre dominis està mal traçada.
  3. rutes.jsx continua a l'arrel i sí que importa de diversos dominis: és, per definició, el mapa que els uneix.
  4. La migració és gradual. Es mou un domini cada vegada, i l'aplicació continua funcionant a cada pas.

En un projecte de la mida de CicloUrbano l'organització per tipus encara aguanta. La raó per canviar no és estètica: és que la carpeta per funcionalitat fa visible l'acoblament. Quan reserves/ necessita cinc coses de cataleg/, es veu als import i és un senyal de disseny, no un detall de col·locació.

  1. Comparativa final honesta

Amb tot el mòdul a la mà, aquesta és la comparació per a una aplicació com CicloUrbano.

Context + useReducer Redux Toolkit Zustand
Dependències Cap 2 paquets, ~15-20 kB 1 paquet, ~1 kB
Codi per començar Un fitxer de context Magatzem + un slice per domini Un fitxer
Selecció granular No: repinta tot el consumidor Sí, amb useSelector Sí, amb selector
Eines de depuració No Excel·lents DevTools de Redux via complement
Viatge en el temps No Limitat
Middleware No Sí, més senzill
Asincronia Manual createAsyncThunk Funcions async normals
Cerimònia Mitjana Alta Molt baixa
Estat fora de React No
Convenció d'equip Cal inventar-la Imposada i documentada Cal inventar-la
Corba d'aprenentatge Ja la coneixes Mitjana Baixa
Presència al mercat laboral Molt freqüent Creixent

I la recomanació, dita sense adorns:

Situació Elecció
Projecte personal, 1 persona, aplicació petita Context + useReducer. Ja el tens i funciona
Equip de 2-4, aplicació mitjana, sense lògica d'estat complexa Zustand, o context si l'estat global és poc
Equip de 5+, aplicació gran, lògica de negoci a auditar Redux Toolkit. La convenció i les eines es paguen soles
Aplicació l'estat de la qual és gairebé tot dades de servidor TanStack Query i poca cosa més d'estat de client (07-06)
Projecte que ja usa Redux clàssic Migrar a RTK gradualment, slice a slice

Per a CicloUrbano, amb honestedat: és una aplicació petita amb un equip petit, i context + useReducer continuaria sent suficient. Redux s'ha introduït perquè és el que et trobaràs a la feina, perquè el model mental —accions, reductors purs, selectors, estat normalitzat— es transfereix íntegre a Zustand i a Jotai, i perquè les DevTools són una eina que convé haver usat almenys una vegada. El que no farem és fingir que era imprescindible.

Un últim avís, que és el més important del mòdul i el fil cap a la propera lliçó: quan 07-06 tregui les bicicletes, les estacions i les reserves del magatzem cap a una memòria cau d'estat de servidor, el que quedarà a Redux serà molt poc. En moltes aplicacions reals, després d'aquest moviment l'estat de client cap en dos contextos, i aquesta és una conclusió legítima a la qual cal arribar havent entès Redux, no evitant-lo.

Errors Comuns i Consells

Error 1: un selector que retorna un objecte o un array construït. L'error número u, amb diferència. Provoca repintats amb cada acció de l'aplicació. Primitives, referències de l'estat o createSelector.

Error 2: agrupar diversos valors en un objecte «per comoditat». useSelector((e) => ({ a: e.x, b: e.y })) sense shallowEqual és l'error 1 disfressat. Dos useSelector són més simples i més ràpids.

Error 3: oblidar .unwrap() en despatxar un thunk. await despatxar(thunk()) es resol sempre, així que el catch no s'executa mai i les fallades es traguen en silenci.

Error 4: seleccionar l'array complet al pare d'una llista. Selecciona els identificadors al pare i l'entitat a cada fill: de N repintats passes a un.

Error 5: connectar components de presentació. TargetaBicicleta amb useSelector deixa de poder-se reutilitzar i de poder-se provar sense magatzem. Pàgines i panells es connecten; la resta rep props.

Error 6: duplicar a Redux alguna cosa que ja viu a la URL i sincronitzar-la amb un efecte. Tria una font de veritat i esborra l'altra.

Error 7: llegir l'estat amb magatzem.getState() dins d'un component. No subscriu a res, així que el component no es repinta quan canvia. getState() és per a thunks i middleware.

Consell 1: activa l'avís de selectors inestables. react-redux ja l'emet en desenvolupament. No l'ignoris: cadascun d'aquests avisos és un component repintant-se de més.

Consell 2: anomena els selectors seleccionarX i exporta'ls des del slice. Un component no hauria d'escriure mai estat.reserves.entitats directament.

Consell 3: mesura amb el Profiler abans de memoritzar. createSelector en un selector trivial és cost sense benefici. El Mòdul 8 ensenya a mesurar-ho (08-05).

Consell 4: mantén el magatzem petit a propòsit. Cada camp que afegeixes és un camp que algú haurà d'entendre. La taula de l'apartat 11 és una eina de decisió, no una llista tancada.

Exercicis

Exercici 1. Aquest component provoca un repintat amb cada acció del magatzem, té tres problemes diferents i a més falla en enviar. Troba'ls i reescriu-lo.

function ResumFlota({ estacioId }) {
  const { bicicletes, estacions } = useSelector((estat) => ({
    bicicletes: estat.cataleg.bicicletes,
    estacions: estat.estacions.llista
  }));

  const deLEstacio = useSelector((estat) =>
    estat.cataleg.bicicletes.filter((b) => b.estacioId === estacioId)
  );

  const despatxar = useDispatch();

  async function gestionarRecarregar() {
    try {
      await despatxar(carregarBicicletes(estacioId));
      mostrarAvis('exit', 'Flota actualitzada.');
    } catch (fallada) {
      mostrarAvis('error', 'No s\'ha pogut actualitzar.');
    }
  }

  return <Panell titol={`Flota (${deLEstacio.length})`}>{/* … */}</Panell>;
}

Exercici 2. Escriu un selector memoritzat seleccionarResumReserves que retorni { actives, confirmades, cancellades, totalHores } a partir de l'estat normalitzat de sliceReserves, i el component DistintiuReserves que el consumeixi mostrant només el nombre d'actives. Justifica per què el component no hauria d'usar aquest selector tal qual i què faries en el seu lloc.

Exercici 3. Tradueix aquest component heretat a hooks, conservant el comportament.

class LlistaEstacions extends React.Component {
  componentDidMount() {
    this.props.carregar();
  }
  render() {
    const { estacions, carregant, alSeleccionar } = this.props;
    if (carregant) return <IndicadorDeCarrega />;
    return (
      <ul>
        {estacions.map((e) => (
          <li key={e.id} onClick={() => alSeleccionar(e.id)}>{e.nom}</li>
        ))}
      </ul>
    );
  }
}

const mapStateToProps = (estat) => ({
  estacions: estat.estacions.llista,
  carregant: estat.estacions.estatCarrega === 'carregant'
});

const mapDispatchToProps = {
  carregar: carregarEstacions,
  alSeleccionar: estacioSeleccionada
};

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

Solucions

Solució 1. Els tres problemes de rendiment i el d'enviament:

  1. El primer useSelector retorna un objecte literal. Referència nova a cada crida: repintat amb cada acció.
  2. El segon useSelector retorna el resultat d'un filter. Array nou a cada crida: el mateix problema, per segona vegada.
  3. bicicletes i estacions se seleccionen i no s'usen. Subscripció gratuïta a dues branques de l'estat.
  4. Falta .unwrap(): await despatxar(carregarBicicletes(...)) es resol fins i tot quan el thunk acaba en rejected, així que el catch mai s'executa i una fallada de xarxa mostraria «Flota actualitzada».
// src/funcionalitats/cataleg/selectors.js
export const crearSelectorBicicletesDeEstacio = () =>
  createSelector(
    [seleccionarBicicletes, (estat, estacioId) => estacioId],
    (bicicletes, estacioId) => bicicletes.filter((b) => b.estacioId === estacioId)
  );
// src/funcionalitats/estacions/ResumFlota.jsx
import { useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { crearSelectorBicicletesDeEstacio } from '../cataleg/selectors.js';
import { carregarBicicletes } from '../cataleg/sliceCataleg.js';
import { useAvisosAccions } from '../../contextos/ContextAvisos.jsx';

function ResumFlota({ estacioId }) {
  const despatxar = useDispatch();
  const { mostrarAvis } = useAvisosAccions();

  // Una instància memoritzada per component muntat
  const selector = useMemo(crearSelectorBicicletesDeEstacio, []);
  const deLEstacio = useSelector((estat) => selector(estat, estacioId));

  async function gestionarRecarregar() {
    try {
      await despatxar(carregarBicicletes(estacioId)).unwrap();   // 4)
      mostrarAvis('exit', 'Flota actualitzada.');
    } catch (fallada) {
      mostrarAvis('error', 'No s\'ha pogut actualitzar.');
    }
  }

  return (
    <Panell titol={`Flota (${deLEstacio.length})`}>
      <button type="button" onClick={gestionarRecarregar}>Recarregar</button>
      {/* … */}
    </Panell>
  );
}

S'han eliminat les dues subscripcions inútils, el filtre es memoritza amb una fàbrica —perquè dos ResumFlota d'estacions diferents no s'invalidin entre si— i l'enviament informa correctament de la fallada.

Solució 2.

// src/funcionalitats/reserves/selectors.js
import { createSelector } from '@reduxjs/toolkit';
import { seleccionarEntitatsReserves, seleccionarIdsReserves } from './sliceReserves.js';

export const seleccionarResumReserves = createSelector(
  [seleccionarEntitatsReserves, seleccionarIdsReserves],
  (entitats, ids) => {
    const resum = { actives: 0, confirmades: 0, cancellades: 0, totalHores: 0 };
    for (const id of ids) {
      const reserva = entitats[id];
      if (reserva.estat === 'activa') resum.actives += 1;
      if (reserva.estat === 'confirmada') resum.confirmades += 1;
      if (reserva.estat === 'cancelada') resum.cancellades += 1;
      if (reserva.estat !== 'cancelada') resum.totalHores += reserva.hores;
    }
    return resum;
  }
);

Per què DistintiuReserves no l'hauria d'usar tal qual: el selector retorna un objecte, i encara que createSelector el memoritza, es recalcula —i retorna un objecte nou— cada vegada que canvia qualsevol reserva. Si el distintiu només mostra el nombre d'actives, es repintaria en cancel·lar una reserva, en confirmar-ne una altra o en canviar les hores d'una tercera, encara que el nombre d'actives continuï igual.

La solució és seleccionar la primitiva:

// ✅ Només es repinta quan canvia el nombre d'actives
function DistintiuReserves() {
  const actives = useSelector((estat) => seleccionarResumReserves(estat).actives);
  if (actives === 0) return null;
  return <span className="distintiu" aria-label={`${actives} reserves actives`}>{actives}</span>;
}

El selector memoritzat fa el càlcul una sola vegada i es comparteix entre tots els consumidors; cada component extreu el primitiu que necessita i useSelector compara aquest número. És la combinació de les solucions 1 i 3, i és el patró recomanat per a selectors que retornen agregats: memoritza l'agregat, selecciona el camp.

Solució 3.

// src/funcionalitats/estacions/LlistaEstacions.jsx
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { carregarEstacions, estacioSeleccionada, seleccionarEstacions,
         seleccionarEstatCarregaEstacions } from './sliceEstacions.js';
import IndicadorDeCarrega from '../../components/IndicadorDeCarrega.jsx';

function LlistaEstacions() {
  // mapStateToProps → un useSelector per valor
  const estacions = useSelector(seleccionarEstacions);
  const carregant = useSelector((estat) => seleccionarEstatCarregaEstacions(estat) === 'carregant');

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

  // componentDidMount → useEffect amb [] (més despatxar, que és estable)
  useEffect(() => {
    despatxar(carregarEstacions());
  }, [despatxar]);

  if (carregant) return <IndicadorDeCarrega missatge="Carregant estacions…" />;

  return (
    <ul>
      {estacions.map((estacio) => (
        <li key={estacio.id}>
          <button type="button" onClick={() => despatxar(estacioSeleccionada(estacio.id))}>
            {estacio.nom}
          </button>
        </li>
      ))}
    </ul>
  );
}

export default LlistaEstacions;

Quatre millores que la traducció porta de propina:

  • Desapareix el component embolcallador que connect inseria a l'arbre.
  • carregant és un booleà derivat al selector, no un objecte: comparació per identitat perfecta.
  • <li onClick> passa a ser un <button> dins del <li>. L'original no era accessible: un <li> no rep focus ni respon al teclat (03-06). Migrar codi heretat és un bon moment per arreglar això.
  • despatxar a les dependències de l'efecte és correcte i no causa bucles, precisament per ser estable.

Conclusió

El circuit està tancat. useSelector subscriu un component al magatzem, executa la seva funció selectora després de cada acció i compara el resultat amb Object.is, d'on surt la regla d'or: un selector que construeix un objecte o un array nou a cada crida provoca repintats amb cada acció de l'aplicació, vingui del domini que vingui. Les quatre solucions, en ordre de preferència: seleccionar primitives, dividir en diversos useSelector, memoritzar amb createSelector —recordant que els selectors d'entrada han de ser barats i que la memorització només compensa quan hi ha càlcul— i, com a últim recurs, una funció d'igualtat com shallowEqual. useDispatch retorna la funció dispatch del magatzem, que és estable per sempre: un component que només despatxa mai es repinta per Redux, i despatxar pot anar a les dependències d'un efecte sense por.

CicloUrbano queda connectat amb criteri. MenuUsuari llegeix dues primitives de sliceSessio i conserva el seu desplegable a useAlternar. PaginaCataleg fa conviure tres fonts d'estat sense duplicar-ne cap: el filtre a la URL —que continua sent la font de veritat, amb el tipus passat com a argument al selector memoritzat—, el terme i el catàleg al magatzem, i la selecció en un useState local. PaginaReserves selecciona només els identificadors i PanellReserva selecciona la seva pròpia entitat, de manera que confirmar una reserva repinta exactament una fila: la recompensa directa d'haver normalitzat l'estat. Els thunks es despatxen amb .unwrap() perquè try/catch funcioni, i la càrrega i l'error es llegeixen del magatzem, no d'un useState paral·lel. I la taula de què es queda fora —tema, modals, filtre d'URL, selecció del catàleg, esborrany del formulari, avisos— resumeix el criteri de tot el mòdul: alguna cosa entra a Redux quan el seu canvi és un succés del domini que mereix quedar registrat.

També saps llegir connect, mapStateToProps i mapDispatchToProps sense escriure'ls mai, usar l'historial de DevTools per canviar la pregunta de «per què l'estat està malament?» a «quina acció l'ha deixat així?», i organitzar el codi per funcionalitat en lloc de per tipus, amb components/ reservat al genuïnament compartit. La comparativa final és honesta: per a una aplicació de la mida de CicloUrbano, context + useReducer hauria bastat; Redux Toolkit es paga sol quan hi ha equip, mida i lògica a auditar; i Zustand ocupa un terme mitjà molt raonable.

Queda la peça més grossa, i la que canviarà l'equilibri de tot l'anterior. Al magatzem hi ha avui un camp bicicletes carregat amb un createAsyncThunk, i ja s'ha dit dues vegades que hi era de forma provisional: les dades que vénen d'un servidor no són estat de l'aplicació. No et pertanyen, es queden obsoletes soles, es comparteixen entre pestanyes i usuaris i arriben tard. Tractar-les com a estat de client obliga a escriure a mà càrrega, error, cancel·lació, condicions de cursa, deduplicació, revalidació, invalidació, reintents i paginació —per a cada recurs—. A la propera lliçó muntaràs una API fictícia amb json-server, coneixeràs TanStack Query, entendràs el cicle de vida d'una dada en memòria cau, faràs mutacions amb actualització optimista i la seva reversió, reescriuràs useFetchBicicletes com useBicicletes, i fixaràs l'arquitectura final de CicloUrbano: Redux per a l'estat del client, Query per al del servidor. La propera lliçó és Estat del Servidor: Peticions, Memòria Cau i Sincronització.

Curs de React

Mòdul 1: Introducció a React

Mòdul 2: Components de React

Mòdul 3: Treballar amb Esdeveniments

Mòdul 4: Conceptes Avançats de Components

Mòdul 5: Hooks de React

Mòdul 6: Enrutament a React

Mòdul 7: Gestió de l'Estat

Mòdul 8: Optimització del Rendiment

Mòdul 9: Proves a React

Mòdul 10: Temes Avançats

Mòdul 11: Projecte: Construir una Aplicació Completa

© Copyright 2026. Tots els drets reservats