El magatzem de CicloUrbano existeix, arrenca i es veu a DevTools, però està buit: tres slices provisionals amb reducers: {} que no accepten cap acció. Aquesta lliçó l'omple. Aquí és on es fa la feina de veritat de Redux, que no consisteix a configurar res sinó a modelar l'estat i les seves transicions: decidir quina forma té cada domini, quins successos pot rebre i com canvia amb cadascun. Veuràs l'anatomia d'una acció i com anomenar-la, createSlice a fons i tot el que genera per tu, la immutabilitat aparent d'Immer —el mecanisme que més sorprèn a qui ve del Redux clàssic— i l'única regla que no es pot trencar amb ell, la preparació d'accions amb prepare per mantenir els reductors purs, els tres slices complets de CicloUrbano, els selectors com a frontera entre els components i la forma de l'estat, la normalització d'entitats per identificador i la lògica asíncrona amb createAsyncThunk. En acabar, el magatzem tindrà tot el comportament del client de CicloUrbano modelat i provat. Connectar-lo als components continua sent cosa de 07-05: aquí no apareix ni un useSelector.
Contingut
- Anatomia d'una acció i com anomenar-la
createSlicea fons: què rep i què genera- La immutabilitat aparent d'Immer
- La regla que no es pot trencar
- Reductors amb preparació:
prepare sliceCataleg: filtre, cerca i ordresliceSessio: usuari i accéssliceReserves: crear, confirmar i cancel·lar- Selectors: la frontera amb la forma de l'estat
- Normalitzar l'estat per identificador
createEntityAdapteren resum- Lògica asíncrona amb
createAsyncThunk extraReducersper reaccionar a altres slices- Provar reductors i selectors
- Anatomia d'una acció i com anomenar-la
Una acció de Redux és un objecte pla amb una forma fixada per convenció:
{
type: 'reserves/reservaConfirmada', // obligatori: què ha passat
payload: 'res-01' // opcional: les dades del succés
}| Camp | Obligatori | Què conté |
|---|---|---|
type |
Sí | Una cadena que identifica el succés. Ha de ser única a tota l'aplicació |
payload |
No | Les dades necessàries per aplicar el canvi. Un valor, un objecte, el que calgui |
meta |
No | Informació addicional que no forma part del canvi (per exemple, l'identificador de la petició) |
error |
No | true si l'acció representa un fallo |
Anomenar bé és la meitat de la feina. La convenció d'RTK és domini/succésOcorregut:
| ✅ Bé | ❌ Malament | Per què |
|---|---|---|
reserves/reservaConfirmada |
CONFIRMAR_RESERVA |
L'acció descriu el que ha passat, no una ordre. En passat, com a 05-05 |
cataleg/tipusCanviat |
SET_TIPUS |
SET_X descriu una assignació, no un succés del domini |
sessio/sessioTancada |
LOGOUT |
El prefix del domini evita col·lisions entre slices |
reserves/reservaCancellada |
updateReservaStatus |
Un nom genèric obliga a llegir el reductor per saber què fa |
El prefix del domini no és decoratiu: fa que l'historial de DevTools es llegeixi com una crònica —sessio/sessioIniciada, cataleg/tipusCanviat, reserves/reservaCreada— i que el type sigui únic sense esforç. Amb createSlice no cal que l'escriguis: es genera concatenant el name del slice i el nom del reductor.
Aquesta és la mateixa disciplina de 05-05 —accions en passat que descriuen successos— amb dos ajustos obligatoris de Redux: el camp es diu type i no tipus, i les dades van en payload en lloc de camps solts. Són APIs de la biblioteca i, com la resta del curs, no es tradueixen.
createSlice a fons: què rep i què genera
createSlice a fons: què rep i què generaUn slice és «una porció de l'estat amb tot el que la governa»: el seu estat inicial, les seves transicions i, per convenció, els seus selectors. createSlice rep un objecte de configuració:
| Clau | Què és |
|---|---|
name |
Nom del domini. S'usa com a prefix de tots els type |
initialState |
L'estat de partida d'aquesta porció |
reducers |
Un objecte { nomDelSuccés: (estat, accio) => … }. Cada clau genera un creador d'acció |
extraReducers |
Respostes a accions definides fora d'aquest slice (apartats 12 i 13) |
selectors |
Selectors declarats junt al slice (apartat 9) |
I retorna un objecte amb tres coses:
const sliceCataleg = createSlice({
name: 'cataleg',
initialState: { tipus: 'todos' },
reducers: {
tipusCanviat(estat, accio) {
estat.tipus = accio.payload;
}
}
});
sliceCataleg.name; // 'cataleg'
sliceCataleg.reducer; // la funció reductora, per a configureStore
sliceCataleg.actions; // { tipusCanviat: creador d'acció }
sliceCataleg.actions.tipusCanviat('electrica');
// → { type: 'cataleg/tipusCanviat', payload: 'electrica' }Val la pena aturar-se en el que acaba de passar, perquè és la major part de l'estalvi respecte del Redux clàssic. D'una definició de nou línies n'han sortit:
- La constant de tipus
'cataleg/tipusCanviat', que no has escrit i per tant no pots escriure malament. - El creador d'acció
tipusCanviat, que empaqueta el seu argument com apayload. - El cas del reductor que respon a aquest tipus.
- El
defaultque retorna l'estat intacte quan l'acció no és seva.
En el Redux clàssic això eren tres fitxers i una trentena de línies.
Convenció d'exportació del slice —la que s'usa a tot CicloUrbano i encaixa amb la del projecte («export default per als components i exportació amb nom per a la resta»), amb una excepció pràctica:
// Els creadors d'acció, amb nom
export const { tipusCanviat, termeCanviat, ordreCanviat } = sliceCataleg.actions;
// El reductor, per defecte: és l'únic que importa configureStore
export default sliceCataleg.reducer;El reductor va per defecte perquè cada fitxer de slice en té exactament un, i així l'import reductorCataleg from '…/sliceCataleg.js' del magatzem queda net.
- La immutabilitat aparent d'Immer
Torna a mirar el reductor de l'apartat anterior:
Això contradiu frontalment el principi 3 de 07-03 i tot el que vas aprendre a 05-01 i 05-05. I tanmateix és correcte, és la forma recomanada i no muta res.
L'explicació és Immer, una biblioteca que RTK inclou i aplica automàticament dins de createSlice. El que passa per sota:
- Abans de cridar el teu reductor, Immer embolcalla l'estat actual en un esborrany (draft), un objecte proxy que sembla l'estat real.
- El teu codi escriu sobre l'esborrany:
estat.tipus = 'electrica',estat.ids.push('res-02'),delete estat.entitats['res-01']. - El proxy no aplica aquests canvis: els anota.
- En acabar el reductor, Immer construeix un objecte nou aplicant les anotacions, copiant només les branques afectades i reutilitzant les referències de tot el que no ha canviat.
flowchart LR
A["Estat actual<br/>(immutable)"] --> B["Immer crea<br/>un esborrany (proxy)"]
B --> C["El teu reductor «muta»<br/>l'esborrany"]
C --> D["Immer anota<br/>els canvis"]
D --> E["Estat nou<br/>copiant només<br/>les branques tocades"]
A -. "les branques intactes<br/>comparteixen referència" .-> E
Compara el mateix canvi escrit de les dues formes:
// Sense Immer: immutabilitat a mà, tres nivells de propagació
function reductor(estat, accio) {
return {
...estat,
entitats: {
...estat.entitats,
[accio.payload]: {
...estat.entitats[accio.payload],
estat: 'confirmada'
}
}
};
}
// Amb Immer, dins de createSlice
reservaConfirmada(estat, accio) {
estat.entitats[accio.payload].estat = 'confirmada';
}Cinc línies de propagació de ... substituïdes per una que diu exactament el que fa. I l'última fila del diagrama importa per al rendiment: les branques que no es toquen conserven la seva referència, de manera que un component que llegeixi estat.cataleg no veurà cap canvi quan es confirmi una reserva. És el que fa que la comparació per identitat de useSelector (07-05) funcioni tan bé.
Dues precisions perquè el model mental sigui correcte:
- Immer només actua dins de
createSliceicreateReducer. Un reductor escrit a mà i passat directament aconfigureStoreno té esborrany: allà mutar és mutar, i saltarà l'avís d'immutableCheckque vas veure a 07-03. - L'esborrany només existeix durant l'execució del reductor. No te'l pots guardar, ni passar-lo a un
setTimeout, ni retornar-lo des d'una promesa.
- La regla que no es pot trencar
Immer té exactament una regla, i trencar-la produeix fallades difícils d'entendre:
O modifiques l'esborrany, o retornes un estat nou. Mai les dues coses en el mateix reductor.
// ✅ CORRECTE: modificar l'esborrany i no retornar res
reservaConfirmada(estat, accio) {
estat.entitats[accio.payload].estat = 'confirmada';
}
// ✅ CORRECTE: retornar un estat nou i no tocar l'esborrany
catalegReiniciat() {
return ESTAT_INICIAL_CATALEG;
}
// ❌ INCORRECTE: les dues coses
reservaConfirmada(estat, accio) {
estat.entitats[accio.payload].estat = 'confirmada';
return { ...estat, ultimaAccio: 'confirmar' }; // Immer no sap què fer amb això
}El cas del reinici mereix atenció perquè és el que més falla:
// ❌ NO funciona: reassignar el paràmetre no canvia res fora
catalegReiniciat(estat) {
estat = ESTAT_INICIAL_CATALEG; // només canvia la variable local
}
// ✅ Sí que funciona
catalegReiniciat() {
return ESTAT_INICIAL_CATALEG;
}Reassignar estat és JavaScript bàsic: canvies la variable local, no l'objecte al qual apuntava. Immer no ho pot detectar.
I un tercer cas, el més subtil de tots:
// ❌ Perillós: es guarda una referència a l'esborrany
let ultimEstat;
reservaCreada(estat, accio) {
estat.entitats[accio.payload.id] = accio.payload;
ultimEstat = estat; // l'esborrany es revoca en acabar; usar-lo després llança un error
}Fora del reductor, ultimEstat apunta a un proxy revocat i qualsevol accés llança Cannot perform 'get' on a proxy that has been revoked. Si necessites l'estat resultant, llegeix-lo del magatzem.
Operacions segures sobre l'esborrany, les que faràs servir cada dia:
| Operació | Exemple |
|---|---|
| Assignar una propietat | estat.tipus = accio.payload |
| Afegir a un array | estat.ids.push(nouId) |
| Treure d'un array | estat.ids = estat.ids.filter((id) => id !== accio.payload) |
| Afegir una clau a un objecte | estat.entitats[id] = reserva |
| Esborrar una clau | delete estat.entitats[id] |
| Modificar un element imbricat | estat.entitats[id].estat = 'confirmada' |
- Reductors amb preparació:
prepare
prepareEl principi 3 diu que un reductor és pur: sense identificadors aleatoris, sense new Date(), sense res no determinista. A 05-05 vas resoldre això generant la reserva al gestor i passant-la ja construïda al reductor. Funciona, però desplaça el problema: cada lloc que creï una reserva ha de recordar construir-la igual.
RTK ofereix una solució millor: la notació de preparació. En comptes d'una funció, un reductor pot ser un objecte amb reducer i prepare.
reservaCreada: {
// prepare construeix l'acció: aquí SÍ que hi pot haver efectes no deterministes
prepare(bicicletaId, usuariId, dataInici, hores) {
return {
payload: {
id: `res-${crypto.randomUUID().slice(0, 8)}`,
bicicletaId,
usuari: usuariId,
dataInici,
hores,
estat: 'activa',
creadaEn: new Date().toISOString()
}
};
},
// reducer continua sent pur: només col·loca el que li arriba
reducer(estat, accio) {
const reserva = accio.payload;
estat.entitats[reserva.id] = reserva;
estat.ids.push(reserva.id);
}
}// Qui l'usa no sap res d'identificadors ni de dates
reservaCreada('bici-002', 'usr-01', '2026-05-04T09:00', 2);
// → { type: 'reserves/reservaCreada',
// payload: { id: 'res-3f7a1c9e', bicicletaId: 'bici-002', usuari: 'usr-01',
// dataInici: '2026-05-04T09:00', hores: 2,
// estat: 'activa', creadaEn: '2026-05-04T08:52:10.114Z' } }Què s'hi guanya:
- El reductor continua sent pur, així que es pot provar amb una entrada fixa i continua funcionant el viatge en el temps: en repetir l'historial, l'acció ja té el seu identificador i la seva data gravats, i el resultat és idèntic.
- La construcció està en un únic lloc. El formulari, un botó de «repetir reserva» i una prova creen reserves exactament igual.
- La signatura és còmoda.
reservaCreada('bici-002', 'usr-01', …)en lloc d'obligar cada cridador a muntar l'objecte sencer. - Les dates es guarden com a cadenes ISO, respectant la serialitzabilitat de 07-03.
prepare també pot retornar meta i error a més de payload, tot i que a CicloUrbano no farà falta.
sliceCataleg: filtre, cerca i ordre
sliceCataleg: filtre, cerca i ordreComencem pel més senzill, que a més prepara el terreny per als selectors derivats.
// src/funcionalitats/cataleg/sliceCataleg.js
import { createSlice } from '@reduxjs/toolkit';
const ESTAT_INICIAL = {
tipus: 'todos', // 'todos' | 'urbana' | 'electrica' | 'carga'
terme: '', // text del cercador, ja sense retard
ordre: 'model', // 'model' | 'preu'
bicicletes: [], // catàleg carregat des de l'API (temporal: veure nota)
estatCarrega: 'inactiu', // 'inactiu' | 'carregant' | 'correcte' | 'error'
error: null // missatge de l'última fallada de càrrega
};
const sliceCataleg = createSlice({
name: 'cataleg',
initialState: ESTAT_INICIAL,
reducers: {
tipusCanviat(estat, accio) {
estat.tipus = accio.payload;
},
termeCanviat(estat, accio) {
estat.terme = accio.payload;
},
ordreCanviat(estat, accio) {
estat.ordre = accio.payload;
},
filtresReiniciats(estat) {
// Es reinicien els criteris, NO les bicicletes ja carregades
estat.tipus = 'todos';
estat.terme = '';
estat.ordre = 'model';
}
},
selectors: {
seleccionarBicicletes: (estat) => estat.bicicletes,
seleccionarTipus: (estat) => estat.tipus,
seleccionarTerme: (estat) => estat.terme,
seleccionarOrdre: (estat) => estat.ordre,
seleccionarEstatCarregaCataleg: (estat) => estat.estatCarrega,
seleccionarErrorCataleg: (estat) => estat.error
}
});
export const { tipusCanviat, termeCanviat, ordreCanviat, filtresReiniciats } =
sliceCataleg.actions;
export const {
seleccionarBicicletes, seleccionarTipus, seleccionarTerme,
seleccionarOrdre, seleccionarEstatCarregaCataleg, seleccionarErrorCataleg
} = sliceCataleg.selectors;
export default sliceCataleg.reducer;Dos comentaris sobre decisions concretes:
filtresReiniciatsno retornaESTAT_INICIAL. Si ho fes, esborraria també les bicicletes carregades i l'estat de càrrega. Modifica els tres camps que li corresponen i deixa la resta intacta: és la regla de l'apartat 4 aplicada amb criteri.bicicletesés aquí de forma provisional i amb data de caducitat. Segons la taxonomia de 07-01, és estat del servidor i no hauria de viure en un magatzem de client. Es posa aquí per poder ensenyarcreateAsyncThunkamb un cas real, i a 07-06 sortirà del slice cap a una memòria cau de consultes. Està bé que ho vegis fer i desfer: és exactament el que passa en un projecte real quan s'introdueix una biblioteca d'estat del servidor.
I una advertència que es resol del tot a 07-05: que tipus existeixi al slice no vol dir que el filtre deixi de viure a la URL. El filtre del catàleg continua sent estat d'URL (06-02, 07-01) i no pot tenir dues fonts de veritat. Com es concilien les dues coses és una decisió que es pren en connectar els components.
sliceSessio: usuari i accés
sliceSessio: usuari i accés// src/funcionalitats/sessio/sliceSessio.js
import { createSlice } from '@reduxjs/toolkit';
import { usuaris } from '../../dades/domini.js';
const ESTAT_INICIAL = {
usuari: null, // { id, nom, email, rol } o null si no hi ha sessió
carregant: true, // true mentre es comprova si hi ha una sessió desada
error: null // missatge si l'accés falla
};
const sliceSessio = createSlice({
name: 'sessio',
initialState: ESTAT_INICIAL,
reducers: {
sessioIniciada(estat, accio) {
estat.usuari = accio.payload; // l'usuari complet, ja resolt
estat.carregant = false;
estat.error = null;
},
sessioTancada(estat) {
estat.usuari = null;
estat.carregant = false;
estat.error = null;
},
accesFallit(estat, accio) {
estat.usuari = null;
estat.carregant = false;
estat.error = accio.payload;
},
comprovacioAcabada(estat) {
// S'ha comprovat l'emmagatzematge local i no hi havia sessió
estat.carregant = false;
}
},
selectors: {
seleccionarUsuari: (estat) => estat.usuari,
seleccionarEsOperari: (estat) => estat.usuari?.rol === 'operario',
seleccionarCarregantSessio: (estat) => estat.carregant,
seleccionarErrorSessio: (estat) => estat.error
}
});
export const { sessioIniciada, sessioTancada, accesFallit, comprovacioAcabada } =
sliceSessio.actions;
export const {
seleccionarUsuari,
seleccionarEsOperari,
seleccionarCarregantSessio,
seleccionarErrorSessio
} = sliceSessio.selectors;
export default sliceSessio.reducer;Decisions que convé justificar:
sessioIniciadarep l'usuari complet, no el seu identificador. Buscar l'usuari ausuarisés una operació de cerca que pot fallar i que, quan hi hagi API de veritat, serà asíncrona: no és feina del reductor. Si prefereixes la comoditat de cridar amb l'identificador, és un cas perfecte per aprepare.carregant: truecom a valor inicial és elcarregantSessiode 06-05, i continua sent imprescindible: començar enfalsefaria queRutaProtegidaexpulsés un usuari amb sessió vàlida durant el primer render.esOperarino és a l'estat, és un selector. És l'estat derivat de 07-01 i 05-01 aplicat a Redux: es calcula, no es guarda. Si estigués guardat, caldria recordar-se d'actualitzar-lo a les quatre transicions.
sliceReserves: crear, confirmar i cancel·lar
sliceReserves: crear, confirmar i cancel·larEl slice central, ja en forma normalitzada (el perquè és a l'apartat 10).
// src/funcionalitats/reserves/sliceReserves.js
import { createSlice } from '@reduxjs/toolkit';
const ESTAT_INICIAL = {
entitats: {}, // { 'res-01': { id, bicicletaId, usuari, dataInici, hores, estat }, … }
ids: [], // ordre de presentació: ['res-01', …]
estatCarrega: 'inactiu', // 'inactiu' | 'carregant' | 'correcte' | 'error'
error: null, // missatge de l'última fallada
estatEnviament: 'inactiu' // 'inactiu' | 'enviant' | 'enviat' | 'error'
};
const sliceReserves = createSlice({
name: 'reserves',
initialState: ESTAT_INICIAL,
reducers: {
reservaCreada: {
prepare(bicicletaId, usuariId, dataInici, hores) {
return {
payload: {
id: `res-${crypto.randomUUID().slice(0, 8)}`,
bicicletaId,
usuari: usuariId,
dataInici,
hores,
estat: 'activa',
creadaEn: new Date().toISOString()
}
};
},
reducer(estat, accio) {
const reserva = accio.payload;
estat.entitats[reserva.id] = reserva;
estat.ids.push(reserva.id);
estat.estatEnviament = 'enviat';
estat.error = null;
}
},
reservaConfirmada(estat, accio) {
const reserva = estat.entitats[accio.payload];
// Guarda defensiva: l'acció podria arribar amb un id que ja no existeix
if (!reserva) return;
// Només una reserva activa pot confirmar-se
if (reserva.estat !== 'activa') return;
reserva.estat = 'confirmada';
},
reservaCancellada: {
prepare(idReserva) {
return { payload: { idReserva, moment: new Date().toISOString() } };
},
reducer(estat, accio) {
const { idReserva, moment } = accio.payload;
const reserva = estat.entitats[idReserva];
if (!reserva) return;
if (reserva.estat === 'cancelada') return;
reserva.estat = 'cancelada';
reserva.cancelladaEn = moment;
}
},
enviamentIniciat(estat) {
estat.estatEnviament = 'enviant';
estat.error = null;
},
enviamentFallit(estat, accio) {
estat.estatEnviament = 'error';
estat.error = accio.payload;
}
},
selectors: {
seleccionarIdsReserves: (estat) => estat.ids,
seleccionarEntitatsReserves: (estat) => estat.entitats,
seleccionarReservaPerId: (estat, id) => estat.entitats[id],
seleccionarEstatEnviament: (estat) => estat.estatEnviament,
seleccionarErrorReserves: (estat) => estat.error
}
});
export const {
reservaCreada, reservaConfirmada, reservaCancellada, enviamentIniciat, enviamentFallit
} = sliceReserves.actions;
export const {
seleccionarIdsReserves, seleccionarEntitatsReserves,
seleccionarReservaPerId, seleccionarEstatEnviament, seleccionarErrorReserves
} = sliceReserves.selectors;
export default sliceReserves.reducer;El cicle de vida d'una reserva queda modelat així:
stateDiagram-v2
[*] --> activa: reservaCreada
activa --> confirmada: reservaConfirmada
activa --> cancelada: reservaCancellada
confirmada --> cancelada: reservaCancellada
cancelada --> [*]
Dos detalls que marquen la diferència entre un reductor correcte i un de fràgil:
- Les guardes retornen sense fer res (
if (!reserva) return;). Dins decreateSlice, unreturnsense valor significa «no canvia res», i Immer retorna el mateix estat. No ho confonguis amb unreturn undefinedexplícit en un reductor escrit a mà, on seria un error. if (reserva.estat !== 'activa') return;codifica una regla de negoci en l'únic lloc on es pot aplicar sempre. Confirmar una reserva ja cancel·lada deixa de ser possible des de cap punt de l'aplicació, i aquesta garantia és exactament el que es compra amb el principi 2.
Fixa't també que l'esborrany del formulari no és en aquest slice, a diferència del reductorReserves de 05-05. És una decisió deliberada de l'auditoria de 07-01: l'esborrany és estat de formulari, canvia amb cada tecla i no té per què provocar un cicle de comparació a tots els subscriptors del magatzem. Es queda junt al formulari.
- Selectors: la frontera amb la forma de l'estat
Un selector és una funció que rep l'estat i retorna una porció o un derivat d'ell.
Sembla trivial, i tanmateix resol un problema molt car. Sense selectors, els components coneixen la forma exacta de l'estat:
// ❌ Sense selectors: vint components acoblats a la forma del magatzem
const usuari = useSelector((estat) => estat.sessio.usuari);
const reserves = useSelector((estat) => estat.reserves.ids.map((id) => estat.reserves.entitats[id]));El dia que sessio passi a dir-se autenticacio, o que les reserves deixin d'estar normalitzades, cal buscar i canviar vint components, amb la certesa d'oblidar-ne dos. Amb selectors, la forma de l'estat la coneix un sol fitxer: el del slice.
Per què es declaren junt al slice: el slice és l'únic mòdul que pot canviar la forma de l'estat, així que és l'únic que l'ha de conèixer. Si canvia, se n'actualitzen els selectors i ningú més se n'assabenta. És encapsulació clàssica, aplicada a l'estat.
RTK ofereix la clau selectors dins de createSlice, i té una particularitat molt útil: els selectors declarats allà reben l'estat del slice, no l'estat global, i RTK els adapta automàticament perquè des de fora es cridin amb l'estat complet.
// Dins del slice, 'estat' és l'estat de sessio
selectors: {
seleccionarUsuari: (estat) => estat.usuari // no estat.sessio.usuari
}
// Des de fora s'usen amb l'estat global, i RTK fa la traducció
seleccionarUsuari(magatzem.getState()); // funcionaAixò funciona perquè createSlice sap sota quina clau es muntarà el slice... sempre que la clau del reducer a configureStore coincideixi amb el name del slice. És un altre motiu perquè coincideixin.
Selectors derivats
Els interessants són els que calculen en lloc de només llegir. Aquest és el que governa el catàleg de CicloUrbano:
// src/funcionalitats/cataleg/selectors.js
import { seleccionarBicicletes, seleccionarTipus, seleccionarTerme, seleccionarOrdre }
from './sliceCataleg.js';
/**
* Bicicletes que s'han de veure, aplicant tipus, terme i ordre.
* És estat DERIVAT: no es guarda al magatzem, es calcula.
*/
export function seleccionarBicicletesVisibles(estat) {
const bicicletes = seleccionarBicicletes(estat);
const tipus = seleccionarTipus(estat);
const terme = seleccionarTerme(estat).trim().toLowerCase();
const ordre = seleccionarOrdre(estat);
const filtrades = bicicletes.filter((bici) => {
const coincideixTipus = tipus === 'todos' || bici.tipus === tipus;
const coincideixTerme = terme === '' || bici.model.toLowerCase().includes(terme);
return coincideixTipus && coincideixTerme;
});
return [...filtrades].sort((a, b) =>
ordre === 'preu' ? a.preuHora - b.preuHora : a.model.localeCompare(b.model)
);
}Això és 07-01 portat a Redux: visibles no es guarda, es calcula. No hi ha cap acció visiblesActualitzades ni cap efecte que sincronitzi, així que és impossible que el filtre i la llista es contradiguin.
Però aquest selector té un problema que cal anunciar aquí i resoldre a la propera lliçó: retorna un array nou a cada crida, perquè filter i sort creen arrays nous. Quan l'usis amb useSelector, la comparació per identitat donarà sempre «ha canviat» i el component es repintarà amb cada acció del magatzem, encara que no tingui res a veure amb el catàleg. La solució és createSelector, que memoritza el resultat i només recalcula si canvien les seves entrades. S'explica a fons a 07-05, on la fallada es reprodueix i es corregeix.
Selectors amb argument
seleccionarReservaPerId rep un argument a més de l'estat:
És perfectament vàlid i molt útil. Tingues en compte que un selector així no es pot memoritzar amb createSelector tal qual, perquè la memorització guarda un únic resultat i canviar d'identificador l'invalidaria cada vegada. Per a això existeixen les fàbriques de selectors, un patró que també es veu a 07-05.
- Normalitzar l'estat per identificador
Els dos slices amb entitats —reserves ara, i bicicletes mentre sigui aquí— usen la forma { entitats, ids } en lloc d'un array. Això és normalització, i el motiu es veu millor comparant.
// SENSE normalitzar: un array
{
reserves: [
{ id: 'res-01', bicicletaId: 'bici-002', usuari: 'usr-01', hores: 2, estat: 'activa' }
]
}
// NORMALITZAT: entitats per id + ordre a part
{
entitats: {
'res-01': { id: 'res-01', bicicletaId: 'bici-002', usuari: 'usr-01', hores: 2, estat: 'activa' }
},
ids: ['res-01']
}| Operació | Amb array | Normalitzat |
|---|---|---|
| Buscar per id | reserves.find(r => r.id === id) — recorre la llista |
entitats[id] — accés directe |
| Actualitzar-ne una | map que crea un array nou sencer |
entitats[id].estat = … — toca una branca |
| Inserir | [...reserves, nova] |
Dues escriptures puntuals |
| Esborrar | filter sobre tot l'array |
delete + un filter sobre els ids (cadenes) |
| Repintats provocats | Tots els que llegeixin la llista | Només els que llegeixin aquesta entitat |
L'última fila és la decisiva. Amb un array, confirmar una reserva crea un array nou i tot component que llegeixi la llista es repinta. Normalitzat, canvia una branca d'entitats i les altres conserven la seva referència (apartat 3), de manera que un component que llegeixi entitats['res-07'] no veu cap canvi.
I el motiu de fons: res de dades imbricades. Fixa't que la reserva guarda bicicletaId: 'bici-002' i usuari: 'usr-01', no la bicicleta ni l'usuari complets. Si guardés l'objecte sencer:
- El model de
bici-002estaria duplicat a cada reserva que la fes servir, i actualitzar el preu obligaria a recórrer-les totes. - Dues reserves podrien mostrar dades diferents de la mateixa bicicleta.
- Una única font de veritat deixaria d'existir, contradient el principi 1.
La regla és la mateixa que en una base de dades relacional: cada entitat una vegada, i les relacions per identificador. Recompondre l'objecte complet per pintar-lo és feina d'un selector derivat, no de l'estat.
ids a part compleix una funció que no es veu a primer cop d'ull: guarda l'ordre. Les claus d'un objecte no garanteixen un ordre útil, així que la seqüència de presentació es representa explícitament com un array de cadenes, barat de copiar i de comparar.
createEntityAdapter en resum
createEntityAdapter en resumEscriure a mà entitats[id] = x; ids.push(id) a cada reductor és repetitiu i fàcil de desincronitzar. RTK porta createEntityAdapter, que genera aquestes operacions.
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
const adaptadorReserves = createEntityAdapter({
// Com s'ordenen els ids. Sense aquesta opció, ordre d'inserció.
sortComparer: (a, b) => b.dataInici.localeCompare(a.dataInici)
});
const sliceReserves = createSlice({
name: 'reserves',
// getInitialState genera { ids: [], entities: {} } i hi afegeix el teu
initialState: adaptadorReserves.getInitialState({
estatCarrega: 'inactiu',
error: null
}),
reducers: {
// Els mètodes de l'adaptador SÓN reductors vàlids
reservaAfegida: adaptadorReserves.addOne,
reservesRebudes: adaptadorReserves.setAll,
reservaActualitzada: adaptadorReserves.updateOne, // { id, changes: { estat: 'confirmada' } }
reservaEliminada: adaptadorReserves.removeOne
}
});
// I genera els selectors bàsics, ja memoritzats
export const {
selectAll: seleccionarTotesLesReserves,
selectById: seleccionarReservaPerId,
selectIds: seleccionarIdsReserves
} = adaptadorReserves.getSelectors((estat) => estat.reserves);| Mètode | Què fa |
|---|---|
addOne / addMany |
Afegeix sense tocar les existents |
setAll |
Substitueix tot el conjunt: el típic després de carregar del servidor |
upsertOne / upsertMany |
Afegeix o fusiona si ja existeix |
updateOne |
Aplica canvis parcials: { id, changes } |
removeOne / removeAll |
Elimina |
Nota important sobre noms: l'adaptador usa entities i ids en anglès, perquè són de l'API de RTK. És el mateix criteri de sempre: les APIs no es tradueixen. Per això els slices escrits a mà d'aquesta lliçó usen entitats, que és un camp nostre, i si adoptes l'adaptador el camp passa a dir-se entities.
A CicloUrbano, amb cinc bicicletes i unes poques reserves, l'adaptador és més maquinària de la necessària i per això els slices d'abans estan escrits a mà: es veu millor el que passa. En una aplicació amb milers d'entitats i mitja dotzena de tipus, createEntityAdapter estalvia molt codi i elimina tota una família de fallades per desincronització entre ids i entitats.
- Lògica asíncrona amb
createAsyncThunk
createAsyncThunkEls reductors són purs: no poden fer peticions. Les peticions es fan en un thunk, una funció que es despatxa en lloc d'un objecte i que rep dispatch i getState. RTK embolcalla el patró en createAsyncThunk.
// src/funcionalitats/cataleg/sliceCataleg.js (continuació)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
/**
* Carrega el catàleg de bicicletes des de l'API fictícia.
* (L'API amb json-server es munta a 07-06; aquí ja se n'usa la URL.)
*/
export const carregarBicicletes = createAsyncThunk(
'cataleg/carregarBicicletes', // prefix dels tres tipus generats
async (estacioId, utilitats) => { // funció que retorna una promesa
const { signal, rejectWithValue } = utilitats;
const url = estacioId
? `http://localhost:3001/bicicletas?estacionId=${estacioId}`
: 'http://localhost:3001/bicicletas';
const resposta = await fetch(url, { signal }); // cancel·lable: veure a baix
if (!resposta.ok) {
return rejectWithValue(`El servidor ha respost ${resposta.status}`);
}
return resposta.json(); // serà el payload de 'fulfilled'
}
);createAsyncThunk genera tres tipus d'acció a partir del prefix:
| Acció generada | Quan es despatxa | payload |
|---|---|---|
cataleg/carregarBicicletes/pending |
Just en començar | L'argument, a meta.arg |
cataleg/carregarBicicletes/fulfilled |
En resoldre's la promesa | El que retorni la funció |
cataleg/carregarBicicletes/rejected |
En llançar o rebutjar | L'error, o el que s'hagi passat a rejectWithValue |
sequenceDiagram
participant C as Component
participant T as carregarBicicletes
participant A as Magatzem
participant S as API :3001
C->>T: dispatch(carregarBicicletes('est-01'))
T->>A: /pending
A->>A: estatCarrega = 'carregant'
T->>S: GET /bicicletas?estacionId=est-01
alt Resposta correcta
S-->>T: 200 + JSON
T->>A: /fulfilled amb payload
A->>A: bicicletes = payload · estatCarrega = 'correcte'
else Fallada
S-->>T: 500 o error de xarxa
T->>A: /rejected amb el missatge
A->>A: estatCarrega = 'error' · error = missatge
end
Aquestes tres accions no són a reducers perquè no les defineix aquest slice: les defineix el thunk. Es gestionen a extraReducers.
const sliceCataleg = createSlice({
name: 'cataleg',
initialState: ESTAT_INICIAL,
reducers: { /* … tipusCanviat, termeCanviat, ordreCanviat … */ },
extraReducers: (constructor) => {
constructor
.addCase(carregarBicicletes.pending, (estat) => {
estat.estatCarrega = 'carregant';
estat.error = null;
})
.addCase(carregarBicicletes.fulfilled, (estat, accio) => {
estat.bicicletes = accio.payload;
estat.estatCarrega = 'correcte';
})
.addCase(carregarBicicletes.rejected, (estat, accio) => {
estat.estatCarrega = 'error';
// payload si es va usar rejectWithValue; si no, el missatge de l'error
estat.error = accio.payload ?? accio.error.message;
});
}
});Aquest patró estatCarrega + error és el mateix de useFetchBicicletes (05-06), amb 'inactiu' | 'carregant' | 'correcte' | 'error' com a única variable de fase, i pel mateix motiu: una sola variable fa impossibles els estats contradictoris del tipus «carregant i amb error alhora».
Detalls del thunk que convé fixar:
utilitats(el segon paràmetre) porta molt més quesignal:dispatchper despatxar altres accions,getStateper llegir l'estat actual,rejectWithValueper retornar un error controlat irequestIdper identificar aquesta execució concreta.signalés unAbortSignal: passant-lo afetch, la petició es cancel·la si el thunk s'avorta. És l'AbortControllerde 05-02, ja muntat.conditionevita peticions duplicades, un problema que a 05-02 vas resoldre a mà:
export const carregarBicicletes = createAsyncThunk(
'cataleg/carregarBicicletes',
async (estacioId, { signal }) => { /* … */ },
{
condition(estacioId, { getState }) {
// Si ja s'està carregant, no llancis una altra petició
if (getState().cataleg.estatCarrega === 'carregant') return false;
}
}
);- La funció retorna la dada, no la guarda. El thunk obté; el reductor col·loca. Mantenir aquesta separació és el que permet provar cada part per separat.
Un avís honest, que és el fil cap al final del mòdul: tot això —càrrega, error, cancel·lació, evitar duplicats— és infraestructura que estàs escrivint a mà per a cada recurs. I encara falten la revalidació, la invalidació després d'escriure, els reintents i la memòria cau. Aquesta llista completa és la que justifica la lliçó 07-06.
extraReducers per reaccionar a altres slices
extraReducers per reaccionar a altres slicesextraReducers no serveix només per a thunks: serveix perquè un slice respongui a qualsevol acció, incloses les d'un altre domini. És la resposta al senyal 4 de 07-02 («diversos proveïdors necessiten reaccionar al mateix succés»).
Cas concret: en tancar sessió, les reserves de l'usuari han de desaparèixer del magatzem.
// src/funcionalitats/reserves/sliceReserves.js
import { sessioTancada } from '../sessio/sliceSessio.js';
const sliceReserves = createSlice({
name: 'reserves',
initialState: ESTAT_INICIAL,
reducers: { /* … */ },
extraReducers: (constructor) => {
constructor
.addCase(sessioTancada, () => ESTAT_INICIAL) // retornar estat nou: correcte
.addMatcher(
(accio) => accio.type.endsWith('/rejected'),
(estat, accio) => {
estat.error = accio.payload ?? accio.error?.message ?? 'Fallada desconeguda';
}
)
.addDefaultCase((estat) => estat);
}
});| Mètode del constructor | Què fa |
|---|---|
addCase(accio, reductor) |
Respon a un tipus concret. Ha d'anar abans que els addMatcher |
addMatcher(predicat, reductor) |
Respon a totes les accions que compleixin una condició |
addDefaultCase(reductor) |
Respon al que no ha encaixat en res anterior |
El que és important conceptualment: una acció es despatxa una vegada i tots els reductors la veuen. sessioTancada la defineix sliceSessio i també la consumeix sliceReserves, sense que cap dels dos conegui l'altre més enllà de l'import del creador d'acció. És el desacoblament que el context no donava: allà hauries hagut de passar una funció d'un proveïdor a un altre.
Compte amb una dependència circular: sliceReserves importa sessioTancada de sliceSessio. Si sliceSessio importés alguna cosa de sliceReserves, tindries un cicle. La convenció habitual és que les accions «transversals» visquin al slice que les origina i que la resta les importin, mai al revés.
- Provar reductors i selectors
Aquí es cobra la promesa del principi 3: reductors i selectors són funcions pures, així que provar-les és cridar-les i comparar la sortida. Sense React, sense DOM, sense magatzem, sense simulacions.
// src/funcionalitats/reserves/sliceReserves.test.js
import reductor, { reservaConfirmada, reservaCancellada, reservaCreada } from './sliceReserves.js';
const ESTAT_AMB_UNA = {
entitats: {
'res-01': {
id: 'res-01', bicicletaId: 'bici-002', usuari: 'usr-01',
dataInici: '2026-05-04T09:00', hores: 2, estat: 'activa'
}
},
ids: ['res-01'],
estatCarrega: 'correcte',
error: null,
estatEnviament: 'inactiu'
};
test('confirma una reserva activa', () => {
const seguent = reductor(ESTAT_AMB_UNA, reservaConfirmada('res-01'));
expect(seguent.entitats['res-01'].estat).toBe('confirmada');
});
test('no confirma una reserva ja cancel·lada', () => {
const cancellada = reductor(ESTAT_AMB_UNA, reservaCancellada('res-01'));
const intent = reductor(cancellada, reservaConfirmada('res-01'));
expect(intent.entitats['res-01'].estat).toBe('cancelada');
});
test('no muta l\'estat rebut', () => {
reductor(ESTAT_AMB_UNA, reservaConfirmada('res-01'));
expect(ESTAT_AMB_UNA.entitats['res-01'].estat).toBe('activa'); // intacte
});
test('ignora una reserva inexistent', () => {
const seguent = reductor(ESTAT_AMB_UNA, reservaConfirmada('res-99'));
expect(seguent).toBe(ESTAT_AMB_UNA); // mateixa referència: no ha canviat res
});
test('crea una reserva en estat activa', () => {
const seguent = reductor(ESTAT_AMB_UNA, reservaCreada('bici-004', 'usr-01', '2026-05-06T10:00', 3));
expect(seguent.ids).toHaveLength(2);
const nova = seguent.entitats[seguent.ids[1]];
expect(nova.estat).toBe('activa');
expect(nova.bicicletaId).toBe('bici-004');
});Fixa't en dues proves especialment valuoses:
- «no muta l'estat rebut» verifica el principi 3 directament. És la xarxa de seguretat davant d'un futur refactor que tregui el reductor de
createSlicei perdi Immer. - «ignora una reserva inexistent» comprova amb
toBeque la referència és la mateixa. És la prova que la guarda funciona i que no es generen estats nous innecessaris, la qual cosa al seu torn evita repintats.
Els selectors derivats es proven igual de fàcil:
import { seleccionarBicicletesVisibles } from './selectors.js';
test('filtra per tipus i ordena per preu', () => {
const estat = {
cataleg: {
tipus: 'electrica', terme: '', ordre: 'preu',
bicicletes: [
{ id: 'bici-002', model: 'Elèctrica Pro', tipus: 'electrica', preuHora: 4.0 },
{ id: 'bici-001', model: 'Urbana Clàssica', tipus: 'urbana', preuHora: 2.5 },
{ id: 'bici-005', model: 'Elèctrica Pro', tipus: 'electrica', preuHora: 4.0 }
],
estatCarrega: 'correcte', error: null
}
};
const visibles = seleccionarBicicletesVisibles(estat);
expect(visibles).toHaveLength(2);
expect(visibles.every((b) => b.tipus === 'electrica')).toBe(true);
});Aquesta és una de les avantatges més infravalorades de Redux: tota la lògica de negoci queda en funcions pures que es proven sense muntar ni un sol component. Les eines concretes —Jest, asercions, cobertura— són el Mòdul 9; el que interessa avui és que el disseny ho fa possible.
Errors Comuns i Consells
Error 1: mutar i retornar alhora. estat.x = 1; return {…estat} trenca l'única regla d'Immer. O una cosa, o l'altra.
Error 2: reassignar el paràmetre estat. estat = ESTAT_INICIAL no fa res. Per reiniciar, return ESTAT_INICIAL.
Error 3: generar identificadors o dates dins del reductor. Trenca la puresa i amb ella el viatge en el temps i les proves. Va a prepare.
Error 4: guardar estat derivat. Un camp visibles, total o esOperari a l'estat és un camp que algun dia es contradirà amb la seva font. Selector.
Error 5: imbricar entitats completes. Guardar la bicicleta sencera dins de cada reserva duplica dades i trenca la font única de veritat. Guarda bicicletaId i recomposa-ho en un selector.
Error 6: anomenar les accions com a ordres. cataleg/setTipus descriu una assignació; cataleg/tipusCanviat descriu un succés. Amb la segona forma, DevTools es llegeix com una crònica del que ha fet l'usuari.
Error 7: posar els addMatcher abans que els addCase. El constructor d'extraReducers els avalua en ordre i RTK avisa si els barreges malament. Primer els casos concrets, després els matcher, i addDefaultCase al final.
Error 8: initialState compartit per referència. Si diversos slices comparteixen el mateix objecte literal i algun el retorna tal qual des d'un reinici, un canvi posterior podria afectar-los tots dos. Defineix una constant per slice.
Consell 1: un fitxer per slice, i el slice amo de la seva forma. Estat, reductors i selectors junts. Ningú de fora hauria d'escriure estat.reserves.entitats.
Consell 2: escriu primer l'estat inicial, comentat. Decidir la forma abans que les transicions evita refer els reductors tres vegades. Els comentaris de tipus ('activa' | 'confirmada' | 'cancelada') valen el seu pes en or.
Consell 3: prova cada reductor nou el mateix dia. Costa dos minuts perquè són funcions pures, i aquestes proves continuen sent vàlides quan el component que les usa es reescrigui sencer.
Consell 4: posa guardes als reductors que busquen per id. if (!entitat) return; converteix una fallada en un no-canvi. Sense la guarda, una acció amb un identificador obsolet tomba l'aplicació.
Exercicis
Exercici 1. Afegeix a sliceReserves una acció reservaProlongada que sumi hores a una reserva. Requisits: només es pot prolongar una reserva 'activa'; el màxim són 24 hores en total; cal registrar el moment de la prolongació; i si la reserva no existeix o no compleix les condicions, l'estat no ha de canviar. Escriu també dues proves.
Exercici 2. Aquest slice té cinc problemes. Troba'ls i reescriu-lo.
import { createSlice } from '@reduxjs/toolkit';
const sliceEstacions = createSlice({
name: 'estacions',
initialState: {
llista: [],
seleccionada: null,
totalPlaces: 0,
ultimaConsulta: new Date()
},
reducers: {
ESTABLIR_ESTACIONS(estat, accio) {
estat.llista = accio.payload;
estat.totalPlaces = accio.payload.reduce((s, e) => s + e.places, 0);
return { ...estat, ultimaConsulta: new Date() };
},
seleccionarEstacio(estat, accio) {
const trobada = estat.llista.find((e) => e.id === accio.payload);
estat.seleccionada = trobada;
},
reiniciar(estat) {
estat = { llista: [], seleccionada: null, totalPlaces: 0, ultimaConsulta: new Date() };
}
}
});Exercici 3. Escriu un createAsyncThunk anomenat enviarReserva que enviï una reserva nova a POST http://localhost:3001/reservas i gestioni els seus tres estats a sliceReserves usant el camp estatEnviament. Ha de: llegir l'usuari actual del magatzem en lloc de rebre'l com a argument, retornar un error controlat si la resposta no és correcta, i afegir la reserva a l'estat normalitzat quan el servidor la confirmi.
Solucions
Solució 1.
// Dins de reducers, a sliceReserves
reservaProlongada: {
prepare(idReserva, horesExtra) {
return { payload: { idReserva, horesExtra, moment: new Date().toISOString() } };
},
reducer(estat, accio) {
const { idReserva, horesExtra, moment } = accio.payload;
const reserva = estat.entitats[idReserva];
if (!reserva) return; // no existeix
if (reserva.estat !== 'activa') return; // només les actives
if (horesExtra <= 0) return; // prolongar zero o menys no té sentit
const total = reserva.hores + horesExtra;
if (total > 24) return; // límit del domini
reserva.hores = total;
reserva.prolongadaEn = moment;
}
}test('prolonga una reserva activa dins del límit', () => {
const seguent = reductor(ESTAT_AMB_UNA, reservaProlongada('res-01', 3));
expect(seguent.entitats['res-01'].hores).toBe(5); // 2 + 3
expect(seguent.entitats['res-01'].prolongadaEn).toBeDefined();
});
test('no prolonga per sobre de 24 hores', () => {
const seguent = reductor(ESTAT_AMB_UNA, reservaProlongada('res-01', 30));
expect(seguent).toBe(ESTAT_AMB_UNA); // mateixa referència: no ha canviat res
});El moment va a prepare perquè new Date() no pot estar al reductor, i el límit de 24 hores es codifica al reductor —no al component— perquè cap pantalla futura se'l pugui saltar.
Solució 2. Els cinc problemes:
ESTABLIR_ESTACIONS: nom en estil de constant i amb forma d'ordre. Ha de serestacionsRebudes, un succés en passat.totalPlacesés estat derivat guardat al magatzem. És una suma sobrellista: va en un selector.ESTABLIR_ESTACIONSmuta i retorna en el mateix reductor: trenca la regla d'Immer.new Date()en dos llocs: ainitialStatei als reductors. No és serialitzable i no és pur. Cadena ISO, i generada aprepare.reiniciarreassigna el paràmetre, així que no fa res. Ha de retornar l'estat inicial.
I un sisè de propina: seleccionada guarda l'objecte complet, duplicant l'entitat. Ha de guardar l'identificador i recompondre's amb un selector.
import { createSlice } from '@reduxjs/toolkit';
const ESTAT_INICIAL = {
llista: [],
idSeleccionada: null, // 6) només l'id
ultimaConsulta: null // 4) cadena ISO o null
};
const sliceEstacions = createSlice({
name: 'estacions',
initialState: ESTAT_INICIAL,
reducers: {
estacionsRebudes: { // 1) succés en passat
prepare(estacions) { // 4) el moment es genera fora del reductor
return { payload: { estacions, moment: new Date().toISOString() } };
},
reducer(estat, accio) { // 3) només muta l'esborrany, no retorna
estat.llista = accio.payload.estacions;
estat.ultimaConsulta = accio.payload.moment;
}
},
estacioSeleccionada(estat, accio) {
estat.idSeleccionada = accio.payload;
},
estacionsReiniciades() {
return ESTAT_INICIAL; // 5) retornar, no reassignar
}
},
selectors: {
seleccionarEstacions: (estat) => estat.llista,
// 2) derivat: es calcula, no es guarda
seleccionarTotalPlaces: (estat) =>
estat.llista.reduce((suma, est) => suma + est.places, 0),
// 6) l'entitat completa es recomposa aquí
seleccionarEstacioSeleccionada: (estat) =>
estat.llista.find((est) => est.id === estat.idSeleccionada) ?? null
}
});
export const { estacionsRebudes, estacioSeleccionada, estacionsReiniciades } =
sliceEstacions.actions;
export const { seleccionarEstacions, seleccionarTotalPlaces, seleccionarEstacioSeleccionada } =
sliceEstacions.selectors;
export default sliceEstacions.reducer;Solució 3.
// src/funcionalitats/reserves/sliceReserves.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const enviarReserva = createAsyncThunk(
'reserves/enviarReserva',
async (esborrany, { getState, rejectWithValue, signal }) => {
// L'usuari es llegeix del magatzem: no cal que el passi el component
const usuari = getState().sessio.usuari;
if (!usuari) {
return rejectWithValue('Has d\'iniciar sessió per reservar.');
}
const cos = {
id: `res-${crypto.randomUUID().slice(0, 8)}`,
bicicletaId: esborrany.bicicletaId,
usuari: usuari.id,
dataInici: esborrany.dataInici,
hores: Number(esborrany.hores),
estat: 'activa'
};
try {
const resposta = await fetch('http://localhost:3001/reservas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cos),
signal
});
if (!resposta.ok) {
return rejectWithValue(`No s'ha pogut crear la reserva (${resposta.status}).`);
}
return resposta.json(); // el servidor retorna la reserva creada
} catch (fallada) {
if (fallada.name === 'AbortError') throw fallada; // la cancel·lació no és un error de negoci
return rejectWithValue('No hi ha connexió amb el servidor.');
}
},
{
// Evita el doble enviament per doble clic
condition(esborrany, { getState }) {
if (getState().reserves.estatEnviament === 'enviant') return false;
}
}
);
// I a extraReducers de sliceReserves:
extraReducers: (constructor) => {
constructor
.addCase(enviarReserva.pending, (estat) => {
estat.estatEnviament = 'enviant';
estat.error = null;
})
.addCase(enviarReserva.fulfilled, (estat, accio) => {
const reserva = accio.payload;
estat.entitats[reserva.id] = reserva; // estat normalitzat
estat.ids.push(reserva.id);
estat.estatEnviament = 'enviat';
estat.error = null;
})
.addCase(enviarReserva.rejected, (estat, accio) => {
estat.estatEnviament = 'error';
estat.error = accio.payload ?? accio.error.message;
});
}Tres decisions que mereixen comentari:
getState()dins del thunk evita que cada component hagi de llegir l'usuari i passar-lo. La regla que hi ha darrere: el thunk és el lloc on viu la lògica que necessita llegir l'estat; el reductor només aplica el resultat.rejectWithValueenfront de llançar. Llançant,accio.error.messageporta el missatge tècnic de l'error (Failed to fetch); ambrejectWithValuecontroles exactament el text que veurà l'usuari. Per a missatges d'interfície, semprerejectWithValue.- La reserva la construeix el thunk, no el reductor ni
prepare, perquè qui mana és la resposta del servidor: elfulfilledguarda el que ha retornat l'API, no el que s'ha enviat. Si el servidor assignés el seu propi identificador, això seria imprescindible.
Conclusió
El magatzem de CicloUrbano ja té comportament. Saps que una acció és { type, payload }, que s'anomena domini/succésOcorregut en passat —reserves/reservaConfirmada, cataleg/tipusCanviat, sessio/sessioTancada— i que aquest nomenat és el que converteix l'historial de DevTools en una crònica llegible. createSlice rep name, initialState i reducers i genera d'una sola vegada els tipus, els creadors d'acció, el reductor i el cas per defecte: dels tres o quatre fitxers del Redux clàssic es passa a un de sol. Immer et deixa escriure estat.entitats[id].estat = 'confirmada' dins d'un slice perquè el que toques és un esborrany que anota canvis i produeix un objecte nou copiant només les branques afectades —la resta conserva la seva referència, que és el que farà eficient useSelector—, amb una única regla inviolable: o modifiques l'esborrany o retornes un estat nou, mai les dues coses. I prepare manté la puresa traient del reductor allò no determinista, els identificadors i les dates ISO.
CicloUrbano queda amb tres slices complets: sliceCataleg amb tipus, terme i ordre més la càrrega del catàleg, sliceSessio amb l'usuari, el carregant inicial imprescindible per a RutaProtegida i esOperari com a selector i no com a camp, i sliceReserves amb el cicle activa → confirmada / cancelada, guardes que codifiquen les regles de negoci a l'únic lloc on no es poden saltar, i estat normalitzat { entitats, ids }: cada entitat una vegada, les relacions per identificador —bicicletaId, no la bicicleta sencera— i l'ordre explícit en un array de cadenes. Els selectors viuen junt al seu slice perquè el slice és l'únic que ha de conèixer la forma de l'estat, i els derivats com seleccionarBicicletesVisibles calculen en lloc de guardar, amb l'advertència pendent que retornen un array nou a cada crida. createAsyncThunk resol l'asincronia amb les seves tres accions pending/fulfilled/rejected gestionades a extraReducers amb el patró estatCarrega/error, i extraReducers serveix a més perquè un slice reaccioni a les accions d'un altre sense acoblar-s'hi. Tot això són funcions pures, així que provar-les és cridar-les i comparar, sense React ni DOM.
Falta la part que es veu. A la propera lliçó connectaràs el magatzem als components amb useSelector i useDispatch, i allà apareix la regla d'or que decideix si Redux et repintarà l'aplicació sencera o només el necessari: la comparació per identitat. Veuràs la fallada reproduïda amb seleccionarBicicletesVisibles, les seves quatre solucions incloent-hi la memorització amb createSelector, reescriuràs PaginaCataleg, PaginaReserves, PanellReserves i MenuUsuari, i decidiràs amb arguments què es queda fora del magatzem. La propera lliçó és Redux: Connectar-lo a React.
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
