Skip to content

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:

  1. Buscar mientras el usuario escribe, con debounce (esperar a que deje de teclear). Enter busca de inmediato.
  2. Estados visibles: inicio, cargando, no encontrado (404) y error (con botón Reintentar).
  3. Tarjeta con imagen oficial, número, tipos traducidos, altura, peso y barras de estadísticas.
  4. Historial de búsquedas recientes (máx. 6, sin duplicados) guardado en localStorage.
  5. Cancelar peticiones viejas con AbortController.
  6. 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.

Buscador de Pokémon

Estructura de archivos ​

txt
buscador-pokemon/
├── index.html
├── styles.css
└── app.js

Conoce 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/25 funciona igual.
  • Si no existe, responde con status 404 (no es un error de red: fetch no 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)──► cargando

render() 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:

CasoQué pasaQué devolvemos
200 OKHay datosUn objeto pokemon simplificado
404No existenull (no es un error, es una respuesta válida)
500, sin redFallaLanzamos 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
  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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
  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ónPor qué
status único en el estadoEvita combinaciones imposibles (cargando y con error a la vez).
Debounce de 400 msMenos peticiones, menos carga en la API, mejor rendimiento.
AbortControllerElimina 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 externoProtección contra XSS al usar template literals.
aria-live y aria-busyEl 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 ​

  1. Botón aleatorio: agrega un botón "Sorpréndeme" que busque un Pokémon con id aleatorio entre 1 y 1025.
  2. Tiempo límite: si la API tarda más de 8 segundos, cancela y muestra "La API tardó demasiado".
  3. 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.
  • fetch no falla con 404: revisa respuesta.ok y respuesta.status.
  • Debounce reduce peticiones mientras el usuario escribe.
  • AbortController cancela peticiones viejas y evita condiciones de carrera; ignora el AbortError.
  • Un Map es 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

Academia Web — aprende, copia y construye.