El teu projecte funciona: el domini és en verd, el primer tall vertical es veu al navegador i les regles R1–R15 es compleixen. I tanmateix hi ha una mentida en marxa, perquè en recarregar la pàgina tot desapareix. El repositori en memòria va fer exactament el que havia de fer —no bloquejar-te mentre construïes l'important— i ara toca canviar-lo per alguna cosa real, sense tocar ni una línia de vista. Aquesta és la prova de foc del contracte que vas definir a la lliçó anterior. Però persistir no és només cridar localStorage.setItem: és decidir quin emmagatzematge encaixa amb el teu cas, versionar el format que deses per poder canviar-lo d'aquí a tres mesos sense perdre les dades de ningú, parlar amb una API de manera que les errades de xarxa siguin part del disseny i no una sorpresa, decidir qui guanya quan dues persones editen el mateix, permetre treballar sense connexió amb una cua de canvis que es reenvia sola, i fer que l'aplicació sembli instantània amb actualitzacions optimistes que es reverteixen si el servidor diu que no. I hi ha una part que no és tècnica i sí que és obligatòria: què no ha d'estar mai al navegador, i què implica legalment desar dades de persones. En acabar tindràs persistència amb migracions provades, una capa d'API amb els seus estats, i una cua offline funcionant.
Contingut
- Triar l'emmagatzematge segons el cas
- Quan
localStoragees queda curt - IndexedDB a nivell pràctic
- Un embolcall mínim amb promeses
- Versionar el format desat
- Migracions numerades i les seves proves
- El repositori com a frontera: una interfície, tres implementacions
- Parlar amb l'API:
fetchrobust - Els estats d'una operació asíncrona
- Reintents, cancel·lació i
AbortController - Sincronització: qui guanya quan dos editen alhora
- La cua de canvis pendents
- Idempotència i reenviament segur
- Actualització optimista amb reversió
- Temps real sense duplicar canvis propis
- Seguretat i privacitat del que es desa
- Errades de sincronització i el seu tractament
- Errors Habituals i Consells
- Exercicis
- Conclusió
- Triar l'emmagatzematge segons el cas
La lliçó 07-01 va presentar les opcions d'emmagatzematge del navegador. Aquí la taula torna amb la columna que llavors no importava i ara sí: quan triar-ne cadascuna per al teu projecte.
| Opció | Capacitat típica | Síncron | Estructurat | Persisteix | Quan la tries |
|---|---|---|---|---|---|
| Variables en memòria | RAM | Sí | Sí | Només la sessió | Estat d'interfície que no ha de sobreviure a la recàrrega |
sessionStorage |
~5 MB | Sí | No (només text) | Fins a tancar la pestanya | Dades d'un flux en curs: un formulari llarg a mitges |
localStorage |
~5–10 MB | Sí | No (només text) | Fins que s'esborri | Preferències, i conjunts de dades petits i estables |
| IndexedDB | Centenars de MB a GB | No (promeses) | Sí (objectes, índexs) | Fins que s'esborri | Volums grans, consultes per índex, dades binàries |
| Cache Storage | Centenars de MB | No | Peticions i respostes | Fins que s'esborri | Recursos de l'aplicació: el service worker de 07-05 |
| Cookies | ~4 kB | Sí | No | Configurable | Només identificació de sessió amb el servidor |
| API remota | Il·limitada | No | Sí | Sempre | Multidispositiu, multiusuari, dades que importen de debò |
Dos advertiments que canvien decisions:
localStorage és síncron, i això significa que bloqueja el fil principal. Desar 2 MB de JSON pot costar desenes de mil·lisegons durant els quals la interfície no respon. Amb el pressupost d'INP ≤ 200 ms de 11-01, això importa. IndexedDB és asíncrona i no bloqueja.
Res del que és al navegador no és privat. Qualsevol persona amb accés al dispositiu, i qualsevol script que s'executi a la teva pàgina, ho pot llegir tot. Hi tornarem a l'apartat 16, però tingues-ho present ja en decidir què deses.
L'arbre de decisió per al teu projecte:
flowchart TD
A["Les dades s'han de veure<br/>en un altre dispositiu?"] -->|Si| B["API remota<br/>+ memoria cau local"]
A -->|No| C["Quant ocupen<br/>en el pitjor cas?"]
C -->|"< 1 MB i estable"| D["localStorage<br/>amb migracions"]
C -->|"> 1 MB o creix sense limit"| E["IndexedDB"]
C -->|"No ho se"| F["Mesura-ho amb dades<br/>realistes ABANS de decidir"]
F --> C
E --> G["Necessites consultar<br/>per alguna cosa que no sigui l'id?"]
G -->|Si| H["IndexedDB amb indexs"]
G -->|No| I["IndexedDB com a<br/>magatzem clau-valor"]
style D fill:#dcfce7,stroke:#16a34a
style B fill:#dbeafe,stroke:#2563eb
style F fill:#fef3c7,stroke:#d97706
El node taronja és l'important. «No ho sé» és la resposta honesta al principi, i la sortida no és endevinar: és mesurar amb dades realistes. Genera 500 tasques, 3.000 entrades d'historial i 10 usuaris ficticis, serialitza-ho i mira quant ocupa:
// Un calcul de trenta segons que evita una decisio equivocada
const dades = generarDadesRealistes({ tasques: 500, historial: 3000, usuaris: 10 });
const text = JSON.stringify(dades);
console.log('Mida:', (new Blob([text]).size / 1024).toFixed(1), 'kB');
console.time('serialitzar'); JSON.stringify(dades); console.timeEnd('serialitzar');Per a Òrbita, amb aquest volum, el resultat ronda els 900 kB i la serialització uns 12 ms. Conclusió: localStorage serveix per al MVP, amb dues condicions — que l'historial es podi (apartat 2) i que l'escriptura no passi a cada pulsació de tecla.
- Quan
localStorage es queda curt
localStorage es queda curtTé cinc límits, i convé reconèixer-los abans de topar-hi:
| Límit | Símptoma | Moment en què apareix |
|---|---|---|
| Quota (~5–10 MB) | QuotaExceededError en desar |
Quan l'historial (R14) porta uns mesos creixent |
| Síncron | La interfície es congela en desar | Amb més d'~1 MB, o en desar molt sovint |
| Només text | JSON.parse a cada lectura |
Cost de CPU proporcional al total, encara que només vulguis una tasca |
| Tot o res | Cal llegir i escriure el document sencer | Canviar una tasca reescriu les 500 |
| Sense consultes | Filtrar exigeix carregar-ho tot en memòria | Sempre, encara que amb volums petits no es noti |
El més perillós és el primer, perquè falla en producció i al dispositiu d'una altra persona, no al teu. I la gestió correcta no és un try/catch buit:
// src/dades/repositori-local.js
async desarTot(doc) {
const text = JSON.stringify(doc);
try {
localStorage.setItem(this.clau, text);
} catch (error) {
if (esErrorDeQuota(error)) {
// 1 · Podar el que es pot podar: l'historial antic
const podat = podarHistorial(doc, { conservar: 200 });
try {
localStorage.setItem(this.clau, JSON.stringify(podat));
this.avisos.emetre('historial-podat', { eliminades: quantes });
return;
} catch { /* continua sense cabre-hi */ }
}
// 2 · Si no hi cap ni podat, ES UN ERROR DE L USUARI I CAL DIR-LI
throw new ErrorDeDades(
'No hi ha espai per desar. Exporta les teves dades i allibera espai.',
{ causa: error, recuperable: false }
);
}
}
function esErrorDeQuota(error) {
return error instanceof DOMException &&
(error.name === 'QuotaExceededError' ||
error.name === 'NS_ERROR_DOM_QUOTA_REACHED'); // Firefox antic
}Tres decisions d'aquest fragment:
- S'intenta podar abans de rendir-se. L'historial és l'única cosa prescindible; les tasques no ho són mai.
- S'avisa de la poda. Esborrar dades de l'usuari en silenci és inacceptable, encara que siguin dades secundàries.
- Si no hi cap, es llança amb un missatge accionable. «Error en desar» no ajuda; «exporta les teves dades i allibera espai» sí. I
recuperable: falsediu a la interfície que no ofereixi un botó de reintentar que tornaria a fallar.
La comprovació que gairebé ningú no fa: en mode privat d'alguns navegadors, i amb certes configuracions de bloqueig, localStorage existeix però llança en escriure. Comprova-ho en arrencar i degrada amb elegància:
export function hiHaEmmagatzematge() {
try {
const prova = '__orbita_prova__';
localStorage.setItem(prova, '1');
localStorage.removeItem(prova);
return true;
} catch { return false; }
}Si retorna false, l'aplicació ha de continuar funcionant en memòria i avisar clarament que els canvis no es desaran. És la diferència entre una aplicació trencada i una aplicació honesta.
- IndexedDB a nivell pràctic
IndexedDB és la base de dades del navegador. Té fama d'incòmoda i la té merescuda: la seva API nativa és del 2010, basada en esdeveniments i verbosa. Però la part que necessites és petita.
El model mental, en quatre conceptes:
| Concepte | Equivalent | A Òrbita |
|---|---|---|
| Base de dades | Una base de dades | orbita |
| Magatzem d'objectes (object store) | Una taula | tasques, usuaris, historial, cua |
| Clau | Clau primària | id de la tasca |
| Índex | Índex de columna | perResponsable, perDataLimit |
I quatre regles de funcionament que cal entendre abans de fer-la servir:
- Tot passa dins d'una transacció, que pot ser
readonlyoreadwrite. - Les transaccions es tanquen soles així que el bucle d'esdeveniments es queda sense feina pendent per a elles. Si fas un
awaitd'una cosa aliena enmig, la transacció mor. És la font número u d'errors desconcertants. - L'esquema només es canvia a
onupgradeneeded, que es dispara en obrir amb un número de versió superior. És l'equivalent a la migració de l'apartat 6, però per a l'estructura. - Desa objectes, no text. Fa servir l'algorisme de clonació estructurada, així que admet
Date,Map,Set,ArrayBuffer… però no funcions ni instàncies de classe amb els seus mètodes. Desa objectes plans i reconstrueix les entitats en llegir, exactament com ambdesDeJSON.
Quan migrar de localStorage a IndexedDB, amb criteris objectius:
| Senyal | Llindar |
|---|---|
| El document serialitzat supera | ~2 MB |
| El temps de desat supera | ~16 ms (un fotograma) |
| Necessites llegir una part sense carregar-ho tot | Sempre |
| Deses dades binàries (imatges, adjunts) | Sempre |
| Necessites consultar per alguna cosa que no sigui la clau | Sempre |
Per al MVP d'Òrbita, amb 900 kB estimats, no cal. I això també és una decisió defensable que mereix el seu ADR: «es tria localStorage perquè el volum previst és un ordre de magnitud per sota del límit, i la frontera del repositori permet canviar a IndexedDB sense tocar la resta».
- Un embolcall mínim amb promeses
Si el teu projecte sí que necessita IndexedDB, no facis servir l'API nativa directament al teu repositori: embolcalla-la una vegada, en un fitxer, i oblida-te'n.
// src/dades/idb.js — embolcall minim amb promeses
export function obrir(nom, versio, enActualitzar) {
return new Promise((resoldre, rebutjar) => {
const peticio = indexedDB.open(nom, versio);
peticio.onupgradeneeded = (e) => enActualitzar(e.target.result, e.oldVersion, e.newVersion);
peticio.onsuccess = () => resoldre(peticio.result);
peticio.onerror = () => rebutjar(new ErrorDeDades('No s ha pogut obrir la base', { causa: peticio.error }));
peticio.onblocked = () => rebutjar(new ErrorDeDades('Hi ha una altra pestanya amb una versio antiga oberta'));
});
}
function promesaDe(peticio) {
return new Promise((resoldre, rebutjar) => {
peticio.onsuccess = () => resoldre(peticio.result);
peticio.onerror = () => rebutjar(peticio.error);
});
}
export async function llegirTot(db, magatzem) {
const tx = db.transaction(magatzem, 'readonly');
return promesaDe(tx.objectStore(magatzem).getAll());
}
export async function escriureLot(db, magatzem, objectes) {
const tx = db.transaction(magatzem, 'readwrite');
const store = tx.objectStore(magatzem);
// COMPTE: res d await alie aqui dins, o la transaccio es tanca
for (const objecte of objectes) store.put(objecte);
return new Promise((resoldre, rebutjar) => {
tx.oncomplete = () => resoldre();
tx.onerror = () => rebutjar(tx.error);
tx.onabort = () => rebutjar(tx.error ?? new Error('Transaccio avortada'));
});
}Quatre punts que expliquen per què aquest embolcall és així:
1 · promesaDe converteix el patró d'esdeveniments en una promesa. És exactament la tècnica de «promisificació» de 05-06: una funció que embolcalla una API de callbacks en un new Promise. Escrita una vegada, serveix per a totes les operacions.
2 · onblocked està contemplat. Passa quan l'usuari té dues pestanyes obertes i una intenta actualitzar l'esquema mentre l'altra fa servir la versió antiga. Ignorar-ho produeix una penjada silenciosa que és dificilíssima de diagnosticar.
3 · El comentari de l'await no és decoratiu. Aquest és el parany clàssic:
// ❌ La transaccio mor a mitges
const tx = db.transaction('tasques', 'readwrite');
for (const tasca of tasques) {
const validada = await validarAlServidor(tasca); // ← await alie: tx es tanca
tx.objectStore('tasques').put(validada); // ← TransactionInactiveError
}
// ✅ Preparar-ho tot abans, escriure despres
const validades = await Promise.all(tasques.map(validarAlServidor));
const tx = db.transaction('tasques', 'readwrite');
for (const t of validades) tx.objectStore('tasques').put(t);4 · S'espera oncomplete, no l'última petició. Que l'última escriptura tingui èxit no significa que la transacció s'hagi confirmat. Només oncomplete garanteix que les dades són al disc.
- Versionar el format desat
Aquí hi ha l'apartat que separa un projecte de joguina d'un de seriós, i val la pena dir-ho sense embuts:
El dia que canviïs el model de dades, els usuaris ja tindran dades desades amb el format antic. Si no ho has previst, les perds.
A Nómada Tasques, la clau era 'nomada:tauler:v1'. Aquell v1 era la llavor d'aquesta idea. Ara es converteix en un mecanisme complet.
El document desat mai no és la llista de tasques i prou. És un sobre amb metadades:
{
"versio": 3,
"desatEl": "2026-09-20T18:42:11.320Z",
"aplicacio": "orbita",
"dades": {
"tasques": [],
"usuaris": [],
"historial": []
}
}| Camp | Per a què |
|---|---|
versio |
L'únic imprescindible. Diu quines migracions cal aplicar |
desatEl |
Depuració i resolució de conflictes per marca de temps |
aplicacio |
Detectar que la clau l'ha escrit una altra cosa; evita corrompre dades alienes |
dades |
El contingut real, sempre imbricat, mai a l'arrel |
Aquesta imbricació importa: si les dades van a l'arrel al costat de versio, afegir una metadada nova pot xocar amb una entitat. Amb dades a part, el sobre i el contingut evolucionen per separat.
Regla de la versió: el número només puja, d'un en un, i cada pujada té la seva migració. Mai no es reutilitza un número, ni tan sols durant el desenvolupament — perquè el teu propi navegador de desenvolupament ja té dades de la versió anterior, i és allà on trobaràs les errades de migració abans que ningú.
- Migracions numerades i les seves proves
Una migració és una funció pura que transforma el document de la versió N a la N+1.
// src/dades/migracions.js
export const MIGRACIONS = [
{
a: 1,
descripcio: 'Format inicial',
migrar: (doc) => doc
},
{
a: 2,
descripcio: 'responsable (text) passa a responsableId (referencia)',
migrar: (doc) => {
const perNom = new Map(doc.dades.usuaris.map((u) => [u.nom, u.id]));
return {
...doc,
dades: {
...doc.dades,
tasques: doc.dades.tasques.map(({ responsable, revisor, ...resta }) => ({
...resta,
responsableId: responsable ? (perNom.get(responsable) ?? null) : null,
revisorId: revisor ? (perNom.get(revisor) ?? null) : null
}))
}
};
}
},
{
a: 3,
descripcio: 'Afegir tascaMareId i creadaEl a les tasques existents',
migrar: (doc) => ({
...doc,
dades: {
...doc.dades,
tasques: doc.dades.tasques.map((t) => ({
...t,
tascaMareId: t.tascaMareId ?? null,
creadaEl: t.creadaEl ?? doc.desatEl ?? '2026-01-01T00:00:00.000Z'
}))
}
})
}
];
export const VERSIO_ACTUAL = MIGRACIONS.at(-1).a;
export function migrar(docOriginal) {
let doc = docOriginal;
const desDe = doc.versio ?? 0;
if (desDe > VERSIO_ACTUAL) {
throw new ErrorDeDades(
`Les dades son d una versio mes recent (${desDe}) que aquesta aplicacio (${VERSIO_ACTUAL}). ` +
'Actualitza l aplicacio per poder obrir-les.'
);
}
for (const pas of MIGRACIONS) {
if (pas.a <= desDe) continue;
doc = { ...pas.migrar(doc), versio: pas.a };
}
return doc;
}Sis propietats d'aquest disseny, i per què cadascuna importa:
1 · Les migracions són funcions pures. Reben un document i en retornen un altre. No llegeixen ni escriuen localStorage. Per això es poden provar amb un objecte literal, sense muntar res.
2 · S'apliquen en cadena. Un usuari que va abandonar l'aplicació a la versió 1 i torna avui passa per 1→2 i 2→3 automàticament. No cal una migració «d'1 a 3».
3 · Cada migració té la seva descripció. És documentació que viu al costat del codi i apareix al registre quan s'aplica.
4 · La versió futura es rebutja amb un missatge clar. Passa de debò: l'usuari té dos dispositius i un es va actualitzar abans. Intentar llegir un format futur i «apedaçar-ho» corromp les dades; negar-s'hi i explicar-ho, no.
5 · Els valors per defecte són conservadors. creadaEl fa servir desatEl si existeix, i només si no hi ha res recorre a una data fixa. Inventar new Date() posaria totes les tasques antigues com a creades avui, trencant R4 i l'ordre de l'historial.
6 · La migració 2 fa servir l'índex perNom. I quan un nom no existeix entre els usuaris, posa null en lloc de fallar. És la decisió correcta: perdre una assignació és dolent, però no poder obrir l'aplicació és pitjor.
6.1 Com es proven les migracions
Aquesta és la part que gairebé ningú no fa i la que evita el desastre. La tècnica: desa documents reals de cada versió antiga com a fitxers de prova.
test/dades/fixtures/
document-v1.json ← copiat literalment d un localStorage real de la v1
document-v2.json
document-v1-buit.json
document-v1-corrupte.json// test/dades/migracions.test.js
import { migrar, VERSIO_ACTUAL } from '../../src/dades/migracions.js';
import v1 from './fixtures/document-v1.json';
import v2 from './fixtures/document-v2.json';
describe('Migracions', () => {
test.each([
['v1', v1],
['v2', v2]
])('%s migra a la versio actual sense perdre tasques', (nom, original) => {
const resultat = migrar(structuredClone(original));
expect(resultat.versio).toBe(VERSIO_ACTUAL);
expect(resultat.dades.tasques).toHaveLength(original.dades.tasques.length);
});
test('v1: cada responsable amb nom conegut conserva la seva assignacio', () => {
const resultat = migrar(structuredClone(v1));
const original = v1.dades.tasques.find((t) => t.responsable === 'Marta');
const migrada = resultat.dades.tasques.find((t) => t.id === original.id);
expect(migrada.responsableId).toBe('u-marta');
expect(migrada).not.toHaveProperty('responsable'); // el camp vell se n va
});
test('un responsable desconegut passa a null, no trenca la migracio', () => {
const ambFantasma = structuredClone(v1);
ambFantasma.dades.tasques[0].responsable = 'Persona Que No Existeix';
expect(() => migrar(ambFantasma)).not.toThrow();
expect(migrar(ambFantasma).dades.tasques[0].responsableId).toBeNull();
});
test('el resultat de migrar es valid per al domini', () => {
const resultat = migrar(structuredClone(v1));
for (const pla of resultat.dades.tasques) {
expect(() => Tasca.desDeJSON(pla)).not.toThrow();
}
});
test('migrar es idempotent: aplicar-la dues vegades no canvia res', () => {
const una = migrar(structuredClone(v1));
const dues = migrar(structuredClone(una));
expect(dues).toEqual(una);
});
test('una versio futura es rebutja amb missatge explicatiu', () => {
expect(() => migrar({ versio: 99, dades: {} })).toThrow(/mes recent/);
});
});Les sis proves cobreixen les sis errades possibles, i la quarta i la cinquena són les que més valor tenen:
- «El resultat és vàlid per al domini» és la que de debò tanca el cercle. Una migració pot produir un document sintàcticament correcte i semànticament invàlid —una tasca sense títol, unes hores a 0— que rebentarà en construir l'entitat. Validar cada objecte migrat contra el domini ho detecta allà.
- La idempotència protegeix contra l'errada més comuna de les migracions: aplicar-les dues vegades per un error de flux. Si
migrar(migrar(x)) === migrar(x), aquest error és inofensiu.
I la regla d'or operativa: abans d'aplicar migracions sobre dades reals, fes una còpia de seguretat.
async function carregarAmbMigracio() {
const cru = localStorage.getItem(CLAU);
if (!cru) return docBuit();
const doc = JSON.parse(cru);
if (doc.versio === VERSIO_ACTUAL) return doc;
// Copia de seguretat ABANS de tocar res
localStorage.setItem(`${CLAU}:copia:v${doc.versio}`, cru);
try {
const migrat = migrar(doc);
localStorage.setItem(CLAU, JSON.stringify(migrat));
return migrat;
} catch (error) {
registrar(error, { cas: 'migracio', desDe: doc.versio });
throw new ErrorDeDades(
'No s han pogut actualitzar les teves dades. Se n conserva una copia de seguretat.',
{ causa: error, recuperable: false }
);
}
}La còpia de seguretat costa una línia i converteix un desastre irreversible en un incident recuperable. A la lliçó 11-04 depuraràs precisament una migració que corromp dades, i aquesta còpia serà el que et permetrà investigar.
- El repositori com a frontera: una interfície, tres implementacions
Ara es cobra la inversió de la lliçó anterior. El contracte de src/dades/repositori.js no canvia; apareixen dues implementacions més:
flowchart LR
A["aplicacio/<br/>casos d'us"] --> C{{"Contracte Repositori<br/>llistarTasques, desarTasca,<br/>esborrarTasca, afegirCanvi…"}}
C --> M["RepositoriMemoria<br/><i>proves, arrencada</i>"]
C --> L["RepositoriLocal<br/><i>localStorage + migracions</i>"]
C --> P["RepositoriApi<br/><i>fetch + reintents</i>"]
C --> S["RepositoriSincronitzat<br/><i>local + api + cua</i>"]
style C fill:#f3e8ff,stroke:#9333ea
style S fill:#dbeafe,stroke:#2563eb
I la mateixa bateria de proves de contracte s'executa contra les quatre:
// test/dades/repositoris.test.js
import { provesDeContracte } from './contracte-repositori.js';
provesDeContracte('memoria', async () => new RepositoriMemoria());
provesDeContracte('local', async () => {
localStorage.clear();
return new RepositoriLocal('orbita:proves');
});
provesDeContracte('api', async () => {
servidorSimulat.reiniciar();
return new RepositoriApi('http://localhost:3001');
});Si les tres passen les mateixes proves, són substituïbles, i canviar d'emmagatzematge és canviar una línia a main.js:
// src/main.js
const repo = import.meta.env.VITE_ORIGEN === 'api'
? new RepositoriApi(import.meta.env.VITE_API_URL)
: new RepositoriLocal('orbita:tauler');Això és el que la lliçó 08-04 anomenava injecció de dependències, i aquí se'n veu el valor complet: la mateixa decisió de disseny que va fer possible provar amb dobles fa possible canviar de tecnologia d'emmagatzematge. No són dos beneficis: és el mateix, mirat des de dos llocs.
RepositoriSincronitzat és el que construiràs als apartats 12 a 14: combina local (ràpid, sempre disponible) amb API (compartit, autoritatiu) i una cua per al que no s'ha pogut enviar. I compleix el mateix contracte, així que l'aplicació no s'assabenta de la diferència.
- Parlar amb l'API:
fetch robust
fetch robustSi el teu projecte parlarà amb un servidor, la capa de xarxa es construeix una vegada i bé. És el que feia js/dades/http.js a Nómada Tasques amb demanarJson, ErrorDeApi i ambReintents, i val la pena reconstruir-ho entenent cada decisió.
// src/dades/http.js
export class ErrorDeApi extends Error {
constructor(missatge, { status, codi, cos } = {}) {
super(missatge);
this.name = 'ErrorDeApi';
this.status = status ?? 0;
this.codi = codi ?? null;
this.cos = cos ?? null;
}
get reintentable() {
// 0 = fallada de xarxa. 408 temps esgotat. 429 massa peticions. 5xx servidor.
return this.status === 0 || this.status === 408 || this.status === 429 || this.status >= 500;
}
get esDeClient() { return this.status >= 400 && this.status < 500; }
}
export async function demanarJson(url, opcions = {}) {
const { tempsMaxim = 8000, senyal, ...resta } = opcions;
const abortador = new AbortController();
const temporitzador = setTimeout(() => abortador.abort('temps esgotat'), tempsMaxim);
senyal?.addEventListener('abort', () => abortador.abort(senyal.reason), { once: true });
try {
const resposta = await fetch(url, {
...resta,
signal: abortador.signal,
headers: { 'Content-Type': 'application/json', ...resta.headers }
});
if (!resposta.ok) {
const cos = await llegirCosSegur(resposta);
throw new ErrorDeApi(cos?.missatge ?? `Error ${resposta.status}`, {
status: resposta.status,
codi: cos?.codi,
cos
});
}
return resposta.status === 204 ? null : resposta.json();
} catch (error) {
if (error instanceof ErrorDeApi) throw error;
if (error.name === 'AbortError') {
throw new ErrorDeApi('La peticio ha trigat massa', { status: 408 });
}
throw new ErrorDeApi('No hi ha connexio amb el servidor', { status: 0 });
} finally {
clearTimeout(temporitzador);
}
}Els set punts que fan robusta aquesta funció, i que 07-03 va introduir:
fetchno llança amb 404 ni 500. Només llança si la xarxa falla. Sense la comprovació deresposta.ok, un 500 es processaria com si fos un èxit amb cos estrany. És l'error número u ambfetch.- Temps màxim explícit.
fetchno té temps d'espera per defecte: una petició pot quedar-se penjada indefinidament i amb ella el teu indicador de càrrega. - El senyal extern es propaga. Permet que qui crida pugui cancel·lar (apartat 10) a més del temporitzador intern.
- El cos de l'error es llegeix. Les API retornen informació útil al cos del 400: quin camp va fallar i per què. Descartar-lo obliga a mostrar «Error 400» a un usuari que no hi pot fer res.
llegirCosSegurembolcalla eljson()entry/catch, perquè un error de servidor pot retornar HTML en lloc de JSON, i llavors eljson()llança i tapa l'error original.- 204 retorna
null. Sense contingut significa sense contingut; cridarjson()sobre un cos buit llança. - Tot error acaba sent
ErrorDeApi. La capa superior gestiona un tipus, no cinc. Això simplifica moltíssim elcatchdels casos d'ús.
I ambReintents, amb retrocés exponencial i variació aleatòria:
export async function ambReintents(fn, { intents = 3, base = 300, senyal } = {}) {
for (let i = 0; i < intents; i++) {
try {
return await fn();
} catch (error) {
const ultim = i === intents - 1;
if (ultim || !(error instanceof ErrorDeApi) || !error.reintentable) throw error;
const espera = base * 2 ** i + Math.random() * 200; // 300, 600, 1200 ms + soroll
await dormir(espera, senyal);
}
}
}Només es reintenta el reintentable. Reintentar un 400 («falta el títol») és inútil: el resultat serà idèntic les tres vegades, i hauràs multiplicat per tres l'espera de l'usuari abans de mostrar-li un error que ja es coneixia al primer intent.
La variació aleatòria (jitter) evita que, si el servidor cau i torna, tots els clients reintentin al mateix mil·lisegon i el tombin una altra vegada. Amb un sol usuari tant és; és una d'aquelles coses que costen una línia i s'agraeixen quan n'hi ha mil.
- Els estats d'una operació asíncrona
Una operació de xarxa no té dos desenllaços, en té quatre estats, i la interfície ha de poder pintar-los tots:
stateDiagram-v2
[*] --> Inactiu
Inactiu --> Carregant: es llanca la peticio
Carregant --> Exit: 2xx
Carregant --> Error: 4xx, 5xx o xarxa
Carregant --> Cancellat: AbortController
Error --> Carregant: reintentar
Exit --> Carregant: recarregar
Cancellat --> Inactiu
| Estat | Què es mostra | Error típic si s'ignora |
|---|---|---|
| Inactiu | Res, o l'estat buit | — |
| Carregant | Esquelet o indicador + aria-busy="true" |
Doble enviament per impaciència |
| Èxit | Les dades, anunciades si canvien | — |
| Error | Missatge comprensible + acció («Reintentar») | Pantalla en blanc sense explicació |
| Cancel·lat | Es torna a l'estat anterior, sense error | Missatge d'error per alguna cosa que l'usuari ha cancel·lat |
Dos detalls d'implementació que marquen la diferència:
Deshabilita el disparador mentre carrega. Un botó «Desar» que continua sent clicable durant els dos segons de la petició produeix tres tasques idèntiques. És l'errada més freqüent de les aplicacions que parlen amb servidors.
Retarda l'indicador de càrrega uns 200 ms. Si la resposta arriba en 80 ms, un indicador que apareix i desapareix produeix un espurneig desagradable i contribueix al CLS. Mostrar-lo només si l'operació s'allarga és un detall petit amb efecte gran en la percepció de qualitat.
let temporitzadorCarrega = setTimeout(() => magatzem.actualitzar({ carregant: true }), 200);
try {
const dades = await repo.llistarTasques();
magatzem.actualitzar({ tasques: dades, carregant: false, error: null });
} finally {
clearTimeout(temporitzadorCarrega);
}Els esquelets davant dels indicadors giratoris. Un esquelet —blocs grisos amb la forma del contingut que arribarà— informa millor i, sobretot, reserva l'espai, evitant el salt de disseny que castiga el CLS del pressupost de 11-01. Un indicador giratori centrat no reserva res.
- Reintents, cancel·lació i
AbortController
AbortControllerLa cancel·lació és la part que més s'oblida, i produeix una errada molt concreta: la resposta obsoleta que trepitja la bona.
L'escenari, que passa sempre que hi ha un cercador:
t=0 ms L usuari escriu "ser" → peticio A
t=120 ms L usuari escriu "seri" → peticio B
t=400 ms Arriba la resposta de B → es pinten els resultats de "seri" ✅
t=650 ms Arriba la resposta de A → es pinten els resultats de "ser" ❌L'usuari veu resultats d'una cerca que ja no està escrita. I no és una errada rara: és el que passa per defecte quan les peticions triguen diferent.
La solució amb AbortController:
// src/aplicacio/casos-us.js
let abortadorCerca = null;
export async function cercar(magatzem, repo, text) {
abortadorCerca?.abort('cerca superada'); // cancella l anterior
abortadorCerca = new AbortController();
try {
const resultats = await repo.cercarTasques(text, { senyal: abortadorCerca.signal });
magatzem.actualitzar({ resultats, carregant: false });
} catch (error) {
if (error.name === 'AbortError' || error.causa?.name === 'AbortError') return; // esperat
magatzem.actualitzar({ error: aErrorInterficie(error), carregant: false });
}
}Tres regles de la cancel·lació:
- Una cancel·lació no és un error. S'ignora en silenci. Mostrar «Error: petició avortada» per alguna cosa que ha provocat el teu propi codi és desconcertant per a l'usuari.
- Cancel·la també en desmuntar. El
destruir()d'una vista ha d'avortar les seves peticions en vol. Si no, la resposta arriba a una vista que ja no existeix, intenta tocar un DOM desconnectat i deixa viva tota la seva cadena de referències: és una de les fuites de memòria que buscaràs a 11-04. - Cancel·lació i
debouncees combinen. Eldebouncede 09-02 redueix el nombre de peticions; la cancel·lació assegura que, de les que sí que surten, només importa l'última. Necessites totes dues.
- Sincronització: qui guanya quan dos editen alhora
Així que hi ha més d'un dispositiu, apareix el problema central de la sincronització: dues persones editen la mateixa tasca i cal decidir què preval.
Les tres estratègies, amb les seves contrapartides reals:
| Estratègia | Com funciona | Avantatges | Inconvenients | Quan triar-la |
|---|---|---|---|---|
| Última escriptura guanya | El servidor accepta l'últim que arriba | Trivial d'implementar; mai no bloqueja | Perd canvis en silenci; depèn de rellotges | Dades d'un sol amo; preferències |
Versió / updatedAt |
El client envia la versió que va llegir; el servidor rebutja si ha canviat (409) | No perd res sense avisar; detectable i explicable | Requereix resoldre el conflicte a la interfície | La recomanada per a Òrbita |
| Fusió per camps | Es combinen canvis de camps diferents | Molts conflictes desapareixen sols | Complexa; pot produir estats incoherents | Documents amb camps independents |
| CRDT / fusió automàtica | Estructures que convergeixen sense conflicte | Col·laboració real en temps real | Molt complexa; format de dades condicionat | Edició col·laborativa tipus document |
Per al teu projecte, la segona. És la que ofereix la millor relació entre cost i garantia, i s'implementa així:
// El client envia la versio que tenia
await demanarJson(`${base}/tasques/${id}`, {
method: 'PUT',
headers: { 'If-Match': tasca.versio }, // o al cos, si l API ho prefereix
body: JSON.stringify(tasca.toJSON())
});
// El servidor respon 409 Conflict si la seva versio es diferentI què fer amb el 409, que és la part que decideix la qualitat de l'aplicació:
| Opció | Experiència | Recomanació |
|---|---|---|
| Sobreescriure sense preguntar | Es perd la feina d'una altra persona | Mai |
| Descartar el meu sense preguntar | Es perd la meva feina | Mai |
| Recarregar i avisar | «Aquesta tasca ha canviat; s'han recarregat les dades» | Acceptable si el meu canvi era trivial |
| Mostrar totes dues versions i triar | «Tu has posat 12 h; la Marta ha posat 8 h» amb dos botons | La correcta |
Implementar la quarta costa una pantalla petita i és exactament el tipus de detall que a 11-06 podràs explicar en una entrevista: demostra que has pensat en el cas incòmode.
Sobre els rellotges. «Última escriptura guanya» compara marques de temps, i els rellotges dels clients no són fiables: poden estar desajustats hores. Si fas servir marques de temps per decidir, fes servir sempre les del servidor, mai les del client. És un detall que produeix errades impossibles de reproduir.
- La cua de canvis pendents
Treballar sense connexió —la promesa de la PWA de 07-05— exigeix que els canvis que no s'han pogut enviar no es perdin. L'estructura que ho resol és una cua persistent d'operacions.
// src/dades/cua.js
export class CuaDeCanvis {
#clau;
encuar(operacio) {
const entrada = {
id: crypto.randomUUID(), // clau d idempotencia (apartat 13)
tipus: operacio.tipus, // 'crear' | 'actualitzar' | 'esborrar'
recurs: operacio.recurs, // 'tasca' | 'usuari'
carregaUtil: operacio.carregaUtil,
creadaEl: new Date().toISOString(),
intents: 0,
ultimError: null
};
this.#persistir([...this.llistar(), entrada]);
return entrada.id;
}
llistar() { /* llegeix de localStorage o IndexedDB */ }
marcarIntent(id, error) { /* intents++, ultimError */ }
eliminar(id) { /* en confirmar-se */ }
get pendents() { return this.llistar().length; }
}El cicle de vida d'una operació en cua:
flowchart TD
A["L usuari actua"] --> B["S aplica en local<br/><i>optimista</i>"]
B --> C{"Hi ha connexio?"}
C -->|Si| D["Enviar al servidor"]
C -->|No| E["Encuar"]
D -->|2xx| F["Confirmar:<br/>eliminar de la cua"]
D -->|"4xx (no reintentable)"| G["Revertir + avisar<br/>+ treure de la cua"]
D -->|"5xx / xarxa"| E
E --> H["Esperar esdeveniment 'online'<br/>o reintent programat"]
H --> I["Buidar la cua<br/>en ordre"]
I --> D
style E fill:#fef3c7,stroke:#d97706
style G fill:#fee2e2,stroke:#b91c1c
style F fill:#dcfce7,stroke:#16a34a
Les cinc regles d'una cua que funciona:
- Ordre estricte. Les operacions es reenvien en l'ordre en què es van encuar. Si «crear tasca 7» i «actualitzar tasca 7» s'envien a l'inrevés, la segona falla amb 404.
- Persistent, no en memòria. Si viu en una variable, es perd en tancar la pestanya — justament quan més falta fa.
- Cada entrada amb la seva clau d'idempotència. Apartat següent.
- Límit d'intents. Després de 5 fallades, l'entrada passa a «necessita atenció» i es mostra a l'usuari. Una cua que reintenta eternament una operació impossible consumeix bateria i no avisa mai.
- Visible. L'usuari ha de poder veure quants canvis estan pendents i per què. Un indicador discret: «3 canvis sense sincronitzar».
El dispar del buidatge, amb tres fonts:
window.addEventListener('online', () => sincronitzador.buidar());
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') sincronitzador.buidar();
});
setInterval(() => { if (navigator.onLine) sincronitzador.buidar(); }, 60_000);Compte amb navigator.onLine: diu si hi ha interfície de xarxa, no si hi ha internet. Amb una wifi d'hotel sense autenticar, retorna true i les peticions fallen igual. Serveix com a pista per no intentar-ho quan clarament no hi ha xarxa, però la veritat la dona el fetch, no la propietat.
- Idempotència i reenviament segur
Una operació és idempotent si executar-la diverses vegades produeix el mateix resultat que executar-la una. És la propietat que fa que reenviar sigui segur, i sense ella la cua de l'apartat anterior és perillosa.
| Operació | Idempotent per naturalesa? | Risc en reenviar |
|---|---|---|
GET /tasques |
Sí | Cap |
PUT /tasques/7 amb l'objecte complet |
Sí | Cap |
DELETE /tasques/7 |
Sí (el segon dona 404, i tant és) | Cap |
POST /tasques |
No | Tasques duplicades |
PATCH /tasques/7 { hores: +2 } (increment) |
No | Hores sumades dues vegades |
L'escenari del duplicat és aquest, i passa més del que sembla:
1. El client envia POST /tasques
2. El servidor la crea correctament
3. La resposta es perd (xarxa caiguda justament llavors)
4. El client creu que ha fallat i reencua
5. En recuperar la xarxa, reenvia → SEGONA TASCA IDENTICALa solució: clau d'idempotència. El client genera un identificador únic per operació —no per reintent— i l'envia a cada intent:
await demanarJson(`${base}/tasques`, {
method: 'POST',
headers: { 'Idempotency-Key': entrada.id }, // el mateix als 5 reintents
body: JSON.stringify(entrada.carregaUtil)
});El servidor desa les claus ja processades i, si en veu una de repetida, retorna el resultat original en lloc de crear-la un altre cop. És el mecanisme que fan servir les passarel·les de pagament, per raons evidents.
I si el servidor no admet claus d'idempotència? Amb json-server o una altra API que no controles, hi ha dues mitigacions parcials:
- Que el client generi l'
id(un UUID) en lloc de deixar-ho al servidor. Llavors elPOSTés efectivament unPUTsobre un identificador conegut, i el segon enviament sobreescriu en comptes de duplicar. Trenca la R1 tal com estava escrita, així que és una decisió que cal anotar. - Comprovar abans de reenviar:
GETper un camp distintiu per veure si ja existeix. És més fràgil (hi ha una condició de cursa entre la comprovació i la creació) però millor que res.
I una regla de disseny d'API que convé conèixer: prefereix operacions absolutes a incrementals. PATCH { hores: 14 } és idempotent; PATCH { hores: '+2' } no ho és. La primera forma elimina un problema sencer en lloc de gestionar-lo.
- Actualització optimista amb reversió
Una actualització optimista aplica el canvi a la interfície abans que el servidor confirmi, suposant que funcionarà. Si falla, es reverteix.
| Enfocament | Percepció | Risc |
|---|---|---|
| Pessimista: esperar la resposta | 300–800 ms d'espera a cada acció | Cap, però se sent lent |
| Optimista: aplicar ja, revertir si falla | Instantani | Cal gestionar bé la reversió |
La diferència en sensació és enorme, i per això les aplicacions que se senten ràpides ho fan així. El patró:
export async function marcarFeta(magatzem, repo, id) {
const abans = magatzem.obtenir().tasques; // 1 · desar per revertir
const optimista = abans.map((t) => t.id === id ? t.ambEstat('feta') : t);
magatzem.actualitzar({ tasques: optimista }); // 2 · aplicar ja
anunciar('Tasca marcada com a feta');
try {
const confirmada = await repo.actualitzarTasca(id, { estat: 'feta' });
magatzem.actualitzar({ // 3 · substituir pel del servidor
tasques: magatzem.obtenir().tasques.map((t) => t.id === id ? confirmada : t)
});
} catch (error) {
magatzem.actualitzar({ tasques: abans, error: aErrorInterficie(error) }); // 4 · revertir
anunciar('No s ha pogut desar el canvi. S ha desfet.');
throw error;
}
}Les quatre regles de l'actualització optimista:
- Desa l'estat anterior complet abans de tocar res. És el que permet revertir amb exactitud.
- Substitueix pel que retorna el servidor, no deixis la teva versió optimista. El servidor pot haver afegit camps —un
updatedAt, unidreal— o haver normalitzat alguna cosa. - La reversió ha de ser visible i anunciada. Un canvi que es desfà en silenci fa que l'usuari es cregui que va desar una cosa que no va desar. És pitjor que no ser optimista.
- No siguis optimista amb tot. La taula:
| Operació | Optimista? | Per què |
|---|---|---|
| Canviar d'estat, marcar, reordenar | Sí | Reversible, de baix risc, molt freqüent |
| Editar un camp de text | Sí | Ídem |
| Crear una tasca | Amb compte | Sí, però amb id temporal marcat com a «pendent» |
| Esborrar | No, millor confirmar | Difícil de revertir de manera convincent; espanta si reapareix |
| Pagar, enviar, tancar definitivament | Mai | Les accions irreversibles es confirmen abans de mostrar-se fetes |
L'id temporal en crear mereix explicació. Si crees la tasca optimista amb id: -1 i després el servidor retorna id: 42, tot el que es fes amb la tasca mentrestant apuntaria a un id inexistent. Dues solucions: generar l'UUID al client (apartat 13), o marcar visualment la tasca com a «desant» i impedir accions sobre ella fins que es confirmi. La primera és més neta.
- Temps real sense duplicar canvis propis
Si el teu projecte incorpora temps real —el CanalTauler de 07-04, que estenia EventTarget amb reconnexió, retrocés i batec—, hi ha un problema molt concret que apareix sempre:
El servidor et reenvia el teu propi canvi, i l'apliques dues vegades.
El símptoma típic: crees una tasca, apareix, i mig segon després apareix un altre cop. O el comptador d'hores es dobla. Tres maneres de resoldre-ho, de pitjor a millor:
| Solució | Com | Valoració |
|---|---|---|
| Ignorar missatges durant N ms després d'actuar | Finestra temporal | Fràgil: depèn de la latència |
| Identificador de client | Cada missatge porta origenId; s'ignora si és el propi |
Simple i fiable |
| Reconciliació per id i versió | S'aplica sempre, però com a substitució idempotent | La més robusta |
La segona és suficient per a gairebé tot:
// src/dades/temps-real.js
const EL_MEU_ID = crypto.randomUUID(); // un per pestanya, en memoria
canal.addEventListener('tasca-actualitzada', (esdeveniment) => {
const { tasca, origenId } = esdeveniment.detail;
if (origenId === EL_MEU_ID) return; // es el meu propi eco
magatzem.actualitzar({ tasques: fusionarPerId(magatzem.obtenir().tasques, tasca) });
anunciar(`${tasca.titol} ha estat actualitzada per una altra persona`);
});I fusionarPerId implementa la tercera de propina: substitueix per id si existeix, afegeix si no, i ignora si la versió que arriba és més antiga que la que tens. Amb aquesta condició, aplicar el mateix missatge dues vegades és inofensiu, i l'ordre d'arribada deixa d'importar.
Tres regles del temps real que eviten problemes:
- El canal es destrueix en sortir. El
destruir()de 07-04 tanca el socket i treu les escoltes. Sense ell, navegar entre pantalles obre connexions que no es tanquen. - No confiïs en l'ordre d'arribada. Els missatges poden arribar desordenats. Per això la fusió ha de ser idempotent i basada en versió, no en «aplicar el que arribi».
- Anuncia els canvis aliens, no els propis. Que la interfície canviï sola sense explicació és desconcertant, especialment per a qui fa servir lector de pantalla.
aria-live="polite"amb «La Marta ha marcat Inventari de tintes com a feta».
- Seguretat i privacitat del que es desa
Aquest apartat no és opcional i convé llegir-lo sencer.
16.1 Què no ha d'estar mai al navegador
| No desis | Per què | Què fer en el seu lloc |
|---|---|---|
| Claus d'API i secrets | Tot el JavaScript del client és públic: es llegeix amb Veure Codi Font | El servidor desa la clau i exposa un punt d'accés propi |
| Contrasenyes, encara que estiguin «codificades» | Base64 no és xifratge; es descodifica en un segon | Mai no surten del servidor; s'envien i s'obliden |
Tokens de llarga durada a localStorage |
Qualsevol XSS els roba (apartat 16.2) | Cookie HttpOnly + Secure + SameSite, o token curt en memòria |
| Dades personals sensibles | Salut, ideologia, biometria… categoria especial del RGPD | No les tractis; i si el producte les exigeix, amb assessorament legal |
| Dades de tercers sense base legal | No són teves | Dades fictícies en desenvolupament, sempre |
El cas dels tokens mereix detall perquè és l'error més freqüent. Un token de sessió a localStorage és accessible des de qualsevol JavaScript de la pàgina. Si un atacant aconsegueix executar codi —una dependència compromesa, un XSS—, s'emporta la sessió completa. Una cookie HttpOnly no és accessible des de JavaScript, així que aquest vector desapareix. No és una diferència teòrica: és la diferència entre un XSS molest i un robatori de comptes.
16.2 XSS: per què importa més quan hi ha API
La lliçó 06-02 va establir la regla: textContent per a dades, innerHTML només per a literals teus. Amb dades que vénen d'una API, el risc es multiplica, perquè el contingut l'ha escrit una altra persona.
// ❌ Si el titol ve d una API i conte <img src=x onerror="...">, s executa
fila.innerHTML = `<h3>${tasca.titol}</h3>`;
// ✅ El text es text
fila.querySelector('h3').textContent = tasca.titol;La regla completa:
| Situació | Ús correcte |
|---|---|
| Text de dades | textContent |
| Atribut de dades | setAttribute amb valor validat |
| URL de dades | Validar l'esquema: només http: i https: |
| HTML enriquit d'usuari | Sanejar amb una llibreria mantinguda, mai a mà |
| Estructura teva, fixa | innerHTML amb literals, o <template> |
La validació d'URL és la que s'oblida: javascript:alert(1) en un camp d'enllaç s'executa en prémer. Comprova sempre l'esquema abans d'assignar un href.
I a 11-05 hi afegiràs la segona capa: una Content-Security-Policy que impedeix executar scripts en línia encara que se'n coli un.
16.3 Dades personals i RGPD
Així que la teva aplicació desa noms, correus electrònics, fotos o qualsevol dada que identifiqui una persona —fins i tot indirectament—, estàs tractant dades personals, i a la Unió Europea això està regulat pel RGPD.
El que sí que et puc dir amb seguretat, perquè són principis del reglament:
| Principi | Què significa al teu projecte |
|---|---|
| Minimització | Desa només el necessari. Necessites la data de naixement? Gairebé segur que no |
| Limitació de finalitat | Les dades recollides per a una cosa no es fan servir per a una altra |
| Limitació del termini | Defineix quant es conserven i esborra-les després |
| Integritat i confidencialitat | Xifratge en trànsit (HTTPS, 11-05) i control d'accés |
| Transparència | La persona ha de saber què deses, per què i durant quant de temps |
| Drets | Accés, rectificació, supressió i portabilitat de les seves dades |
I el que no et puc donar: assessorament legal.
Advertiment explícit. Un producte real que tracti dades de persones reals exigeix una revisió legal i de compliment normatiu: base legal del tractament, informació a la persona interessada, registre d'activitats, encarregats de tractament (qualsevol servei extern que facis servir), transferències internacionals, i en alguns casos avaluació d'impacte. Res d'això no es resol amb codi i res d'això no és matèria d'un curs de JavaScript. Mentre aprens, fes servir dades fictícies —com les de Taller Nómada— i no publiquis un producte amb dades reals sense haver-ho consultat amb qui correspongui.
El que sí que pots fer des d'ara, i és bona enginyeria a més de bona pràctica legal:
- Documenta a
docs/model-dades.mdquins camps són personals i per a què. - Implementa l'exportació de totes les dades d'una persona (et serveix a més com a còpia de seguretat).
- Implementa l'esborrat de debò, no un
actiu: falsedisfressat. Compte: l'esborrat real xoca amb l'historial immutable de R14. La solució habitual és anonimitzar les entrades d'historial —substituir el nom per «Usuari eliminat»— conservant la integritat del registre. És una decisió que mereix el seu ADR. - No registris dades personals al sistema d'errors, com ja es va dir a 11-02.
- Errades de sincronització i el seu tractament
Aquesta taula és el resum operatiu de tota la lliçó. Tingues-la a mà mentre implementes: cada fila és una errada que passarà.
| # | Errada | Símptoma | Causa | Tractament |
|---|---|---|---|---|
| 1 | Sense connexió en desar | L'acció no arriba al servidor | Xarxa caiguda | Encuar + aplicar en local + indicador de pendents |
| 2 | Servidor caigut (5xx) | Error després d'esperar | Fallada temporal | Reintentar amb retrocés; després de N, encuar |
| 3 | Petició penjada | Indicador etern | Sense temps màxim | AbortController amb temps màxim (apartat 8) |
| 4 | Resposta obsoleta | Dades d'una cerca anterior | Cursa entre peticions | Cancel·lar l'anterior abans de llançar |
| 5 | Conflicte d'edició (409) | El canvi es rebutja | Una altra persona va editar abans | Mostrar totes dues versions i deixar triar |
| 6 | Duplicat en reenviar | Dues tasques idèntiques | POST no idempotent |
Clau d'idempotència o id generat al client |
| 7 | Eco del temps real | El canvi propi apareix dues vegades | El servidor reenvia a tothom | origenId + fusió per id i versió |
| 8 | Quota plena | QuotaExceededError |
Historial crescut | Podar, avisar, i si no hi cap, missatge accionable |
| 9 | Dades de versió futura | No es pot obrir | Un altre dispositiu es va actualitzar abans | Rebutjar amb missatge clar; no endevinar |
| 10 | Migració que corromp | Dades estranyes després d'actualitzar | Migració amb una errada | Còpia de seguretat prèvia + validació contra el domini |
| 11 | JSON corrupte | SyntaxError en arrencar |
Escriptura interrompuda | try/catch en analitzar + arrencar des de la còpia |
| 12 | Rellotge del client desajustat | Ordre de canvis equivocat | Hora local errònia | Fer servir sempre la marca de temps del servidor |
| 13 | Cua encallada | Res no se sincronitza mai | Una operació impossible bloqueja l'ordre | Límit d'intents + entrada «necessita atenció» |
| 14 | Dues pestanyes del mateix usuari | Es trepitgen les dades locals | Totes dues escriuen a la mateixa clau | storage event o BroadcastChannel per coordinar |
El cas 14 és el que més sorprèn qui l'ensopega per primera vegada. Dues pestanyes obertes amb la mateixa aplicació escriuen al mateix localStorage sense saber-ho. L'esdeveniment storage avisa les altres pestanyes quan una escriu:
window.addEventListener('storage', (e) => {
if (e.key !== CLAU) return;
magatzem.actualitzar({ ...llegirDocument(), avisAltraPestanya: true });
});Amb cinc línies, les dues pestanyes es mantenen coherents. Sense elles, l'última que desa trepitja la feina de l'altra.
Errors Habituals i Consells
No versionar el format desat. És l'error que més dades destrueix. Sense versio al document, el dia que afegeixis un camp obligatori tindràs dos formats indistingibles convivint, i cap manera neta de saber quin és quin. Posar versio: 1 des del primer dia costa una línia.
Migrar sense còpia de seguretat. Una migració amb una errada destrueix dades de manera irreversible. Desar l'original sota una altra clau abans de tocar-lo costa una línia i converteix el desastre en un incident.
Migracions que no es proven amb dades reals. Provar la migració amb un objecte que has escrit a mà per a la prova demostra poc: les dades reals tenen camps inesperats, null on no els esperaves i estructures de versions intermèdies. Desa documents reals com a fitxers de prova.
Suposar que fetch llança amb un error HTTP. No ho fa. Sense if (!resposta.ok), un 500 es processa com a èxit i l'errada apareix tres capes més amunt amb un missatge incomprensible.
No posar temps màxim a les peticions. fetch espera indefinidament. Amb una xarxa dolenta, l'indicador de càrrega es queda girant per sempre i l'usuari no té sortida.
Reintentar el que no s'ha de reintentar. Un 400 donarà 400 les tres vegades. Reintentar-ho només triplica l'espera abans del mateix error. Només es reintenta el reintentable: xarxa, 408, 429 i 5xx.
No cancel·lar peticions obsoletes. Produeix l'errada més desconcertant de totes: resultats d'una cerca anterior que trepitgen els bons, de manera intermitent i depenent de la latència. Pràcticament impossible de reproduir a propòsit si no saps que existeix.
POST a la cua sense idempotència. Reenviar una creació després d'una fallada de xarxa duplica el registre. És l'errada que produeix «tinc la mateixa tasca tres vegades» i que l'usuari no sap explicar mai.
Revertir en silenci. Si una actualització optimista falla i desfàs sense avisar, l'usuari es creu que va desar una cosa que no es va desar. És pitjor que no haver estat optimista.
Desar tokens a localStorage. Qualsevol XSS s'emporta la sessió completa. Cookie HttpOnly o token curt en memòria.
Posar dades personals al registre d'errors. És un problema legal, no només d'estil, i s'agreuja així que aquest registre s'envia a un servei extern (11-05). Registra identificadors i noms de camp, mai valors.
Consell · Mesura la mida de les teves dades abans de triar emmagatzematge. Trenta segons de JSON.stringify amb dades realistes eviten tant la ingenuïtat de localStorage amb 20 MB com la sobreenginyeria d'IndexedDB amb 200 kB.
Consell · Prova amb la xarxa estrangulada i desconnectada. El panell Network de DevTools té mode Offline i perfils lents. La meitat de les errades d'aquesta lliçó només apareixen allà. Fes-ho part de la teva rutina de tancament d'increment.
Consell · Implementa exportar i importar aviat. Un botó que descarrega totes les dades en JSON et serveix de còpia de seguretat manual, d'eina de depuració, de manera de moure dades entre dispositius sense servidor, i de compliment del dret de portabilitat. Quatre beneficis per una tarda de feina.
Consell · Deixa un plafó de diagnòstic amagat. Una pantalla amb la versió del format, la mida ocupada, les entrades de la cua i els últims errors registrats. T'estalviarà hores quan alguna cosa falli en un dispositiu que no és el teu.
Exercicis
Aquests exercicis són la fita H4 del teu projecte: persistència amb migracions, la capa d'API amb els seus estats, i la cua offline.
Exercici 1 — Persistència amb migracions provades.
- Implementa
RepositoriLocalcomplint el contracte complet, amb el document embolcallat (versio,desatEl,aplicacio,dades). - Executa la bateria de proves de contracte contra ell i contra
RepositoriMemoria; totes dues han de passar exactament les mateixes proves. - Implementa
migracions.jsamb almenys tres migracions reals del teu projecte (no inventades: canvis que de debò hagis fet o faràs al model). - Desa documents de prova reals de cada versió antiga a
test/dades/fixtures/, incloent-hi un de buit i un amb una dada inesperada. - Escriu les sis proves de migració de l'apartat 6.1: no perd dades, mapeja correctament, tolera dades desconegudes, produeix documents vàlids per al domini, és idempotent, i rebutja versions futures.
- Implementa la còpia de seguretat prèvia, la gestió de
QuotaExceededErroramb poda de l'historial i avís, i la detecció d'emmagatzematge no disponible amb degradació a memòria. - Implementa exportar i importar totes les dades en JSON, amb validació en importar.
Exercici 2 — La capa d'API amb els seus estats.
Munta un servidor de proves local (json-server o equivalent) i:
- Implementa
http.jsambErrorDeApi(ambstatus,codi,reintentable),demanarJsonamb temps màxim iambReintentsamb retrocés exponencial i variació aleatòria. - Implementa
RepositoriApicomplint el contracte i passant la mateixa bateria de proves que els altres dos. - Implementa els cinc estats a la interfície: inactiu, carregant (amb esquelet,
aria-busyi retard de 200 ms), èxit, error (amb acció de reintent) i cancel·lat. - Implementa la cancel·lació de la cerca amb
AbortController, combinada ambdebounce, i demostra amb una prova que una resposta obsoleta no trepitja la bona. - Implementa la gestió del 409 amb la pantalla de resolució que mostra totes dues versions.
- Escriu proves amb
fetchsimulat (08-04) per a: 200, 400 amb cos útil, 500 amb reintent reeixit al segon intent, temps esgotat, i cancel·lació.
Exercici 3 — La cua offline i l'actualització optimista.
- Implementa
CuaDeCanvispersistent, ambidd'idempotència, comptador d'intents, ordre estricte i límit de reintents. - Implementa
RepositoriSincronitzatque compleix el mateix contracte combinant local + API + cua. - Implementa el buidatge disparat per
online,visibilitychangei temporitzador, amb l'advertiment denavigator.onLine. - Implementa l'actualització optimista amb reversió en almenys tres operacions, amb anunci en cas de reversió, i respecta la taula de què no ha de ser optimista.
- Implementa l'indicador de «N canvis sense sincronitzar» accessible i una vista de la cua amb les entrades que necessiten atenció.
- Implementa la coordinació entre pestanyes amb l'esdeveniment
storageoBroadcastChannel. - Escriu proves amb temporitzadors falsos per a: encuar sense xarxa, buidar en recuperar-la, ordre preservat, no duplicar en reenviar, i reversió en fallar.
- Demostra-ho a mà: amb DevTools en mode
Offline, fes cinc canvis, torna a connectar i comprova que els cinc arriben en ordre i sense duplicats. Grava un GIF: et servirà per a la demo de 11-06.
Solucions
Criteris d'acceptació de l'exercici 1 — Persistència
| # | Criteri | Com es comprova |
|---|---|---|
| 1 | Les dades sobreviuen a la recàrrega | Crear una tasca, F5, hi continua |
| 2 | El document porta versio |
Inspeccionar localStorage a DevTools |
| 3 | Un document v1 s'obre sense perdre res | Enganxar un v1 real i comprovar el nombre de tasques |
| 4 | Es fa còpia de seguretat abans de migrar | Existeix orbita:tauler:copia:v1 després de migrar |
| 5 | Migrar és idempotent | migrar(migrar(x)) és igual a migrar(x) |
| 6 | El migrat és vàlid per al domini | Tasca.desDeJSON no llança amb cap element |
| 7 | Una versió futura es rebutja amb missatge | Posar versio: 99 i veure l'avís |
| 8 | Un JSON corrupte no impedeix arrencar | Escriure {{{ a la clau; l'aplicació arrenca i avisa |
| 9 | Quota plena poda i avisa | Omplir localStorage a propòsit i observar |
| 10 | Sense emmagatzematge, funciona en memòria i avisa | Simular la fallada de setItem |
| 11 | Contracte: memòria i local passen el mateix | La mateixa funció de proves, dues crides |
| 12 | Exportar produeix un JSON reimportable | Exportar, esborrar-ho tot, importar, comparar |
Rúbrica de l'exercici 1 (21 punts)
| Dimensió | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| Versionat | Sense versió | Camp present | Document embolcallat complet | A més amb aplicacio i comprovació |
| Migracions | Cap | Una, sense provar | ≥ 3 provades | Amb dades reals i validació contra el domini |
| Robustesa | Sense try/catch |
Captura genèrica | Quota, corrupció i no disponible tractats | A més amb degradació i missatges accionables |
| Còpia de seguretat | No n'hi ha | Manual | Automàtica abans de migrar | A més recuperable des de la interfície |
| Contracte | Només una implementació | Dues sense proves comunes | Proves comunes | Idèntiques i en verd per a les tres |
| Exportar/importar | No | Exporta | Exporta i importa | Amb validació i missatges d'error per fila |
| Proves | < 5 | 5–9 | ≥ 10 incloses les 6 de migració | A més amb casos límit reals |
Llindar: 15/21, amb obligatòriament 3 a «Migracions». És la part que destrueix dades si està malament.
Criteris d'acceptació de l'exercici 2 — API
| # | Criteri | Com es comprova |
|---|---|---|
| 1 | Un 500 es tracta com a error | Simular i comprovar que no es processa com a èxit |
| 2 | Un 400 mostra el missatge del servidor | El cos de l'error arriba a la interfície |
| 3 | Una petició penjada es talla | Retardar 30 s; als 8 s hi ha error de temps esgotat |
| 4 | Un 5xx es reintenta, un 400 no | Comptar les crides al fetch simulat |
| 5 | El retrocés és exponencial amb soroll | Comprovar els temps amb temporitzadors falsos |
| 6 | L'indicador triga 200 ms a aparèixer | Resposta de 80 ms: no espurneja |
| 7 | El disparador es deshabilita en carregar | Doble clic ràpid produeix una petició |
| 8 | La resposta obsoleta no trepitja | Prova amb dues respostes desordenades |
| 9 | Cancel·lar no mostra error | Cap alerta en avortar |
| 10 | El 409 ofereix triar versió | Pantalla amb tots dos valors i dues accions |
| 11 | Contracte: l'API passa les mateixes proves | Bateria comuna en verd |
| 12 | Cap secret al client | Cercar claus a dist/ després de compilar: zero resultats |
Criteris d'acceptació de l'exercici 3 — Cua i optimisme
| # | Criteri | Com es comprova |
|---|---|---|
| 1 | Sense xarxa, l'acció s'aplica i s'encua | Mode Offline + inspeccionar la cua |
| 2 | La cua sobreviu al tancament de la pestanya | Tancar i reobrir; les entrades hi continuen |
| 3 | En recuperar la xarxa es buida sola | Tornar a Online sense recarregar |
| 4 | L'ordre es preserva | Crear i després actualitzar: arriben en aquest ordre |
| 5 | No hi ha duplicats | Tallar la xarxa després d'enviar i abans de rebre; en reenviar, una sola tasca |
| 6 | Després de N intents es marca «necessita atenció» | Forçar un 400 permanent |
| 7 | La reversió es veu i s'anuncia | Forçar la fallada; el canvi es desfà amb avís |
| 8 | Esborrar no és optimista | Es confirma abans |
| 9 | L'indicador de pendents és accessible | Text, no només icona; anunciat en canviar |
| 10 | Dues pestanyes es mantenen coherents | Canviar en una, veure l'efecte a l'altra |
| 11 | L'eco de temps real no duplica | Si ho implementes: crear i observar una sola targeta |
| 12 | La demostració manual funciona | El GIF de 5 canvis offline |
Rúbrica global de la fita H4 (24 punts)
| Dimensió | Pes | Què s'avalua |
|---|---|---|
| Persistència i migracions | 6 | Versionat, cadena de migracions, còpia de seguretat, proves amb dades reals |
| Robustesa de xarxa | 5 | Errors tipats, temps màxim, reintents selectius, cancel·lació |
| Estats d'interfície | 4 | Els cinc estats, sense espurneig, sense doble enviament, accessibles |
| Cua i sense connexió | 5 | Persistència, ordre, idempotència, límit d'intents, visibilitat |
| Conflictes | 2 | Detecció i resolució amb participació de l'usuari |
| Seguretat i privacitat | 2 | Sense secrets, sense innerHTML amb dades, registre sense dades personals |
Llindar: 17/24. Un 0 a «Seguretat i privacitat» invalida la fita independentment de la resta: un producte que filtra una clau d'API o que executa l'HTML que li envien no està acabat, per bé que funcioni tota la resta.
Autoavaluació de la fita H4:
| Pregunta | Sí / No |
|---|---|
Podria canviar de localStorage a IndexedDB tocant només un fitxer? |
|
| Sé què passa si un usuari obre dades d'una versió antiga? I d'una de futura? | |
| He provat la meva aplicació amb la xarxa desconnectada? | |
| Hi ha alguna clau, token o secret al meu codi de client? | |
Faig servir innerHTML amb alguna dada que no hagi escrit jo? |
|
| El meu registre d'errors conté alguna dada personal? | |
| Puc exportar totes les meves dades i tornar-les a importar? |
Les preguntes 4, 5 i 6 són les que cal respondre «no». Si alguna és «sí», arregla-la abans de passar a la lliçó següent: a 11-05 aquesta aplicació estarà publicada a internet.
Conclusió
Has convertit una aplicació que ho perdia tot en recarregar en un producte les dades del qual sobreviuen, es comparteixen i es recuperen.
Saps triar l'emmagatzematge amb un arbre de decisió que comença per una pregunta honesta —«quant ocupa en el pitjor cas?»— la resposta correcta de la qual al principi és «no ho sé» i la sortida de la qual no és endevinar sinó mesurar amb dades realistes: trenta segons de JSON.stringify que eviten tant la ingenuïtat com la sobreenginyeria. Coneixes els cinc límits de localStorage —quota, sincronia, només text, tot o res, sense consultes—, saps que el perillós és la quota perquè falla al dispositiu d'una altra persona, i saps tractar-lo podant el prescindible, avisant de la poda i donant un missatge accionable quan ja no hi cap. I coneixes IndexedDB a nivell pràctic: transaccions que es tanquen soles si hi poses un await aliè, esquema que només canvia a onupgradeneeded, onblocked per a les dues pestanyes, i un embolcall amb promeses que s'escriu una vegada.
Tens el que separa un projecte seriós d'un de joguina: el format desat versionat en un sobre amb versio, desatEl i aplicacio, i una cadena de migracions numerades que són funcions pures, s'apliquen en seqüència, rebutgen les versions futures amb un missatge clar, fan servir valors per defecte conservadors i prefereixen perdre una assignació abans que impedir obrir l'aplicació. Amb les sis proves que les avalen —no perd dades, mapeja bé, tolera el desconegut, produeix documents vàlids per al domini, és idempotent i rebutja el futur— i amb documents reals desats com a fitxers de prova, perquè les dades de debò tenen camps que tu no hauries escrit a mà. I amb la còpia de seguretat prèvia, que costa una línia i converteix un desastre irreversible en un incident investigable.
Has cobrat la inversió del contracte del repositori: la mateixa bateria de proves executada contra memòria, local i API demostra que són intercanviables, i canviar d'emmagatzematge és canviar una línia a main.js. És la injecció de dependències de 08-04 mirada des de l'altre costat: el que va fer possible provar amb dobles fa possible canviar de tecnologia.
Saps parlar amb una API amb els set punts de demanarJson: comprovar resposta.ok perquè fetch no llança amb un 500, posar temps màxim perquè no en té, propagar el senyal extern, llegir el cos de l'error perquè allà hi ha la informació útil, embolcallar el json() perquè un 500 pot retornar HTML, tractar el 204, i unificar-ho tot a ErrorDeApi. Amb reintents només del reintentable, retrocés exponencial i variació aleatòria. I saps que una operació asíncrona té cinc estats i no dos, que l'indicador s'ha de retardar 200 ms per no espurnejar, que el disparador es deshabilita mentre carrega, i que un esquelet reserva l'espai que un indicador giratori no reserva.
Saps cancel·lar, que és el que evita l'errada més desconcertant de totes: la resposta obsoleta que trepitja la bona. Amb les tres regles —una cancel·lació no és un error, es cancel·la també en desmuntar, i debounce i cancel·lació es combinen perquè resolen coses diferents.
Saps decidir qui guanya quan dues persones editen: la taula de quatre estratègies, la recomanació de versió amb 409, i sobretot què fer amb aquest 409 — ni sobreescriure ni descartar en silenci, sinó mostrar totes dues versions i deixar triar, que és el detall que demostra que has pensat en el cas incòmode. Amb l'advertiment sobre els rellotges del client, que no són fiables mai.
Tens la cua de canvis pendents amb les seves cinc regles —ordre estricte, persistent, amb clau d'idempotència, amb límit d'intents i visible—, els seus tres disparadors de buidatge, i l'advertiment sobre navigator.onLine, que diu si hi ha interfície de xarxa i no si hi ha internet. Saps què és la idempotència, per què POST és l'únic verb perillós, com la clau d'idempotència evita el duplicat que produeix «tinc la mateixa tasca tres vegades», i per què convé preferir operacions absolutes a incrementals.
Saps fer que l'aplicació sembli instantània amb actualitzacions optimistes: desar l'estat anterior, aplicar ja, substituir pel que retorna el servidor i revertir amb avís si falla — perquè una reversió silenciosa és pitjor que no haver estat optimista. I saps amb què no ser optimista: esborrar, pagar, i tot el que és irreversible. I saps integrar el temps real sense duplicar els teus propis canvis, amb origenId i una fusió per id i versió que fa inofensiu aplicar el mateix missatge dues vegades.
I saps el que no pot estar al navegador: claus, contrasenyes, tokens de llarga durada a localStorage que qualsevol XSS s'emporta, i dades personals sense base legal. Saps que textContent per a dades i innerHTML només per al que és teu importa molt més quan el contingut ve d'una API, que les URL cal validar-les per esquema, i que els principis del RGPD —minimització, finalitat, termini, transparència, drets— es tradueixen en decisions de modelatge concretes. Amb l'advertiment sense embuts: un producte real amb dades de persones reals exigeix revisió legal i de compliment, i això no es resol amb codi.
Tanques amb la taula de les catorze errades de sincronització que passaran, inclosa la de les dues pestanyes del mateix usuari que es trepitgen i que es resol amb cinc línies i l'esdeveniment storage.
La fita H4 està tancada: persistència amb migracions, capa d'API amb els seus estats i cua offline. El teu projecte ja fa tot el que promet. La pregunta que queda és la incòmoda: com saps que ho continua fent demà? Perquè ara hi ha migracions que poden corrompre dades, curses que només apareixen amb mala xarxa i vistes que poden retenir memòria en navegar — tres errades que no es veuen mirant la pantalla. Convertir la qualitat en alguna cosa automàtica i verificable, i depurar aquestes tres errades concretes amb mètode, és Proves i Depuració del Projecte.
Curs de JavaScript: De Principiant a Avançat
Mòdul 1: Introducció a JavaScript
- Què és JavaScript?
- Configuració del teu Entorn de Desenvolupament
- El teu Primer Programa en JavaScript
- Sintaxi i Conceptes Bàsics de JavaScript
- Variables i Tipus de Dades
- Operadors Bàsics
- Conversió de Tipus i Comparacions
- El Projecte del Curs: Nómada Tasques
Mòdul 2: Estructures de Control
- Sentències Condicionals
- Bucles: for, while, do-while
- Sentències Switch
- Control del Flux: break, continue i Bucles Imbricats
- Gestió d'Errors amb try-catch
Mòdul 3: Funcions
- Definició i Crida de Funcions
- Expressions de Funció i Funcions Fletxa
- Paràmetres i Valors de Retorn
- Àmbit i Closures
- Hoisting i el Context d'Execució
- Funcions d'Ordre Superior
- Recursivitat
Mòdul 4: Objectes i Arrays
- Introducció als Objectes
- Mètodes d'Objecte i la Paraula Clau
this - Arrays: Conceptes Bàsics i Mètodes
- Iteració sobre Arrays
- Cercar, Ordenar i Agregar Dades: find, sort i reduce
- Desestructuració d'Arrays
- Desestructuració d'Objectes, Spread i Rest
- JSON i Còpies d'Objectes
Mòdul 5: Objectes i Funcions Avançades
- Prototips i Herència
- Classes i Programació Orientada a Objectes
- Encapsulació: Getters, Setters i Camps Privats
- Mòduls i Importació/Exportació
- JavaScript Asíncron: Callbacks
- Promeses i Async/Await
- El Bucle d'Esdeveniments i la Cua de Microtasques
- Iteradors i Generadors
Mòdul 6: El Model d'Objectes del Document (DOM)
- Introducció al DOM
- Selecció i Manipulació d'Elements del DOM
- Gestió d'Esdeveniments
- Propagació, Delegació i Esdeveniments Personalitzats
- Creació i Eliminació d'Elements del DOM
- Renderitzat de Llistes i Plantilles HTML
- Gestió i Validació de Formularis
Mòdul 7: APIs del Navegador i Temes Avançats
- Emmagatzematge Local i de Sessió
- Fetch API i AJAX
- Peticions Robustes: Errors, Timeouts i AbortController
- WebSockets
- Service Workers i Aplicacions Web Progressives (PWAs)
- APIs del Navegador Essencials
- Introducció a WebAssembly
Mòdul 8: Proves i Depuració
- Depuració de JavaScript
- Qualitat de Codi: ESLint, Prettier i Convencions
- Proves Unitàries amb Jest
- Dobles de Prova: Mocks, Stubs i Spies
- Proves d'Integració
- Proves d'Extrem a Extrem amb Cypress
Mòdul 9: Rendiment i Optimització
- Mesurar Abans d'Optimitzar: DevTools i Web Vitals
- Optimització del Rendiment de JavaScript
- Gestió de Memòria
- Manipulació Eficient del DOM
- Càrrega Diferida i Divisió de Codi
Mòdul 10: Frameworks i Llibreries de JavaScript
- Per Què Existeixen els Frameworks
- Introducció a React
- Gestió d'Estat amb Redux
- Conceptes Bàsics de Vue.js
- Conceptes Bàsics d'Angular
- Triar el Framework Adequat
