La lliçó anterior va acabar amb una llista incòmoda: el wifi del taller que cau a mitja POST, l'API que triga quinze segons, el 503 d'un desplegament, les vuit peticions que dispara l'Iván en teclejar al cercador i que tornen desordenades. js/dades/api-tasques.js funciona perfectament mentre tot va bé, i a la xarxa res va bé tota l'estona. Aquesta lliçó és la que converteix una demostració en una aplicació: aprendràs a classificar les fallades i decidir què fer amb cadascuna, a encapsular-les en un ErrorDeApi propi, a cancel·lar peticions amb AbortController —tancant el deute que vas deixar pendent a 06-04—, a imposar timeouts, a reintentar només el que és segur reintentar, a llançar peticions en paral·lel sense que una sola caiguda ho tombi tot, i a modelar els quatre estats que tota pantalla que parla amb una xarxa ha de saber mostrar. Acabaràs amb js/dades/http.js i amb una TaulerVista que sap dir «carregant», «això ha fallat, reintenta» i «aquí no hi ha res».

Contingut

  1. Les set fallades d'una petició
  2. Un error propi: ErrorDeApi
  3. demanarJson: l'embolcall definitiu
  4. AbortController a fons
  5. Cancel·lar la petició anterior: el cercador
  6. Timeouts: Promise.race davant d'AbortSignal.timeout()
  7. AbortSignal.any(): combinar senyals
  8. Reintents amb retrocés exponencial i jitter
  9. Idempotència: què es pot reintentar
  10. Paral·lel sense fragilitat: allSettled davant d'all
  11. Els quatre estats de la interfície
  12. Avisos accessibles amb aria-live
  13. Optimistic UI: aplicar abans de confirmar
  14. Nómada Tasques: js/dades/http.js i la vista
  15. Errors Habituals i Consells
  16. Exercicis
  17. Conclusió

  1. Les set fallades d'una petició

Abans d'escriure una línia, convé tenir el mapa complet. No totes les fallades mereixen el mateix tractament, i tractar-les igual produeix interfícies que menteixen («Error desconegut») o que insisteixen quan no han de fer-ho.

Fallada Què ha passat Com es detecta Reintentar? Què dir a l'usuari
Xarxa caiguda Sense connexió, wifi perdut fetch rebutja amb TypeError , amb retrocés «Sense connexió. Reintentant…»
DNS / host inabastable El domini no resol fetch rebutja amb TypeError Sí, poques vegades «No es pot contactar amb el servidor»
CORS bloquejat El servidor no autoritza el teu origen fetch rebutja amb TypeError; el motiu, només a la consola No «Error de configuració» + avisar desenvolupament
4xx del client Dades malament, sense permís, no existeix resposta.ok === false, status 400-499 No (llevat de 408 i 429) El missatge concret: «Falten hores», «Sessió caducada»
5xx del servidor El servidor ha fallat resposta.ok === false, status 500-599 «El servidor no respon. Reintentant…»
Resposta no-JSON Ha arribat HTML d'un proxy o d'un login json() llança SyntaxError No «Resposta inesperada del servidor»
Resposta lenta El servidor triga massa Res… fins que hi poses un timeout Sí, una vegada «Està trigant més del normal»

Tres lectures d'aquesta taula:

  • Només dues files fan rebutjar la promesa de fetch: les fallades de transport (xarxa, DNS, CORS, cancel·lació). Tota la resta arriba com a resposta «normal» i cal mirar-la, tal com vas aprendre a 07-02.
  • La frontera 4xx / 5xx decideix si cal reintentar. Insistir davant d'un 400 és garantia de tornar a fallar; insistir davant d'un 503 sol funcionar.
  • La resposta lenta no es detecta sola. És l'única fallada que tu has de provocar, posant-hi un límit. Sense timeout, una petició es pot quedar penjada indefinidament i la teva interfície mostrarà «Carregant…» per sempre.

Hi ha dos casos de 4xx que sí que es reintenten, i convé recordar-los: 408 Request Timeout i 429 Too Many Requests. El 429 a més sol portar una capçalera Retry-After que diu quants segons esperar; respectar-la és de bona educació i evita que et bloquegin.

  1. Un error propi: ErrorDeApi

A 05-02 vas crear ErrorDeValidacio extends Error amb un camp camp, i a 06-07 aquell camp va permetre a la vista saber a quin <input> moure el focus. Aquí es repeteix exactament la mateixa jugada: un error que transporta la informació que la vista necessitarà per decidir què fer.

// js/model/errors.js — s'afegeix als que ja existien
export class ErrorDeApi extends Error {
  /**
   * @param {string} missatge  Text llegible, apte per mostrar
   * @param {object} dades     { status, codi, url, detall, causa }
   */
  constructor(missatge, { status = 0, codi = 'desconegut', url = '', detall = null, causa = null } = {}) {
    super(missatge);
    this.name = 'ErrorDeApi';
    this.status = status;         // 0 si ni tan sols hi va haver resposta HTTP
    this.codi = codi;             // 'xarxa' | 'timeout' | 'cancel·lat' | 'client' | 'servidor' | 'format'
    this.url = url;
    this.detall = detall;         // el cos de l'error, si el servidor n'ha enviat un
    this.causa = causa;           // l'error original, per depurar
  }

  /** Val la pena tornar-ho a intentar? */
  get reintentable() {
    if (this.codi === 'xarxa' || this.codi === 'timeout' || this.codi === 'servidor') return true;
    return this.status === 408 || this.status === 429;
  }

  /** Cal demanar a l'usuari que iniciï sessió una altra vegada? */
  get requereixSessio() {
    return this.status === 401;
  }

  descriure() {
    return `[${this.name} ${this.status} ${this.codi}] ${this.message}`;
  }
}

El que fa valuós aquest error no és que sigui una classe: és que el coneixement sobre reintents viu en un sol lloc. La vista no ha de saber que un 429 es reintenta i un 403 no; pregunta error.reintentable i ja està. És el mateix principi de 06-07: les regles viuen on toca, i qui les consumeix només pregunta.

Un apunt d'ES2022 que encaixa aquí: Error accepta una opció cause estàndard, new Error('…', { cause: original }). Nosaltres fem servir un camp propi causa per coherència amb ErrorDeDades, però conèixer la forma estàndard t'evitarà confusions en llegir codi aliè.

  1. demanarJson: l'embolcall definitiu

Ara la funció central de tota la capa de xarxa. Escrita una vegada, feta servir per tots els mètodes de l'API.

// js/dades/http.js
import { ErrorDeApi } from '../model/errors.js';

/**
 * Fa una petició i retorna el JSON, o llança SEMPRE un ErrorDeApi classificat.
 * Mai deixa escapar un TypeError ni un SyntaxError sense traduir.
 */
export async function demanarJson(url, opcions = {}) {
  let resposta;

  // ── 1 · Fallades de transport: les úniques que fan rebutjar fetch ──
  try {
    resposta = await fetch(url, opcions);
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new ErrorDeApi('Petició cancel·lada.', { codi: 'cancel·lat', url, causa: error });
    }
    if (error.name === 'TimeoutError') {
      throw new ErrorDeApi('El servidor ha trigat massa.', { codi: 'timeout', url, causa: error });
    }
    // TypeError: xarxa caiguda, DNS, CORS. El navegador no distingeix quin per no filtrar informació.
    throw new ErrorDeApi("No s'ha pogut contactar amb el servidor.", { codi: 'xarxa', url, causa: error });
  }

  // ── 2 · Respostes d'error: 4xx i 5xx ──
  if (!resposta.ok) {
    const detall = await llegirDetall(resposta);
    const codi = resposta.status >= 500 ? 'servidor' : 'client';
    const missatge = detall?.missatge ?? detall?.message ?? `Error ${resposta.status} del servidor.`;
    throw new ErrorDeApi(missatge, { status: resposta.status, codi, url: String(url), detall });
  }

  // ── 3 · Èxit sense cos ──
  if (resposta.status === 204 || resposta.headers.get('content-length') === '0') return null;

  // ── 4 · Èxit amb cos: és realment JSON? ──
  const tipus = resposta.headers.get('content-type') ?? '';
  if (!tipus.includes('json')) {
    const text = await resposta.text().catch(() => '');
    throw new ErrorDeApi(`S'esperava JSON i ha arribat "${tipus}".`, {
      status: resposta.status, codi: 'format', url: String(url), detall: text.slice(0, 200)
    });
  }

  try {
    return await resposta.json();
  } catch (error) {
    throw new ErrorDeApi('La resposta no és JSON vàlid.', {
      status: resposta.status, codi: 'format', url: String(url), causa: error
    });
  }
}

/** Intenta llegir el cos de l'error com a JSON; si no pot, com a text; si no, null. */
async function llegirDetall(resposta) {
  const copia = resposta.clone();                  // ← clonar ABANS de llegir (07-02)
  try {
    return await resposta.json();
  } catch {
    return await copia.text().catch(() => null);
  }
}

Atura't en quatre punts:

  • La funció té un únic tipus de sortida d'error. Qui la cridi només necessita conèixer ErrorDeApi. Res de comprovar si és un TypeError, un SyntaxError o un objecte Response.
  • El clone() de l'apartat llegirDetall és imprescindible: si json() falla a mitges, el flux del cos ja està consumit i text() no el podria llegir.
  • Es comprova el content-type. Aquest és el que salva dels diagnòstics impossibles: un proxy corporatiu que retorna la seva pàgina d'inici de sessió amb estat 200, i un json() que peta amb un SyntaxError: Unexpected token '<'. Amb aquesta comprovació, el missatge diu exactament què va passar.
  • status: 0 significa «no hi va haver resposta HTTP». És la convenció que distingeix una fallada de transport d'una d'aplicació.

  1. AbortController a fons

A 06-04 vas fer servir { signal } per desconnectar oients d'esdeveniments de cop, i va quedar dit que s'aprofundiria aquí. AbortController és un mecanisme general de cancel·lació: un objecte amb dues peces.

const controlador = new AbortController();

console.log(controlador.signal);            // AbortSignal { aborted: false, reason: undefined }
console.log(controlador.signal.aborted);    // false

controlador.abort();                        // ← es dispara la cancel·lació

console.log(controlador.signal.aborted);    // true
console.log(controlador.signal.reason);     // DOMException: signal is aborted without reason
Peça Qui la té Per a què
controller Qui decideix cancel·lar Crida .abort(motiu?)
controller.signal Qui executa l'operació Es passa a fetch, a addEventListener
signal.aborted true si ja s'ha cancel·lat
signal.reason El motiu passat a abort(), o un AbortError per defecte
signal.throwIfAborted() Llança si ja està cancel·lada; útil en bucles llargs
Esdeveniment abort a signal Per netejar recursos propis

Aplicat a fetch:

const controlador = new AbortController();

// Cancel·lar als 3 segons, passi el que passi
setTimeout(() => controlador.abort(), 3000);

try {
  const resposta = await fetch(url, { signal: controlador.signal });
  const dades = await resposta.json();
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Cancel·lada a propòsit, no és una fallada');   // ← NO és un error per mostrar
  } else {
    throw error;
  }
}

Distingir AbortError d'un error real és obligatori. Quan tu canceles una petició perquè ja no t'interessa, mostrar un missatge d'error a l'usuari és un bug: no ha fallat res, has estat tu. Aquell if (error.name === 'AbortError') apareix en tota aplicació seriosa, i per això demanarJson el tradueix a codi: 'cancel·lat', que la vista sap ignorar.

Tres detalls que convé tenir clars:

  • Un controlador és d'un sol ús. Un cop avortat, el seu senyal queda avortat per sempre. Per a l'operació següent, un controlador nou.
  • Pots passar un motiu: controlador.abort(new Error("L'usuari ha canviat de pàgina")). Apareixerà a signal.reason i ajuda molt a depurar.
  • Un mateix senyal pot cancel·lar diverses coses. Un fetch, tres addEventListener i un setInterval propi, tots amb el mateix signal: un abort() els desconnecta tots. És el patró de neteja d'una vista que es destrueix.
/** Tot el que registra aquesta vista mor amb un únic abort(). */
function muntarPanell(contenidor, { signal }) {
  contenidor.addEventListener('click', enFerClic, { signal });
  window.addEventListener('resize', enRedimensionar, { signal });
  const id = setInterval(refrescar, 30000);
  signal.addEventListener('abort', () => clearInterval(id));   // neteja del que no accepta signal
}

const controlador = new AbortController();
muntarPanell($('#panell'), { signal: controlador.signal });
// …més tard…
controlador.abort();      // se'n van els tres oients i l'interval, de cop

  1. Cancel·lar la petició anterior: el cercador

Aquest és el cas on la cancel·lació deixa de ser un adorn. L'Iván escriu «serigrafia» al cercador del tauler. Sense cura, això són deu pulsacions i deu peticions, i les respostes no tornen en ordre: la de «serig» pot arribar després de la de «serigrafia» i sobreescriure la pantalla amb resultats equivocats. És la race condition clàssica de les interfícies.

sequenceDiagram
    participant U as Iván
    participant V as Vista
    participant A as API

    U->>V: tecleja "serig"
    V->>A: GET ?text=serig
    U->>V: tecleja "serigrafia"
    V->>A: GET ?text=serigrafia
    A-->>V: resposta de "serigrafia" (ràpida)
    V->>V: pinta 3 resultats ✅
    A-->>V: resposta de "serig" (lenta)
    V->>V: pinta 11 resultats ❌ obsoleta!

La solució combina dues eines que ja coneixes: el debounce de 03-04 per no disparar a cada tecla, i AbortController perquè la petició anterior no arribi a retornar res.

// js/dades/api-tasques.js
let controladorCerca = null;

export async function cercarTasques(text) {
  controladorCerca?.abort();                           // ← mata l'anterior, si n'hi havia
  controladorCerca = new AbortController();

  try {
    const url = construirUrl('/tasques', { q: text });
    const planes = await demanarJson(url, { signal: controladorCerca.signal });
    return planes.map((d) => Tasca.desDeJSON(d));
  } catch (error) {
    if (error.codi === 'cancel·lat') return null;      // ← null significa "ignora'm"
    throw error;
  }
}
// js/vista/controlador.js
import { debounce } from '../util/temps.js';

const cercar = debounce(async (text) => {
  const resultat = await cercarTasques(text);
  if (resultat === null) return;                       // ha estat cancel·lada: la substitueix una de més nova
  vista.actualitzar({ tauler: new Tauler('Taller Nómada', resultat) });
}, 300);

$('#cercador').addEventListener('input', (esdeveniment) => cercar(esdeveniment.target.value));

El debounce redueix deu peticions a una o dues; l'abort garanteix que, de les que s'arriben a llançar, només l'última pinta. Les dues peces juntes, no una de sola.

  1. Timeouts: Promise.race davant d'AbortSignal.timeout()

fetch no té timeout. Una petició pot esperar minuts si el servidor accepta la connexió i no contesta mai. Hi ha dues maneres d'imposar un límit.

La clàssica, amb Promise.race (05-06):

/** Fa córrer la promesa contra un rellotge: guanya la primera que se salda. */
function ambLimit(promesa, ms) {
  const rellotge = new Promise((_, rebutjar) => {
    setTimeout(() => rebutjar(new Error(`Temps esgotat després de ${ms} ms`)), ms);
  });
  return Promise.race([promesa, rellotge]);
}

const dades = await ambLimit(fetch(url).then((r) => r.json()), 5000);

Funciona, però té un defecte greu: la petició continua en marxa. Promise.race decideix qui guanya la cursa, no atura el perdedor. El servidor continua processant, els bytes continuen baixant i la connexió continua ocupada. Amb molts timeouts seguits, acumules peticions zombis.

La moderna, amb AbortSignal.timeout(), que cancel·la de debò:

const dades = await demanarJson(url, { signal: AbortSignal.timeout(5000) });
// Si passen 5 s, la petició s'AVORTA i l'error té name 'TimeoutError'

Una línia, i la petició es talla d'arrel. La comparació:

Promise.race AbortSignal.timeout(ms)
Talla la petició de debò No, continua en marxa
Allibera la connexió No
Codi necessari Una funció auxiliar Res, és natiu
Nom de l'error El que hi posis tu TimeoutError
Serveix per a qualsevol promesa (càlculs, altres APIs) Només per al que accepti signal
Disponibilitat Universal Navegadors moderns

La conclusió pràctica: fes servir AbortSignal.timeout() per a peticions de xarxa, i reserva Promise.race per posar límit a promeses que no accepten senyals. Conèixer les dues importa perquè Promise.race continua sent l'eina general.

I un advertiment sobre el valor: un timeout massa curt converteix una xarxa lenta en una fallada. Xifres raonables per començar: 5 s per a lectures que bloquegen la pantalla, 10-15 s per a escriptures que l'usuari ja ha confirmat, i més marge per a pujades de fitxers.

  1. AbortSignal.any(): combinar senyals

De vegades una petició s'ha de cancel·lar per dos motius diferents: perquè ha vençut el temps, o perquè l'usuari se n'ha anat de la pantalla. AbortSignal.any() fabrica un senyal que s'avorta així que qualsevol dels que li passes s'avorta.

const controladorVista = new AbortController();      // s'avorta en desmuntar la vista

const senyal = AbortSignal.any([
  controladorVista.signal,                           // l'usuari se n'ha anat
  AbortSignal.timeout(8000)                          // o ha trigat massa
]);

const tasques = await demanarJson(url, { signal: senyal });

És la contrapartida de Promise.any de 05-06, però per a cancel·lacions: la primera que es dispari guanya. Aplicat al projecte:

// js/dades/http.js
/** Senyal combinat: timeout propi + cancel·lació externa (per exemple, en desmuntar). */
export function senyalAmb(ms, externa) {
  const propis = [AbortSignal.timeout(ms)];
  if (externa) propis.push(externa);
  return AbortSignal.any(propis);
}

Si el teu navegador objectiu no té AbortSignal.any, l'equivalent manual és curt i val la pena entendre'l:

function combinar(senyals) {
  const controlador = new AbortController();
  for (const senyal of senyals) {
    if (senyal.aborted) { controlador.abort(senyal.reason); break; }
    senyal.addEventListener('abort', () => controlador.abort(senyal.reason), { once: true });
  }
  return controlador.signal;
}

  1. Reintents amb retrocés exponencial i jitter

Un 503 durant un desplegament dura segons. Reintentar té sentit… però no de qualsevol manera.

Reintentar immediatament empitjora les coses: si el servidor està saturat, mil clients reintentant alhora l'acaben de rematar. La tècnica correcta s'anomena retrocés exponencial (exponential backoff): esperar cada vegada més entre intents.

intent 1 → falla → espera 300 ms
intent 2 → falla → espera 600 ms
intent 3 → falla → espera 1200 ms
intent 4 → falla → es rendeix

I hi falta un ingredient. Si mil clients fallen alhora i tots esperen exactament 300 ms, tornen tots alhora 300 ms després. Aquell pic sincronitzat s'anomena thundering herd. La solució és el jitter: afegir un component aleatori a l'espera per escampar els reintents.

// js/dades/http.js
const dormir = (ms) => new Promise((resoldre) => setTimeout(resoldre, ms));

/**
 * Executa `operacio()` reintentant les fallades reintentables.
 * @param {Function} operacio  Funció asíncrona SENSE arguments (tanca-la amb un closure)
 */
export async function ambReintents(operacio, {
  intents = 3,
  baseMs = 300,
  maxMs = 5000,
  senyal = null
} = {}) {
  let ultimError;

  for (let intent = 1; intent <= intents; intent += 1) {
    try {
      return await operacio();
    } catch (error) {
      ultimError = error;

      // No reintentar el que no té remei: 400, 401, 403, 404, cancel·lacions, format
      if (!(error instanceof ErrorDeApi) || !error.reintentable) throw error;
      if (intent === intents) break;
      if (senyal?.aborted) throw error;

      // El servidor pot dir quant esperar (429 amb Retry-After); si no, exponencial
      const suggerit = Number(error.detall?.retryAfter) * 1000;
      const exponencial = Math.min(baseMs * 2 ** (intent - 1), maxMs);
      const jitter = Math.random() * exponencial * 0.3;          // ±30 % de dispersió
      const espera = Number.isFinite(suggerit) && suggerit > 0 ? suggerit : exponencial + jitter;

      console.warn(`[nomada] Intent ${intent}/${intents} ha fallat (${error.codi}). Reintent en ${Math.round(espera)} ms.`);
      await dormir(espera);
    }
  }

  throw ultimError;
}
// Ús: l'operació s'embolcalla en una fletxa per poder repetir-la
const tasques = await ambReintents(
  () => demanarJson(construirUrl('/tasques'), { signal: AbortSignal.timeout(5000) }),
  { intents: 3 }
);

Fixa't que l'operació és una funció, no una promesa. Una promesa ja llançada no es pot «tornar a llançar»: cal poder crear-ne una de nova a cada intent. És el mateix motiu pel qual ambLimit rebia una promesa (només l'observa) i ambReintents rep una funció (l'executa diverses vegades).

  1. Idempotència: què es pot reintentar

Abans de reintentar qualsevol cosa, la pregunta decisiva: què passa si la petició sí que va arribar i només es va perdre la resposta?

sequenceDiagram
    participant C as Client
    participant S as Servidor

    C->>S: POST /v1/tasques {"titol":"Revisar premsa"}
    S->>S: Crea la tasca 7 ✅
    S--xC: La resposta es perd (xarxa caiguda)
    Note over C: El client creu que ha fallat
    C->>S: POST /v1/tasques {"titol":"Revisar premsa"}
    S->>S: Crea la tasca 8 ❌ DUPLICADA!

Per això la idempotència de 07-02 deixa de ser teoria:

Verb Idempotent? Reintentar sense més?
GET , sempre
HEAD
PUT /tasques/6 Sí (reemplaça amb el mateix contingut)
DELETE /tasques/6 Sí (esborrar el ja esborrat no canvia res) Sí, tolerant el 404 del segon intent
PATCH /tasques/6 {estat:'feta'} Depèn: sí si assigna, no si incrementa Amb compte
POST /tasques No No, sense clau d'idempotència

La solució estàndard per poder reintentar un POST és la clau d'idempotència: el client genera un identificador únic de l'operació i l'envia en una capçalera. El servidor recorda les claus ja processades i, si en veu una de repetida, retorna el resultat anterior en lloc de crear un altre recurs.

export async function crearTasca(dades) {
  const clau = crypto.randomUUID();                  // ← la MATEIXA en tots els reintents

  return ambReintents(() => demanarJson(construirUrl('/tasques'), {
    method: 'POST',
    headers: { ...CAPCALERES_JSON, 'Idempotency-Key': clau },
    body: JSON.stringify(dades),
    signal: AbortSignal.timeout(10000)
  }), { intents: 3 });
}

El crucial és que crypto.randomUUID() es crida fora del closure que es reintenta: si fos a dins, cada intent generaria una clau diferent i tornaries al problema dels duplicats. I això només funciona si el servidor implementa la capçalera; si no ho fa, la regla és simple: no reintentis els POST automàticament. Ofereix un botó «Reintentar» i que decideixi la persona.

  1. Paral·lel sense fragilitat: allSettled davant d'all

El panell de la Marta necessita tres coses: les tasques, les hores de l'equip i els avisos. Demanar-les en seqüència suma latències; fer-ho en paral·lel les solapa. Però l'elecció del combinador importa molt, i reprèn directament 05-06.

// ✗ Fràgil: si els avisos fallen, no hi ha tauler
const [tasques, hores, avisos] = await Promise.all([
  llistarTasques(), llistarHoresEquip(), llistarAvisos()
]);

Promise.all rebutja així que una rebutja. Un 500 a l'endpoint menys important deixa la pantalla en blanc. Per a un panell compost de parts independents, això és un mal repartiment de conseqüències.

// ✓ Robust: cada part es pinta si ha pogut, i les que han fallat es marquen
const resultats = await Promise.allSettled([
  llistarTasques(), llistarHoresEquip(), llistarAvisos()
]);

const [tasques, hores, avisos] = resultats.map((r) =>
  r.status === 'fulfilled' ? r.value : null
);

if (tasques === null) {
  vista.mostrarError("No s'ha pogut carregar el tauler.", { reintentar: () => arrencar() });
} else {
  vista.actualitzar({ tauler: new Tauler('Taller Nómada', tasques) });
  if (hores === null) vista.marcarSeccioCaiguda('#hores-equip');
  if (avisos === null) vista.marcarSeccioCaiguda('#avisos');
}

El recordatori dels quatre combinadors, ara amb el criteri de quan fer servir cadascun:

Combinador Se salda quan… Fes-lo servir quan…
Promise.all totes compleixen, o una falla Les parts són indispensables entre si (no pots pintar res sense totes)
Promise.allSettled totes acaben, passi el que passi Parts independents d'un panell. El cas habitual en interfícies
Promise.race la primera que se salda, compleixi o falli Timeouts, «el que arribi abans»
Promise.any la primera que compleix Diversos miralls d'una mateixa dada, val qualsevol

I un advertiment sobre el paral·lelisme: llançar cinquanta peticions alhora no és més ràpid, és una manera de saturar el servidor i el navegador (que limita les connexions simultànies per origen). Quan en tinguis moltes, trosseja-les en lots.

/** Executa les operacions en lots de `mida`, en lloc de totes alhora. */
export async function perLots(operacions, mida = 5) {
  const sortida = [];
  for (let i = 0; i < operacions.length; i += mida) {
    const lot = operacions.slice(i, i + mida);
    sortida.push(...await Promise.allSettled(lot.map((op) => op())));
  }
  return sortida;
}

  1. Els quatre estats de la interfície

Una pantalla que parla amb una xarxa mai té dos estats, en té quatre. Programar només «amb dades» i «sense dades» és la causa de les pantalles en blanc eternes i dels «Carregant…» que no s'acaben.

stateDiagram-v2
    [*] --> Inicial
    Inicial --> Carregant: arrencar()
    Carregant --> Exit: arriben dades
    Carregant --> Buit: arriben 0 tasques
    Carregant --> Error: ErrorDeApi
    Error --> Carregant: reintentar()
    Exit --> Carregant: refrescar()
    Buit --> Carregant: refrescar()
    Exit --> Exit: canvi local (optimista)
Estat Què es mostra Què NO fer
Carregant Indicador o esquelet de contingut Pantalla en blanc sense explicació
Èxit Les dades
Buit «Encara no hi ha tasques» + acció per crear-ne una Confondre'l amb un error, o amb «carregant»
Error Què ha passat, en llenguatge planer, + botó Reintentar «Error: TypeError: Failed to fetch»

L'estat buit és el que més s'oblida, i el que més desconcerta: una llista buida sense missatge és indistingible d'una llista que no ha carregat. I l'estat error ha de portar sempre una sortida; un missatge sense botó deixa l'usuari amb l'única opció de recarregar a mà.

Implementat amb el que ja tens de 06-06:

// js/vista/tauler-vista.js (ampliació)
const ESTATS_UI = Object.freeze({ INICIAL: 'inicial', CARREGANT: 'carregant',
                                  EXIT: 'exit', BUIT: 'buit', ERROR: 'error' });

export class TaulerVista {
  // …el que ja hi havia…
  #ui = { fase: ESTATS_UI.INICIAL, error: null };

  /** Únic punt de canvi de fase: impossible deixar la pantalla en un estat inconsistent. */
  #canviarFase(fase, { error = null } = {}) {
    this.#ui = { fase, error };
    this.#pintarFase();
  }

  #pintarFase() {
    const { fase, error } = this.#ui;

    $('#carregant').hidden = fase !== ESTATS_UI.CARREGANT;
    $('#buit').hidden      = fase !== ESTATS_UI.BUIT;
    $('#error').hidden     = fase !== ESTATS_UI.ERROR;
    this.#contenidor.hidden = fase !== ESTATS_UI.EXIT;

    if (fase === ESTATS_UI.ERROR) {
      $('#error-missatge').textContent = error.message;                 // textContent, no innerHTML (06-02)
      $('#error-reintentar').hidden = !error.reintentable;
    }
  }

  async carregarDesDeApi(api) {
    this.#canviarFase(ESTATS_UI.CARREGANT);
    try {
      const tasques = await ambReintents(() => api.llistarTasques(), { intents: 3 });
      this.#estat.tauler = new Tauler('Taller Nómada', tasques);
      this.#canviarFase(tasques.length === 0 ? ESTATS_UI.BUIT : ESTATS_UI.EXIT);
      this.render();
    } catch (error) {
      if (error.codi === 'cancel·lat') return;                          // no és una fallada
      this.#canviarFase(ESTATS_UI.ERROR, { error });
    }
  }
}

La clau de disseny és #canviarFase: un únic punt pel qual passen totes les transicions. Sense ell, acabes amb sis llocs que posen i treuen hidden i, tard o d'hora, amb l'indicador de càrrega i el missatge d'error visibles alhora.

  1. Avisos accessibles amb aria-live

A 06-07 vas aprendre que un missatge que només es veu en vermell no existeix per a qui no veu la pantalla. Amb la xarxa, el problema s'agreuja: els canvis passen sols, sense que l'usuari hagi fet res, i un lector de pantalla no els anuncia tret que li ho demanis.

<!-- Estat de càrrega i errors, anunciats automàticament -->
<p id="carregant" class="avis" role="status" aria-live="polite" hidden>Carregant tasques…</p>

<div id="error" class="avis avis--error" role="alert" hidden>
  <p id="error-missatge"></p>
  <button type="button" id="error-reintentar">Reintentar</button>
</div>

<p id="buit" class="avis" hidden>Encara no hi ha tasques. <button type="button" id="crear-primera">Crear la primera</button></p>
Atribut Quan anuncia Per a què
aria-live="polite" En acabar el que el lector estigui dient Canvis d'estat, «Carregant», «3 tasques»
aria-live="assertive" Interromp immediatament Només urgències reals
role="status" Equival a polite Missatges informatius
role="alert" Equival a assertive Errors que exigeixen atenció

Quatre regles pràctiques:

  • El contenidor ha d'existir al DOM abans de ficar-hi text. Si el crees i li poses el missatge en el mateix instant, molts lectors no l'anuncien. D'aquí el hidden en lloc de crear i destruir.
  • No abusis d'assertive. Interrompre a mitja frase és agressiu; reserva'l per a errors.
  • Esmorteeix els avisos repetitius. Un aria-live que canvia a cada tecla converteix el lector en una metralladora: és el mateix argument que justificava el debounce a 06-07.
  • Mou el focus al botó «Reintentar» quan aparegui un error després d'una acció de l'usuari. Un missatge anunciat però inabastable amb el teclat serveix de poc.

  1. Optimistic UI: aplicar abans de confirmar

La Marta marca una tasca com a feta. Si esperes la resposta del servidor per moure la targeta, la interfície es percep lenta encara que trigui 200 ms. La tècnica optimista consisteix a aplicar el canvi immediatament, enviar la petició en segon pla i revertir si falla.

sequenceDiagram
    participant M as Marta
    participant V as Vista
    participant A as API

    M->>V: clic a "marcar feta"
    V->>V: 1 · desa còpia de l'estat anterior
    V->>V: 2 · aplica el canvi i repinta ⚡ (instantani)
    V->>A: 3 · PATCH /tasques/6 {"estat":"feta"}
    alt Èxit
        A-->>V: 200 OK
        V->>V: confirma (treu la marca de "pendent de sincronitzar")
    else Fallada
        A-->>V: 500 / xarxa caiguda
        V->>V: 4 · REVERTEIX a l'estat desat
        V->>M: «No s'ha pogut desar. Reintentar»
    end

Aquí és on rendeixen les actualitzacions immutables de 04-07: com que no vas mutar l'estat anterior, revertir és senzillament tornar-lo a fer servir.

// js/vista/controlador.js
async function canviarEstatOptimista(id, estatNou) {
  const anterior = tauler.cercarPerId(id).toJSON();      // 1 · foto de l'estat previ

  tauler.canviarEstat(id, estatNou);                     // 2 · canvi local immediat
  vista.render();
  vista.marcarPendent(id, true);                         //     senyal visual subtil de "sense confirmar"

  try {
    await ambReintents(
      () => api.actualitzarTasca(id, { estat: estatNou }),
      { intents: 2 }
    );
    vista.marcarPendent(id, false);                      // 3 · confirmat
    repositori.desar(tauler);
  } catch (error) {
    tauler.substituir(Tasca.desDeJSON(anterior));        // 4 · revertir
    vista.render();
    vista.avisar(`No s'ha pogut desar «${anterior.titol}». ${error.message}`, {
      accio: { text: 'Reintentar', enPremer: () => canviarEstatOptimista(id, estatNou) }
    });
  }
}

Quan fer-la servir i quan no:

Fes servir optimisme quan… Evita'l quan…
El canvi gairebé sempre té èxit El servidor el pot rebutjar sovint
És fàcil de revertir (un estat, un text) Hi ha efectes irreversibles (cobraments, correus enviats)
L'usuari percep la latència L'operació triga igualment (pujar un fitxer)
La dada és del propi usuari Altres poden haver-la canviat alhora

I una regla d'honestedat: si apliques un canvi optimista, marca'l. Un punt, una opacitat lleugera, una icona de «sincronitzant». Que l'usuari tanqui el portàtil creient que alguna cosa s'ha desat quan no ha estat així és pitjor que fer-lo esperar 200 ms.

  1. Nómada Tasques: js/dades/http.js i la vista

Totes les peces juntes. El mòdul http.js queda com a capa genèrica reutilitzable, i api-tasques.js s'hi recolza:

// js/dades/http.js — resum del que exporta
export { demanarJson };        // fetch + classificació d'errors en ErrorDeApi
export { ambReintents };       // retrocés exponencial amb jitter, només per a errors reintentables
export { senyalAmb };          // AbortSignal.any([timeout, externa])
export { perLots };            // paral·lelisme limitat amb allSettled
// js/dades/api-tasques.js — reescrit sobre http.js
import { demanarJson, ambReintents, senyalAmb } from './http.js';
import { Tasca } from '../model/tasca.js';

const TEMPS = Object.freeze({ lectura: 5000, escriptura: 10000 });

export function crearApiTasques({ base = 'http://localhost:3000', signal = null } = {}) {
  const url = (ruta, params = {}) => {
    const u = new URL(base + ruta);
    for (const [k, v] of Object.entries(params)) {
      if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, v);
    }
    return u;
  };

  return {
    /** Lectura: idempotent, es reintenta sense por. */
    async llistarTasques(filtres = {}) {
      const planes = await ambReintents(
        () => demanarJson(url('/tasques', filtres), { signal: senyalAmb(TEMPS.lectura, signal) }),
        { intents: 3, senyal: signal }
      );
      return planes.map((d) => Tasca.desDeJSON(d));
    },

    /** Creació: NO idempotent. Un sol intent + clau, i que l'usuari decideixi si reintenta. */
    async crearTasca(dades) {
      const plana = await demanarJson(url('/tasques'), {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
        body: JSON.stringify(dades),
        signal: senyalAmb(TEMPS.escriptura, signal)
      });
      return Tasca.desDeJSON(plana);
    },

    /** PATCH que assigna un valor fix: idempotent, reintentable. */
    async actualitzarTasca(id, canvis) {
      const plana = await ambReintents(
        () => demanarJson(url(`/tasques/${id}`), {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(canvis),
          signal: senyalAmb(TEMPS.escriptura, signal)
        }),
        { intents: 2 }
      );
      return Tasca.desDeJSON(plana);
    },

    /** DELETE: idempotent. Un 404 al segon intent significa que el primer va funcionar. */
    async esborrarTasca(id) {
      try {
        await ambReintents(
          () => demanarJson(url(`/tasques/${id}`), { method: 'DELETE', signal: senyalAmb(TEMPS.escriptura, signal) }),
          { intents: 2 }
        );
      } catch (error) {
        if (error.status !== 404) throw error;      // ja estava esborrada: és el resultat desitjat
      }
      return true;
    }
  };
}

I l'arrencada completa de l'aplicació, amb local primer, xarxa després, i tots els estats coberts:

// js/app.js
const controladorApp = new AbortController();
const api = crearApiTasques({ base: 'http://localhost:3000', signal: controladorApp.signal });
const repositori = new RepositoriLocal();

async function arrencar() {
  // 1 · El que és local es pinta a l'instant: mai una pantalla en blanc si hi ha dades
  const local = repositori.carregar();
  if (local !== null) { vista.actualitzar({ tauler: local }); }

  // 2 · La veritat del servidor, amb tots els estats gestionats
  await vista.carregarDesDeApi(api);
  repositori.desar(vista.tauler);
}

$('#error-reintentar').addEventListener('click', arrencar, { signal: controladorApp.signal });
window.addEventListener('online',  () => arrencar(), { signal: controladorApp.signal });
window.addEventListener('offline', () => vista.avisar('Sense connexió. Els canvis es desen en local.'),
                        { signal: controladorApp.signal });

arrencar();

Aquell únic controladorApp cancel·la de cop les peticions en vol i tots els oients quan l'aplicació es desmunti. Un AbortController per a tota la vida de l'aplicació, i altres d'efímers per a operacions concretes com la cerca: aquest és el repartiment habitual.

Errors Habituals i Consells

  • Tractar AbortError com una fallada. Mostrar «Error en carregar» quan ets tu qui va cancel·lar és un bug clàssic. Filtra sempre per error.name === 'AbortError' o, amb demanarJson, per codi === 'cancel·lat'.
  • Reutilitzar un AbortController ja avortat. El seu senyal queda avortat per sempre i la petició següent mor a l'instant. Crea'n un de nou per operació.
  • Reintentar un POST sense clau d'idempotència. Crea duplicats invisibles: l'usuari veu una tasca, la base de dades en té tres.
  • Reintentar un 400 o un 403. No funcionarà mai; només afegeix latència i soroll als registres.
  • Reintentar sense jitter. Sincronitza tots els clients i colpeja el servidor en onades justament quan pitjor està.
  • Confiar en Promise.race com si cancel·lés. No cancel·la: només ignora el perdedor, que continua consumint recursos.
  • No posar cap timeout. «Carregant…» etern. fetch no ho fa per tu.
  • Fer servir Promise.all per a un panell de parts independents. Una fallada secundària deixa la pantalla buida. allSettled.
  • Oblidar l'estat buit. Una llista sense elements i sense missatge sembla trencada.
  • Mostrar el missatge tècnic a l'usuari. TypeError: Failed to fetch no significa res per a la Marta. Tradueix, i desa el detall tècnic a la consola.
  • Aplicar un canvi optimista sense poder revertir-lo. Si vas mutar l'estat anterior, no hi ha marxa enrere. Les actualitzacions immutables de 04-07 són el requisit, no un adorn.
  • Consell: prova les fallades a propòsit. A DevTools → Network pots simular Offline i Slow 3G, i https://httpstat.us/503 et retorna el codi que vulguis. Una gestió d'errors que mai s'ha executat no està provada.
  • Consell: registra l'error tècnic complet, mostra l'humà. console.error(error.descriure(), error.causa) a la consola; a la pantalla, la frase curta.
  • Consell: un timeout per tipus d'operació. Llegir i escriure no mereixen el mateix marge.
  • Consell: no reintentis en bucle infinit. Tres intents i una sortida clara. La insistència eterna esgota bateria i paciència.

Exercicis

Exercici 1 — Reintents amb Retry-After. Amplia ambReintents perquè, quan l'error sigui un 429, llegeixi la capçalera Retry-After de la resposta i esperi exactament aquell temps en lloc d'aplicar el retrocés exponencial. Necessitaràs que demanarJson desi aquella capçalera a l'ErrorDeApi. La capçalera pot venir en segons (Retry-After: 30) o com a data HTTP (Retry-After: Wed, 20 Sep 2026 10:00:00 GMT): resol els dos formats i limita l'espera a un màxim de 60 segons.

Exercici 2 — Cercador cancel·lable amb estats. Escriu crearCercador({ camp, enCanviar, enError, retard = 300 }) que retorni una funció cercar(text) i una funció cancellar(). Ha d'esmorteir amb debounce, cancel·lar la petició anterior a cada cerca nova, ignorar els AbortError, imposar un timeout de 4 segons i cridar enCanviar amb { fase: 'carregant' | 'exit' | 'buit' | 'error', tasques, error }. Que un text buit cancel·li el que hi hagués en vol i retorni la fase 'inicial'.

Exercici 3 — Canvi d'estat optimista amb reversió. Escriu crearAccioOptimista({ tauler, vista, api, repositori }) que retorni canviarEstat(id, estatNou) amb el cicle complet: foto de l'estat previ amb toJSON(), canvi local, render, marca de pendent, petició amb dos reintents, i en cas de fallada reversió amb Tasca.desDeJSON, render i avís amb acció de reintent. Afegeix una salvaguarda: si ja hi ha una operació en vol per a aquella mateixa tasca, ignora la nova pulsació en lloc d'encadenar dos canvis optimistes.

Solucions

Solució 1

// A demanarJson, en construir l'error de resposta:
throw new ErrorDeApi(missatge, {
  status: resposta.status,
  codi,
  url: String(url),
  detall,
  reintentarDespres: resposta.headers.get('retry-after')     // ← es desa tal com ha arribat
});
/** Converteix la capçalera Retry-After (segons o data HTTP) en mil·lisegons d'espera. */
function esperaSuggerida(capcalera, maxMs = 60000) {
  if (!capcalera) return null;

  const segons = Number(capcalera);
  if (Number.isFinite(segons)) return Math.min(segons * 1000, maxMs);

  const data = Date.parse(capcalera);                        // format data HTTP
  if (Number.isNaN(data)) return null;
  return Math.min(Math.max(data - Date.now(), 0), maxMs);
}
// Dins del catch d'ambReintents, substituint el càlcul de l'espera:
const suggerida = esperaSuggerida(error.reintentarDespres);
const exponencial = Math.min(baseMs * 2 ** (intent - 1), maxMs);
const espera = suggerida ?? (exponencial + Math.random() * exponencial * 0.3);

El Number(capcalera) distingeix els dos formats sense expressions regulars: Number('30') dona 30, mentre que Number('Wed, 20 Sep…') dona NaN i cau al Date.parse. I el sostre de 60 s protegeix d'un servidor que demani esperar una hora: passat cert punt, val més rendir-se i deixar que l'usuari decideixi.

Solució 2

import { debounce } from '../util/temps.js';
import { demanarJson } from './http.js';
import { Tasca } from '../model/tasca.js';

export function crearCercador({ base, enCanviar, retard = 300 }) {
  let controlador = null;

  function cancellar() {
    controlador?.abort();
    controlador = null;
  }

  const llancar = debounce(async (text) => {
    cancellar();                                    // 1 · mata l'anterior
    controlador = new AbortController();
    enCanviar({ fase: 'carregant', tasques: [], error: null });

    try {
      const url = new URL(`${base}/tasques`);
      url.searchParams.set('q', text);

      const planes = await demanarJson(url, {
        signal: AbortSignal.any([controlador.signal, AbortSignal.timeout(4000)])
      });
      const tasques = planes.map((d) => Tasca.desDeJSON(d));

      enCanviar({ fase: tasques.length === 0 ? 'buit' : 'exit', tasques, error: null });
    } catch (error) {
      if (error.codi === 'cancel·lat') return;      // 2 · la substitueix una cerca més nova
      enCanviar({ fase: 'error', tasques: [], error });
    }
  }, retard);

  function cercar(text) {
    const net = text.trim();
    if (net === '') {
      cancellar();
      enCanviar({ fase: 'inicial', tasques: [], error: null });
      return;
    }
    llancar(net);
  }

  return { cercar, cancellar };
}

Fixa't en l'ordre dins de llancar: primer es cancel·la l'anterior, després es crea el controlador nou i només llavors s'anuncia «carregant». A l'inrevés, la cancel·lació de l'anterior podria arribar a sobreescriure la fase que acabes de posar. I AbortSignal.any combina els dos motius per cancel·lar —una cerca més nova, o el timeout— en un sol senyal.

Solució 3

export function crearAccioOptimista({ tauler, vista, api, repositori }) {
  const enVol = new Set();                          // ids amb una operació pendent

  async function canviarEstat(id, estatNou) {
    if (enVol.has(id)) return false;                // salvaguarda: una alhora per tasca
    enVol.add(id);

    const anterior = tauler.cercarPerId(id).toJSON();    // foto immutable de l'estat previ

    try {
      tauler.canviarEstat(id, estatNou);            // pot llançar ErrorDeValidacio per la R6
    } catch (error) {
      enVol.delete(id);
      vista.avisar(error.message);
      return false;
    }

    vista.render();
    vista.marcarPendent(id, true);

    try {
      await ambReintents(() => api.actualitzarTasca(id, { estat: estatNou }), { intents: 2 });
      vista.marcarPendent(id, false);
      repositori.desar(tauler);
      return true;
    } catch (error) {
      if (error.codi === 'cancel·lat') return false;

      tauler.substituir(Tasca.desDeJSON(anterior));       // reversió
      vista.render();
      vista.avisar(`No s'ha pogut desar «${anterior.titol}».`, {
        accio: { text: 'Reintentar', enPremer: () => canviarEstat(id, estatNou) }
      });
      return false;
    } finally {
      enVol.delete(id);                                   // passi el que passi, s'allibera
    }
  }

  return { canviarEstat, hiHaPendents: () => enVol.size > 0 };
}

Tres detalls importants. El Set d'ids en vol evita que dues pulsacions ràpides encadenin dos canvis optimistes amb reversions creuades —un origen habitual d'estats impossibles—. El finally allibera l'id sempre, inclosos els camins d'error i el return primerenc. I la validació local (canviarEstat del tauler, que aplica la regla R6) es fa abans de pintar res: no té sentit ser optimista amb un canvi que el teu propi model rebutja.

Conclusió

Aquesta lliçó és la frontera entre un exercici i un producte. Tens el mapa complet de les set fallades d'una petició —xarxa, DNS, CORS, 4xx, 5xx, resposta no-JSON i resposta lenta— i, per a cadascuna, com detectar-la, si mereix reintent i què explicar a l'usuari; amb les dues idees que governen tota la resta: només les fallades de transport fan rebutjar fetch, i la resposta lenta no es detecta sola, l'has de provocar tu amb un timeout. Aquell coneixement viu encapsulat a ErrorDeApi extends Error, amb el seu status, el seu codi i els seus getters reintentable i requereixSessio, exactament el mateix patró que et va donar ErrorDeValidacio amb el seu camp camp a 05-02.

Saps escriure demanarJson, que tradueix qualsevol fallada a un ErrorDeApi classificat, comprova el content-type per caçar l'HTML disfressat de JSON, respecta el 204 sense cos i clona la resposta abans de llegir el detall de l'error. Domines AbortController: el parell controlador/senyal, aborted i reason, l'ús d'un sol senyal per cancel·lar peticions i oients alhora, la regla que un controlador és d'un sol ús, i l'obligació de distingir AbortError d'un error real —cancel·lar a propòsit no és fallar—. Ho has aplicat al cas que ho justifica: el cercador on debounce redueix les peticions i abort garanteix que només l'última pinti, tancant la race condition de les respostes que tornen desordenades.

Saps imposar timeouts i per què AbortSignal.timeout() és superior a Promise.race —el primer talla la petició, el segon només ignora el perdedor mentre continua consumint la connexió—, i combinar motius de cancel·lació amb AbortSignal.any(). Saps reintentar amb retrocés exponencial i jitter, entens per què existeix el jitter (el thundering herd) i, sobretot, saps què es pot reintentar: GET, PUT i DELETE sense problema, POST només amb clau d'idempotència generada fora del closure que es repeteix. Saps triar combinador per al paral·lelisme, amb Promise.allSettled com a opció per defecte en interfícies perquè una fallada secundària no ha de buidar la pantalla, i trossejar en lots quan les peticions són moltes.

I saps que una pantalla connectada a una xarxa té quatre estats, no dos: carregant, èxit, buit i error amb reintent; que les transicions han de passar per un únic punt per no deixar la interfície en estats impossibles; que els avisos necessiten aria-live, role="status" i role="alert" per existir també per a qui no veu la pantalla; i que l'optimistic UI —aplicar el canvi, enviar, revertir si falla— només és possible perquè les actualitzacions immutables de 04-07 conserven intacte l'estat anterior, i només és honest si marques visualment el que encara no està confirmat.

Amb js/dades/http.js i js/dades/api-tasques.js reescrits, Nómada Tasques ja és una aplicació connectada que aguanta el món real. Però li queda un límit conceptual, no tècnic: el navegador només s'assabenta de les coses quan pregunta. Si l'Iván mou una tasca des del seu portàtil, el tauler de la Marta continuarà mostrant l'estat antic fins que ella recarregui o premi refrescar. Podries preguntar cada cinc segons, però això és gastar bateria i amplada de banda perquè gairebé sempre la resposta sigui «res de nou». El que cal és que el servidor pugui parlar primer: una connexió permanent i bidireccional per la qual els canvis arribin sols, en l'instant en què passen. Això és WebSockets, on el tauler del Taller Nómada deixarà de ser una còpia per persona per convertir-se en un de sol, compartit i viu.

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