Tema
Proyecto: buscador con API
Casi todas las apps que usas a diario buscan algo en un servidor mientras escribes: productos, películas, direcciones. Parece sencillo, pero hacerlo bien tiene trampas: peticiones de más, respuestas que llegan en desorden, errores de red y resultados que no existen. En este proyecto construirás un buscador de Pokémon con la PokéAPI resolviendo cada una de esas trampas como lo haría un profesional.
Objetivo y requisitos
Buscar un Pokémon por nombre o número y mostrar su tarjeta con imagen, tipos y estadísticas.
Requisitos funcionales:
- Buscar mientras el usuario escribe, con debounce (esperar a que deje de teclear). Enter busca de inmediato.
- Estados visibles: inicio, cargando, no encontrado (404) y error (con botón Reintentar).
- Tarjeta con imagen oficial, número, tipos traducidos, altura, peso y barras de estadísticas.
- Historial de búsquedas recientes (máx. 6, sin duplicados) guardado en
localStorage. - Cancelar peticiones viejas con AbortController.
- Caché en memoria con
Map: buscar dos veces lo mismo no vuelve a llamar a la API.
Requisitos técnicos: async/await, manejo de errores completo, datos externos escapados antes de insertarlos como HTML y región aria-live para anunciar resultados.
Demo final
Escribe "pika", espera un momento, prueba con "25", con un nombre que no exista o con los chips de recientes.
Estructura de archivos
txt
buscador-pokemon/
├── index.html
├── styles.css
└── app.jsConoce la API antes de programar
Abre https://pokeapi.co/api/v2/pokemon/pikachu en el navegador. Es un JSON enorme; sólo necesitamos esto:
json
{
"id": 25, "name": "pikachu",
"height": 4, "weight": 60,
"sprites": { "front_default": "...", "other": { "official-artwork": { "front_default": "..." } } },
"types": [{ "slot": 1, "type": { "name": "electric" } }],
"stats": [{ "base_stat": 35, "stat": { "name": "hp" } }]
}- Acepta nombre en minúsculas o número:
/pokemon/25funciona igual. - Si no existe, responde con status 404 (no es un error de red:
fetchno lanza excepción). - La altura viene en decímetros y el peso en hectogramos (repasa Fetch y APIs).
La arquitectura: una pequeña máquina de estados
En lugar de variables sueltas como cargando = true y hayError = false (que pueden quedar en combinaciones imposibles, como cargando y con error a la vez), el estado tiene un campo status con un solo valor posible a la vez:
txt
escribe respuesta OK
inicio ───────────► cargando ─────────────────► ok
│ │
404 │ │ fallo de red / 5xx
no-encontrado ◄───────┘ └──────────────────► error ──(Reintentar)──► cargandorender() sólo tiene que mirar estado.status y pintar la vista correspondiente. Es el mismo patrón estado + render del proyecto anterior.
Paso 1: el HTML
html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Buscador de Pokémon</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<main class="app">
<h1>Pokédex</h1>
<form class="busqueda" id="form-busqueda" role="search">
<label for="campo" class="sr-only">Nombre o número de Pokémon</label>
<input id="campo" type="search" placeholder="Busca: pikachu, 25, eevee…" autocomplete="off" spellcheck="false">
</form>
<div class="recientes" id="recientes"></div>
<section class="resultado" id="resultado" aria-live="polite"></section>
</main>
</body>
</html>role="search" marca la zona como buscador para tecnologías de asistencia, y aria-live="polite" hace que el lector de pantalla anuncie "Buscando…" y luego el resultado, sin que el usuario tenga que mover el foco.
Paso 2: los estilos
La tarjeta usa CSS Grid: imagen a la izquierda y datos a la derecha, y en pantallas angostas una sola columna. Cada tipo tiene su color con una clase tipo--fire, tipo--water, etc., generada desde JS con el nombre que manda la API. Estas son las piezas clave (el CSS completo está en la pestaña CSS del demo, con botón para copiar):
css
.tarjeta { display: grid; grid-template-columns: 150px 1fr; gap: 16px; align-items: center; }
.stats { grid-column: 1 / -1; } /* las stats ocupan todo el ancho */
.tipo--fire { background: #f97316; } .tipo--water { background: #3b82f6; }
.spinner { width: 36px; height: 36px; border: 4px solid #fee2e2; border-top-color: #ef4444; border-radius: 50%; animation: girar .8s linear infinite; }
@keyframes girar { to { transform: rotate(360deg); } }
@media (max-width: 420px) { .tarjeta { grid-template-columns: 1fr; text-align: center; } }Paso 3: debounce
Sin debounce, escribir "charizard" lanza 9 peticiones. Con debounce esperamos 400 ms desde la última tecla y lanzamos sólo una:
js
function debounce(fn, espera) {
let temporizador;
return (...args) => {
clearTimeout(temporizador); // cada tecla reinicia la cuenta
temporizador = setTimeout(() => fn(...args), espera);
};
}Luego const buscarConEspera = debounce(buscar, ESPERA_MS) se usa en el evento input, mientras que el submit del formulario (Enter) llama a buscar directo. Es un closure: temporizador sobrevive entre llamadas porque la función devuelta lo recuerda (repasa Scope y closures).
Paso 4: pedir los datos y distinguir el 404
js
async function obtenerPokemon(termino, signal) {
if (cache.has(termino)) return cache.get(termino);
const respuesta = await fetch(API + encodeURIComponent(termino), { signal });
if (respuesta.status === 404) {
cache.set(termino, null); // también recordamos los "no encontrado"
return null;
}
if (!respuesta.ok) throw new Error(`Error del servidor (${respuesta.status})`);
// ...
}Tres resultados posibles, tres caminos distintos:
| Caso | Qué pasa | Qué devolvemos |
|---|---|---|
| 200 OK | Hay datos | Un objeto pokemon simplificado |
| 404 | No existe | null (no es un error, es una respuesta válida) |
| 500, sin red | Falla | Lanzamos un Error que atrapa buscar() |
simplificar() transforma la respuesta gigante en un objeto pequeño con lo que de verdad usamos. Así, si la API cambia, sólo corriges una función. Y encodeURIComponent evita que caracteres raros (espacios, ?, /) rompan la URL.
Error común: creer que fetch falla con 404
fetch sólo rechaza la promesa si no hubo respuesta (sin red, CORS, petición cancelada). Un 404 o un 500 llegan como respuesta "exitosa" con ok: false. Siempre revisa respuesta.ok o respuesta.status.
Paso 5: AbortController y las condiciones de carrera
Imagina: escribes "mew", haces pausa, y luego "mewtwo". Salen dos peticiones. Si la de "mew" tarda más, llega después y la pantalla muestra a Mew aunque buscaste Mewtwo. Esto se llama condición de carrera (race condition). La solución: cancelar la petición anterior antes de empezar una nueva:
js
async function buscar(texto) {
const termino = normalizar(texto);
controlador?.abort(); // cancela la petición anterior
if (!termino) return setEstado({ status: 'inicio', termino: '' });
controlador = new AbortController();
setEstado({ status: 'cargando', termino });
try {
const pokemon = await obtenerPokemon(termino, controlador.signal);
// ...
} catch (error) {
if (error.name === 'AbortError') return; // la cancelamos nosotros: no es un error
// ...
}
}Al llamar abort(), el fetch pendiente se rechaza con un error llamado AbortError. Lo ignoramos en silencio: no queremos mostrar "Error" por algo que hicimos a propósito. De paso, el navegador corta la descarga y ahorra datos.
Paso 6: caché con Map
Un Map guarda pares clave → valor y es perfecto como caché: has, get y set son instantáneos. En obtenerPokemon guardamos el mismo objeto con tres claves: lo que escribió el usuario, el nombre (cache.set(pokemon.nombre, pokemon)) y el número. Si buscas "25" y luego "pikachu", la segunda búsqueda es instantánea. Y como guardamos null para los 404, tampoco repetimos búsquedas fallidas. Los errores de red no se guardan, para que Reintentar sí vuelva a intentarlo.
¿Por qué no un objeto {}?
Un Map acepta cualquier tipo de clave, sabe su tamaño con cache.size y no hereda propiedades raras como toString o constructor, que en un objeto normal podrían confundirse con un Pokémon llamado así.
Paso 7: render por estado y escapar HTML
renderResultado() usa un objeto vistas con una función por estado (inicio, cargando, no-encontrado, error, ok) y hace resultado.innerHTML = vistas[estado.status](). Sin cadenas de if: agregar un estado nuevo es agregar una propiedad.
En el proyecto anterior usamos createElement + textContent. Para una tarjeta con mucho marcado, los template literals son más legibles, pero entonces hay que escapar cualquier dato externo:
js
function escaparHTML(valor) {
const mapa = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return String(valor).replace(/[&<>"']/g, (c) => mapa[c]);
}Seguridad: el término de búsqueda también es "dato externo"
En la vista de "no encontrado" mostramos lo que escribió el usuario. Si escribe <img src=x onerror=alert(1)> y lo insertas sin escapar, se ejecuta. Escapa todo lo que no escribiste tú: lo que teclea el usuario y lo que responde la API (aunque sea una API confiable, puede ser hackeada o cambiar).
Paso 8: historial de recientes
js
function agregarReciente(nombre) {
estado.recientes = [nombre, ...estado.recientes.filter((n) => n !== nombre)].slice(0, MAX_RECIENTES);
guardarRecientes();
}Una línea hace tres cosas: pone el nombre al inicio, quita el duplicado si ya existía y recorta a 6. Guardamos el nombre oficial que devuelve la API, no lo que escribió el usuario, así "25" y "Pikachu" no generan dos chips distintos. Los chips se crean con textContent y se manejan con un solo listener en el contenedor (delegación).
Código completo: app.js
El HTML está completo en el paso 1 y el CSS en la pestaña CSS del demo. Este es el JavaScript final, idéntico al del demo:
js
// ===== Configuración =====
const API = 'https://pokeapi.co/api/v2/pokemon/';
const CLAVE_RECIENTES = 'academia:pokemon-recientes';
const MAX_RECIENTES = 6;
const ESPERA_MS = 400;
const NOMBRES_STAT = {
hp: 'PS', attack: 'Ataque', defense: 'Defensa',
'special-attack': 'At. esp.', 'special-defense': 'Def. esp.', speed: 'Velocidad'
};
const NOMBRES_TIPO = {
normal: 'Normal', fire: 'Fuego', water: 'Agua', grass: 'Planta', electric: 'Eléctrico',
ice: 'Hielo', fighting: 'Lucha', poison: 'Veneno', ground: 'Tierra', flying: 'Volador',
psychic: 'Psíquico', bug: 'Bicho', rock: 'Roca', ghost: 'Fantasma', dragon: 'Dragón',
dark: 'Siniestro', steel: 'Acero', fairy: 'Hada'
};
// ===== Estado =====
const estado = {
status: 'inicio', // 'inicio' | 'cargando' | 'ok' | 'no-encontrado' | 'error'
termino: '',
pokemon: null,
error: '',
recientes: cargarRecientes()
};
const cache = new Map(); // termino -> pokemon (o null si no existe)
let controlador = null; // AbortController de la petición en curso
// ===== DOM =====
const form = document.querySelector('#form-busqueda');
const campo = document.querySelector('#campo');
const contRecientes = document.querySelector('#recientes');
const resultado = document.querySelector('#resultado');
// ===== Utilidades =====
function debounce(fn, espera) {
let temporizador;
return (...args) => {
clearTimeout(temporizador);
temporizador = setTimeout(() => fn(...args), espera);
};
}
function escaparHTML(valor) {
const mapa = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return String(valor).replace(/[&<>"']/g, (c) => mapa[c]);
}
const normalizar = (texto) => texto.trim().toLowerCase().replace(/^#/, '').replace(/\s+/g, '-');
// ===== Persistencia del historial =====
function cargarRecientes() {
try {
const datos = JSON.parse(localStorage.getItem(CLAVE_RECIENTES));
return Array.isArray(datos) ? datos : ['pikachu', 'charizard', 'gengar'];
} catch {
return [];
}
}
function guardarRecientes() {
try {
localStorage.setItem(CLAVE_RECIENTES, JSON.stringify(estado.recientes));
} catch (error) {
console.warn('No se pudo guardar el historial', error);
}
}
function agregarReciente(nombre) {
estado.recientes = [nombre, ...estado.recientes.filter((n) => n !== nombre)].slice(0, MAX_RECIENTES);
guardarRecientes();
}
// ===== API =====
function simplificar(datos) {
return {
id: datos.id,
nombre: datos.name,
imagen: datos.sprites.other?.['official-artwork']?.front_default ?? datos.sprites.front_default,
tipos: datos.types.map((t) => t.type.name),
stats: datos.stats.map((s) => ({ nombre: s.stat.name, valor: s.base_stat })),
altura: datos.height / 10, // decímetros -> metros
peso: datos.weight / 10 // hectogramos -> kg
};
}
async function obtenerPokemon(termino, signal) {
if (cache.has(termino)) return cache.get(termino);
const respuesta = await fetch(API + encodeURIComponent(termino), { signal });
if (respuesta.status === 404) {
cache.set(termino, null); // también recordamos los "no encontrado"
return null;
}
if (!respuesta.ok) throw new Error(`Error del servidor (${respuesta.status})`);
const pokemon = simplificar(await respuesta.json());
cache.set(termino, pokemon);
cache.set(pokemon.nombre, pokemon);
cache.set(String(pokemon.id), pokemon);
return pokemon;
}
// ===== Flujo principal =====
function setEstado(cambios) {
Object.assign(estado, cambios);
render();
}
async function buscar(texto) {
const termino = normalizar(texto);
controlador?.abort(); // cancela la petición anterior
if (!termino) return setEstado({ status: 'inicio', termino: '' });
if (termino === estado.termino && estado.status === 'ok') return;
controlador = new AbortController();
setEstado({ status: 'cargando', termino });
try {
const pokemon = await obtenerPokemon(termino, controlador.signal);
if (pokemon === null) return setEstado({ status: 'no-encontrado' });
agregarReciente(pokemon.nombre);
setEstado({ status: 'ok', pokemon });
} catch (error) {
if (error.name === 'AbortError') return; // la cancelamos nosotros: no es un error
const mensaje = navigator.onLine ? error.message : 'Parece que no tienes conexión.';
setEstado({ status: 'error', error: mensaje });
}
}
const buscarConEspera = debounce(buscar, ESPERA_MS);
// ===== Render =====
function tarjetaHTML(p) {
const tipos = p.tipos
.map((t) => `<span class="tipo tipo--${escaparHTML(t)}">${escaparHTML(NOMBRES_TIPO[t] ?? t)}</span>`)
.join('');
const stats = p.stats
.map((s) => `
<li>
<span>${escaparHTML(NOMBRES_STAT[s.nombre] ?? s.nombre)}</span>
<div class="barra"><span style="width:${Math.min(100, Number(s.valor) / 1.8)}%"></span></div>
<strong>${Number(s.valor)}</strong>
</li>`)
.join('');
return `
<article class="tarjeta">
<img class="tarjeta__img" src="${escaparHTML(p.imagen ?? '')}" alt="${escaparHTML(p.nombre)}" width="150" height="150">
<div>
<span class="tarjeta__num">#${String(p.id).padStart(3, '0')}</span>
<h2>${escaparHTML(p.nombre)}</h2>
<div class="tipos">${tipos}</div>
<p class="medidas">Altura: ${p.altura} m · Peso: ${p.peso} kg</p>
</div>
<ul class="stats" aria-label="Estadísticas base">${stats}</ul>
</article>`;
}
function renderResultado() {
const t = escaparHTML(estado.termino);
const vistas = {
inicio: () => '<p class="mensaje">Escribe el nombre o número de un Pokémon.</p>',
cargando: () => '<div class="mensaje"><div><div class="spinner"></div>Buscando…</div></div>',
'no-encontrado': () => `<p class="mensaje">No encontramos «${t}». Revisa la ortografía.</p>`,
error: () => `<div class="mensaje mensaje--error"><div>${escaparHTML(estado.error)}<br>
<button type="button" data-accion="reintentar">Reintentar</button></div></div>`,
ok: () => tarjetaHTML(estado.pokemon)
};
resultado.innerHTML = vistas[estado.status]();
resultado.setAttribute('aria-busy', estado.status === 'cargando');
}
function renderRecientes() {
contRecientes.replaceChildren();
if (estado.recientes.length === 0) return;
contRecientes.append('Recientes:');
for (const nombre of estado.recientes) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'chip';
chip.dataset.nombre = nombre;
chip.textContent = nombre;
contRecientes.append(chip);
}
const borrar = document.createElement('button');
borrar.type = 'button';
borrar.className = 'chip chip--borrar';
borrar.dataset.accion = 'borrar-historial';
borrar.textContent = 'Borrar';
contRecientes.append(borrar);
}
function render() {
renderResultado();
renderRecientes();
}
// ===== Eventos =====
campo.addEventListener('input', () => buscarConEspera(campo.value));
form.addEventListener('submit', (e) => {
e.preventDefault();
buscar(campo.value); // Enter: buscar sin esperar
});
contRecientes.addEventListener('click', (e) => {
const boton = e.target.closest('button');
if (!boton) return;
if (boton.dataset.accion === 'borrar-historial') {
estado.recientes = [];
guardarRecientes();
return render();
}
campo.value = boton.dataset.nombre;
buscar(boton.dataset.nombre);
});
resultado.addEventListener('click', (e) => {
if (e.target.closest('[data-accion="reintentar"]')) buscar(campo.value);
});
// ===== Inicio =====
render();Decisiones de diseño
| Decisión | Por qué |
|---|---|
status único en el estado | Evita combinaciones imposibles (cargando y con error a la vez). |
| Debounce de 400 ms | Menos peticiones, menos carga en la API, mejor rendimiento. |
| AbortController | Elimina condiciones de carrera y ahorra datos. |
Caché con Map (incluye 404) | Respuestas instantáneas al repetir búsquedas. |
simplificar() | El resto del código no depende de la forma exacta de la API. |
escaparHTML() en todo dato externo | Protección contra XSS al usar template literals. |
aria-live y aria-busy | El lector de pantalla anuncia carga y resultados. |
Buena práctica: respeta la API
Las APIs públicas gratuitas tienen límites de uso. Debounce + caché también evitan que te bloqueen por hacer demasiadas peticiones.
Retos de mejora
- Botón aleatorio: agrega un botón "Sorpréndeme" que busque un Pokémon con id aleatorio entre 1 y 1025.
- Tiempo límite: si la API tarda más de 8 segundos, cancela y muestra "La API tardó demasiado".
- Mínimo de caracteres: no busques nombres de menos de 3 letras (pero sí números como "1").
Ver solución
js
// 1. Botón aleatorio (botón con id="btn-azar")
document.querySelector('#btn-azar').addEventListener('click', () => {
const id = String(Math.floor(Math.random() * 1025) + 1);
campo.value = id;
buscar(id);
});
// 2. Tiempo límite: AbortSignal.timeout + AbortSignal.any
const signal = AbortSignal.any([controlador.signal, AbortSignal.timeout(8000)]);
const pokemon = await obtenerPokemon(termino, signal);
// en el catch:
if (error.name === 'TimeoutError') return setEstado({ status: 'error', error: 'La API tardó demasiado.' });
// 3. Mínimo de caracteres (dentro de buscar, después de normalizar)
const esNumero = /^\d+$/.test(termino);
if (!esNumero && termino.length < 3) return setEstado({ status: 'inicio', termino: '' });Resumen
- Explora la API antes de programar y simplifica su respuesta en una función.
fetchno falla con 404: revisarespuesta.okyrespuesta.status.- Debounce reduce peticiones mientras el usuario escribe.
- AbortController cancela peticiones viejas y evita condiciones de carrera; ignora el
AbortError. - Un
Mapes una caché sencilla y eficaz (guarda también los "no encontrado"). - Modela la interfaz como una máquina de estados con un solo
status. - Si usas template literals con
innerHTML, escapa todo dato externo.
Siguiente: Proyecto: carrito de compras