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
- Símptomes que
useStates'ha quedat curt - Què és un reductor i què significa que sigui pur
- La signatura de
useReducer - Anatomia d'una acció i convenció de noms
despatxarés estable: per què això importa- El flux complet, pas a pas
- Cas CicloUrbano:
reductorReservescomplet - La tercera forma: inicialització mandrosa
useStateenfront deuseReducer: taula de decisió- Provar un reductor sense React
useReducer+useContext: estat compartit
- Símptomes que
useState s'ha quedat curt
useState s'ha quedat curtuseReducer 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.
- 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.
- La signatura de
useReducer
useReducer| 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.
- 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
despatxar és estable: per què això importa
despatxar és estable: per què això importaIgual 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 trucsEl 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.
- 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 |
- Cas CicloUrbano:
reductorReserves complet
reductorReserves completAnem 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
caseretorna un objecte nou amb...estatcom a base. Mai es muta. És el mateix receptari d'immutabilitat de 05-01, ara concentrat en un sol fitxer. reserva_creadarep la reserva ja construïda. L'identificadorres-${crypto.randomUUID().slice(0, 8)}i la data es generen al gestor, perquè no són deterministes.reserva_cancelladarepaccio.momentpel mateix motiu:new Date().toISOString()no pot viure dins del reductor.- Una acció canvia diverses peces alhora.
reserva_creadatoca 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_reiniciatconserva les reserves i reinicia la resta. L'estat inicial es reutilitza, sense repetir literals.- El
defaultllança un error. És deliberat: unreturn estatsilenciós convertiria una errada com'reseva_creada'en una fallada invisible que es manifestaria com «el botó no fa res». Amb elthrow, la fallada apareix a l'acte i —si has posat unLimitError(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.
- La tercera forma: inicialització mandrosa
useReducer accepta un tercer argument: una funció que calcula l'estat inicial a partir del segon argument.
// 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:
useState enfront de useReducer: taula de decisió
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
useReducerper a l'assumpte complex i algunuseStateper al que és local i trivial.
- 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.
useReducer + useContext: estat compartit
useReducer + useContext: estat compartitCombinant 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["<ProveidorReserves><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(),fetcholocalStoragetrenquen la puresa. Calcula-ho al gestor i passa-ho a l'acció. - Un
defaultque 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
useReducerun 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:
despatxarno canviaestaten el render en curs. - Oblidar
...estaten 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
casees 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:
bicicleta_afegidamuta l'estat ambpushi retorna la mateixa referència. React no veu cap canvi i no repinta.Math.random()dins del reductor trenca el determinisme. L'identificador s'ha de generar al gestor i arribar dins de l'acció.bicicleta_a_tallerno copia la resta de l'estat. Retorna un objecte amb nomésbicicletes, així que qualsevol altra peça (filtres, selecció, error) es perd.flota_recarregadafafetchi muta l'estat althen. 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
- Què és React?
- Configuració de l'Entorn de Desenvolupament
- Hola Món amb React
- JSX: Extensió de Sintaxi de JavaScript
- Com Renderitza React: Virtual DOM i Reconciliació
Mòdul 2: Components de React
- Entendre els Components
- Components Funcionals vs de Classe
- Props: Passar Dades als Components
- State: Gestió de l'Estat del Component
- Estils en els Components: CSS, Mòduls i Utilitats
Mòdul 3: Treballar amb Esdeveniments
- Gestió d'Esdeveniments a React
- Renderitzat Condicional
- Llistes i Claus
- Formularis i Components Controlats
- Validació de Formularis i Components No Controlats
- Accessibilitat en Components Interactius
Mòdul 4: Conceptes Avançats de Components
- Elevar l'Estat
- Composició vs Herència
- Mètodes del Cicle de Vida de React
- Hooks: Introducció i Ús Bàsic
- Límits d'Error: Capturar Fallades a la Interfície
Mòdul 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef i Accés al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalitzats
Mòdul 6: Enrutament a React
- Introducció a React Router
- Configuració de React Router
- Rutes Imbricades
- Navegació Programàtica
- Rutes Protegides i Control d'Accés
Mòdul 7: Gestió de l'Estat
- Introducció a la Gestió de l'Estat
- API de Context
- Redux: Introducció i Configuració
- Redux: Accions i Reductors
- Redux: Connectar-lo a React
- Estat del Servidor: Peticions, Memòria Cau i Sincronització
Mòdul 8: Optimització del Rendiment
- Tècniques d'Optimització del Rendiment a React
- Memoïtzació amb React.memo
- Hooks useMemo i useCallback
- Divisió de Codi i Càrrega Mandrosa
- Mesurar el Rendiment amb React DevTools Profiler
Mòdul 9: Proves a React
- Introducció a les Proves
- Proves Unitàries amb Jest
- Proves de Components amb React Testing Library
- Proves de Codi Asíncron i Simulació d'APIs
- Proves d'Extrem a Extrem amb Cypress
Mòdul 10: Temes Avançats
- Renderitzat al Servidor (SSR) amb Next.js
- Generació de Llocs Estàtics (SSG) amb Next.js
- Suspense i React Server Components
- TypeScript amb React
- React Native: Creació d'Aplicacions Mòbils
