← El DOM: la web que responde

Sesión 5

Datos remotos

Tres horas. Media hora para entender por qué JavaScript no espera y cómo se escribe código que sí lo parece, y dos horas y media cargando tu catálogo desde la red con todos los estados que eso implica.

Hoy · Hoja de ruta

  1. 1. Aprende: Por qué JavaScript no espera a lo lento, qué es una promesa, cómo se escribe código asíncrono que se lee en orden, y qué puede salir mal al pedir datos a un servidor.
  2. 2. Haz: Predice órdenes de ejecución, reescribe tus simulaciones con await y carga el catálogo desde la red con sus estados.
  3. 3. Comprueba: Con la red simulada lenta o caída, tu página se comporta bien.

Antes de empezar · 5 minutos, sin apuntes

  1. Si pedir datos a un servidor tarda dos segundos, ¿qué debería hacer la página mientras tanto?
  2. ¿En qué orden crees que se ejecutan tres líneas si la de en medio tarda?
  3. ¿Qué ve tu usuario mientras los datos tardan, y qué si el servidor responde con un error?

Se explica

25 minutos · conceptos y demostración

Toda la sesión sale de un hecho: JavaScript tiene un solo hilo y no puede permitirse esperar. De ahí vienen las promesas, await, y los cuatro estados que toda carga de datos tiene que contemplar.

Un solo hilo

JavaScript ejecuta una cosa cada vez. Si una operación bloquea, se bloquea todo: no responden los clics, no se desplaza la página, no se reproduce una animación.

Por eso las operaciones lentas —una petición de red, un temporizador, leer un fichero— no se esperan. Se encargan, y el resultado llega después.

console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");

// 1, 3, 2

Aunque el retraso sea cero. La función encargada se pone en cola y se ejecuta cuando el trabajo actual termina.

Cómo se reparte el trabajo
  1. Se ejecuta el código actual hasta el final
  2. Las tareas lentas se encargan fuera
  3. Cuando terminan, su función entra en la cola
  4. El bucle de eventos la ejecuta cuando hay hueco

De ahí sale el fallo característico de la semana:

let productos = [];

setTimeout(() => {
  productos = [{ nombre: "Teclado" }];
}, 1000);

console.log(productos.length);    // 0, no 1

Un valor que llega tarde no se puede leer pronto

El array no está mal: se mira antes de tiempo. La consecuencia práctica es una regla de diseño: todo lo que dependa del dato tiene que ocurrir dentro de lo que se ejecuta cuando el dato llega, no en la línea de después.

Cuando pidas datos al servidor y te salga una lista vacía, esta será la primera sospecha.

Una promesa es un valor futuro

Promesa

Un objeto que representa un resultado que todavía no está: puede quedar cumplida con un valor o rechazada con un error. No contiene el dato, sino el compromiso de proporcionarlo.

Se consumen con then, catch y finally, o con la sintaxis que veremos enseguida:

const promesa = new Promise((resolver, rechazar) => {
  setTimeout(() => resolver("Datos listos"), 1000);
});

promesa
  .then((valor) => console.log(valor))
  .catch((error) => console.error(error))
  .finally(() => console.log("Terminado"));

Casi nunca tendrás que crear promesas: te las darán hechas fetch y casi todas las APIs modernas. Lo que sí harás cada día es consumirlas, y la forma de hacerlo es esta:

async function cargarProductos() {
  try {
    const respuesta = await pedirAlServidor();
    const productos = await respuesta.json();
    return productos;
  } catch (error) {
    console.error(`No se pudieron cargar: ${error.message}`);
    return [];
  }
}

await pausa esa función hasta que la promesa se resuelva, sin bloquear el resto de la página. El código se lee de arriba abajo, como el síncrono, y los errores se capturan con el try/catch de la UD3.

Regla Consecuencia
await solo dentro de async Fuera, es un error de sintaxis (salvo en el nivel superior de un módulo)
Una función async devuelve una promesa Quien la llame necesita await, o .then
const productos = cargarProductos();          // una promesa, no la lista
const productos = await cargarProductos();    // ahora sí, la lista

Ese primer caso —imprimir una promesa creyendo que son los datos— es el error número uno de esta semana. La consola muestra Promise { <pending> }, y ahí está la pista.

Cuando dos peticiones no dependen entre sí, se lanzan a la vez:

// En serie: dos segundos si cada una tarda uno
const productos = await cargarProductos();
const categorias = await cargarCategorias();

// A la vez: un segundo, porque no dependen entre sí
const [productos, categorias] = await Promise.all([
  cargarProductos(),
  cargarCategorias()
]);

Pedir datos

export async function obtenerProductos() {
  const respuesta = await fetch("/api/productos");

  if (!respuesta.ok) {
    throw new Error(`El servidor respondió ${respuesta.status}`);
  }

  return respuesta.json();
}

Un 404 no rechaza la promesa

fetch solo falla si no hubo respuesta: sin red, DNS caído, petición cancelada. Un 404 o un 500 son una respuesta, así que la promesa se cumple y tu código sigue como si nada, con un cuerpo que no es lo que esperabas.

Por eso la comprobación de respuesta.ok no es opcional: es la línea que convierte un error del servidor en un error de tu programa.

Las dos fases importan: fetch resuelve cuando llegan las cabeceras, y .json() es una segunda promesa que se resuelve al terminar de leer el cuerpo. De ahí los dos await.

Los cuatro estados de cualquier carga

Lo que tiene que contemplar toda petición
  1. Cargando
  2. Error
  3. Vacío
  4. Datos
async function cargar() {
  estado.cargando = true;
  estado.error = null;
  actualizar();

  try {
    estado.productos = await obtenerProductos();
  } catch (error) {
    estado.error = "No se pudo cargar el catálogo. Inténtalo de nuevo.";
  } finally {
    estado.cargando = false;
    actualizar();
  }
}

El estado que montaste en la sesión 4 ya tenía sitio para cargando y error: la función de render decide qué pintar en cada caso, y ninguna otra parte del código se entera de nada.

El mensaje de error es para la persona; el detalle, para la consola

«No se pudo cargar el catálogo. Inténtalo de nuevo» es útil. «TypeError: Failed to fetch» no lo es, y además cuenta cosas de tu sistema que no hacen falta ahí.

Registra el error técnico con console.error y muestra el mensaje humano, con una salida: reintentar, volver, avisar.

Por último, si pides datos a otro dominio y no ha dado permiso, el navegador bloquea la respuesta y la consola informa de un error de CORS. No se trata de un defecto del código propio, sino de una política de seguridad del navegador que se resuelve en el servidor. Lo harás tú mismo en la UD6.

Se trabaja

150 minutos · práctica sobre tu propio proyecto

El primer paso se hace en un archivo de pruebas y consiste casi entero en predecir. Del segundo en adelante, el catálogo empieza a venir de fuera.

Paso 1 · Predecir el orden · 25 min

  1. Escribe cinco fragmentos que mezclen código normal y temporizadores; predice el orden por escrito y solo después compruébalo.
  2. Reproduce el fallo del array vacío y explícalo en un comentario.
  3. Monta un contador con setInterval y detenlo con un botón.
  4. Provoca un intervalo no detenido y observa el efecto en consola.
  5. Pon un bucle de diez millones de vueltas dentro de un manejador de clic y describe qué le pasa a la página mientras corre. Es la demostración del hilo único.
Consultar · temporizadores
const id = setTimeout(() => console.log("Una vez"), 1000);
clearTimeout(id);

const otro = setInterval(() => console.log("Cada segundo"), 1000);
clearInterval(otro);

Guarda siempre el identificador: un intervalo que nadie detiene sigue corriendo mientras la página esté abierta.

Paso 2 · Reescribir con await · 35 min

  1. Convierte las simulaciones del paso 1 en funciones async.
  2. Escribe esperar(ms) que devuelva una promesa, y úsala.
  3. Encadena tres operaciones y mide su duración total.
  4. Repite con Promise.all y compara los dos tiempos. Anótalos.
  5. Provoca un rechazo y captúralo con try/catch.
  6. Imprime a propósito la promesa sin await y escribe qué ves y por qué.

Antes de continuar: puedes decir, sin mirar, qué devuelve una función async y qué hace falta para obtener su valor.

Paso 3 · Catálogo desde la red · 45 min

Es el trabajo central de la sesión.

  1. Coloca tu catálogo como fichero .json y cárgalo con fetch.
  2. Comprueba respuesta.ok y lanza un error con el código de estado.
  3. Usa cargando y error del estado que montaste en la sesión 4, y píntalos desde la función de render. Ningún manejador debe tocar el DOM.
  4. Muestra un indicador de carga y un mensaje de error con botón de reintentar.
  5. Comprueba que el estado vacío —un JSON que es una lista sin elementos— se distingue del estado de error. No son lo mismo y no se dicen igual.
  6. Consume además una API pública real y observa su respuesta en la pestaña Network.

Paso 4 · Las cuatro situaciones · 25 min

Con la red local todo va tan rápido que el indicador de carga no se llega a ver. En DevTools, pestaña Network, simula cada situación y rellena la tabla:

Situación Qué se ve ¿Hay salida para la persona? Corrección
Carga normal
Red lenta (Slow 3G)
Sin red (Offline)
El servidor responde 404
El servidor responde 500
La respuesta es una lista vacía

Las dos últimas filas son las que se olvidan. Un 500 con tu comprobación de ok debe producir el mismo mensaje que un 404, y una lista vacía no constituye un error, sino un resultado legítimo que se comunica de otra forma.

Paso 5 · El mensaje de error útil · 20 min

Revisa todos los mensajes de error que muestra tu página y pásalos por estas tres preguntas:

  1. ¿Dice qué ha pasado, en palabras que entienda quien no programó esto?
  2. ¿Ofrece una salida: reintentar, volver, buscar otra cosa?
  3. ¿Filtra el detalle técnico a la consola en lugar de enseñarlo?

Reescribe los que no pasen las tres. Después comprueba una cosa más: que el mensaje de error se anuncia en la región aria-live que montaste en la sesión 4, o tiene la suya. Un error que solo se ve no llega a todo el mundo.

Ampliación si has completado el trabajo

Primero termina y comprueba los cinco pasos. Los dos retos atacan lo que ocurre cuando la red no se porta: ni es instantánea ni es fiable ni respeta el orden.

Reto 1 · Reintentar sin castigar al servidor

Un fallo de red suele ser pasajero. Reintentar es razonable; reintentar mal, no.

  1. Escribe pedirConReintentos(url, intentos = 3) que vuelva a intentarlo si la petición falla.
  2. Espera entre intentos, y que la espera crezca: 1 s, 2 s, 4 s. Averigua cómo se llama esa técnica y escríbelo.
  3. Decide qué errores merecen reintento y cuáles no. Un 500 quizá sí; un 404 no, porque volver a pedir lo mismo dará lo mismo. Documenta tu criterio.
  4. Muestra en la interfaz que se está reintentando, y en qué intento va.
  5. Después del último fallo, el mensaje debe ofrecer un reintento manual.
  6. Explica en tres líneas qué pasaría si mil clientes reintentaran a la vez, cada 100 ms, contra un servidor que ya estaba en apuros. Esa es la razón de que la espera crezca.

Reto 2 · La respuesta que llega tarde

Este fallo es real, frecuente y no da ningún error. Monta una búsqueda que pida los resultados al servidor con cada cambio del campo.

  1. Simula que el servidor tarda un tiempo aleatorio entre 100 y 1500 ms.
  2. Escribe deprisa «teclado». Se lanzan varias peticiones. Observa el resultado final.
  3. Tarde o temprano verás lo siguiente: los resultados de «tec» llegan después de los de «teclado» y sobrescriben la lista. La interfaz muestra una respuesta que no corresponde a lo que hay escrito en el campo. Reprodúcelo y descríbelo por escrito.
  4. Primera defensa: aplica el debounce de la sesión 4. Comprueba que reduce el problema y no lo elimina. Explica por qué.
  5. Segunda defensa: cancela la petición anterior con AbortController cuando llega una nueva. Compruébalo en la pestaña Network, donde las canceladas aparecen marcadas.
  6. Tercera defensa, por si acaso: guarda cuál fue la última consulta lanzada y descarta cualquier respuesta que no corresponda a ella. Implementa esta también y explica por qué conviene tenerla aunque uses AbortController.

Este fallo tiene nombre en la disciplina. Búscalo y escríbelo: lo vas a volver a encontrar en cuanto trabajes con datos remotos.

Objetivo mínimoÓrdenes de ejecución predichos, funciones async con errores capturados, y el catálogo cargado desde JSON con sus cuatro estados.
Si lo tienesLa tabla de las seis situaciones de red contestada y los mensajes de error reescritos y anunciados.
RetoLos reintentos con espera creciente y criterio por código de estado, o la búsqueda protegida contra las respuestas fuera de orden.

Cierre

5 minutos · comprobación y recuerdo

Lista de verificación de la sesión

  • Explicas por qué JavaScript no espera y qué es una promesa.
  • Usas async/await con los errores capturados.
  • Compruebas respuesta.ok en toda petición.
  • Tu página contempla cargando, error, vacío y datos, y los distingue.
  • Has probado con red lenta, sin red y con respuesta de error.
  • Los mensajes de error ofrecen una salida y se anuncian.

Antes de cerrar · 3 minutos, sin mirar

  1. ¿Por qué JavaScript no espera a lo lento?
  2. ¿Qué imprime console.log de una función async llamada sin await?
  3. ¿Cuándo se espera en serie y cuándo con Promise.all?
  4. ¿Por qué un 404 no rechaza la promesa de fetch?
  5. Nombra los cuatro estados de una carga.
  6. ¿Qué es un error de CORS y dónde se resuelve?
Ver respuestas

1 · Porque tiene un solo hilo: esperar bloquearía toda la interfaz.

2 · Una promesa pendiente: falta el await.

3 · En serie cuando la segunda operación necesita el resultado de la primera; a la vez cuando no dependen entre sí.

4 · Porque el 404 es una respuesta válida: la promesa se cumple y hay que mirar ok o status.

5 · Cargando, error, vacío y datos.

6 · Una política del navegador sobre peticiones a otro origen, y se resuelve en el servidor.

Microprueba semanal 5 · 5–10 minutos

Individual, sin IA y sin apuntes.

  1. Predice el orden de tres líneas con un setTimeout de cero milisegundos en medio.
  2. ¿Por qué un 404 no rechaza la promesa de fetch? Escribe la comprobación que falta.
  3. Nombra los cuatro estados de una carga.