La lliçó anterior acabava amb una pregunta oberta. useFiltreResponsable funcionava perquè el filtre vivia a App i des d'allà baixava als dos components que el necessitaven. Però tan bon punt aquest mateix filtre el necessiten també l'encaminador, la capçalera, l'informe i un botó enterrat a sis nivells de profunditat, comencen els problemes: props que travessen components que no les fan servir, còpies de la mateixa dada en dos llocs que es desincronitzen, i un flux de dades que ja no cap al cap. Aquesta lliçó tracta d'això, i no comença per Redux: comença pel problema, segueix per les alternatives més barates —elevar l'estat, Context, un magatzem extern— i només arriba a Redux quan les anteriors es queden curtes, perquè la majoria de les aplicacions no necessiten Redux i dir-ho forma part d'ensenyar-lo bé. Veuràs els tres principis de Redux i el cicle acció → reductor → nou estat → render, amb el paral·lelisme directe amb el teu Tauler de memòria cau per versió de 09-02 i els teus CustomEvent de 06-04; aprendràs Redux Toolkit, que és la forma correcta i actual de fer-lo servir, amb configureStore, createSlice, Immer, useSelector/useDispatch i els selectors memoïtzats de createSelector; resoldràs la lògica asíncrona amb createAsyncThunk i els tres estats d'una petició aplicats al teu llistarTasques de 07-02, i coneixeràs RTK Query com l'eina específica per a estat de servidor; veuràs les DevTools amb el seu viatge en el temps i per què la traçabilitat és l'avantatge real; normalitzaràs 600 tasques amb createEntityAdapter; i acabaràs amb els errors clàssics i una taula honesta d'alternatives lleugeres. Tot aplicat al filtre i al tauler de Nómada Tasques.

Contingut

  1. El problema abans de l'eina
  2. Prop drilling: el filtre que travessa sis components
  3. Estat duplicat i desincronització
  4. Les alternatives per ordre de cost
  5. Alternativa 1: elevar l'estat
  6. Alternativa 2: Context de React
  7. Què és Context i què no és
  8. Alternativa 3: un magatzem extern
  9. Quan cadascuna n'hi ha prou
  10. Els tres principis de Redux
  11. El cicle acció → reductor → nou estat → render
  12. El paral·lelisme amb el teu Tauler i els teus CustomEvent
  13. Redux Toolkit: per què ningú no escriu Redux a mà
  14. configureStore: el magatzem
  15. createSlice: accions i reductors junts
  16. Immer: escriure mutacions que produeixen estat immutable
  17. useSelector i useDispatch
  18. Selectors memoïtzats amb createSelector
  19. Lògica asíncrona amb createAsyncThunk
  20. Els tres estats d'una petició
  21. RTK Query: l'eina per a l'estat de servidor
  22. Les DevTools i el viatge en el temps
  23. Normalitzar dades amb createEntityAdapter
  24. Nómada Tasques: el filtre i el tauler complets
  25. Alternatives lleugeres: Zustand, Jotai i els magatzems natius
  26. Errors Habituals i Consells
  27. Exercicis
  28. Conclusió

  1. El problema abans de l'eina

Redux té mala fama, i se la va guanyar. Durant anys va ser la resposta automàtica a la pregunta «com gestiono l'estat?», fins i tot quan la pregunta correcta era «de debò necessito gestionar estat global?». Es van escriure milers d'aplicacions amb tres carpetes de boilerplateactions/, reducers/, constants/— per guardar si un modal estava obert.

Aquella època ja ha passat. El Redux modern, a través de Redux Toolkit, és molt més concís, i —més important— la indústria va aprendre a distingir quan cal i quan no. Aquesta lliçó respecta aquest aprenentatge: primer el problema, després les alternatives barates, i Redux al final, quan s'ha guanyat el lloc.

El problema té tres cares, i convé veure-les amb el filtre de responsable de Nómada Tasques, que és un cas perfectament realista.

  1. Prop drilling: el filtre que travessa sis components

Imagina't que Nómada Tasques ha crescut. La pantalla del tauler té aquesta estructura de components:

graph TD
  App --> Capcalera
  App --> Plafo
  App --> PeuDePagina
  Capcalera --> FiltreResponsable
  Capcalera --> ResumCapcalera
  Plafo --> LlistaTasques
  Plafo --> BarraLateral
  LlistaTasques --> TargetaTasca
  BarraLateral --> InformeCarrega
  PeuDePagina --> ComptadorVisibles

El filtre de responsable el canvia FiltreResponsable, i el necessiten:

  • LlistaTasques, per filtrar.
  • ResumCapcalera, per dir «3 de 6 tasques · 25 h».
  • InformeCarrega, per destacar la persona filtrada.
  • ComptadorVisibles, al peu.
  • L'encaminador, per reflectir el filtre a l'URL (?responsable=Ivan) com vas fer amb la History API a 07-06.

Si l'estat viu a App —que és l'avantpassat comú—, cal passar-lo cap avall:

// ❌ El filtre travessa components als quals no els importa
function App() {
  const [responsable, setResponsable] = useState(null);

  return (
    <>
      <Capcalera responsable={responsable} enCanviar={setResponsable} />
      <Plafo responsable={responsable} />
      <PeuDePagina responsable={responsable} />
    </>
  );
}

function Capcalera({ responsable, enCanviar }) {
  // Capcalera no fa servir `responsable` per a res propi: només el transporta
  return (
    <header>
      <FiltreResponsable valor={responsable} enCanviar={enCanviar} />
      <ResumCapcalera responsable={responsable} />
    </header>
  );
}

function Plafo({ responsable }) {
  // Plafo tampoc no el fa servir: el transporta
  return (
    <div className="plafo">
      <LlistaTasques responsable={responsable} />
      <BarraLateral responsable={responsable} />
    </div>
  );
}

A això se li diu prop drilling: perforar l'arbre de components amb una prop que només interessa a les fulles. Els seus costos són concrets:

Cost Què significa
Soroll Capcalera i Plafo tenen a la seva signatura una prop que no fan servir. El seu contracte menteix sobre el que fan
Canvis en cascada Afegir un segon filtre (per prioritat) obliga a tocar els sis components intermedis
Reutilització trencada Plafo ja no es pot fer servir en una altra pantalla sense inventar-li un responsable
Renderitzats de més Canviar el filtre torna a renderitzar Capcalera i Plafo sencers, encara que només canviï el que hi ha a dins
Proves més pesades Provar LlistaTasques obliga a saber per on li arriba la prop

Un matís honest que gairebé mai no s'esmenta: amb dos o tres nivells, el prop drilling no és un problema. És explícit, es llegeix bé i no necessita cap eina. El problema apareix amb profunditat real i amb diverses dades compartides alhora. No corris a instal·lar res per passar una prop dos nivells.

  1. Estat duplicat i desincronització

El segon símptoma és més greu, i és el problema 1 de 10-01 reaparegut. Quan passar la prop es fa incòmode, la temptació és que cada component guardi la seva pròpia còpia:

// ❌ Dues còpies de la mateixa dada
function ResumCapcalera() {
  const [responsable, setResponsable] = useState(null);   // còpia 1
  // …
}

function LlistaTasques() {
  const [responsable, setResponsable] = useState(null);   // còpia 2
  // …
}

Ara hi ha dues fonts de veritat per a un sol fet. Tan bon punt una canviï sense l'altra —i passarà—, la capçalera diu «3 de 6 tasques» mentre la llista en mostra sis. L'usuari veu una pantalla que es contradiu a si mateixa.

Aquest símptoma té una variant més subtil i molt freqüent: guardar valors derivats. Si a més de responsable guardes visibles i horesVisibles com a estat, tens tres coses per sincronitzar en lloc d'una que es calcula. És exactament l'error de l'apartat 16 de 10-02, elevat a global.

La regla que resol les dues cares: per a cada fet de l'aplicació hi ha d'haver un sol lloc on viu, i tota la resta es calcula a partir d'ell. La pregunta que queda és on és aquest lloc, i és aquí on cal triar eina.

  1. Les alternatives per ordre de cost

Aquesta és la taula més important de la lliçó. Llegeix-la de dalt a baix i queda't a la primera fila que resolgui el teu problema.

# Alternativa Cost Resol Es queda curta quan…
0 Res: deixar-ho local Zero El 70 % dels casos Dos components germans necessiten la mateixa dada
1 Elevar l'estat a l'avantpassat comú Zero Gairebé tota la resta L'avantpassat és a cinc nivells i les props travessen components aliens
2 Context de React Baix El transport: elimina el prop drilling Hi ha moltes escriptures i tot el que consumeix el context es repinta
3 Magatzem extern lleuger (Zustand, Jotai) Mitjà Transport + subscripció selectiva Cal traçabilitat, middleware, o disciplina d'equip
4 Redux Toolkit Alt Tot l'anterior + traçabilitat, DevTools, convencions Gairebé mai no es queda curt; el problema és que sobra
Llibreria de dades (TanStack Query, RTK Query) Mitjà L'estat de servidor, que és un altre problema No substitueix l'estat d'interfície

L'última fila és el parany on cau mig sector. Abans de decidir quin magatzem fer servir, decideix quin estat tens. Si fas l'inventari d'una aplicació real, el repartiment acostuma a ser aquest:

Tipus d'estat Proporció típica On ha de viure
De servidor (llistes, detalls, catàlegs) ~60 % Memòria cau de dades (RTK Query, TanStack Query)
Local d'un component (obert/tancat, text en curs) ~25 % useState
D'URL (filtres, pàgina, ordenació) ~10 % L'URL, amb l'encaminador
Veritablement global (usuari, tema, permisos) ~5 % Un magatzem compartit

Aquest 5 % és el territori de Redux. Si has fet bé el repartiment, el magatzem global d'una aplicació gran és sorprenentment petit. Quan algú diu «Redux és massa boilerplate per a això», gairebé sempre el que passa és que està ficant a Redux el 60 % que no li correspon.

Detall important per a Nómada Tasques: el filtre de responsable pertany a la fila de l'estat d'URL. Que la Marta pugui enviar per xat l'enllaç ?responsable=Ivan i el seu company vegi el mateix és una funcionalitat, no un caprici. La solució més barata per al filtre no és Redux ni Context: és l'URL, que ja saps manejar des de 07-06. El farem servir igualment com a exemple perquè il·lustra bé el mecanisme, però convé tenir-ho present.

  1. Alternativa 1: elevar l'estat

És el que ja vas fer a 10-02 i al teu app.js de JavaScript pur amb l'objecte estat. La dada puja a l'avantpassat comú més proper de tots els que la necessiten.

A favor: cost zero, flux explícit, qualsevol que llegeixi el codi veu d'on ve cada dada, no hi ha dependències noves.

En contra: quan l'avantpassat comú és l'arrel i hi ha cinc nivells pel mig, apareix el prop drilling de l'apartat 2.

Abans de descartar-la, hi ha una tècnica que la porta molt més lluny del que la gent es pensa: compondre amb children en lloc de perforar.

// En lloc de passar `responsable` a Plafo perquè el passi a LlistaTasques…
function App() {
  const [responsable, setResponsable] = useState(null);

  return (
    <>
      <Capcalera>
        <FiltreResponsable valor={responsable} enCanviar={setResponsable} />
        <ResumCapcalera responsable={responsable} />
      </Capcalera>

      <Plafo
        llista={<LlistaTasques responsable={responsable} />}
        lateral={<InformeCarrega responsable={responsable} />}
      />
    </>
  );
}

function Plafo({ llista, lateral }) {
  // Plafo ja no sap res de `responsable`: només col·loca el que li donen
  return <div className="plafo">{llista}<aside>{lateral}</aside></div>;
}

L'element es crea a App, on viu l'estat, i es col·loca a Plafo, que només aporta l'estructura. Plafo recupera la seva signatura honesta i la seva reutilització. Aquesta tècnica elimina una quantitat enorme de prop drilling sense cap llibreria, i per això va abans que Context a la llista de costos.

  1. Alternativa 2: Context de React

Context permet que un component posi un valor a disposició de tot el seu subarbre, sense passar-lo per les props intermèdies.

// src/context/FiltreContext.jsx
import { createContext, useContext, useState, useMemo } from 'react';

const FiltreContext = createContext(null);

export function ProveidorFiltre({ children }) {
  const [responsable, setResponsable] = useState(null);

  // El valor es memoïtza: si no, seria un objecte nou a cada render
  // i TOTS els consumidors es repintarien sempre.
  const valor = useMemo(() => ({ responsable, setResponsable }), [responsable]);

  return <FiltreContext.Provider value={valor}>{children}</FiltreContext.Provider>;
}

/** Hook d'accés, amb comprovació d'ús correcte. */
export function useFiltre() {
  const context = useContext(FiltreContext);
  if (context === null) {
    throw new Error('useFiltre requereix estar dins de <ProveidorFiltre>');
  }
  return context;
}

I el consum, des de qualsevol profunditat:

function ResumCapcalera({ tasques }) {
  const { responsable } = useFiltre();     // sense props intermèdies
  const visibles = responsable === null ? tasques : tasques.filter((t) => t.responsable === responsable);
  return <p>{visibles.length} de {tasques.length} tasques</p>;
}

Els components intermedis (Capcalera, Plafo) recuperen la seva signatura neta. El prop drilling desapareix.

  1. Què és Context i què no és

Aquí hi ha el malentès més estès de tot React, i mereix ser explícit:

Context és un mecanisme de transport, no un gestor d'estat.

Context resol «com faig arribar aquest valor fins allà baix». No resol «com organitzo els canvis», «com evito renderitzats innecessaris» ni «com sé qui va canviar què». L'estat continua vivint en un useState corrent; Context només evita les escales de props.

I té una limitació tècnica amb conseqüències reals: quan el valor del context canvia, es tornen a renderitzar tots els components que el consumeixen, sense importar quina part del valor facin servir.

// Un context amb diverses coses a dins
const valor = useMemo(() => ({ usuari, tema, filtre, setFiltre }), [usuari, tema, filtre]);

Si canvia filtre, es repinta també el component que només llegeix tema. Amb un context petit i canvis poc freqüents (l'usuari identificat, l'idioma, el tema clar/fosc) és completament irrellevant. Amb un context que canvia a cada pulsació de tecla i quaranta consumidors, és un problema de rendiment mesurable.

Els dos pal·liatius habituals:

  1. Partir-lo en diversos contextos per freqüència de canvi: un per al que gairebé mai no canvia (usuari, tema) i un altre per al que canvia molt (filtres). És la mateixa idea que el manualChunks per freqüència de canvi de 09-05.
  2. Separar valor i accions en dos contextos: els components que només despatxen accions no es repinten quan canvia el valor, perquè les funcions són estables.
Context serveix per a… Context NO serveix per a…
Valors estables: usuari, tema, idioma, configuració Estat que canvia moltes vegades per segon
Injectar dependències (un client d'API, un servei) Subscripció selectiva a un tros de l'estat
Evitar el prop drilling Traçabilitat de qui va canviar què i quan
Aplicacions petites i mitjanes Lògica complexa amb efectes derivats

La conclusió pràctica: Context + useState cobreix a la perfecció la immensa majoria d'aplicacions. Si aquesta combinació et va bé, no necessites res més i aquest és el final de la lliçó per a tu. Els apartats següents expliquen què passa quan no arriba.

  1. Alternativa 3: un magatzem extern

Un magatzem (store) és un objecte que viu fora de l'arbre de components, guarda l'estat, permet subscriure-s'hi i notifica els subscriptors quan canvia.

La diferència decisiva amb Context és la subscripció selectiva: cada component declara quin tros de l'estat li interessa, i només es torna a renderitzar si aquell tros concret canvia. És, conceptualment, la diferència entre difondre a tothom i notificar els interessats — i si et sona, és perquè és exactament la diferència entre el DOM virtual i els senyals de 10-01, aplicada a l'estat en lloc de a la pantalla.

I una cosa que s'aprecia més tard: com que el magatzem viu fora de React, es pot fer servir des de fora de React. El teu CanalTauler de 07-04 pot despatxar accions en rebre un missatge del WebSocket sense estar dins de cap component. Un service worker el pot consultar. Una prova el pot muntar sense renderitzar res.

Redux és un magatzem d'aquest tipus, amb tres decisions molt concretes al damunt. Anem-hi.

  1. Quan cadascuna n'hi ha prou

Abans d'entrar en Redux, la taula de decisió que evita el 90 % dels errors:

Situació Eina suficient
Un desplegable obert en una targeta useState local
El filtre compartit per dos germans Elevar l'estat
El filtre compartit per cinc components en dues branques Composició amb children, o Context
Filtre que s'ha de poder compartir per enllaç L'URL i l'encaminador (07-06)
Tema clar/fosc, idioma, usuari identificat Context
Llista de tasques del servidor Memòria cau de dades (RTK Query, TanStack Query)
Cistella de la compra amb regles, descomptes i historial Magatzem extern
Editor col·laboratiu amb desfés/refés Redux (el viatge en el temps és literalment això)
Aplicació gran, equip de sis, tres anys de vida Redux Toolkit, per la disciplina i la traçabilitat
Depurar «l'estat es corromp i no sé qui el toca» Redux, sens dubte: és el seu avantatge real

  1. Els tres principis de Redux

Redux es defineix per tres regles. No són arbitràries: cadascuna compra una propietat concreta.

Principi 1 · Font única de veritat. Tot l'estat de l'aplicació viu en un únic objecte, dins d'un únic magatzem.

  • El que compra: no hi ha còpies per desincronitzar, l'estat complet es pot bolcar a un fitxer, guardar a localStorage (07-01), enviar en un informe d'error o restaurar tal qual.

Principi 2 · L'estat és de només lectura. L'única manera de canviar-lo és despatxar una acció: un objecte pla que descriu què ha passat.

{ type: 'filtre/responsableCanviat', payload: 'Iván' }
{ type: 'tasques/estatCanviat', payload: { id: 6, desti: 'en-curs' } }
  • El que compra: tots els canvis passen per un sol punt. Es poden registrar, gravar, reproduir i revertir. Ningú no pot canviar l'estat d'amagat.

Fixa't en el vocabulari: l'acció descriu què ha passat (responsableCanviat), no què cal fer (canviarResponsable). És una convenció amb conseqüències: una sola acció pot afectar diverses parts de l'estat, i el registre d'accions es llegeix com una crònica del que va fer la usuària.

Principi 3 · Els canvis es fan amb funcions pures. Un reductor és una funció (estatActual, accio) => estatNou. Pura: sense efectes, sense peticions, sense Math.random(), sense Date.now(), sense mutar l'entrada.

function reductorFiltre(estat = { responsable: null }, accio) {
  switch (accio.type) {
    case 'filtre/responsableCanviat':
      return { ...estat, responsable: accio.payload };   // objecte NOU (04-07)
    default:
      return estat;
  }
}
  • El que compra: la mateixa seqüència d'accions produeix sempre el mateix estat. Això és el que fa possible el viatge en el temps, la reproducció d'errors i les proves trivials.

I sí, la paraula «reductor» és la de 04-05: la signatura (acumulat, element) => nouAcumulat és idèntica a la de reduce. L'estat de la teva aplicació és literalment el resultat de reduir l'historial d'accions sobre un estat inicial.

  1. El cicle acció → reductor → nou estat → render

graph TD
  U["La usuària prem el filtre"] --> D["dispatch de l'acció<br/>type: filtre/responsableCanviat<br/>payload: Iván"]
  D --> M["Middleware<br/>(registre, asincronia, DevTools)"]
  M --> R["Reductors<br/>(estatActual, accio) =&gt; estatNou"]
  R --> S["Magatzem: estat nou"]
  S --> N["Notificació als subscriptors"]
  N --> C["Els components el tros dels quals ha canviat<br/>es tornen a renderitzar"]
  C --> U

Cinc propietats d'aquest cicle que convé fixar:

  1. És unidireccional. Les dades van sempre en el mateix sentit. No hi ha dreceres: un component no pot escriure a l'estat directament.
  2. És síncron al seu nucli. dispatch → reductors → estat nou passa d'una tirada. L'asincronia es gestiona fora, al middleware (apartat 19).
  3. És serialitzable. Accions i estat són dades planes, així que es poden guardar, enviar i reproduir.
  4. És auditable. Cada canvi té un nom i unes dades. Cap no és anònim.
  5. És interceptable. El middleware pot registrar, retardar, cancel·lar o transformar accions sense tocar reductors ni components.

  1. El paral·lelisme amb el teu Tauler i els teus CustomEvent

Ara la part que fa que tot això no soni abstracte. Ja has construït les tres peces d'aquest cicle, per separat i amb altres noms.

Peça de Redux El teu equivalent a Nómada Tasques Lliçó
Magatzem amb estat únic L'objecte estat = { tauler, filtres, ordre } d'app.js 06-06
Acció (què ha passat) El CustomEvent amb ESDEVENIMENTS.TASCA_AVANCADA i el seu detail 06-04
dispatch emetre(ESDEVENIMENTS.TASCA_AVANCADA, { id }) 06-04
Subscripció addEventListener sobre el contenidor 06-04
Reductor El gestor que aplica el canvi i crida render() 06-06
Invalidació de derivats #versio += 1 a cada mutació del Tauler 09-02
Selector memoïtzat La memòria cau { versio, avui, valor } de resum() 09-02

L'equivalència de les dues últimes files és especialment exacta. El teu Tauler fa això:

// js/model/tauler.js — 09-02
resum(avui = AVUI) {
  if (this.#cacheResum?.versio === this.#versio && this.#cacheResum.avui === avui) {
    return this.#cacheResum.valor;         // encert de memòria cau: zero feina
  }
  const valor = this.#calcularResum(avui);
  this.#cacheResum = { versio: this.#versio, avui, valor };
  return valor;
}

I createSelector de Redux fa exactament el mateix, amb una diferència: on tu compares un comptador de versió, ell compara les referències de les entrades. Com que l'estat és immutable, si la referència no ha canviat, el contingut tampoc. La immutabilitat fa innecessari el comptador de versió: la referència és el comptador.

I hi ha una diferència real a favor de Redux que convé reconèixer. Els teus CustomEvent són un mecanisme de difusió: qualsevol pot escoltar, no hi ha registre central de qui escolta què, i per seguir el flux cal cercar la cadena 'tasca:avancada' per tot el projecte. Redux imposa que tots els canvis passin per un punt que es pot observar. Aquest és l'avantatge real, i no és de rendiment: és de traçabilitat.

  1. Redux Toolkit: per què ningú no escriu Redux a mà

El Redux clàssic exigia escriure, per a cada canvi, una constant, un creador d'acció, un cas del switch i una actualització immutable a mà:

// Redux clàssic: quatre llocs per a un sol canvi
export const RESPONSABLE_CANVIAT = 'filtre/responsableCanviat';

export const responsableCanviat = (nom) => ({ type: RESPONSABLE_CANVIAT, payload: nom });

export function reductorFiltre(estat = INICIAL, accio) {
  switch (accio.type) {
    case RESPONSABLE_CANVIAT:
      return { ...estat, responsable: accio.payload };
    default:
      return estat;
  }
}

Multiplica això per quaranta accions i per actualitzacions imbricades del tipus {...estat, tasques: {...estat.tasques, [id]: {...estat.tasques[id], estat: 'feta'}}} i entendràs la mala fama.

Redux Toolkit (RTK) és la resposta oficial, i avui és la forma correcta de fer servir Redux. No és una alternativa ni una capa opcional: la documentació oficial desaconsella escriure Redux sense ell. Porta quatre coses:

Peça Què resol
configureStore Munta el magatzem amb DevTools, middleware i comprovacions de desenvolupament ja configurats
createSlice Genera accions i reductor alhora, a partir d'un sol objecte
Immer (inclòs) Permet escriure mutacions que produeixen estat immutable
createAsyncThunk / RTK Query Asincronia i estat de servidor
npm install @reduxjs/toolkit react-redux

Dos paquets: @reduxjs/toolkit (el magatzem, independent de la vista) i react-redux (els hooks que el connecten amb React).

  1. configureStore: el magatzem

// src/magatzem/index.js
import { configureStore } from '@reduxjs/toolkit';
import filtreReducer from './filtreSlice.js';
import tasquesReducer from './tasquesSlice.js';

export const magatzem = configureStore({
  reducer: {
    filtre: filtreReducer,     // l'estat quedarà a estat.filtre
    tasques: tasquesReducer    // i a estat.tasques
  }
});

I la connexió amb React, una sola vegada, a l'arrel:

// src/main.jsx
import { Provider } from 'react-redux';
import { magatzem } from './magatzem/index.js';

createRoot(document.getElementById('arrel')).render(
  <StrictMode>
    <Provider store={magatzem}>
      <App />
    </Provider>
  </StrictMode>
);

configureStore ve amb coses activades per defecte que val la pena conèixer, perquè són de les que més ajuden en el dia a dia:

  • Les DevTools estan connectades sense configurar res.
  • Un comprovador de mutacions avisa en desenvolupament si algun reductor muta l'estat fora d'Immer.
  • Un comprovador de serialitzabilitat avisa si fiques a l'estat alguna cosa que no és una dada plana: una Date, un Map, una promesa, una instància de classe. Això molesta al principi i després s'agraeix, perquè és el que protegeix el principi 1.

Aquest últim punt té una conseqüència directa per a Nómada Tasques: les teves instàncies de Tasca amb #estat privat no poden anar al magatzem. A Redux, l'estat són dades planes i les regles viuen en funcions. És el mateix intercanvi que ja vas assumir a 10-02 en triar objectes literals per a l'estat de React, ara convertit en norma comprovada.

  1. createSlice: accions i reductors junts

Un slice és una porció de l'estat amb les seves accions i el seu reductor, definits d'una tirada:

// src/magatzem/filtreSlice.js
import { createSlice } from '@reduxjs/toolkit';

const filtreSlice = createSlice({
  name: 'filtre',
  initialState: {
    responsable: null,      // R8: null, mai ''
    text: '',
    ordre: 'prioritat'
  },
  reducers: {
    responsableCanviat(estat, accio) {
      estat.responsable = accio.payload;    // ← sembla mutació; no ho és (apartat 16)
    },
    textCanviat(estat, accio) {
      estat.text = accio.payload;
    },
    ordreCanviat(estat, accio) {
      estat.ordre = accio.payload;
    },
    filtresNetejats(estat) {
      estat.responsable = null;
      estat.text = '';
    }
  }
});

export const { responsableCanviat, textCanviat, ordreCanviat, filtresNetejats } =
  filtreSlice.actions;

export default filtreSlice.reducer;

Què genera createSlice automàticament:

  • Els creadors d'acció: responsableCanviat('Iván') retorna { type: 'filtre/responsableCanviat', payload: 'Iván' }. El type es compon amb name + nom del reductor, així que no hi ha constants per mantenir ni risc de col·lisió.
  • El reductor combinat, que despatxa internament per type.

Compara amb el Redux clàssic de l'apartat 13: quatre llocs s'han convertit en un.

  1. Immer: escriure mutacions que produeixen estat immutable

estat.responsable = accio.payload sembla violar el principi 3. No ho fa, i entendre per què evita molta desconfiança.

RTK inclou Immer, una llibreria que embolcalla l'estat en un Proxy (el mateix mecanisme que la reactivitat de Vue de 10-04). Aquest proxy intercepta les escriptures i, en lloc d'aplicar-les a l'objecte real, apunta què s'ha volgut canviar. En acabar el reductor, Immer construeix un objecte nou aplicant aquests canvis, reutilitzant tot el que no s'ha tocat.

graph LR
  A["Estat actual<br/>(congelat)"] --> B["Esborrany<br/>(Proxy que registra)"]
  B --> C["El teu reductor escriu<br/>estat.responsable = 'Iván'"]
  C --> D["Immer aplica els canvis"]
  D --> E["Estat nou<br/>(referències compartides<br/>amb el que no s'ha modificat)"]

El valor d'això es veu de debò amb estat imbricat. Sense Immer:

// ❌ Actualització immutable a mà d'una tasca dins d'un array
return {
  ...estat,
  tasques: estat.tasques.map((t) =>
    t.id === accio.payload.id ? { ...t, estat: accio.payload.desti } : t
  )
};

Amb Immer:

// ✅ El mateix, llegible
const tasca = estat.tasques.find((t) => t.id === accio.payload.id);
if (tasca) tasca.estat = accio.payload.desti;

I una propietat valuosa que es perd de vista: Immer produeix actualitzacions estructuralment compartides. Les tasques que no canvien conserven la seva referència exacta. Això és justament el que necessita React.memo de 10-02 per no repintar les 599 targetes que no han canviat.

Tres regles d'Immer que cal respectar:

  1. O mutes l'esborrany, o retornes un valor nou. Mai les dues coses.
// ❌ Les dues coses alhora: comportament indefinit
reducers: {
  malament(estat, accio) {
    estat.responsable = accio.payload;
    return { ...estat, text: '' };
  }
}
  1. Per reemplaçar l'estat sencer, retorna'l, no reassignis el paràmetre:
reducers: {
  reiniciat() {
    return { responsable: null, text: '', ordre: 'prioritat' };   // ✅
  }
}
  1. Immer només funciona dins dels reductors de RTK. Fora —en un component, en un selector— les regles d'immutabilitat de 04-07 continuen vigents tal qual.

  1. useSelector i useDispatch

Els dos hooks que connecten els components amb el magatzem:

// src/components/FiltreResponsable.jsx
import { useSelector, useDispatch } from 'react-redux';
import { responsableCanviat } from '../magatzem/filtreSlice.js';

export function FiltreResponsable({ responsables }) {
  const responsable = useSelector((estat) => estat.filtre.responsable);
  const despatxar = useDispatch();

  return (
    <label htmlFor="filtre-responsable">
      Responsable:
      <select
        id="filtre-responsable"
        value={responsable ?? ''}
        onChange={(e) => despatxar(responsableCanviat(e.target.value || null))}
      >
        <option value="">Tots</option>
        {responsables.map((n) => <option key={n} value={n}>{n}</option>)}
      </select>
    </label>
  );
}

Fixa't en el que ha desaparegut: aquest component no rep cap prop relacionada amb el filtre. Ni valor ni enCanviar. Els sis nivells de prop drilling de l'apartat 2 s'han evaporat, perquè el component parla directament amb el magatzem des d'on sigui.

useSelector(fn) executa fn(estat) i retorna el resultat, subscrivint-s'hi. I aquí hi ha la diferència clau amb Context: el component només es torna a renderitzar si el resultat del selector canvia, no si canvia qualsevol cosa del magatzem. Canviar estat.tasques no repinta aquest <select>.

La comparació es fa per defecte amb Object.is, la mateixa referència de 10-02. D'aquí surt l'error més freqüent amb Redux:

// ❌ Retorna un ARRAY NOU a cada crida: es repinta sempre
const visibles = useSelector((e) => e.tasques.llista.filter((t) => t.estat !== 'feta'));

// ❌ Objecte nou a cada crida: el mateix
const { responsable, text } = useSelector((e) => ({ ...e.filtre }));

Tres solucions, per ordre de preferència:

// ✅ 1 · Un useSelector per valor primitiu
const responsable = useSelector((e) => e.filtre.responsable);
const text = useSelector((e) => e.filtre.text);

// ✅ 2 · Retornar una referència estable de l'estat
const filtre = useSelector((e) => e.filtre);       // el mateix objecte si no ha canviat

// ✅ 3 · Un selector memoïtzat (apartat 18)
const visibles = useSelector(seleccionarVisibles);

useDispatch() retorna la funció dispatch. És estable entre renderitzats, així que es pot fer servir en dependències d'efectes sense problemes — a diferència de les funcions que crees tu.

  1. Selectors memoïtzats amb createSelector

Un selector és una funció que extreu o calcula alguna cosa a partir de l'estat. Escriure'ls a part té dos avantatges: es reutilitzen, i desacoblen els components de la forma de l'estat. Si demà estat.filtre.responsable es mou a estat.ui.filtres.responsable, només canvia el selector.

// src/magatzem/selectors.js
import { createSelector } from '@reduxjs/toolkit';
import { PESOS } from '../domini/regles.js';

// Selectors d'entrada: barats, sense càlcul
export const seleccionarTasques     = (estat) => estat.tasques.llista;
export const seleccionarResponsable = (estat) => estat.filtre.responsable;
export const seleccionarText        = (estat) => estat.filtre.text;
export const seleccionarOrdre       = (estat) => estat.filtre.ordre;

// Selector derivat i memoïtzat
export const seleccionarVisibles = createSelector(
  [seleccionarTasques, seleccionarResponsable, seleccionarText, seleccionarOrdre],
  (tasques, responsable, text, ordre) => {
    const t = text.trim().toLowerCase();
    return tasques
      .filter((x) => responsable === null || x.responsable === responsable)
      .filter((x) => t === '' || x.titol.toLowerCase().includes(t))
      .toSorted(COMPARADORS[ordre] ?? COMPARADORS.prioritat);
  }
);

export const seleccionarResumVisible = createSelector(
  [seleccionarVisibles],
  (visibles) => ({
    total: visibles.length,
    horesObertes: visibles.filter((t) => t.estat !== 'feta')
                          .reduce((s, t) => s + t.horesEstimades, 0),
    esforc: visibles.reduce((s, t) => s + t.horesEstimades * PESOS[t.prioritat], 0)
  })
);

const COMPARADORS = {
  prioritat: (a, b) => PESOS[b.prioritat] - PESOS[a.prioritat] || a.id - b.id,
  data:      (a, b) => a.dataLimit.localeCompare(b.dataLimit),
  hores:     (a, b) => b.horesEstimades - a.horesEstimades
};

Com funciona createSelector, que és on es tanca el paral·lelisme amb 09-02:

  1. Executa els selectors d'entrada, que són barats.
  2. Compara els seus resultats amb els de la crida anterior fent servir Object.is.
  3. Si tots són idèntics, retorna el resultat cachejat sense executar el càlcul.
  4. Si algun ha canviat, recalcula i guarda.

És la teva memòria cau per versió, amb la referència de l'estat immutable fent de número de versió. I té un efecte que no és de rendiment sinó de correcció: com que retorna la mateixa referència mentre les entrades no canviïn, resol el problema d'useSelector de l'apartat anterior. Sense memoïtzar, filter crea un array nou cada vegada i el component es repinta sempre.

Dos avisos:

  • Per defecte la memòria cau és de mida u. Si dos components criden el mateix selector amb estats diferents —típic amb selectors parametritzats per id—, s'anul·len mútuament. RTK ofereix maneres de crear una instància per component o ampliar la memòria cau.
  • Memoïtzar selectors trivials no aporta res. (e) => e.filtre.responsable no necessita createSelector: no calcula res i ja retorna una referència estable. És el mateix avís de 09-01 i de 10-02: memoïtzar té cost.

  1. Lògica asíncrona amb createAsyncThunk

Els reductors són purs: no poden demanar dades. L'asincronia viu al middleware, i RTK porta ja configurat el thunk, que permet despatxar una funció en lloc d'un objecte.

createAsyncThunk construeix aquesta funció i despatxa automàticament tres accions per cada petició:

// src/magatzem/tasquesSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { llistarTasques } from '../dades/api-tasques.js';   // el teu mòdul de 07-02

export const carregarTasques = createAsyncThunk(
  'tasques/carregar',
  async ({ responsable } = {}, { signal, rejectWithValue }) => {
    try {
      // `signal` l'aporta RTK: és l'AbortController de 07-03, ja integrat
      return await llistarTasques({ responsable, signal });
    } catch (error) {
      // ErrorDeApi de 07-03: n'extraiem el que és serialitzable (principi 1)
      return rejectWithValue({ missatge: error.message, estat: error.estat ?? null });
    }
  }
);

Tres detalls importants:

  • signal ve de regal. RTK crea un AbortController per cada execució del thunk i el cancel·la si es crida promesa.abort() o si es descarta la petició. El teu demanarJson de 07-03 l'accepta tal qual: zero adaptadors.
  • rejectWithValue existeix perquè un Error no és serialitzable i violaria el principi 1. Es guarda un objecte pla amb el que interessi mostrar.
  • La deduplicació es pot configurar amb l'opció condition, per no llançar la mateixa petició si ja n'hi ha una en vol.

  1. Els tres estats d'una petició

Cada thunk despatxa tasques/carregar/pending, tasques/carregar/fulfilled i tasques/carregar/rejected. El slice els recull a extraReducers:

const tasquesSlice = createSlice({
  name: 'tasques',
  initialState: {
    llista: [],
    fase: 'inactiu',     // 'inactiu' | 'carregant' | 'llest' | 'error'
    error: null
  },
  reducers: {
    estatCanviat(estat, accio) {
      const { id, desti } = accio.payload;
      const tasca = estat.llista.find((t) => t.id === id);
      if (!tasca) return;
      if (SEGUENT[tasca.estat] !== desti) return;   // R6: transició no permesa
      tasca.estat = desti;                           // Immer
    }
  },
  extraReducers: (constructor) => {
    constructor
      .addCase(carregarTasques.pending, (estat) => {
        estat.fase = 'carregant';
        estat.error = null;
      })
      .addCase(carregarTasques.fulfilled, (estat, accio) => {
        estat.fase = 'llest';
        estat.llista = accio.payload;
      })
      .addCase(carregarTasques.rejected, (estat, accio) => {
        estat.fase = 'error';
        estat.error = accio.payload ?? { missatge: accio.error.message };
      });
  }
});

I el consum:

function Tauler() {
  const despatxar = useDispatch();
  const fase = useSelector((e) => e.tasques.fase);

  useEffect(() => {
    const promesa = despatxar(carregarTasques({}));
    return () => promesa.abort();      // cancel·lació en desmuntar (10-02, apartat 24)
  }, [despatxar]);

  if (fase === 'carregant') return <p role="status">Carregant tasques…</p>;
  if (fase === 'error')     return <p role="alert">No s&apos;han pogut carregar les tasques.</p>;
  return <LlistaTasques />;
}

Quatre punts:

  • La condició de cursa de 10-02 continua existint. Si es despatxen dues càrregues, l'última a arribar guanya. La solució és avortar l'anterior, com aquí, o fer servir condition.
  • Un sol camp fase en lloc de tres booleans: la regla de l'estat impossible de 10-02.
  • estatCanviat implementa la R6 al reductor, amb la mateixa taula SEGUENT del teu domini en JavaScript pur. Les regles de negoci no s'han mudat a Redux: es consulten des d'ell.
  • El reductor és pur i per tant trivial de provar amb Jest, sense muntar React ni el magatzem: reductor(estatPrevi, accio) i comproves la sortida. És la millor propietat de Redux per a les proves de 08-03.

  1. RTK Query: l'eina per a l'estat de servidor

L'apartat anterior funciona, però repeteix-lo per a quinze recursos i apareixeran les preguntes de 10-02: qui cacheja?, qui dedueix duplicats?, qui invalida en crear una tasca?, qui refresca en tornar a la pestanya?

RTK Query és la resposta que RTK porta inclosa, amb la mateixa filosofia que TanStack Query:

// src/magatzem/apiTasques.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const apiTasques = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
  tagTypes: ['Tasca'],
  endpoints: (constructor) => ({
    llistarTasques: constructor.query({
      query: (responsable) => (responsable ? `tasques?responsable=${responsable}` : 'tasques'),
      providesTags: ['Tasca']            // aquesta consulta depèn de l'etiqueta 'Tasca'
    }),
    canviarEstat: constructor.mutation({
      query: ({ id, desti }) => ({
        url: `tasques/${id}`,
        method: 'PATCH',
        body: { estat: desti }
      }),
      invalidatesTags: ['Tasca']         // en mutar, s'invalida i la llista es recarrega sola
    })
  })
});

export const { useLlistarTasquesQuery, useCanviarEstatMutation } = apiTasques;
function LlistaRemota({ responsable }) {
  const { data: tasques = [], isFetching, error } = useLlistarTasquesQuery(responsable);
  const [canviarEstat] = useCanviarEstatMutation();

  if (error) return <p role="alert">Error en carregar</p>;

  return (
    <ul aria-busy={isFetching}>
      {tasques.map((t) => (
        <li key={t.id}>
          {t.titol}
          <button onClick={() => canviarEstat({ id: t.id, desti: 'feta' })}>Feta</button>
        </li>
      ))}
    </ul>
  );
}

El que ha desaparegut d'aquest codi: l'useEffect, l'AbortController, l'estat de càrrega, l'estat d'error, el dispatch manual i —el més important— la recàrrega després de la mutació. El sistema d'etiquetes la fa sola.

Problema de l'estat de servidor Qui el resol aquí
Dos components demanen el mateix Deduplicació per clau de consulta
Tornar enrere mostra pantalla de càrrega Dades cachejades mentre revalida
Dades ràncies al cap d'una hora Refresc en enfocar i en reconnectar
Després de crear, la llista no s'actualitza invalidatesTags
Xarxa inestable Reintents configurables (el teu ambReintents de 07-03)
Canvi optimista i reversió onQueryStarted amb patchResult.undo()

La conclusió de 10-01 es confirma: si el 60 % del teu estat és de servidor i el gestiona RTK Query, el que queda per als slices manuals és molt poc. I això és exactament el que hi hauria de quedar.

  1. Les DevTools i el viatge en el temps

Amb l'extensió Redux DevTools instal·lada i configureStore, tens això sense configurar res:

Funció Per a què serveix de debò
Llista d'accions La crònica del que ha fet la usuària, en ordre i amb nom
Diff per acció Què ha canviat exactament a l'estat, camp a camp
Viatge en el temps Retrocedir a qualsevol acció anterior i veure la pantalla d'aquell moment
Saltar acció Anul·lar una acció de l'historial i recalcular tota la resta
Exportar / importar estat Reproduir l'error d'una altra persona a la teva màquina
Traçat Des de quina línia de codi es va despatxar cada acció

El viatge en el temps funciona pel principi 3: com que els reductors són purs, l'estat en el moment n es pot recalcular reproduint les n primeres accions sobre l'estat inicial. No es guarden còpies de l'estat: es guarda l'historial i es recalcula. És el mateix raonament de 03-07 sobre funcions deterministes, aplicat a una aplicació sencera.

I aquí hi ha l'avantatge real de Redux, que no és el rendiment ni el prop drilling:

Quan algú diu «se m'esborra el filtre en tornar del detall i no sé per què», amb Redux obres les DevTools, reprodueixes el recorregut i veus l'acció que ho va fer, amb el seu nom i el seu origen. Sense Redux, cerques pel projecte i endevines.

Compara-ho amb la teva situació a Nómada Tasques: per depurar per què el tauler canvia de forma inesperada, tens console.log, punts d'interrupció i la cerca d'emetre( per tot el codi. Funciona —ho vas fer a 08-01— però és reconstruir a mà el que aquí queda gravat. En una aplicació de vint pantalles i sis persones, aquesta diferència es mesura en dies.

El preu és igual de clar: perquè això funcioni, tot canvi ha de ser una acció amb nom. Aquesta disciplina és el cost, i només es paga de gust quan l'aplicació és prou gran per necessitar la traçabilitat.

  1. Normalitzar dades amb createEntityAdapter

Amb les 600 tasques de la prova de càrrega de 09-01, guardar un array pla té els problemes que ja vas mesurar a 09-02: cercar per id és O(n), i actualitzar una tasca amb map crea un array nou de 600 posicions.

Normalitzar significa guardar les dades com un diccionari per id més una llista d'ids:

// En lloc d'això…
{ llista: [ {id:1, …}, {id:2, …}, … ] }

// …això
{
  ids: [1, 2, 3, 4, 5, 6],
  entities: {
    1: { id: 1, titol: 'Redissenyar la sala polivalent', … },
    2: { id: 2, titol: 'Cartelleria del taller de serigrafia', … }
  }
}

És el teu índex Map de 09-02, expressat amb objectes plans perquè l'estat ha de ser serialitzable. createEntityAdapter ho gestiona per tu:

import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import { PESOS } from '../domini/regles.js';

const adaptador = createEntityAdapter({
  sortComparer: (a, b) => PESOS[b.prioritat] - PESOS[a.prioritat] || a.id - b.id
});

const tasquesSlice = createSlice({
  name: 'tasques',
  initialState: adaptador.getInitialState({ fase: 'inactiu', error: null }),
  reducers: {
    tascaAfegida: adaptador.addOne,
    tasquesRebudes: adaptador.setAll,
    tascaEliminada: adaptador.removeOne,
    estatCanviat(estat, accio) {
      const { id, desti } = accio.payload;
      const tasca = estat.entities[id];              // O(1), com el teu Map
      if (tasca && SEGUENT[tasca.estat] === desti) tasca.estat = desti;
    }
  }
});

export const { selectAll: seleccionarTotes, selectById: seleccionarPerId } =
  adaptador.getSelectors((estat) => estat.tasques);

Avantatges concrets, amb els números de 09-02:

Operació Array pla (600 tasques) Normalitzat
Cercar per id O(n) — 0,004 ms O(1) — 0,00008 ms
Actualitzar una tasca Recorre les 600 amb map Toca una entrada
Lot de 600 actualitzacions després de reconnectar (07-04) 34,2 ms 0,8 ms
Duplicats impossibles No garantit Per construcció

I un desavantatge real: per pintar la llista cal reconstruir l'array, cosa que fa selectAll. Per això ve memoïtzat. Amb sis tasques, normalitzar és complicar per gust; amb sis-centes i actualitzacions freqüents, és la diferència entre fluid i enganxós — la mateixa frontera que 09-02 va marcar per a l'índex Map.

  1. Nómada Tasques: el filtre i el tauler complets

Ajuntem les peces a la pantalla objectiu del mòdul. El domini continua igual, en JavaScript pur:

// src/domini/regles.js — sense canvis respecte a 10-02
export const SEGUENT = Object.freeze({ pendent: 'en-curs', 'en-curs': 'feta', feta: null });
export const ETIQUETA = Object.freeze({ pendent: 'Començar', 'en-curs': 'Marcar feta', feta: 'Feta' });
export const PESOS = Object.freeze({ alta: 3, mitjana: 2, baixa: 1 });
export const AVUI = '2026-09-20';
export const estaVencuda = (t, avui = AVUI) => t.estat !== 'feta' && t.dataLimit < avui;

El slice de tasques:

// src/magatzem/tasquesSlice.js
import { createSlice } from '@reduxjs/toolkit';
import { SEGUENT } from '../domini/regles.js';
import { BACKLOG } from '../dades/backlog.js';

const tasquesSlice = createSlice({
  name: 'tasques',
  initialState: { llista: BACKLOG, fase: 'llest', error: null },
  reducers: {
    /** Avança l'estat d'una tasca respectant la R6. */
    estatAvancat(estat, accio) {
      const tasca = estat.llista.find((t) => t.id === accio.payload.id);
      if (!tasca) return;
      const desti = SEGUENT[tasca.estat];
      if (desti === null) return;       // des de 'feta' no s'avança
      tasca.estat = desti;              // Immer: mutació aparent, estat nou real
    }
  }
});

export const { estatAvancat } = tasquesSlice.actions;
export default tasquesSlice.reducer;

Els selectors, amb els responsables inclosos:

// src/magatzem/selectors.js
import { createSelector } from '@reduxjs/toolkit';

export const seleccionarTasques     = (e) => e.tasques.llista;
export const seleccionarResponsable = (e) => e.filtre.responsable;

export const seleccionarResponsables = createSelector(
  [seleccionarTasques],
  (tasques) => [...new Set(tasques.map((t) => t.responsable).filter(Boolean))].sort()
);

export const seleccionarVisibles = createSelector(
  [seleccionarTasques, seleccionarResponsable],
  (tasques, responsable) =>
    responsable === null ? tasques : tasques.filter((t) => t.responsable === responsable)
);

export const seleccionarResum = createSelector(
  [seleccionarVisibles, seleccionarTasques],
  (visibles, totes) => ({
    visibles: visibles.length,
    total: totes.length,
    horesObertes: visibles.filter((t) => t.estat !== 'feta')
                          .reduce((s, t) => s + t.horesEstimades, 0)
  })
);

Els components, sense ni una prop de filtre:

// src/components/LlistaTasques.jsx
import { useSelector, useDispatch } from 'react-redux';
import { seleccionarVisibles } from '../magatzem/selectors.js';
import { estatAvancat } from '../magatzem/tasquesSlice.js';
import { TargetaTasca } from './TargetaTasca.jsx';

export function LlistaTasques() {
  const visibles = useSelector(seleccionarVisibles);   // memoïtzat: referència estable
  const despatxar = useDispatch();

  if (visibles.length === 0) {
    return <p className="llista-buida">Cap tasca no coincideix amb el filtre.</p>;
  }

  return (
    <ul className="llista-tasques">
      {visibles.map((tasca) => (
        <TargetaTasca
          key={tasca.id}
          tasca={tasca}
          enAvancar={() => despatxar(estatAvancat({ id: tasca.id }))}
        />
      ))}
    </ul>
  );
}
// src/components/ResumCapcalera.jsx
import { useSelector } from 'react-redux';
import { seleccionarResum } from '../magatzem/selectors.js';

export function ResumCapcalera() {
  const { visibles, total, horesObertes } = useSelector(seleccionarResum);
  return <p className="tauler__resum">{visibles} de {total} tasques · {horesObertes} h obertes</p>;
}
// src/App.jsx — mira el que NO hi ha: ni una prop de filtre
import { FiltreResponsable } from './components/FiltreResponsable.jsx';
import { ResumCapcalera } from './components/ResumCapcalera.jsx';
import { LlistaTasques } from './components/LlistaTasques.jsx';

export default function App() {
  return (
    <main className="tauler">
      <header className="tauler__capcalera">
        <h1>Nómada Tasques</h1>
        <FiltreResponsable />
        <ResumCapcalera />
      </header>
      <LlistaTasques />
    </main>
  );
}

Comprova els números canònics: sense filtre, «6 de 6 tasques · 45 h obertes». Amb «Iván», «3 de 6 · 25 h». Amb «Lucía», «1 de 6 · 14 h». Amb «Marta», «2 de 6 · 6 h». En prémer «Començar» a Pressupost de la fusteria es despatxa tasques/estatAvancat amb {id: 6}, i a les DevTools veus l'acció, el diff exacte d'aquella tasca i pots retrocedir.

I ara el balanç sincer d'aquest apartat, que és el que 10-01 demanava:

Aspecte Abans (10-02, estat elevat) Amb Redux
Props de filtre que travessen l'arbre 6 components 0
Fitxers a tocar per afegir un filtre per prioritat 6 2 (slice + selector)
Fitxers del magatzem 0 4 (index, 2 slices, selectors)
Dependències 2 4
Pes afegit (comprimit, aprox.) ~13 kB
Traçabilitat dels canvis console.log Historial complet amb viatge en el temps
Provar la lògica sense renderitzar Difícil Trivial: els reductors són funcions pures

Compensa per a Nómada Tasques tal com està? No. Amb una pantalla i dos consumidors del filtre, l'estat elevat de 10-02 —o directament l'URL— és la resposta correcta, i afegir Redux és complexitat sense contrapartida. Compensaria a la versió producte per a vint tallers? Sí, i per l'última fila de la taula més que per cap altra.

  1. Alternatives lleugeres: Zustand, Jotai i els magatzems natius

Redux no és l'única manera de tenir un magatzem extern, i des de fa anys no és la més popular per a projectes nous de mida mitjana.

Zustand — un magatzem minimalista amb hooks i subscripció selectiva:

import { create } from 'zustand';
import { SEGUENT } from './domini/regles.js';

export const usarTauler = create((set) => ({
  tasques: BACKLOG,
  responsable: null,

  filtrarPer: (responsable) => set({ responsable }),

  avancar: (id) => set((estat) => ({
    tasques: estat.tasques.map((t) => {
      if (t.id !== id) return t;
      const desti = SEGUENT[t.estat];
      return desti === null ? t : { ...t, estat: desti };
    })
  }))
}));

// En qualsevol component, a qualsevol profunditat:
const responsable = usarTauler((e) => e.responsable);
const avancar = usarTauler((e) => e.avancar);

Sense Provider, sense accions, sense reductors, sense boilerplate. I amb la mateixa subscripció selectiva d'useSelector.

Jotai — estat atòmic: en lloc d'un objecte gran, molts àtoms petits que es componen. Els derivats es declaren a partir d'altres àtoms, a l'estil dels senyals de 10-01:

import { atom, useAtom } from 'jotai';

export const tasquesAtom = atom(BACKLOG);
export const responsableAtom = atom(null);

export const visiblesAtom = atom((get) => {
  const responsable = get(responsableAtom);
  const tasques = get(tasquesAtom);
  return responsable === null ? tasques : tasques.filter((t) => t.responsable === responsable);
});

La taula honesta:

Criteri Redux Toolkit Zustand Jotai Context + useState Pinia (Vue) / Senyals (Angular)
Boilerplate Mitjà Molt baix Molt baix Baix Baix
Pes aproximat ~13 kB ~1 kB ~3 kB 0 Inclòs
Subscripció selectiva Sí (per àtom) No
DevTools i viatge en el temps Excel·lent Bàsic Bàsic No Bo
Convencions imposades Moltes Cap Poques Cap Algunes
Asincronia integrada createAsyncThunk, RTK Query A mà Àtoms asíncrons A mà A mà o llibreria
Corba Alta Molt baixa Mitjana Molt baixa Baixa
Bo per a Equips grans, traçabilitat, aplicacions longeves Gairebé tota la resta Estat molt derivat Valors estables Els seus propis ecosistemes

Com decidir, sense dogmes:

  • Comença sense res. useState + elevar + composició amb children arriba molt més lluny del que es creu.
  • Si el 60 % és estat de servidor, resol-ho amb una memòria cau de dades; el problema global gairebé desapareix.
  • Si necessites un magatzem i l'equip és petit, Zustand o Jotai. Menys cerimònia, mateix resultat.
  • Si necessites traçabilitat, convencions per a sis persones i una aplicació que viurà anys, Redux Toolkit guanya per l'última fila: l'historial d'accions i les DevTools.
  • Si ja treballes en Vue o Angular, els seus magatzems natius —Pinia i els serveis amb senyals— estan integrats i acostumen a ser la resposta correcta. Ho veuràs a 10-04 i 10-05.

Errors Habituals i Consells

Instal·lar Redux per costum. És l'error històric de l'ecosistema. La pregunta correcta és «quin estat tinc i de quin tipus?». Si en fer l'inventari resulta que gairebé tot és de servidor i local, Redux sobra.

Ficar l'estat de servidor en slices manuals. És el 60 % de l'estat i té regles pròpies (caducitat, refresc, invalidació, reintents). Va a RTK Query o TanStack Query. Ficar-lo en slices és escriure a mà una memòria cau pitjor.

Guardar valors derivats a l'estat. Si horesObertes es dedueix de tasques, no es guarda: es calcula amb un selector. Guardar-lo crea dues fonts de veritat i reintrodueix el problema 1 de 10-01. És el mateix error que derivar estat amb useEffect a 10-02.

Retornar objectes o arrays nous des d'useSelector. Referència nova a cada crida, renderitzat a cada acció del magatzem. Fes servir selectors primitius o memoïtza amb createSelector.

Mutar l'estat fora d'un reductor. Immer només funciona dins dels reductors de RTK. En un component, un thunk o un selector, mutar l'estat trenca la detecció de canvis i corromp el viatge en el temps. El comprovador de configureStore avisa en desenvolupament: fes-li cas.

Barrejar mutació i return al mateix reductor. O mutes l'esborrany, o retornes un valor nou. Les dues coses alhora tenen comportament indefinit.

Ficar coses no serialitzables a l'estat. Dates, Map, Set, promeses, funcions, instàncies de classe amb camps privats. Trenquen el principi 1, les DevTools i la persistència. Guarda cadenes ISO (com la teva dataLimit) i objectes plans.

Anomenar les accions com a ordres. establirResponsable descriu què cal fer; responsableCanviat descriu què ha passat. La segona forma permet que diversos reductors reaccionin al mateix fet i fa llegible l'historial.

Un sol slice gegant. Divideix per domini: filtre, tasques, sessio. Un slice de 400 línies és tan difícil de mantenir com qualsevol altre fitxer de 400 línies.

Consell: els reductors són el millor lloc per provar la lògica. Són funcions pures: reductor(estatPrevi, accio) i compares la sortida, sense renderitzar res. És el més barat que ofereix Redux per a les proves de 08-03.

Consell: guarda a l'URL el que hagi de poder compartir-se. Filtres, pàgina, ordenació i pestanya activa pertanyen a l'URL, no al magatzem. La Marta enviant ?responsable=Ivan per xat és una funcionalitat de franc, i ja saps fer-ho des de 07-06.

Consell: si dubtes, comença sense magatzem. Afegir Redux a una aplicació amb l'estat ben modelat és una feina d'un dia. Treure'l d'una on tot hi passa és un trimestre.

Exercicis

Exercici 1 · Diagnosticar abans d'instal·lar

Per a cadascuna d'aquestes cinc dades de la versió producte de Nómada Tasques, decideix on ha de viure triant entre: estat local, elevat, URL, memòria cau de dades de servidor, o magatzem global. Justifica-ho amb un criteri d'aquesta lliçó i digues què passaria si el posessis al lloc equivocat.

  1. Si el menú d'accions d'una targeta està desplegat.
  2. La llista de tasques que retorna llistarTasques().
  3. El responsable pel qual s'està filtrant.
  4. L'usuari identificat i els seus permisos.
  5. El text que la Marta porta escrit al formulari de nova tasca, sense enviar.

Exercici 2 · Escriure un slice complet

Escriu un slice sessioSlice per a la versió producte, amb aquest estat inicial i aquestes tres accions:

{ usuari: null, permisos: [], tema: 'clar' }
  • sessioIniciada: rep { usuari, permisos } i els guarda.
  • sessioTancada: torna a l'estat inicial.
  • temaAlternat: canvia entre 'clar' i 'fosc'.

Després escriu:

  1. Un selector seleccionarPotEditar que retorni true si els permisos inclouen 'tasques:editar'.
  2. Un selector memoïtzat seleccionarTasquesEditables que, combinant seleccionarVisibles i els permisos, retorni les tasques que l'usuari pot editar: totes si té el permís, i només les seves (on és responsable) si no el té.
  3. Una prova amb Jest del reductor, sense renderitzar res.

Pregunta addicional: per què temaAlternat no porta payload?

Exercici 3 · Del CustomEvent a l'acció

Aquest és un fragment real del teu Nómada Tasques en JavaScript pur:

// js/vista/controlador.js — 06-04
contenidor.addEventListener('click', (esdeveniment) => {
  const boto = esdeveniment.target.closest('[data-accio]');
  if (!boto) return;

  const id = Number(boto.closest('[data-id]').dataset.id);

  if (boto.dataset.accio === 'avancar') {
    estat.tauler.canviarEstat(id, SEGUENT[estat.tauler.cercarPerId(id).estat]);
    emetre(ESDEVENIMENTS.TASCA_AVANCADA, { id });
    render();
  }
});
  1. Tradueix-lo al cicle de Redux: identifica quina part és l'acció, quina el reductor, quina el dispatch i quina el renderitzat.
  2. Escriu l'equivalent complet en Redux Toolkit.
  3. Explica tres coses concretes que es guanyen amb la traducció i dues que es perden.
  4. Què passa a cada versió si canviarEstat llança un ErrorDeRegla per violar la R6?

Solucions

Solució 1

# Dada On Criteri Si es posa malament
1 Menú desplegat Local (useState a la targeta) No li importa a ningú més; mor amb el component Al magatzem global: 600 entrades d'escombraries, accions inútils a l'historial i un objecte que creix sense límit
2 Llista de tasques del servidor Memòria cau de dades (RTK Query) És estat de servidor: caduca, es refresca, s'invalida, pot fallar En un slice manual: ningú no sap quan refrescar, cal invalidar a mà després de cada mutació i es reimplementa una memòria cau pitjor
3 Responsable filtrat L'URL Ha de poder compartir-se per enllaç i sobreviure a recarregar la pàgina Al magatzem: es perd en recarregar, ?responsable=Ivan no funciona i cal sincronitzar magatzem i URL a mà
4 Usuari i permisos Magatzem global (o Context) El necessiten parts molt allunyades, canvia poquíssim, condiciona què es pinta Local o elevat: prop drilling del pitjor tipus, travessant tota l'aplicació
5 Text sense enviar del formulari Local (o no controlat, 10-02) És efímer, canvia a cada tecla i només li importa al formulari Al magatzem: una acció per pulsació, historial il·legible a les DevTools i renderitzats en cascada

La dada 3 mereix un comentari: és el cas on més gent s'equivoca, precisament perquè «el comparteixen diversos components» sona a estat global. Però compartir-lo entre components és un problema de transport; que sobrevisqui a una recàrrega i viatgi en un enllaç és un requisit de producte, i només el compleix l'URL. Un encaminador modern permet llegir i escriure paràmetres de consulta com si fossin estat, amb la qual cosa l'ergonomia és la mateixa.

Solució 2

// src/magatzem/sessioSlice.js
import { createSlice, createSelector } from '@reduxjs/toolkit';
import { seleccionarVisibles } from './selectors.js';

const INICIAL = { usuari: null, permisos: [], tema: 'clar' };

const sessioSlice = createSlice({
  name: 'sessio',
  initialState: INICIAL,
  reducers: {
    sessioIniciada(estat, accio) {
      estat.usuari = accio.payload.usuari;
      estat.permisos = accio.payload.permisos;
      // el tema NO es toca: és una preferència que sobreviu al tancament de sessió
    },
    sessioTancada(estat) {
      return { ...INICIAL, tema: estat.tema };   // retornar: reemplaçament total (regla 2 d'Immer)
    },
    temaAlternat(estat) {
      estat.tema = estat.tema === 'clar' ? 'fosc' : 'clar';
    }
  }
});

export const { sessioIniciada, sessioTancada, temaAlternat } = sessioSlice.actions;
export default sessioSlice.reducer;

// ── Selectors ───────────────────────────────────────────────
export const seleccionarUsuari   = (e) => e.sessio.usuari;
export const seleccionarPermisos = (e) => e.sessio.permisos;

/** Trivial: no necessita createSelector, no calcula res car ni crea referències noves. */
export const seleccionarPotEditar = (e) => e.sessio.permisos.includes('tasques:editar');

/** Sí que necessita memoïtzació: retorna un array nou. */
export const seleccionarTasquesEditables = createSelector(
  [seleccionarVisibles, seleccionarPotEditar, seleccionarUsuari],
  (visibles, potEditar, usuari) =>
    potEditar ? visibles : visibles.filter((t) => t.responsable === usuari?.nom)
);

La prova, sense React ni magatzem:

// test/sessioSlice.test.js
import reductor, { sessioIniciada, sessioTancada, temaAlternat } from '../src/magatzem/sessioSlice.js';

describe('sessioSlice', () => {
  const inicial = { usuari: null, permisos: [], tema: 'clar' };

  test('sessioIniciada guarda usuari i permisos', () => {
    const resultat = reductor(inicial, sessioIniciada({
      usuari: { nom: 'Marta' },
      permisos: ['tasques:editar']
    }));
    expect(resultat.usuari).toEqual({ nom: 'Marta' });
    expect(resultat.permisos).toEqual(['tasques:editar']);
  });

  test('sessioTancada neteja la sessió però conserva el tema', () => {
    const ambSessio = { usuari: { nom: 'Iván' }, permisos: ['x'], tema: 'fosc' };
    expect(reductor(ambSessio, sessioTancada())).toEqual({ ...inicial, tema: 'fosc' });
  });

  test('temaAlternat va i torna', () => {
    const fosc = reductor(inicial, temaAlternat());
    expect(fosc.tema).toBe('fosc');
    expect(reductor(fosc, temaAlternat()).tema).toBe('clar');
  });

  test('el reductor rep un estat que no muta', () => {
    const congelat = Object.freeze({ ...inicial, permisos: Object.freeze([]) });
    expect(() => reductor(congelat, temaAlternat())).not.toThrow();
    expect(congelat.tema).toBe('clar');    // l'original intacte
  });
});

Per què temaAlternat no porta payload: perquè el valor nou es dedueix de l'actual, no l'aporta qui despatxa. És la mateixa raó per la qual a 10-02 es feia servir setComptador((n) => n + 1) en lloc de setComptador(comptador + 1). Si el component calculés el tema nou i l'enviés com a payload, dues pulsacions molt seguides podrien enviar el mateix valor. Amb la decisió dins del reductor, que veu sempre l'estat més recent, això és impossible.

L'última prova mereix atenció: congelar l'estat d'entrada amb Object.freeze és una tècnica excel·lent per verificar que un reductor és realment pur, i és l'equivalent en proves del comprovador que configureStore activa en desenvolupament.

Solució 3

1 · La traducció de les peces:

Peça del codi original Equivalent a Redux
esdeveniment.target.closest('[data-accio]') Es queda igual: és DOM. A React seria onClick al botó
estat.tauler.canviarEstat(id, …) El reductor: la transformació pura de l'estat
emetre(ESDEVENIMENTS.TASCA_AVANCADA, {id}) dispatch(estatAvancat({id})): notificar què ha passat
render() Desapareix: useSelector provoca el renderitzat de qui correspongui
SEGUENT[...cercarPerId(id).estat] Es mou dins del reductor, que ja té l'estat

Nota important: a l'original, emetre i canviarEstat són dues coses separades — primer es muta i després s'avisa. A Redux són la mateixa cosa: el dispatch és simultàniament l'avís i la causa del canvi. Aquesta unificació és la que fa que l'historial d'accions sigui complet per construcció; a la teva versió, algú pot cridar canviarEstat sense emetre l'esdeveniment, i llavors l'avís es perd.

2 · L'equivalent complet:

// src/magatzem/tasquesSlice.js
import { createSlice } from '@reduxjs/toolkit';
import { SEGUENT } from '../domini/regles.js';

const tasquesSlice = createSlice({
  name: 'tasques',
  initialState: { llista: BACKLOG, ultimError: null },
  reducers: {
    estatAvancat(estat, accio) {
      estat.ultimError = null;
      const tasca = estat.llista.find((t) => t.id === accio.payload.id);
      if (!tasca) {
        estat.ultimError = `No existeix la tasca ${accio.payload.id}`;
        return;
      }
      const desti = SEGUENT[tasca.estat];
      if (desti === null) {
        estat.ultimError = `La tasca "${tasca.titol}" ja està feta`;   // R6
        return;
      }
      tasca.estat = desti;
    }
  }
});

export const { estatAvancat } = tasquesSlice.actions;
export default tasquesSlice.reducer;
// src/components/TargetaTasca.jsx (fragment)
const despatxar = useDispatch();

<button
  type="button"
  disabled={SEGUENT[tasca.estat] === null}
  onClick={() => despatxar(estatAvancat({ id: tasca.id }))}
>
  {ETIQUETA[tasca.estat]}
</button>

3 · Tres coses que es guanyen:

  • Traçabilitat completa. Cada avanç queda a l'historial de les DevTools amb el seu id i el seu diff, i es pot retrocedir. A la versió original, per saber per què una tasca va acabar en 'feta' cal posar un punt d'interrupció i reproduir el recorregut.
  • La lògica es prova sense DOM. reductor(estat, estatAvancat({id: 6})) és una crida de funció. La versió original necessita jsdom, un contenidor, una plantilla i un clic simulat (08-05).
  • Un sol camí per al canvi. No és possible canviar l'estat d'una tasca sense despatxar l'acció, així que cap mutació no queda fora del registre. A la versió original, tauler.canviarEstat(6, 'feta') des de la consola canvia el model sense avisar ningú i sense repintar.

Dues coses que es perden:

  • Immediatesa i pes. Quatre fitxers nous, dues dependències, ~13 kB i una capa d'indirecció: per seguir què fa el botó cal anar del component a l'acció, de l'acció al slice i del slice al selector. A la versió original hi ha tot en vint línies seguides.
  • Els objectes rics del domini. L'estat són dades planes: es perden la classe Tasca amb el seu #estat privat, els seus getters i els seus mètodes, que garantien la R6 per encapsulació (05-03). A Redux la garantia és per convenció: res no impedeix que un altre reductor escrigui tasca.estat = 'feta' saltant-se SEGUENT.

4 · Què passa si es viola la R6. A la versió original, canviarEstat llança ErrorDeRegla, l'excepció puja pel gestor del clic i —si ningú no la captura— mor a la consola: la interfície no se n'assabenta i no es mostra res a l'usuari. A la versió Redux un reductor no pot llançar: ha de ser pur i total. La transició invàlida es converteix en estat (ultimError), que la interfície pot mostrar com un avís accessible. És una diferència de filosofia que convé entendre: a Redux, els errors previsibles del domini són dades, no excepcions; les excepcions es reserven per al que és veritablement inesperat. És, per cert, exactament el mateix raonament amb què 02-05 distingia entre errors esperables i fallades de programació.

Conclusió

Has après gestió d'estat començant per on calia començar: pel problema. En coneixes les tres cares —el prop drilling del filtre que travessa sis components que no el fan servir, l'estat duplicat que fa que la capçalera i la llista es contradiguin, i els valors derivats guardats que reintrodueixen el problema 1 de 10-01 dins del framework que venia a resoldre'l—. I tens la taula d'alternatives per ordre de cost, amb la instrucció de quedar-te a la primera fila que resolgui el teu cas: res, elevar l'estat, Context, un magatzem lleuger, Redux. Amb l'inventari que gairebé ningú no fa: en una aplicació real, el 60 % de l'estat és de servidor, el 25 % local, el 10 % d'URL i només el 5 % veritablement global — i aquest 5 % és el territori de Redux.

Saps que elevar l'estat arriba molt més lluny amb composició: passar elements ja creats com a children o com a props de contingut elimina el prop drilling sense instal·lar res. Saps què és Context —un mecanisme de transport, no un gestor d'estat— i quina és la seva limitació real: tots els consumidors es repinten quan canvia el valor, amb els dos pal·liatius de partir per freqüència de canvi i separar valor d'accions. I saps què hi afegeix un magatzem extern: subscripció selectiva, i la possibilitat de fer-lo servir des de fora de React —des del teu CanalTauler de 07-04, des d'una prova, des d'on sigui—.

Coneixes els tres principis de Redux i, més important, què compra cadascun: font única de veritat (res per desincronitzar, estat serialitzable i restaurable), estat de només lectura mitjançant accions (tots els canvis per un punt observable), i canvis amb funcions pures (la mateixa seqüència produeix sempre el mateix estat, que és el que fa possible el viatge en el temps). Tens el cicle acció → reductor → nou estat → render amb les seves cinc propietats, i el paral·lelisme exacte amb el que ja havies construït: el teu objecte estat, els teus CustomEvent amb emetre, el teu gestor que aplicava el canvi i cridava render(), i la teva memòria cau per versió de 09-02 — on la immutabilitat fa innecessari el comptador de versió perquè la referència és el comptador.

Domines Redux Toolkit com la forma correcta i actual: configureStore amb els seus comprovadors de mutació i serialitzabilitat, createSlice que converteix quatre llocs en un, Immer amb el seu proxy que registra escriptures i produeix estat nou compartint estructuralment el que no s'ha modificat —amb les seves tres regles: no barrejar mutació i return, retornar per reemplaçar del tot, i no esperar màgia fora dels reductors—, useSelector amb la seva subscripció selectiva i el seu parany de les referències noves, i createSelector com a memoïtzació de derivats. Saps resoldre l'asincronia amb createAsyncThunk i les seves tres accions automàtiques, amb el signal que integra el teu AbortController de 07-03 i rejectWithValue per no ficar excepcions a l'estat; i saps que l'estat de servidor té eina pròpia, RTK Query, amb memòria cau per clau, invalidació per etiquetes, refresc automàtic i mutacions optimistes.

Tens clar on és l'avantatge real de Redux, que no és el rendiment ni el transport: és la traçabilitat. L'historial d'accions amb nom, el diff per acció, el viatge en el temps que funciona perquè els reductors són purs, i la possibilitat d'exportar l'estat d'una altra persona i reproduir el seu error a la teva màquina. I saps normalitzar amb createEntityAdapter quan hi ha 600 tasques, que és el teu índex Map de 09-02 expressat en dades planes, amb la mateixa frontera: per sota de certa mida, complica sense guanyar res.

Has vist el filtre i el tauler de Nómada Tasques en Redux, amb App sense ni una prop de filtre i els números canònics intactes, i el balanç honest: zero props travessant l'arbre i traçabilitat completa, a canvi de quatre fitxers, dues dependències, 13 kB i una capa d'indirecció. Amb la conclusió que 10-01 demanava: per a Nómada Tasques tal com està, no compensa; per a la versió producte de vint tallers, sí. I coneixes les alternatives lleugeres —Zustand, Jotai, i els magatzems natius d'altres ecosistemes— amb la taula que diu quan cadascuna és la resposta raonable.

Tot el que hem vist fins ara ha passat dins del mateix model: components de funció, DOM virtual, estat immutable, memoïtzació manual. La lliçó següent canvia les tres coses. Veuràs la mateixa pantalla en un framework que agrupa plantilla, lògica i estils en un sol fitxer, que detecta les dependències automàticament amb proxies en lloc de comparar arbres, i on l'equivalent d'useMemo no és una optimització sinó la forma natural d'escriure. És la família 2 de reactivitat de 10-01, en el seu exponent més polit: Conceptes Bàsics de Vue.js.

Curs de JavaScript: De Principiant a Avançat

Mòdul 1: Introducció a JavaScript

Mòdul 2: Estructures de Control

Mòdul 3: Funcions

Mòdul 4: Objectes i Arrays

Mòdul 5: Objectes i Funcions Avançades

Mòdul 6: El Model d'Objectes del Document (DOM)

Mòdul 7: APIs del Navegador i Temes Avançats

Mòdul 8: Proves i Depuració

Mòdul 9: Rendiment i Optimització

Mòdul 10: Frameworks i Llibreries de JavaScript

Mòdul 11: Projecte Final

© Copyright 2026. Tots els drets reservats