La lección anterior terminó con una lista incómoda: el wifi del taller que se cae a mitad de un POST, la API que tarda quince segundos, el 503 de un despliegue, las ocho peticiones que dispara Iván al teclear en el buscador y que vuelven desordenadas. js/datos/api-tareas.js funciona perfectamente mientras todo va bien, y en la red nada va bien todo el tiempo. Esta lección es la que convierte una demo en una aplicación: aprenderás a clasificar los fallos y decidir qué hacer con cada uno, a encapsularlos en un ErrorDeApi propio, a cancelar peticiones con AbortController —cerrando la deuda que dejaste pendiente en 06-04—, a imponer timeouts, a reintentar solo lo que es seguro reintentar, a lanzar peticiones en paralelo sin que una sola caída lo tumbe todo, y a modelar los cuatro estados que toda pantalla que habla con una red debe saber mostrar. Terminarás con js/datos/http.js y con una TableroVista que sabe decir "cargando", "esto ha fallado, reintenta" y "no hay nada aquí".
Contenido
- Los siete fallos de una petición
- Un error propio:
ErrorDeApi pedirJson: el envoltorio definitivoAbortControllera fondo- Cancelar la petición anterior: el buscador
- Timeouts:
Promise.racefrente aAbortSignal.timeout() AbortSignal.any(): combinar señales- Reintentos con retroceso exponencial y jitter
- Idempotencia: qué se puede reintentar
- Paralelo sin fragilidad:
allSettledfrente aall - Los cuatro estados de la interfaz
- Avisos accesibles con
aria-live - Optimistic UI: aplicar antes de confirmar
- Nómada Tareas:
js/datos/http.jsy la vista - Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Los siete fallos de una petición
Antes de escribir una línea, conviene tener el mapa completo. No todos los fallos merecen el mismo tratamiento, y tratarlos igual produce interfaces que mienten ("Error desconocido") o que insisten cuando no deben.
| Fallo | Qué ha pasado | Cómo se detecta | ¿Reintentar? | Qué decirle al usuario |
|---|---|---|---|---|
| Red caída | Sin conexión, wifi perdido | fetch rechaza con TypeError |
Sí, con retroceso | «Sin conexión. Reintentando…» |
| DNS / host inalcanzable | El dominio no resuelve | fetch rechaza con TypeError |
Sí, pocas veces | «No se puede contactar con el servidor» |
| CORS bloqueado | El servidor no autoriza tu origen | fetch rechaza con TypeError; el motivo, solo en consola |
No | «Error de configuración» + avisar a desarrollo |
| 4xx del cliente | Datos mal, sin permiso, no existe | respuesta.ok === false, status 400-499 |
No (salvo 408 y 429) |
El mensaje concreto: «Faltan horas», «Sesión caducada» |
| 5xx del servidor | El servidor falló | respuesta.ok === false, status 500-599 |
Sí | «El servidor no responde. Reintentando…» |
| Respuesta no-JSON | Llegó HTML de un proxy o de un login | json() lanza SyntaxError |
No | «Respuesta inesperada del servidor» |
| Respuesta lenta | El servidor tarda demasiado | Nada… hasta que pones un timeout | Sí, una vez | «Está tardando más de lo normal» |
Tres lecturas de esta tabla:
- Solo dos filas hacen rechazar la promesa de
fetch: los fallos de transporte (red, DNS, CORS, cancelación). Todo lo demás llega como respuesta "normal" y hay que mirarlo, tal como aprendiste en 07-02. - La frontera 4xx / 5xx decide si reintentar. Insistir ante un
400es garantía de volver a fallar; insistir ante un503suele funcionar. - La respuesta lenta no se detecta sola. Es el único fallo que tú tienes que provocar, poniendo un límite. Sin timeout, una petición puede quedarse colgada indefinidamente y tu interfaz mostrará "Cargando…" para siempre.
Hay dos casos de 4xx que sí se reintentan, y conviene recordarlos: 408 Request Timeout y 429 Too Many Requests. El 429 además suele traer una cabecera Retry-After que dice cuántos segundos esperar; respetarla es de buena educación y evita que te bloqueen.
- Un error propio:
ErrorDeApi
ErrorDeApiEn 05-02 creaste ErrorDeValidacion extends Error con un campo campo, y en 06-07 ese campo permitió a la vista saber a qué <input> mover el foco. Aquí se repite exactamente la misma jugada: un error que transporta la información que la vista necesitará para decidir qué hacer.
// js/modelo/errores.js — se añade a los que ya existían
export class ErrorDeApi extends Error {
/**
* @param {string} mensaje Texto legible, apto para mostrar
* @param {object} datos { status, codigo, url, detalle, causa }
*/
constructor(mensaje, { status = 0, codigo = 'desconocido', url = '', detalle = null, causa = null } = {}) {
super(mensaje);
this.name = 'ErrorDeApi';
this.status = status; // 0 si ni siquiera hubo respuesta HTTP
this.codigo = codigo; // 'red' | 'timeout' | 'cancelado' | 'cliente' | 'servidor' | 'formato'
this.url = url;
this.detalle = detalle; // el cuerpo del error, si el servidor mandó uno
this.causa = causa; // el error original, para depurar
}
/** ¿Merece la pena volver a intentarlo? */
get reintentable() {
if (this.codigo === 'red' || this.codigo === 'timeout' || this.codigo === 'servidor') return true;
return this.status === 408 || this.status === 429;
}
/** ¿Hay que pedir al usuario que inicie sesión otra vez? */
get requiereSesion() {
return this.status === 401;
}
describir() {
return `[${this.name} ${this.status} ${this.codigo}] ${this.message}`;
}
}Lo que hace valioso a este error no es que sea una clase: es que el conocimiento sobre reintentos vive en un solo sitio. La vista no tiene que saber que un 429 se reintenta y un 403 no; pregunta error.reintentable y ya está. Es el mismo principio de 06-07: las reglas viven donde corresponde, y quien las consume solo pregunta.
Un apunte de ES2022 que encaja aquí: Error acepta una opción cause estándar, new Error('…', { cause: original }). Nosotros usamos un campo propio causa por coherencia con ErrorDeDatos, pero conocer la forma estándar te evitará confusiones al leer código ajeno.
pedirJson: el envoltorio definitivo
pedirJson: el envoltorio definitivoAhora la función central de toda la capa de red. Escrita una vez, usada por todos los métodos de la API.
// js/datos/http.js
import { ErrorDeApi } from '../modelo/errores.js';
/**
* Hace una petición y devuelve el JSON, o lanza SIEMPRE un ErrorDeApi clasificado.
* Nunca deja escapar un TypeError ni un SyntaxError sin traducir.
*/
export async function pedirJson(url, opciones = {}) {
let respuesta;
// ── 1 · Fallos de transporte: los únicos que hacen rechazar a fetch ──
try {
respuesta = await fetch(url, opciones);
} catch (error) {
if (error.name === 'AbortError') {
throw new ErrorDeApi('Petición cancelada.', { codigo: 'cancelado', url, causa: error });
}
if (error.name === 'TimeoutError') {
throw new ErrorDeApi('El servidor tardó demasiado.', { codigo: 'timeout', url, causa: error });
}
// TypeError: red caída, DNS, CORS. El navegador no distingue cuál para no filtrar información.
throw new ErrorDeApi('No se pudo contactar con el servidor.', { codigo: 'red', url, causa: error });
}
// ── 2 · Respuestas de error: 4xx y 5xx ──
if (!respuesta.ok) {
const detalle = await leerDetalle(respuesta);
const codigo = respuesta.status >= 500 ? 'servidor' : 'cliente';
const mensaje = detalle?.mensaje ?? detalle?.message ?? `Error ${respuesta.status} del servidor.`;
throw new ErrorDeApi(mensaje, { status: respuesta.status, codigo, url: String(url), detalle });
}
// ── 3 · Éxito sin cuerpo ──
if (respuesta.status === 204 || respuesta.headers.get('content-length') === '0') return null;
// ── 4 · Éxito con cuerpo: ¿es realmente JSON? ──
const tipo = respuesta.headers.get('content-type') ?? '';
if (!tipo.includes('json')) {
const texto = await respuesta.text().catch(() => '');
throw new ErrorDeApi(`Se esperaba JSON y llegó "${tipo}".`, {
status: respuesta.status, codigo: 'formato', url: String(url), detalle: texto.slice(0, 200)
});
}
try {
return await respuesta.json();
} catch (error) {
throw new ErrorDeApi('La respuesta no es JSON válido.', {
status: respuesta.status, codigo: 'formato', url: String(url), causa: error
});
}
}
/** Intenta leer el cuerpo del error como JSON; si no puede, como texto; si no, null. */
async function leerDetalle(respuesta) {
const copia = respuesta.clone(); // ← clonar ANTES de leer (07-02)
try {
return await respuesta.json();
} catch {
return await copia.text().catch(() => null);
}
}Detente en cuatro puntos:
- La función tiene un único tipo de salida de error. Quien la llame solo necesita conocer
ErrorDeApi. Nada de comprobar si es unTypeError, unSyntaxErroro un objetoResponse. - El
clone()del apartadoleerDetallees imprescindible: sijson()falla a mitad, el flujo del cuerpo ya está consumido ytext()no podría leerlo. - Se comprueba el
content-type. Este es el que salva de los diagnósticos imposibles: un proxy corporativo que devuelve su página de inicio de sesión con estado 200, y unjson()que revienta con unSyntaxError: Unexpected token '<'. Con esta comprobación, el mensaje dice exactamente lo que pasó. status: 0significa "no hubo respuesta HTTP". Es la convención que distingue un fallo de transporte de uno de aplicación.
AbortController a fondo
AbortController a fondoEn 06-04 usaste { signal } para desconectar oyentes de eventos de golpe, y quedó dicho que se profundizaría aquí. AbortController es un mecanismo general de cancelación: un objeto con dos piezas.
const controlador = new AbortController();
console.log(controlador.signal); // AbortSignal { aborted: false, reason: undefined }
console.log(controlador.signal.aborted); // false
controlador.abort(); // ← se dispara la cancelación
console.log(controlador.signal.aborted); // true
console.log(controlador.signal.reason); // DOMException: signal is aborted without reason| Pieza | Quién la tiene | Para qué |
|---|---|---|
controller |
Quien decide cancelar | Llama a .abort(razon?) |
controller.signal |
Quien ejecuta la operación | Se le pasa a fetch, a addEventListener… |
signal.aborted |
— | true si ya se canceló |
signal.reason |
— | El motivo pasado a abort(), o un AbortError por defecto |
signal.throwIfAborted() |
— | Lanza si ya está cancelada; útil en bucles largos |
Evento abort en signal |
— | Para limpiar recursos propios |
Aplicado a fetch:
const controlador = new AbortController();
// Cancelar a los 3 segundos, pase lo que pase
setTimeout(() => controlador.abort(), 3000);
try {
const respuesta = await fetch(url, { signal: controlador.signal });
const datos = await respuesta.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Cancelada a propósito, no es un fallo'); // ← NO es un error que mostrar
} else {
throw error;
}
}Distinguir AbortError de un error real es obligatorio. Cuando tú cancelas una petición porque ya no te interesa, mostrar un mensaje de error al usuario es un bug: no ha fallado nada, has sido tú. Ese if (error.name === 'AbortError') aparece en toda aplicación seria, y por eso pedirJson lo traduce a codigo: 'cancelado', que la vista sabe ignorar.
Tres detalles que conviene tener claros:
- Un controlador es de un solo uso. Una vez abortado, su señal queda abortada para siempre. Para la siguiente operación, un controlador nuevo.
- Puedes pasar una razón:
controlador.abort(new Error('El usuario cambió de página')). Aparecerá ensignal.reasony ayuda mucho a depurar. - Una misma señal puede cancelar varias cosas. Un
fetch, tresaddEventListenery unsetIntervalpropio, todos con la mismasignal: unabort()los desconecta todos. Es el patrón de limpieza de una vista que se destruye.
/** Todo lo que registra esta vista muere con un único abort(). */
function montarPanel(contenedor, { signal }) {
contenedor.addEventListener('click', alHacerClic, { signal });
window.addEventListener('resize', alRedimensionar, { signal });
const id = setInterval(refrescar, 30000);
signal.addEventListener('abort', () => clearInterval(id)); // limpieza de lo que no acepta signal
}
const controlador = new AbortController();
montarPanel($('#panel'), { signal: controlador.signal });
// …más tarde…
controlador.abort(); // se van los tres oyentes y el intervalo, de golpe
- Cancelar la petición anterior: el buscador
Este es el caso donde la cancelación deja de ser un adorno. Iván escribe «serigrafía» en el buscador del tablero. Sin cuidado, eso son diez pulsaciones y diez peticiones, y las respuestas no vuelven en orden: la de «serig» puede llegar después de la de «serigrafía» y sobrescribir la pantalla con resultados equivocados. Es la race condition clásica de las interfaces.
sequenceDiagram
participant U as Iván
participant V as Vista
participant A as API
U->>V: teclea "serig"
V->>A: GET ?texto=serig
U->>V: teclea "serigrafía"
V->>A: GET ?texto=serigrafía
A-->>V: respuesta de "serigrafía" (rápida)
V->>V: pinta 3 resultados ✅
A-->>V: respuesta de "serig" (lenta)
V->>V: pinta 11 resultados ❌ ¡obsoleta!
La solución combina dos herramientas que ya conoces: el debounce de 03-04 para no disparar en cada tecla, y AbortController para que la petición anterior no llegue a devolver nada.
// js/datos/api-tareas.js
let controladorBusqueda = null;
export async function buscarTareas(texto) {
controladorBusqueda?.abort(); // ← mata la anterior, si la había
controladorBusqueda = new AbortController();
try {
const url = construirUrl('/tareas', { q: texto });
const planas = await pedirJson(url, { signal: controladorBusqueda.signal });
return planas.map((d) => Tarea.desdeJSON(d));
} catch (error) {
if (error.codigo === 'cancelado') return null; // ← null significa "ignórame"
throw error;
}
}// js/vista/controlador.js
import { debounce } from '../util/tiempo.js';
const buscar = debounce(async (texto) => {
const resultado = await buscarTareas(texto);
if (resultado === null) return; // fue cancelada: la sustituye otra más nueva
vista.actualizar({ tablero: new Tablero('Taller Nómada', resultado) });
}, 300);
$('#buscador').addEventListener('input', (evento) => buscar(evento.target.value));El debounce reduce diez peticiones a una o dos; el abort garantiza que, de las que se llegan a lanzar, solo la última pinta. Las dos piezas juntas, no una sola.
- Timeouts:
Promise.race frente a AbortSignal.timeout()
Promise.race frente a AbortSignal.timeout()fetch no tiene timeout. Una petición puede esperar minutos si el servidor acepta la conexión y no contesta nunca. Hay dos formas de imponer un límite.
La clásica, con Promise.race (05-06):
/** Corre la promesa contra un reloj: gana la primera que se asiente. */
function conLimite(promesa, ms) {
const reloj = new Promise((_, rechazar) => {
setTimeout(() => rechazar(new Error(`Tiempo agotado tras ${ms} ms`)), ms);
});
return Promise.race([promesa, reloj]);
}
const datos = await conLimite(fetch(url).then((r) => r.json()), 5000);Funciona, pero tiene un defecto grave: la petición sigue en marcha. Promise.race decide quién gana la carrera, no detiene al perdedor. El servidor sigue procesando, los bytes siguen descargándose y la conexión sigue ocupada. Con muchos timeouts seguidos, acumulas peticiones zombis.
La moderna, con AbortSignal.timeout(), que cancela de verdad:
const datos = await pedirJson(url, { signal: AbortSignal.timeout(5000) });
// Si pasan 5 s, la petición se ABORTA y el error tiene name 'TimeoutError'Una línea, y la petición se corta de raíz. La comparación:
Promise.race |
AbortSignal.timeout(ms) |
|
|---|---|---|
| Corta la petición de verdad | No, sigue en marcha | Sí |
| Libera la conexión | No | Sí |
| Código necesario | Una función auxiliar | Nada, es nativo |
| Nombre del error | El que tú pongas | TimeoutError |
| Sirve para cualquier promesa | Sí (cálculos, otras APIs) | Solo para lo que acepte signal |
| Disponibilidad | Universal | Navegadores modernos |
La conclusión práctica: usa AbortSignal.timeout() para peticiones de red, y reserva Promise.race para poner límite a promesas que no aceptan señales. Conocer las dos importa porque Promise.race sigue siendo la herramienta general.
Y una advertencia sobre el valor: un timeout demasiado corto convierte una red lenta en un fallo. Cifras razonables para empezar: 5 s para lecturas que bloquean la pantalla, 10-15 s para escrituras que el usuario ya ha confirmado, y más margen para subidas de archivos.
AbortSignal.any(): combinar señales
AbortSignal.any(): combinar señalesA veces una petición debe cancelarse por dos motivos distintos: porque venció el tiempo, o porque el usuario se fue de la pantalla. AbortSignal.any() fabrica una señal que se aborta en cuanto cualquiera de las que le pasas se aborta.
const controladorVista = new AbortController(); // se aborta al desmontar la vista
const senal = AbortSignal.any([
controladorVista.signal, // el usuario se fue
AbortSignal.timeout(8000) // o tardó demasiado
]);
const tareas = await pedirJson(url, { signal: senal });Es la contrapartida de Promise.any de 05-06, pero para cancelaciones: la primera que se dispare gana. Aplicado en el proyecto:
// js/datos/http.js
/** Señal combinada: timeout propio + cancelación externa (por ejemplo, al desmontar). */
export function senalCon(ms, externa) {
const propias = [AbortSignal.timeout(ms)];
if (externa) propias.push(externa);
return AbortSignal.any(propias);
}Si tu navegador objetivo no tiene AbortSignal.any, el equivalente manual es corto y merece la pena entenderlo:
function combinar(senales) {
const controlador = new AbortController();
for (const senal of senales) {
if (senal.aborted) { controlador.abort(senal.reason); break; }
senal.addEventListener('abort', () => controlador.abort(senal.reason), { once: true });
}
return controlador.signal;
}
- Reintentos con retroceso exponencial y jitter
Un 503 durante un despliegue dura segundos. Reintentar tiene sentido… pero no de cualquier manera.
Reintentar inmediatamente empeora las cosas: si el servidor está saturado, mil clientes reintentando a la vez lo rematan. La técnica correcta se llama retroceso exponencial (exponential backoff): esperar cada vez más entre intentos.
intento 1 → falla → espera 300 ms intento 2 → falla → espera 600 ms intento 3 → falla → espera 1200 ms intento 4 → falla → se rinde
Y falta un ingrediente. Si mil clientes fallan a la vez y todos esperan exactamente 300 ms, vuelven todos a la vez 300 ms después. Ese pico sincronizado se llama thundering herd. La solución es el jitter: añadir un componente aleatorio a la espera para desparramar los reintentos.
// js/datos/http.js
const dormir = (ms) => new Promise((resolver) => setTimeout(resolver, ms));
/**
* Ejecuta `operacion()` reintentando los fallos reintentables.
* @param {Function} operacion Función asíncrona SIN argumentos (ciérrala con un closure)
*/
export async function conReintentos(operacion, {
intentos = 3,
baseMs = 300,
maxMs = 5000,
senal = null
} = {}) {
let ultimoError;
for (let intento = 1; intento <= intentos; intento += 1) {
try {
return await operacion();
} catch (error) {
ultimoError = error;
// No reintentar lo que no tiene remedio: 400, 401, 403, 404, cancelaciones, formato
if (!(error instanceof ErrorDeApi) || !error.reintentable) throw error;
if (intento === intentos) break;
if (senal?.aborted) throw error;
// El servidor puede decir cuánto esperar (429 con Retry-After); si no, exponencial
const sugerido = Number(error.detalle?.retryAfter) * 1000;
const exponencial = Math.min(baseMs * 2 ** (intento - 1), maxMs);
const jitter = Math.random() * exponencial * 0.3; // ±30 % de dispersión
const espera = Number.isFinite(sugerido) && sugerido > 0 ? sugerido : exponencial + jitter;
console.warn(`[nomada] Intento ${intento}/${intentos} falló (${error.codigo}). Reintento en ${Math.round(espera)} ms.`);
await dormir(espera);
}
}
throw ultimoError;
}// Uso: la operación se envuelve en una flecha para poder repetirla
const tareas = await conReintentos(
() => pedirJson(construirUrl('/tareas'), { signal: AbortSignal.timeout(5000) }),
{ intentos: 3 }
);Fíjate en que la operación es una función, no una promesa. Una promesa ya lanzada no se puede "volver a lanzar": hay que poder crear una nueva en cada intento. Es el mismo motivo por el que conLimite recibía una promesa (solo la observa) y conReintentos recibe una función (la ejecuta varias veces).
- Idempotencia: qué se puede reintentar
Antes de reintentar cualquier cosa, la pregunta decisiva: ¿qué pasa si la petición sí llegó y solo se perdió la respuesta?
sequenceDiagram
participant C as Cliente
participant S as Servidor
C->>S: POST /v1/tareas {"titulo":"Revisar prensa"}
S->>S: Crea la tarea 7 ✅
S--xC: La respuesta se pierde (red caída)
Note over C: El cliente cree que falló
C->>S: POST /v1/tareas {"titulo":"Revisar prensa"}
S->>S: Crea la tarea 8 ❌ ¡DUPLICADA!
Por eso la idempotencia de 07-02 deja de ser teoría:
| Verbo | ¿Idempotente? | ¿Reintentar sin más? |
|---|---|---|
GET |
Sí | Sí, siempre |
HEAD |
Sí | Sí |
PUT /tareas/6 |
Sí (reemplaza con el mismo contenido) | Sí |
DELETE /tareas/6 |
Sí (borrar lo ya borrado no cambia nada) | Sí, tolerando el 404 del segundo intento |
PATCH /tareas/6 {estado:'hecha'} |
Depende: sí si asigna, no si incrementa | Con cuidado |
POST /tareas |
No | No, sin clave de idempotencia |
La solución estándar para poder reintentar un POST es la clave de idempotencia: el cliente genera un identificador único de la operación y lo envía en una cabecera. El servidor recuerda las claves ya procesadas y, si ve una repetida, devuelve el resultado anterior en lugar de crear otro recurso.
export async function crearTarea(datos) {
const clave = crypto.randomUUID(); // ← la MISMA en todos los reintentos
return conReintentos(() => pedirJson(construirUrl('/tareas'), {
method: 'POST',
headers: { ...CABECERAS_JSON, 'Idempotency-Key': clave },
body: JSON.stringify(datos),
signal: AbortSignal.timeout(10000)
}), { intentos: 3 });
}Lo crucial es que crypto.randomUUID() se llama fuera del closure que se reintenta: si estuviera dentro, cada intento generaría una clave distinta y volverías al problema de los duplicados. Y esto solo funciona si el servidor implementa la cabecera; si no lo hace, la regla es simple: no reintentes los POST automáticamente. Ofrece un botón «Reintentar» y que decida la persona.
- Paralelo sin fragilidad:
allSettled frente a all
allSettled frente a allEl panel de Marta necesita tres cosas: las tareas, las horas del equipo y los avisos. Pedirlas en secuencia suma latencias; hacerlo en paralelo las solapa. Pero la elección del combinador importa mucho, y retoma directamente 05-06.
// ✗ Frágil: si los avisos fallan, no hay tablero
const [tareas, horas, avisos] = await Promise.all([
listarTareas(), listarHorasEquipo(), listarAvisos()
]);Promise.all rechaza en cuanto una rechaza. Un 500 en el endpoint menos importante deja la pantalla en blanco. Para un panel compuesto de partes independientes, eso es un mal reparto de consecuencias.
// ✓ Robusto: cada parte se pinta si pudo, y las que fallaron se marcan
const resultados = await Promise.allSettled([
listarTareas(), listarHorasEquipo(), listarAvisos()
]);
const [tareas, horas, avisos] = resultados.map((r) =>
r.status === 'fulfilled' ? r.value : null
);
if (tareas === null) {
vista.mostrarError('No se pudo cargar el tablero.', { reintentar: () => arrancar() });
} else {
vista.actualizar({ tablero: new Tablero('Taller Nómada', tareas) });
if (horas === null) vista.marcarSeccionCaida('#horas-equipo');
if (avisos === null) vista.marcarSeccionCaida('#avisos');
}El recordatorio de los cuatro combinadores, ahora con el criterio de cuándo usar cada uno:
| Combinador | Se asienta cuando… | Úsalo cuando… |
|---|---|---|
Promise.all |
todas cumplen, o una falla | Las partes son indispensables entre sí (no puedes pintar nada sin todas) |
Promise.allSettled |
todas terminan, pase lo que pase | Partes independientes de un panel. El caso habitual en interfaces |
Promise.race |
la primera que se asienta, cumpla o falle | Timeouts, "lo que llegue antes" |
Promise.any |
la primera que cumple | Varios espejos de un mismo dato, vale cualquiera |
Y una advertencia sobre el paralelismo: lanzar cincuenta peticiones a la vez no es más rápido, es una forma de saturar al servidor y al navegador (que limita las conexiones simultáneas por origen). Cuando tengas muchas, trocéalas en lotes.
/** Ejecuta las operaciones en lotes de `tamano`, en lugar de todas a la vez. */
export async function porLotes(operaciones, tamano = 5) {
const salida = [];
for (let i = 0; i < operaciones.length; i += tamano) {
const lote = operaciones.slice(i, i + tamano);
salida.push(...await Promise.allSettled(lote.map((op) => op())));
}
return salida;
}
- Los cuatro estados de la interfaz
Una pantalla que habla con una red nunca tiene dos estados, tiene cuatro. Programar solo "con datos" y "sin datos" es la causa de las pantallas en blanco eternas y de los "Cargando…" que no acaban.
stateDiagram-v2
[*] --> Inicial
Inicial --> Cargando: arrancar()
Cargando --> Exito: llegan datos
Cargando --> Vacio: llegan 0 tareas
Cargando --> Error: ErrorDeApi
Error --> Cargando: reintentar()
Exito --> Cargando: refrescar()
Vacio --> Cargando: refrescar()
Exito --> Exito: cambio local (optimista)
| Estado | Qué se muestra | Qué NO hacer |
|---|---|---|
| Cargando | Indicador o esqueleto de contenido | Pantalla en blanco sin explicación |
| Éxito | Los datos | — |
| Vacío | «Aún no hay tareas» + acción para crear una | Confundirlo con un error, o con "cargando" |
| Error | Qué pasó, en lenguaje llano, + botón Reintentar | «Error: TypeError: Failed to fetch» |
El estado vacío es el que más se olvida, y el que más desconcierta: una lista vacía sin mensaje es indistinguible de una lista que no ha cargado. Y el estado error debe traer siempre una salida; un mensaje sin botón deja al usuario con la única opción de recargar a mano.
Implementado con lo que ya tienes de 06-06:
// js/vista/tablero-vista.js (ampliación)
const ESTADOS_UI = Object.freeze({ INICIAL: 'inicial', CARGANDO: 'cargando',
EXITO: 'exito', VACIO: 'vacio', ERROR: 'error' });
export class TableroVista {
// …lo que ya había…
#ui = { fase: ESTADOS_UI.INICIAL, error: null };
/** Único punto de cambio de fase: imposible dejar la pantalla en un estado inconsistente. */
#cambiarFase(fase, { error = null } = {}) {
this.#ui = { fase, error };
this.#pintarFase();
}
#pintarFase() {
const { fase, error } = this.#ui;
$('#cargando').hidden = fase !== ESTADOS_UI.CARGANDO;
$('#vacio').hidden = fase !== ESTADOS_UI.VACIO;
$('#error').hidden = fase !== ESTADOS_UI.ERROR;
this.#contenedor.hidden = fase !== ESTADOS_UI.EXITO;
if (fase === ESTADOS_UI.ERROR) {
$('#error-mensaje').textContent = error.message; // textContent, no innerHTML (06-02)
$('#error-reintentar').hidden = !error.reintentable;
}
}
async cargarDesdeApi(api) {
this.#cambiarFase(ESTADOS_UI.CARGANDO);
try {
const tareas = await conReintentos(() => api.listarTareas(), { intentos: 3 });
this.#estado.tablero = new Tablero('Taller Nómada', tareas);
this.#cambiarFase(tareas.length === 0 ? ESTADOS_UI.VACIO : ESTADOS_UI.EXITO);
this.render();
} catch (error) {
if (error.codigo === 'cancelado') return; // no es un fallo
this.#cambiarFase(ESTADOS_UI.ERROR, { error });
}
}
}La clave de diseño es #cambiarFase: un único punto por el que pasan todas las transiciones. Sin él, acabas con seis sitios que ponen y quitan hidden y, tarde o temprano, con el indicador de carga y el mensaje de error visibles a la vez.
- Avisos accesibles con
aria-live
aria-liveEn 06-07 aprendiste que un mensaje que solo se ve en rojo no existe para quien no ve la pantalla. Con la red, el problema se agrava: los cambios ocurren solos, sin que el usuario haya hecho nada, y un lector de pantalla no los anuncia salvo que se lo pidas.
<!-- Estado de carga y errores, anunciados automáticamente -->
<p id="cargando" class="aviso" role="status" aria-live="polite" hidden>Cargando tareas…</p>
<div id="error" class="aviso aviso--error" role="alert" hidden>
<p id="error-mensaje"></p>
<button type="button" id="error-reintentar">Reintentar</button>
</div>
<p id="vacio" class="aviso" hidden>Aún no hay tareas. <button type="button" id="crear-primera">Crear la primera</button></p>| Atributo | Cuándo anuncia | Para qué |
|---|---|---|
aria-live="polite" |
Al terminar lo que el lector esté diciendo | Cambios de estado, «Cargando», «3 tareas» |
aria-live="assertive" |
Interrumpe de inmediato | Solo urgencias reales |
role="status" |
Equivale a polite |
Mensajes informativos |
role="alert" |
Equivale a assertive |
Errores que exigen atención |
Cuatro reglas prácticas:
- El contenedor debe existir en el DOM antes de meterle texto. Si lo creas y le pones el mensaje en el mismo instante, muchos lectores no lo anuncian. De ahí el
hiddenen lugar de crear y destruir. - No abuses de
assertive. Interrumpir en mitad de una frase es agresivo; resérvalo para errores. - Amortigua los avisos repetitivos. Un
aria-liveque cambia con cada tecla convierte al lector en una ametralladora: es el mismo argumento que justificaba eldebounceen 06-07. - Mueve el foco al botón «Reintentar» cuando aparezca un error tras una acción del usuario. Un mensaje anunciado pero inalcanzable con el teclado sirve de poco.
- Optimistic UI: aplicar antes de confirmar
Marta marca una tarea como hecha. Si esperas la respuesta del servidor para mover la tarjeta, la interfaz se siente lenta aunque tarde 200 ms. La técnica optimista consiste en aplicar el cambio de inmediato, enviar la petición en segundo plano y revertir si falla.
sequenceDiagram
participant M as Marta
participant V as Vista
participant A as API
M->>V: clic en "marcar hecha"
V->>V: 1 · guarda copia del estado anterior
V->>V: 2 · aplica el cambio y repinta ⚡ (instantáneo)
V->>A: 3 · PATCH /tareas/6 {"estado":"hecha"}
alt Éxito
A-->>V: 200 OK
V->>V: confirma (quita la marca de "pendiente de sincronizar")
else Fallo
A-->>V: 500 / red caída
V->>V: 4 · REVIERTE al estado guardado
V->>M: «No se pudo guardar. Reintentar»
end
Aquí es donde rinden las actualizaciones inmutables de 04-07: como no mutaste el estado anterior, revertir es sencillamente volver a usarlo.
// js/vista/controlador.js
async function cambiarEstadoOptimista(id, nuevoEstado) {
const anterior = tablero.buscarPorId(id).toJSON(); // 1 · foto del estado previo
tablero.cambiarEstado(id, nuevoEstado); // 2 · cambio local inmediato
vista.render();
vista.marcarPendiente(id, true); // señal visual sutil de "sin confirmar"
try {
await conReintentos(
() => api.actualizarTarea(id, { estado: nuevoEstado }),
{ intentos: 2 }
);
vista.marcarPendiente(id, false); // 3 · confirmado
repositorio.guardar(tablero);
} catch (error) {
tablero.reemplazar(Tarea.desdeJSON(anterior)); // 4 · revertir
vista.render();
vista.avisar(`No se pudo guardar «${anterior.titulo}». ${error.message}`, {
accion: { texto: 'Reintentar', alPulsar: () => cambiarEstadoOptimista(id, nuevoEstado) }
});
}
}Cuándo usarla y cuándo no:
| Usa optimismo cuando… | Evítalo cuando… |
|---|---|
| El cambio casi siempre tiene éxito | El servidor puede rechazarlo a menudo |
| Es fácil de revertir (un estado, un texto) | Hay efectos irreversibles (cobros, correos enviados) |
| El usuario percibe la latencia | La operación tarda igualmente (subir un archivo) |
| El dato es del propio usuario | Otros pueden haberlo cambiado a la vez |
Y una regla de honestidad: si aplicas un cambio optimista, márcalo. Un punto, una opacidad ligera, un icono de "sincronizando". Que el usuario cierre el portátil creyendo que algo se guardó cuando no fue así es peor que hacerle esperar 200 ms.
- Nómada Tareas:
js/datos/http.js y la vista
js/datos/http.js y la vistaTodas las piezas juntas. El módulo http.js queda como capa genérica reutilizable, y api-tareas.js se apoya en él:
// js/datos/http.js — resumen de lo que exporta
export { pedirJson }; // fetch + clasificación de errores en ErrorDeApi
export { conReintentos }; // retroceso exponencial con jitter, solo para errores reintentables
export { senalCon }; // AbortSignal.any([timeout, externa])
export { porLotes }; // paralelismo limitado con allSettled// js/datos/api-tareas.js — reescrito sobre http.js
import { pedirJson, conReintentos, senalCon } from './http.js';
import { Tarea } from '../modelo/tarea.js';
const TIEMPOS = Object.freeze({ lectura: 5000, escritura: 10000 });
export function crearApiTareas({ 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: idempotente, se reintenta sin miedo. */
async listarTareas(filtros = {}) {
const planas = await conReintentos(
() => pedirJson(url('/tareas', filtros), { signal: senalCon(TIEMPOS.lectura, signal) }),
{ intentos: 3, senal: signal }
);
return planas.map((d) => Tarea.desdeJSON(d));
},
/** Creación: NO idempotente. Un solo intento + clave, y que el usuario decida si reintenta. */
async crearTarea(datos) {
const plana = await pedirJson(url('/tareas'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify(datos),
signal: senalCon(TIEMPOS.escritura, signal)
});
return Tarea.desdeJSON(plana);
},
/** PATCH que asigna un valor fijo: idempotente, reintentable. */
async actualizarTarea(id, cambios) {
const plana = await conReintentos(
() => pedirJson(url(`/tareas/${id}`), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cambios),
signal: senalCon(TIEMPOS.escritura, signal)
}),
{ intentos: 2 }
);
return Tarea.desdeJSON(plana);
},
/** DELETE: idempotente. Un 404 en el segundo intento significa que el primero funcionó. */
async borrarTarea(id) {
try {
await conReintentos(
() => pedirJson(url(`/tareas/${id}`), { method: 'DELETE', signal: senalCon(TIEMPOS.escritura, signal) }),
{ intentos: 2 }
);
} catch (error) {
if (error.status !== 404) throw error; // ya estaba borrada: es el resultado deseado
}
return true;
}
};
}Y el arranque completo de la aplicación, con local primero, red después, y todos los estados cubiertos:
// js/app.js
const controladorApp = new AbortController();
const api = crearApiTareas({ base: 'http://localhost:3000', signal: controladorApp.signal });
const repositorio = new RepositorioLocal();
async function arrancar() {
// 1 · Lo local se pinta al instante: nunca una pantalla en blanco si hay datos
const local = repositorio.cargar();
if (local !== null) { vista.actualizar({ tablero: local }); }
// 2 · La verdad del servidor, con todos los estados manejados
await vista.cargarDesdeApi(api);
repositorio.guardar(vista.tablero);
}
$('#error-reintentar').addEventListener('click', arrancar, { signal: controladorApp.signal });
window.addEventListener('online', () => arrancar(), { signal: controladorApp.signal });
window.addEventListener('offline', () => vista.avisar('Sin conexión. Los cambios se guardan en local.'),
{ signal: controladorApp.signal });
arrancar();Ese único controladorApp cancela de golpe las peticiones en vuelo y todos los oyentes cuando la aplicación se desmonte. Un AbortController para toda la vida de la aplicación, y otros efímeros para operaciones concretas como la búsqueda: ese es el reparto habitual.
Errores Comunes y Consejos
- Tratar
AbortErrorcomo un fallo. Enseñar «Error al cargar» cuando eres tú quien canceló es un bug clásico. Filtra siempre porerror.name === 'AbortError'o, conpedirJson, porcodigo === 'cancelado'. - Reutilizar un
AbortControllerya abortado. Su señal queda abortada para siempre y la siguiente petición muere al instante. Crea uno nuevo por operación. - Reintentar un
POSTsin clave de idempotencia. Crea duplicados invisibles: el usuario ve una tarea, la base de datos tiene tres. - Reintentar un
400o un403. Nunca funcionará; solo añade latencia y ruido en los registros. - Reintentar sin jitter. Sincroniza a todos los clientes y golpea al servidor en oleadas justo cuando peor está.
- Confiar en
Promise.racecomo si cancelara. No cancela: solo ignora al perdedor, que sigue consumiendo recursos. - No poner ningún timeout. «Cargando…» eterno.
fetchno lo hace por ti. - Usar
Promise.allpara un panel de partes independientes. Un fallo secundario deja la pantalla vacía.allSettled. - Olvidar el estado vacío. Una lista sin elementos y sin mensaje parece rota.
- Mostrar el mensaje técnico al usuario.
TypeError: Failed to fetchno significa nada para Marta. Traduce, y guarda el detalle técnico en la consola. - Aplicar un cambio optimista sin poder revertirlo. Si mutaste el estado anterior, no hay marcha atrás. Las actualizaciones inmutables de 04-07 son el requisito, no un adorno.
- Consejo: prueba los fallos a propósito. En DevTools → Network puedes simular Offline y Slow 3G, y
https://httpstat.us/503te devuelve el código que quieras. Un manejo de errores que nunca se ha ejecutado no está probado. - Consejo: registra el error técnico completo, muestra el humano.
console.error(error.describir(), error.causa)en la consola; en pantalla, la frase corta. - Consejo: un timeout por tipo de operación. Leer y escribir no merecen el mismo margen.
- Consejo: no reintentes en bucle infinito. Tres intentos y una salida clara. La insistencia eterna agota batería y paciencia.
Ejercicios
Ejercicio 1 — Reintentos con Retry-After.
Amplía conReintentos para que, cuando el error sea un 429, lea la cabecera Retry-After de la respuesta y espere exactamente ese tiempo en lugar de aplicar el retroceso exponencial. Necesitarás que pedirJson guarde esa cabecera en el ErrorDeApi. La cabecera puede venir en segundos (Retry-After: 30) o como fecha HTTP (Retry-After: Wed, 20 Sep 2026 10:00:00 GMT): resuelve los dos formatos y limita la espera a un máximo de 60 segundos.
Ejercicio 2 — Buscador cancelable con estados.
Escribe crearBuscador({ campo, alCambiar, alError, retardo = 300 }) que devuelva una función buscar(texto) y una función cancelar(). Debe amortiguar con debounce, cancelar la petición anterior en cada nueva búsqueda, ignorar los AbortError, imponer un timeout de 4 segundos y llamar a alCambiar con { fase: 'cargando' | 'exito' | 'vacio' | 'error', tareas, error }. Que un texto vacío cancele lo que hubiera en vuelo y devuelva la fase 'inicial'.
Ejercicio 3 — Cambio de estado optimista con reversión.
Escribe crearAccionOptimista({ tablero, vista, api, repositorio }) que devuelva cambiarEstado(id, nuevoEstado) con el ciclo completo: foto del estado previo con toJSON(), cambio local, render, marca de pendiente, petición con dos reintentos, y en caso de fallo reversión con Tarea.desdeJSON, render y aviso con acción de reintento. Añade una salvaguarda: si ya hay una operación en vuelo para esa misma tarea, ignora la nueva pulsación en lugar de encadenar dos cambios optimistas.
Soluciones
Solución 1
// En pedirJson, al construir el error de respuesta:
throw new ErrorDeApi(mensaje, {
status: respuesta.status,
codigo,
url: String(url),
detalle,
reintentarTras: respuesta.headers.get('retry-after') // ← se guarda tal cual llegó
});/** Convierte la cabecera Retry-After (segundos o fecha HTTP) en milisegundos de espera. */
function esperaSugerida(cabecera, maxMs = 60000) {
if (!cabecera) return null;
const segundos = Number(cabecera);
if (Number.isFinite(segundos)) return Math.min(segundos * 1000, maxMs);
const fecha = Date.parse(cabecera); // formato fecha HTTP
if (Number.isNaN(fecha)) return null;
return Math.min(Math.max(fecha - Date.now(), 0), maxMs);
}// Dentro del catch de conReintentos, sustituyendo el cálculo de la espera:
const sugerida = esperaSugerida(error.reintentarTras);
const exponencial = Math.min(baseMs * 2 ** (intento - 1), maxMs);
const espera = sugerida ?? (exponencial + Math.random() * exponencial * 0.3);El Number(cabecera) distingue los dos formatos sin expresiones regulares: Number('30') da 30, mientras que Number('Wed, 20 Sep…') da NaN y cae al Date.parse. Y el tope de 60 s protege de un servidor que pida esperar una hora: pasado cierto punto, es mejor rendirse y dejar que el usuario decida.
Solución 2
import { debounce } from '../util/tiempo.js';
import { pedirJson } from './http.js';
import { Tarea } from '../modelo/tarea.js';
export function crearBuscador({ base, alCambiar, retardo = 300 }) {
let controlador = null;
function cancelar() {
controlador?.abort();
controlador = null;
}
const lanzar = debounce(async (texto) => {
cancelar(); // 1 · mata la anterior
controlador = new AbortController();
alCambiar({ fase: 'cargando', tareas: [], error: null });
try {
const url = new URL(`${base}/tareas`);
url.searchParams.set('q', texto);
const planas = await pedirJson(url, {
signal: AbortSignal.any([controlador.signal, AbortSignal.timeout(4000)])
});
const tareas = planas.map((d) => Tarea.desdeJSON(d));
alCambiar({ fase: tareas.length === 0 ? 'vacio' : 'exito', tareas, error: null });
} catch (error) {
if (error.codigo === 'cancelado') return; // 2 · la sustituye una búsqueda más nueva
alCambiar({ fase: 'error', tareas: [], error });
}
}, retardo);
function buscar(texto) {
const limpio = texto.trim();
if (limpio === '') {
cancelar();
alCambiar({ fase: 'inicial', tareas: [], error: null });
return;
}
lanzar(limpio);
}
return { buscar, cancelar };
}Fíjate en el orden dentro de lanzar: primero se cancela la anterior, después se crea el controlador nuevo y solo entonces se anuncia «cargando». Al revés, la cancelación de la anterior podría llegar a sobrescribir la fase que acabas de poner. Y AbortSignal.any combina las dos razones para cancelar —una búsqueda más nueva, o el timeout— en una sola señal.
Solución 3
export function crearAccionOptimista({ tablero, vista, api, repositorio }) {
const enVuelo = new Set(); // ids con una operación pendiente
async function cambiarEstado(id, nuevoEstado) {
if (enVuelo.has(id)) return false; // salvaguarda: una a la vez por tarea
enVuelo.add(id);
const anterior = tablero.buscarPorId(id).toJSON(); // foto inmutable del estado previo
try {
tablero.cambiarEstado(id, nuevoEstado); // puede lanzar ErrorDeValidacion por la R6
} catch (error) {
enVuelo.delete(id);
vista.avisar(error.message);
return false;
}
vista.render();
vista.marcarPendiente(id, true);
try {
await conReintentos(() => api.actualizarTarea(id, { estado: nuevoEstado }), { intentos: 2 });
vista.marcarPendiente(id, false);
repositorio.guardar(tablero);
return true;
} catch (error) {
if (error.codigo === 'cancelado') return false;
tablero.reemplazar(Tarea.desdeJSON(anterior)); // reversión
vista.render();
vista.avisar(`No se pudo guardar «${anterior.titulo}».`, {
accion: { texto: 'Reintentar', alPulsar: () => cambiarEstado(id, nuevoEstado) }
});
return false;
} finally {
enVuelo.delete(id); // pase lo que pase, se libera
}
}
return { cambiarEstado, hayPendientes: () => enVuelo.size > 0 };
}Tres detalles importantes. El Set de ids en vuelo evita que dos pulsaciones rápidas encadenen dos cambios optimistas con reversiones cruzadas —un origen habitual de estados imposibles—. El finally libera el id siempre, incluidos los caminos de error y el return temprano. Y la validación local (cambiarEstado del tablero, que aplica la regla R6) se hace antes de pintar nada: no tiene sentido ser optimista con un cambio que tu propio modelo rechaza.
Conclusión
Esta lección es la frontera entre un ejercicio y un producto. Tienes el mapa completo de los siete fallos de una petición —red, DNS, CORS, 4xx, 5xx, respuesta no-JSON y respuesta lenta— y, para cada uno, cómo detectarlo, si merece reintento y qué contarle al usuario; con las dos ideas que gobiernan todo lo demás: solo los fallos de transporte hacen rechazar a fetch, y la respuesta lenta no se detecta sola, tienes que provocarla tú con un timeout. Ese conocimiento vive encapsulado en ErrorDeApi extends Error, con su status, su codigo y sus getters reintentable y requiereSesion, exactamente el mismo patrón que te dio ErrorDeValidacion con su campo campo en 05-02.
Sabes escribir pedirJson, que traduce cualquier fallo a un ErrorDeApi clasificado, comprueba el content-type para cazar el HTML disfrazado de JSON, respeta el 204 sin cuerpo y clona la respuesta antes de leer el detalle del error. Dominas AbortController: el par controlador/señal, aborted y reason, el uso de una sola señal para cancelar peticiones y oyentes a la vez, la regla de que un controlador es de un solo uso, y la obligación de distinguir AbortError de un error real —cancelar a propósito no es fallar—. Lo has aplicado al caso que lo justifica: el buscador donde debounce reduce las peticiones y abort garantiza que solo la última pinte, cerrando la race condition de las respuestas que vuelven desordenadas.
Sabes imponer timeouts y por qué AbortSignal.timeout() es superior a Promise.race —el primero corta la petición, el segundo solo ignora al perdedor mientras sigue consumiendo la conexión—, y combinar motivos de cancelación con AbortSignal.any(). Sabes reintentar con retroceso exponencial y jitter, entiendes por qué el jitter existe (el thundering herd) y, sobre todo, sabes qué se puede reintentar: GET, PUT y DELETE sin problema, POST solo con clave de idempotencia generada fuera del closure que se repite. Sabes elegir combinador para el paralelismo, con Promise.allSettled como opción por defecto en interfaces porque un fallo secundario no debe vaciar la pantalla, y trocear en lotes cuando las peticiones son muchas.
Y sabes que una pantalla conectada a una red tiene cuatro estados, no dos: cargando, éxito, vacío y error con reintento; que las transiciones deben pasar por un único punto para no dejar la interfaz en estados imposibles; que los avisos necesitan aria-live, role="status" y role="alert" para existir también para quien no ve la pantalla; y que el optimistic UI —aplicar el cambio, enviar, revertir si falla— solo es posible porque las actualizaciones inmutables de 04-07 conservan intacto el estado anterior, y solo es honesto si marcas visualmente lo que aún no está confirmado.
Con js/datos/http.js y js/datos/api-tareas.js reescritos, Nómada Tareas ya es una aplicación conectada que aguanta el mundo real. Pero le queda un límite conceptual, no técnico: el navegador solo se entera de las cosas cuando pregunta. Si Iván mueve una tarea desde su portátil, el tablero de Marta seguirá mostrando el estado antiguo hasta que ella recargue o pulse refrescar. Podrías preguntar cada cinco segundos, pero eso es gastar batería y ancho de banda para que casi siempre la respuesta sea «nada nuevo». Lo que hace falta es que el servidor pueda hablar primero: una conexión permanente y bidireccional por la que los cambios lleguen solos, en el instante en que ocurren. Eso es WebSockets, donde el tablero de Taller Nómada dejará de ser una copia por persona para convertirse en uno solo, compartido y vivo.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
