La lliçó anterior va acabar assenyalant el límit: ProveidorUsuari guardava una dada senzilla, però l'estat de reserves de CicloUrbano té quatre peces que canvien juntes —la llista de reserves, l'esborrany en curs, la fase d'enviament i el possible error— i gestors que en toquen tres alhora. Amb useState això es converteix en un grapat de variables soltes, lògica de transició repartida en mitja dotzena de funcions i estats impossibles que ningú ha prohibit explícitament. useReducer canvia el plantejament: en lloc de modificar l'estat des de molts llocs, els components despatxen accions que descriuen el que ha passat, i una única funció pura decideix com passa l'estat d'una forma a una altra. En aquesta lliçó veuràs quan toca fer aquest salt, què és exactament un reductor, com es dissenyen les accions, el cas complet de reductorReserves per a CicloUrbano, com es prova un reductor sense React i com combinar-lo amb useContext per compartir estat en tot l'arbre.

Contingut

  1. Símptomes que useState s'ha quedat curt
  2. Què és un reductor i què significa que sigui pur
  3. La signatura de useReducer
  4. Anatomia d'una acció i convenció de noms
  5. despatxar és estable: per què això importa
  6. El flux complet, pas a pas
  7. Cas CicloUrbano: reductorReserves complet
  8. La tercera forma: inicialització mandrosa
  9. useState enfront de useReducer: taula de decisió
  10. Provar un reductor sense React
  11. useReducer + useContext: estat compartit

  1. Símptomes que useState s'ha quedat curt

useReducer no és «el useState avançat» ni una millora automàtica. És una eina per a una situació concreta, i aquesta és la llista de símptomes que l'anuncien.

Símptoma 1: molts useState relacionats

// Panell de reserves de CicloUrbano amb useState dispers
const [reserves, setReserves] = useState(reservesInicials);
const [esborrany, setEsborrany] = useState(DADES_INICIALS);
const [estatEnviament, setEstatEnviament] = useState('inactiu');
const [error, setError] = useState(null);

Quatre variables que mai s'usen per separat: qualsevol operació real en toca almenys dues.

Símptoma 2: gestors que toquen tres estats alhora

function gestionarConfirmar(idReserva) {
  setEstatEnviament('enviant');
  setError(null);
  setReserves((previes) =>
    previes.map((r) => (r.id === idReserva ? { ...r, estat: 'confirmada' } : r))
  );
  setEsborrany(DADES_INICIALS);
  setEstatEnviament('enviat');
}

El problema no és la longitud: és que la regla de negoci està repartida en quatre crides, i si demà confirmar una reserva també ha de descomptar una plaça de l'estació, cal recordar-se d'afegir la cinquena. En tots els gestors.

Símptoma 3: estats impossibles que ningú prohibeix

Res impedeix que estatEnviament valgui 'enviant' i que error tingui text al mateix temps. L'estructura ho permet i només la disciplina ho evita. Amb un reductor, les transicions vàlides s'escriuen una vegada, en un sol lloc, i el que no està escrit no pot passar.

Símptoma 4: la mateixa lògica repetida en diversos gestors

Cancel·lar, caducar i rebutjar una reserva fan gairebé el mateix. Amb useState acabes copiant el map tres vegades; amb un reductor, les tres accions cauen en un switch on la duplicació salta a la vista i es factoritza.

Regla pràctica: si un canvi d'estat necessita més de dues crides a l'actualitzador, o si dues variables d'estat mai canvien per separat, prova amb useReducer.

  1. Què és un reductor i què significa que sigui pur

Un reductor (reducer) és una funció que rep l'estat actual i una acció, i retorna l'estat nou: (estatPrevi, accio) => estatNou

El nom ve del mètode reduce dels arrays, que també pren un acumulador i un element i retorna l'acumulador següent. Un reductor de React fa el mateix amb l'historial d'accions: si apliquessis totes les accions des del principi, obtindries l'estat actual.

// Un reductor mínim, per veure la forma
function reductorHores(hores, accio) {
  switch (accio.tipus) {
    case 'hora_afegida':
      return hores + 1;
    case 'hora_treta':
      return Math.max(1, hores - 1);
    case 'hores_fixades':
      return accio.hores;
    default:
      throw new Error(`Acció desconeguda: ${accio.tipus}`);
  }
}

Un reductor ha de ser pur, i aquí «pur» significa exactament tres coses:

Requisit Què implica Què NO pots fer a dins
Determinista Els mateixos arguments produeixen sempre el mateix resultat Math.random(), Date.now(), crypto.randomUUID()
Sense efectes secundaris No toca res de fora fetch, localStorage, console.log amb comptadors, mutar variables externes
Sense mutar els arguments L'estat previ es copia, no es modifica estat.reserves.push(x), estat.error = 'alguna cosa'
// ❌ IMPUR: data i aleatori dins del reductor
case 'reserva_creada':
  return {
    ...estat,
    reserves: [...estat.reserves, {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,   // ⚠️ no determinista
      creadaEn: new Date().toISOString()              // ⚠️ no determinista
    }]
  };

// ✅ PUR: els valors no deterministes arriben JA calculats dins de l'acció
case 'reserva_creada':
  return { ...estat, reserves: [...estat.reserves, accio.reserva] };

La regla es resol sempre igual: el que no és determinista es calcula al gestor i viatja dins de l'acció. És el mateix criteri de 04-05 en generar l'identificador d'incidència fora del render.

Per què tanta insistència? Per tres motius pràctics: React pot cridar el reductor dues vegades amb StrictMode per detectar impureses; una funció pura es pot provar sense muntar res (apartat 10); i un reductor pur fa que l'estat sigui reproduïble, cosa que permet eines de depuració que rebobinen l'historial d'accions.

  1. La signatura de useReducer

const [estat, despatxar] = useReducer(reductor, estatInicial);
Argument / valor Què és
reductor La funció (estatPrevi, accio) => estatNou. Es declara fora del component
estatInicial L'estat del primer render
estat L'estat del render en curs. Es llegeix, mai s'assigna
despatxar Funció que envia una acció al reductor i provoca un render

La simetria amb useState és deliberada: tots dos retornen un parell [valor, forma de canviar-lo] i tots dos segueixen la regla de la instantània de 05-01. Cridar despatxar no canvia estat en el render en curs: encua l'acció, React processa la cua i el valor nou apareix en el render següent.

function gestionarAfegirHora() {
  despatxar({ tipus: 'hora_afegida' });
  console.log(estat.hores);   // el valor VELL: mateixa instantània que amb useState
}

El reductor es declara fora del component. No necessita res de l'àmbit del component —rep tot el que li cal com a arguments—, així que posar-lo a dins només aconseguiria recrear-lo a cada render i complicar-ne la prova.

  1. Anatomia d'una acció i convenció de noms

Una acció és un objecte pla que descriu alguna cosa que ha passat. Per convenció porta un camp identificador i les dades que calguin:

{ tipus: 'reserva_confirmada', idReserva: 'res-01' }
{ tipus: 'esborrany_actualitzat', camp: 'hores', valor: 4 }
{ tipus: 'enviament_fallit', missatge: 'El servidor ha respost 503' }

En l'ecosistema el camp es diu type (i a Redux és obligatori que es digui així, com veuràs a 07-04). En aquest curs l'escrivim tipus perquè no és una API de React sinó una convenció nostra, i els nostres noms van en català. Les dades addicionals es diuen genèricament càrrega (payload): pots posar-les soltes —com a dalt— o agrupades en carrega, sempre que siguis coherent.

La regla d'or del nomenat

Anomena les accions per el que ha passat, no per el que cal fer.

❌ Nom imperatiu ✅ Nom declaratiu Per què és millor
posar_estat reserva_confirmada Diu què ha passat; el reductor decideix les conseqüències
set_carregant enviament_iniciat Una acció pot canviar diverses peces alhora
actualitzar_reserves reserva_cancellada Es llegeix com un registre del negoci
netejar_tot formulari_reiniciat S'entén sense llegir el reductor

La diferència sembla cosmètica i no ho és. Amb posar_estat el component decideix com canvia l'estat, i la lògica torna a estar repartida: és useState amb més cerimònia. Amb reserva_confirmada, el component només informa del fet i el reductor concentra totes les conseqüències. Si demà confirmar també ha de buidar l'esborrany i registrar l'hora, es toca una sola línia del reductor i tots els llocs que despatxen aquesta acció queden actualitzats.

Una llista d'accions ben anomenada es llegeix com la crònica del que pot passar a l'aplicació:

reserva_creada · reserva_confirmada · reserva_cancellada
esborrany_actualitzat · esborrany_reiniciat
enviament_iniciat · enviament_completat · enviament_fallit

  1. despatxar és estable: per què això importa

Igual que els actualitzadors de useState (05-01), React garanteix que despatxar és la mateixa funció en tots els renders. Mai es recrea. Tres conseqüències directes:

  • No entra en les dependències d'un efecte (05-02). Pots despatxar des de dins d'un efecte sense provocar reexecucions.
  • No cal embolicar-la en useCallback (08-03) per passar-la a un fill memoritzat: ja és estable.
  • Es pot ficar en un context sense cost: el valor del context no canvia per culpa seva (apartat 11).

Això té un efecte de segon ordre molt valuós. Compara els dos efectes:

// Amb useState: l'efecte necessita l'estat actual, així que en depèn
useEffect(() => {
  const identificador = setInterval(() => {
    setReserves((previes) => previes.map(caducarSiProcedeix));   // forma funcional obligatòria
  }, 60000);
  return () => clearInterval(identificador);
}, []);

// Amb useReducer: l'efecte no necessita saber RES de l'estat
useEffect(() => {
  const identificador = setInterval(() => {
    despatxar({ tipus: 'reserves_caducades_revisades' });
  }, 60000);
  return () => clearInterval(identificador);
}, []);   // ni una dependència, i sense trucs

El segon efecte és més net perquè separa el disparador de la conseqüència: el temporitzador només anuncia que ha passat un minut; què significa això per a les reserves ho decideix el reductor. És la forma més eficaç de simplificar efectes complicats.

  1. El flux complet, pas a pas

flowchart TD
    A["L'usuari prem «Confirmar»"] --> B["Gestor: gestionarConfirmar(id)"]
    B --> C["despatxar({ tipus: 'reserva_confirmada', idReserva: id })"]
    C --> D["React encua l'acció"]
    D --> E["reductorReserves(estatPrevi, accio)"]
    E --> F["Retorna un OBJECTE D'ESTAT NOU"]
    F --> G["React compara i programa un render"]
    G --> H["El component es renderitza amb l'estat nou"]
    H --> I["Pantalla actualitzada"]
    style C fill:#e0f2fe
    style E fill:#fde68a
    style I fill:#dcfce7

El que fa valuós aquest flux és la separació de responsabilitats:

Peça Responsabilitat El que NO fa
El component Detectar la interacció i descriure el fet Decidir com queda l'estat
L'acció Transportar el fet i les seves dades Contenir lògica
El reductor Aplicar totes les regles de transició Tocar el DOM, la xarxa o el rellotge
React Renderitzar amb l'estat resultant Interpretar les accions

  1. Cas CicloUrbano: reductorReserves complet

Anem al cas real. L'estat gestiona les quatre peces de l'apartat 1.

// src/reductors/reserves.js
import { DADES_INICIALS } from '../components/FormulariReserva.jsx';

export const ESTAT_INICIAL_RESERVES = {
  reserves: [],                // llista de Reserva
  esborrany: DADES_INICIALS,   // { bicicletaId, dataInici, hores, condicions }
  estatEnviament: 'inactiu',   // 'inactiu' | 'enviant' | 'enviat' | 'error'
  error: null                  // missatge de fallada, o null
};

/**
 * Reductor del panell de reserves de CicloUrbano.
 * Funció PURA: (estatPrevi, accio) => estatNou
 */
export function reductorReserves(estat, accio) {
  switch (accio.tipus) {
    case 'esborrany_actualitzat':
      return {
        ...estat,
        esborrany: { ...estat.esborrany, [accio.camp]: accio.valor },
        error: null   // en escriure, es retira l'error anterior
      };

    case 'esborrany_reiniciat':
      return { ...estat, esborrany: DADES_INICIALS, error: null };

    case 'enviament_iniciat':
      return { ...estat, estatEnviament: 'enviant', error: null };

    case 'reserva_creada':
      return {
        ...estat,
        reserves: [...estat.reserves, accio.reserva],   // la reserva arriba ja construïda
        esborrany: DADES_INICIALS,
        estatEnviament: 'enviat',
        error: null
      };

    case 'enviament_fallit':
      return { ...estat, estatEnviament: 'error', error: accio.missatge };

    case 'reserva_confirmada':
      return {
        ...estat,
        reserves: estat.reserves.map((reserva) =>
          reserva.id === accio.idReserva ? { ...reserva, estat: 'confirmada' } : reserva
        )
      };

    case 'reserva_cancellada':
      return {
        ...estat,
        reserves: estat.reserves.map((reserva) =>
          reserva.id === accio.idReserva
            ? { ...reserva, estat: 'cancelada', cancelladaEn: accio.moment }
            : reserva
        )
      };

    case 'panell_reiniciat':
      return { ...ESTAT_INICIAL_RESERVES, reserves: estat.reserves };

    default:
      throw new Error(`reductorReserves: acció desconeguda «${accio.tipus}»`);
  }
}

Comentaris sobre decisions concretes del reductor:

  • Cada case retorna un objecte nou amb ...estat com a base. Mai es muta. És el mateix receptari d'immutabilitat de 05-01, ara concentrat en un sol fitxer.
  • reserva_creada rep la reserva ja construïda. L'identificador res-${crypto.randomUUID().slice(0, 8)} i la data es generen al gestor, perquè no són deterministes.
  • reserva_cancellada rep accio.moment pel mateix motiu: new Date().toISOString() no pot viure dins del reductor.
  • Una acció canvia diverses peces alhora. reserva_creada toca la llista, buida l'esborrany, marca l'enviament com a completat i neteja l'error: quatre canvis coherents, impossibles de desincronitzar perquè passen en la mateixa expressió.
  • panell_reiniciat conserva les reserves i reinicia la resta. L'estat inicial es reutilitza, sense repetir literals.
  • El default llança un error. És deliberat: un return estat silenciós convertiria una errada com 'reseva_creada' en una fallada invisible que es manifestaria com «el botó no fa res». Amb el throw, la fallada apareix a l'acte i —si has posat un LimitError (04-05)— amb una interfície alternativa decent.

El component que l'utilitza

// src/components/PanellReserves.jsx
import { useReducer } from 'react';
import { reductorReserves, ESTAT_INICIAL_RESERVES } from '../reductors/reserves.js';
import { validarReserva } from '../utilitats/validarReserva.js';
import Avis from './Avis.jsx';
import estils from './PanellReserves.module.css';

/**
 * Panell de reserves de CicloUrbano.
 * Props:
 *  - bicicletes (array de Bicicleta, opcional, per defecte [])
 *  - usuariId   (cadena, opcional, per defecte 'usr-01')
 */
function PanellReserves({ bicicletes = [], usuariId = 'usr-01' }) {
  const [estat, despatxar] = useReducer(reductorReserves, ESTAT_INICIAL_RESERVES);
  const { reserves, esborrany, estatEnviament, error } = estat;

  // DERIVATS: no són estat (05-01)
  const errors = validarReserva(esborrany, bicicletes);
  const potEnviar = Object.keys(errors).length === 0 && estatEnviament !== 'enviant';

  function gestionarCanviCamp(camp, valor) {
    despatxar({ tipus: 'esborrany_actualitzat', camp, valor });
  }

  async function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();
    if (!potEnviar) return;

    // El que no és determinista es calcula AQUÍ, no al reductor
    const reserva = {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,
      bicicletaId: esborrany.bicicletaId,
      usuari: usuariId,
      dataInici: esborrany.dataInici,
      hores: esborrany.hores,
      estat: 'activa'
    };

    despatxar({ tipus: 'enviament_iniciat' });
    try {
      await enviarReservaAlServidor(reserva);
      despatxar({ tipus: 'reserva_creada', reserva });
    } catch (fallada) {
      despatxar({ tipus: 'enviament_fallit', missatge: fallada.message });
    }
  }

  function gestionarConfirmar(idReserva) {
    despatxar({ tipus: 'reserva_confirmada', idReserva });
  }

  function gestionarCancellar(idReserva) {
    despatxar({
      tipus: 'reserva_cancellada',
      idReserva,
      moment: new Date().toISOString()
    });
  }

  return (
    <section className={estils.panell}>
      <form noValidate onSubmit={gestionarEnviament}>
        {/* camps controlats que criden gestionarCanviCamp (03-04) */}
        <button type="submit" disabled={!potEnviar}>
          {estatEnviament === 'enviant' ? 'Enviant…' : 'Crear reserva'}
        </button>
      </form>

      {estatEnviament === 'error' && <Avis to="error">{error}</Avis>}
      {estatEnviament === 'enviat' && <Avis to="exit">Reserva creada correctament.</Avis>}

      <ul>
        {reserves.map((reserva) => (
          <li key={reserva.id}>
            {reserva.id} · {reserva.hores} h · {reserva.estat}
            <button type="button" onClick={() => gestionarConfirmar(reserva.id)}>Confirmar</button>
            <button type="button" onClick={() => gestionarCancellar(reserva.id)}>Cancel·lar</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

export default PanellReserves;

Compara aquest component amb la versió de quatre useState de l'apartat 1. Els gestors han passat de coordinar diverses crides a anunciar un fet en una línia. Tota la lògica de transició és en un fitxer a part que es llegeix de dalt a baix com el manual de funcionament del panell. I gestionarEnviament deixa claríssim el repartiment: l'asíncron i el no determinista viuen al component; el que li passa a l'estat, al reductor.

  1. La tercera forma: inicialització mandrosa

useReducer accepta un tercer argument: una funció que calcula l'estat inicial a partir del segon argument.

const [estat, despatxar] = useReducer(reductorReserves, reservesGuardades, crearEstatInicial);
// src/reductors/reserves.js
export function crearEstatInicial(reservesGuardades) {
  return {
    ...ESTAT_INICIAL_RESERVES,
    reserves: reservesGuardades.filter((reserva) => reserva.estat !== 'cancelada')
  };
}

És la mateixa idea que la inicialització mandrosa de useState (05-01) i aporta el mateix: el càlcul s'executa una sola vegada, al primer render, en lloc de a tots. Amb l'avantatge afegit que la funció d'inicialització, en viure fora del component, també es pot provar per separat i reutilitzar en una acció de reinici:

case 'panell_reiniciat':
  return crearEstatInicial(accio.reservesGuardades ?? []);

  1. useState enfront de useReducer: taula de decisió

Criteri useState useReducer
Nombre de peces d'estat Una, o diverses independents Diverses que canvien juntes
Complexitat de les transicions El valor nou surt d'una expressió Regles, condicions, diverses peces per canvi
On viu la lògica Repartida pels gestors Concentrada en una funció
Llegibilitat dels gestors Empitjora en créixer Una línia per gestor
Testabilitat Requereix muntar el component Funció pura: es prova sola
Traçabilitat Cal buscar qui crida cada set La llista d'accions documenta el que pot passar
Estats impossibles Possibles si no hi ha disciplina Difícils: només existeix el que el reductor escriu
Dependències d'efectes L'estat sol entrar a [deps] despatxar és estable: efectes més nets
Corba de lectura Immediata Cal anar al reductor per entendre l'efecte d'una acció
Línies per a un comptador 1 ~10

Dos advertiments per no passar-se de frenada:

  • No converteixis tot a useReducer. Per a un panell plegat/desplegat o el text d'un cercador, useState és més curt i més clar. Un reductor per a un booleà és cerimònia pura.
  • Pots barrejar-los en el mateix component. El més habitual és un useReducer per a l'assumpte complex i algun useState per al que és local i trivial.

  1. Provar un reductor sense React

Aquest és un dels avantatges pràctics més grans i mereix veure's encara que les proves siguin el Mòdul 9. Un reductor és una funció pura: no necessita navegador, ni DOM, ni renderitzar res.

// src/reductors/reserves.test.js  (sintaxi de Vitest/Jest — Mòdul 9)
import { describe, it, expect } from 'vitest';
import { reductorReserves, ESTAT_INICIAL_RESERVES } from './reserves.js';

describe('reductorReserves', () => {
  it('crea una reserva, buida l\'esborrany i marca l\'enviament com a completat', () => {
    const reserva = {
      id: 'res-02',
      bicicletaId: 'bici-001',
      usuari: 'usr-01',
      dataInici: '2026-05-05T10:00',
      hores: 3,
      estat: 'activa'
    };

    const resultat = reductorReserves(
      { ...ESTAT_INICIAL_RESERVES, estatEnviament: 'enviant' },
      { tipus: 'reserva_creada', reserva }
    );

    expect(resultat.reserves).toHaveLength(1);
    expect(resultat.estatEnviament).toBe('enviat');
    expect(resultat.esborrany.bicicletaId).toBe('');
    expect(resultat.error).toBeNull();
  });

  it('no muta l\'estat que rep', () => {
    const previ = { ...ESTAT_INICIAL_RESERVES, reserves: [] };
    reductorReserves(previ, { tipus: 'reserva_creada', reserva: { id: 'res-03' } });

    expect(previ.reserves).toHaveLength(0);   // l'original continua intacte
  });

  it('llança davant d\'una acció desconeguda', () => {
    expect(() => reductorReserves(ESTAT_INICIAL_RESERVES, { tipus: 'inventada' })).toThrow();
  });
});

Sense muntar ni un sol component has verificat tres regles de negoci i la immutabilitat. Amb la lògica repartida en gestors dins del component, cadascuna d'aquestes comprovacions exigiria renderitzar, simular clics i esperar. És la raó principal per la qual els equips grans extreuen la lògica d'estat a reductors: el codi pur es prova barat. El detall de les eines arriba a 09-02.

  1. useReducer + useContext: estat compartit

Combinant el d'aquesta lliçó amb el de l'anterior surt un patró molt potent: l'estat complex viu en un reductor, el reductor viu en un proveïdor, i qualsevol component de l'arbre pot llegir l'estat i despatxar accions sense rebre ni una sola prop.

// src/contextos/ContextReserves.jsx
import { createContext, useContext, useReducer } from 'react';
import { reductorReserves, ESTAT_INICIAL_RESERVES } from '../reductors/reserves.js';

const ContextReserves = createContext(null);

/**
 * Proveïdor de l'estat de reserves de CicloUrbano.
 * Props:
 *  - children (contingut)
 */
export function ProveidorReserves({ children }) {
  const [estat, despatxar] = useReducer(reductorReserves, ESTAT_INICIAL_RESERVES);

  return (
    <ContextReserves value={{ estat, despatxar }}>
      {children}
    </ContextReserves>
  );
}

export function useReserves() {
  const context = useContext(ContextReserves);
  if (context === null) {
    throw new Error('useReserves s\'ha d\'utilitzar dins de <ProveidorReserves>');
  }
  return context;
}

I qualsevol component, a qualsevol profunditat:

// src/components/ComptadorReservesActives.jsx
import { useReserves } from '../contextos/ContextReserves.jsx';

function ComptadorReservesActives() {
  const { estat } = useReserves();
  const actives = estat.reserves.filter((reserva) => reserva.estat === 'activa').length;

  return <span>Reserves actives: {actives}</span>;
}
// src/components/BotoCancellarReserva.jsx
import { useReserves } from '../contextos/ContextReserves.jsx';

function BotoCancellarReserva({ idReserva }) {
  const { despatxar } = useReserves();

  return (
    <button
      type="button"
      onClick={() =>
        despatxar({ tipus: 'reserva_cancellada', idReserva, moment: new Date().toISOString() })
      }
    >
      Cancel·lar
    </button>
  );
}
flowchart TD
    PR["&lt;ProveidorReserves&gt;<br/>useReducer(reductorReserves)"] --> DIS["Disseny"]
    DIS --> CAB["Capcalera"]
    CAB --> CNT["ComptadorReservesActives<br/>llegeix estat"]
    DIS --> PAN["PanellReserves"]
    PAN --> BOT["BotoCancellarReserva<br/>despatxa accions"]
    BOT -. "despatxar" .-> PR
    PR -. "estat" .-> CNT
    style PR fill:#fde68a
    style CNT fill:#dcfce7
    style BOT fill:#e0f2fe

Si has sentit parlar de Redux, acabes d'escriure el seu model mental complet: un estat centralitzat, accions que descriuen fets i reductors purs que calculen l'estat següent. Redux hi afegeix eines al voltant —un magatzem únic fora de React, middleware per a l'asíncron, extensions de depuració que rebobinen accions, i utilitats com Redux Toolkit—, però el nucli és exactament això. La comparació completa i quan compensa cada opció es veu a 07-01 i 07-03. Aquí el que importa és que ja entens el mecanisme, així que Redux no et resultarà un misteri sinó un empaquetatge d'una cosa coneguda.

Un apunt de rendiment, en la mateixa línia que el de 05-04: en ficar { estat, despatxar } en un context, qualsevol canvi d'estat repinta tots els consumidors, inclosos els que només despatxen i mai llegeixen. La solució habitual —separar l'estat i el despatxador en dos contextos— pertany a 07-02.

Errors Comuns i Consells

  • Mutar l'estat dins del reductor. estat.reserves.push(nova); return estat; no repinta: la referència és la mateixa. Copia sempre.
  • Ficar codi no determinista o efectes al reductor. Date.now(), crypto.randomUUID(), fetch o localStorage trenquen la puresa. Calcula-ho al gestor i passa-ho a l'acció.
  • Un default que retorna l'estat en silenci. Converteix una errada en un botó que no fa res. Llança un error amb el tipus rebut.
  • Anomenar les accions en imperatiu (posar_error, set_reserves). La lògica torna al component i perds tot l'avantatge.
  • Declarar el reductor dins del component. Es recrea a cada render, no es pot provar per separat i suggereix que depèn de l'àmbit, cosa que no ha de fer.
  • Convertir a useReducer un estat simple. Deu línies per a un booleà no milloren res.
  • Llegir l'estat just després de despatxar. Continua vigent la instantània de 05-01: despatxar no canvia estat en el render en curs.
  • Oblidar ...estat en retornar. return { reserves: [...] }; esborra l'esborrany, l'estat d'enviament i l'error d'un sol cop.
  • Consell: escriu primer la llista d'accions, abans que el reductor. Si aquesta llista es llegeix com la crònica del que pot passar a la pantalla, el disseny és bo.
  • Consell: si un case es repeteix gairebé igual que un altre, extreu una funció auxiliar pura i crida-la des de tots dos. El reductor es pot recolzar en altres funcions pures sense problema.

Exercicis

Exercici 1. Aquest reductor de CicloUrbano té quatre fallades. Troba-les, explica el problema de cadascuna i escriu la versió corregida.

function reductorFlota(estat, accio) {
  switch (accio.tipus) {
    case 'bicicleta_afegida':
      estat.bicicletes.push({ id: `bici-${Math.random()}`, ...accio.dades });
      return estat;

    case 'bicicleta_a_taller':
      return {
        bicicletes: estat.bicicletes.map((b) =>
          b.id === accio.id ? { ...b, estat: 'mantenimiento' } : b
        )
      };

    case 'flota_recarregada':
      fetch('/api/bicicletas').then((r) => r.json()).then((d) => (estat.bicicletes = d));
      return estat;

    default:
      return estat;
  }
}

Exercici 2. Converteix a useReducer aquest component de CicloUrbano. Defineix l'estat inicial, escriu el reductor complet amb noms d'acció declaratius i reescriu els gestors.

function SelectorReserva({ bicicletes }) {
  const [idBicicleta, setIdBicicleta] = useState(null);
  const [hores, setHores] = useState(2);
  const [confirmada, setConfirmada] = useState(false);
  const [error, setError] = useState(null);

  function gestionarSeleccio(id) {
    setIdBicicleta(id);
    setHores(2);
    setConfirmada(false);
    setError(null);
  }

  function gestionarCanviHores(noves) {
    if (noves < 1 || noves > 24) {
      setError('Les reserves van d\'1 a 24 hores.');
      return;
    }
    setHores(noves);
    setError(null);
    setConfirmada(false);
  }

  function gestionarConfirmar() {
    if (!idBicicleta) {
      setError('Tria una bicicleta abans de confirmar.');
      return;
    }
    setConfirmada(true);
    setError(null);
  }
  …
}

Exercici 3. Escriu tres proves del reductorReserves de l'apartat 7, sense renderitzar res: que esborrany_actualitzat canvia només el camp indicat i conserva els altres; que enviament_fallit guarda el missatge i deixa les reserves intactes; i que panell_reiniciat conserva la llista de reserves però buida l'esborrany i l'error.

Solucions

Solució 1.

Les quatre fallades:

  1. bicicleta_afegida muta l'estat amb push i retorna la mateixa referència. React no veu cap canvi i no repinta.
  2. Math.random() dins del reductor trenca el determinisme. L'identificador s'ha de generar al gestor i arribar dins de l'acció.
  3. bicicleta_a_taller no copia la resta de l'estat. Retorna un objecte amb només bicicletes, així que qualsevol altra peça (filtres, selecció, error) es perd.
  4. flota_recarregada fa fetch i muta l'estat al then. Un efecte secundari dins del reductor, a més asíncron: quan arribi la resposta, aquell objecte d'estat farà molt que s'ha descartat. La càrrega de dades va en un efecte (05-02) o al gestor, i el resultat es despatxa com a acció.

Afegit: el default que retorna l'estat en silenci amaga errades en els tipus.

export const ESTAT_INICIAL_FLOTA = { bicicletes: [], carregant: false, error: null };

export function reductorFlota(estat, accio) {
  switch (accio.tipus) {
    case 'bicicleta_afegida':
      // La bicicleta arriba ja construïda, amb el seu id generat fora
      return { ...estat, bicicletes: [...estat.bicicletes, accio.bicicleta] };

    case 'bicicleta_a_taller':
      return {
        ...estat,
        bicicletes: estat.bicicletes.map((bicicleta) =>
          bicicleta.id === accio.id ? { ...bicicleta, estat: 'mantenimiento' } : bicicleta
        )
      };

    case 'recarrega_iniciada':
      return { ...estat, carregant: true, error: null };

    case 'flota_recarregada':
      // El fetch el fa el component; aquí només arriben les dades
      return { ...estat, bicicletes: accio.bicicletes, carregant: false, error: null };

    case 'recarrega_fallida':
      return { ...estat, carregant: false, error: accio.missatge };

    default:
      throw new Error(`reductorFlota: acció desconeguda «${accio.tipus}»`);
  }
}

I el gestor, amb l'asíncron i el no determinista on correspon:

function gestionarAfegir(dades) {
  despatxar({
    tipus: 'bicicleta_afegida',
    bicicleta: { id: `bici-${crypto.randomUUID().slice(0, 8)}`, ...dades }
  });
}

async function gestionarRecarregar() {
  despatxar({ tipus: 'recarrega_iniciada' });
  try {
    const resposta = await fetch('/api/bicicletas');
    if (!resposta.ok) throw new Error(`El servidor ha respost ${resposta.status}`);
    despatxar({ tipus: 'flota_recarregada', bicicletes: await resposta.json() });
  } catch (fallada) {
    despatxar({ tipus: 'recarrega_fallida', missatge: fallada.message });
  }
}

Solució 2.

// src/reductors/selectorReserva.js
export const ESTAT_INICIAL_SELECTOR = {
  idBicicleta: null,
  hores: 2,
  confirmada: false,
  error: null
};

const HORES_MINIMES = 1;
const HORES_MAXIMES = 24;

export function reductorSelector(estat, accio) {
  switch (accio.tipus) {
    case 'bicicleta_triada':
      // Triar bicicleta reinicia tota la reserva en curs
      return { ...ESTAT_INICIAL_SELECTOR, idBicicleta: accio.id };

    case 'hores_canviades':
      if (accio.hores < HORES_MINIMES || accio.hores > HORES_MAXIMES) {
        return {
          ...estat,
          error: `Les reserves van de ${HORES_MINIMES} a ${HORES_MAXIMES} hores.`
        };
      }
      return { ...estat, hores: accio.hores, error: null, confirmada: false };

    case 'reserva_confirmada':
      if (!estat.idBicicleta) {
        return { ...estat, error: 'Tria una bicicleta abans de confirmar.' };
      }
      return { ...estat, confirmada: true, error: null };

    default:
      throw new Error(`reductorSelector: acció desconeguda «${accio.tipus}»`);
  }
}
// src/components/SelectorReserva.jsx
import { useReducer } from 'react';
import { reductorSelector, ESTAT_INICIAL_SELECTOR } from '../reductors/selectorReserva.js';

function SelectorReserva({ bicicletes = [] }) {
  const [estat, despatxar] = useReducer(reductorSelector, ESTAT_INICIAL_SELECTOR);
  const { idBicicleta, hores, confirmada, error } = estat;

  function gestionarSeleccio(id) {
    despatxar({ tipus: 'bicicleta_triada', id });
  }

  function gestionarCanviHores(noves) {
    despatxar({ tipus: 'hores_canviades', hores: noves });
  }

  function gestionarConfirmar() {
    despatxar({ tipus: 'reserva_confirmada' });
  }
  …
}

El que s'ha guanyat: els tres gestors són d'una línia; les regles —els límits d'1 a 24 hores, que triar bicicleta reinicia la reserva, que confirmar sense bicicleta és un error— estan escrites en un sol fitxer i es llegeixen seguides; els límits són constants amb nom en lloc de números solts; i bicicleta_triada reutilitza ESTAT_INICIAL_SELECTOR en lloc de repetir quatre assignacions, de manera que si demà s'afegeix una cinquena peça a l'estat, el reinici la inclou sense tocar res.

Solució 3.

import { describe, it, expect } from 'vitest';
import { reductorReserves, ESTAT_INICIAL_RESERVES } from './reserves.js';

describe('reductorReserves', () => {
  it('esborrany_actualitzat canvia només el camp indicat', () => {
    const previ = {
      ...ESTAT_INICIAL_RESERVES,
      esborrany: { bicicletaId: 'bici-001', dataInici: '2026-05-04T09:00', hores: 2, condicions: false }
    };

    const resultat = reductorReserves(previ, {
      tipus: 'esborrany_actualitzat',
      camp: 'hores',
      valor: 5
    });

    expect(resultat.esborrany.hores).toBe(5);
    expect(resultat.esborrany.bicicletaId).toBe('bici-001');   // els altres intactes
    expect(resultat.esborrany.dataInici).toBe('2026-05-04T09:00');
    expect(previ.esborrany.hores).toBe(2);                     // sense mutació
  });

  it('enviament_fallit guarda el missatge i no toca les reserves', () => {
    const previ = {
      ...ESTAT_INICIAL_RESERVES,
      reserves: [{ id: 'res-01', hores: 2, estat: 'activa' }],
      estatEnviament: 'enviant'
    };

    const resultat = reductorReserves(previ, {
      tipus: 'enviament_fallit',
      missatge: 'El servidor ha respost 503'
    });

    expect(resultat.estatEnviament).toBe('error');
    expect(resultat.error).toBe('El servidor ha respost 503');
    expect(resultat.reserves).toBe(previ.reserves);   // mateixa referència: no s'ha copiat
  });

  it('panell_reiniciat conserva les reserves i neteja la resta', () => {
    const previ = {
      reserves: [{ id: 'res-01', hores: 2, estat: 'activa' }],
      esborrany: { bicicletaId: 'bici-003', dataInici: '2026-05-06T08:00', hores: 6, condicions: true },
      estatEnviament: 'error',
      error: 'Fallada anterior'
    };

    const resultat = reductorReserves(previ, { tipus: 'panell_reiniciat' });

    expect(resultat.reserves).toHaveLength(1);
    expect(resultat.esborrany.bicicletaId).toBe('');
    expect(resultat.estatEnviament).toBe('inactiu');
    expect(resultat.error).toBeNull();
  });
});

Fixa't en la segona prova: expect(resultat.reserves).toBe(previ.reserves) comprova la identitat, no la igualtat. És una afirmació deliberada: enviament_fallit no ha de copiar la llista, perquè copiar-la sense necessitat generaria una referència nova i obligaria a repintar tots els components memoritzats que en depenen (Mòdul 8). Un reductor ben escrit conserva les referències del que no canvia.

Conclusió

useReducer és la resposta quan l'estat deixa de ser una dada i passa a ser un sistema: diverses peces que canvien juntes, transicions amb regles i gestors que es tornen il·legibles. La idea central és un canvi de responsabilitats: el component ja no decideix com queda l'estat, només despatxa accions que descriuen el que ha passat, i una funció pura —el reductor— concentra totes les regles. Has vist la signatura useReducer(reductor, estatInicial) i la seva tercera forma amb inicialització mandrosa; l'anatomia de les accions i per què anomenar-les pel fet (reserva_confirmada) i no per l'operació (posar_estat) és el que evita que la lògica es torni a dispersar; que despatxar és estable i amb això simplifica dependències d'efectes i memoització; el reductorReserves complet de CicloUrbano amb el seu switch, la seva immutabilitat i el seu default que llança; i dos avantatges que es cobren cada dia: un reductor es prova sense React, per ser pur, i es combina amb useContext per donar a tot l'arbre accés a l'estat i a les accions. Aquest últim patró és, literalment, el model mental de Redux, que el Mòdul 7 retornarà a tractar amb la seva comparació completa.

Amb això has recorregut els cinc hooks fonamentals de React: useState, useEffect, useRef, useContext i useReducer. Però queda la peça que dona sentit a tot el disseny dels hooks i que 04-04 va anunciar com la seva raó de ser històrica: reutilitzar lògica amb estat sense embolcalls. El cercador amb retard de l'exercici de 05-02, la subscripció a l'esdeveniment de connexió, el temporitzador de disponibilitat, l'estat sincronitzat amb localStorage del tema… totes aquestes són peces que es repeteixen en diferents components i que avui hauries de copiar i enganxar. La lliçó següent converteix aquesta lògica en funcions reutilitzables que es criden igual que els hooks de React perquè són hooks. La lliçó següent és Hooks Personalitzats.

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