Saltar al contenido

Autocompletar una dirección mexicana desde el código postal, en React

Entran cinco dígitos. Estado y municipio se llenan solos, y la colonia se vuelve una lista corta para elegir en lugar de un campo de texto donde equivocarse. El componente es chico; las dos decisiones que van antes son las que lo hacen correcto.

React 19.2 · ejecutado el 2026-08-17

La API key no va en el navegador

Cualquier llave dentro de un bundle de React la puede leer quien abra la pestaña de red, y se te cobra a ti. Las peticiones desde el navegador sí están permitidas —la API manda los encabezados CORS para eso—, pero eso está ahí para que tu propio front end hable con una ruta tuya, no para repartirle una llave a cada visitante.

Así que el componente de abajo llama a tu servidor, y tu servidor nos llama a nosotros. Con una ruta basta, y los dos tutoriales de servidor terminan justamente en esa ruta: el de Node la construye con Express, y el de Laravel la construye con una semana de caché atrás. Todo lo de abajo da por hecho que GET /api/postal-codes/:code existe y responde con el mismo cuerpo que la API.

Un hook, cuatro estados

La consulta solo tiene sentido cuando hay cinco dígitos, así que el hook no hace absolutamente nada hasta que los hay. Por debajo de cinco está en idle; después pasa a loading y de ahí a ready o a unknown.

usePostalCode.js

import { useEffect, useState } from "react";

const EMPTY = { estado: "", municipio: "", colonias: [] };

export function usePostalCode(code) {
  const [result, setResult] = useState({ status: "idle", ...EMPTY });

  useEffect(() => {
    if (!/^\d{5}$/.test(code)) {
      setResult({ status: "idle", ...EMPTY });

      return;
    }

    const controller = new AbortController();

    const timer = setTimeout(async () => {
      setResult((current) => ({ ...current, status: "loading" }));

      try {
        const response = await fetch(`/api/postal-codes/${code}`, {
          signal: controller.signal,
          headers: { Accept: "application/json" },
        });

        if (response.status === 404) {
          setResult({ status: "unknown", ...EMPTY });

          return;
        }

        if (!response.ok) {
          throw new Error(`The lookup responded ${response.status}`);
        }

        const { data } = await response.json();

        setResult({
          status: "ready",
          estado: data.state.name,
          municipio: data.municipality.name,
          colonias: data.settlements,
        });
      } catch (error) {
        if (error.name !== "AbortError") {
          setResult({ status: "error", ...EMPTY });
        }
      }
    }, 300);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [code]);

  return result;
}

La función de limpieza hace dos trabajos y los dos importan. Limpiar el timer significa que escribir el quinto dígito y luego corregirlo nunca manda la primera petición; abortar el controller significa que una petición ya en vuelo por el código viejo no puede llegar tarde y sobrescribir la respuesta del nuevo. Sin el abort, una respuesta lenta y alguien que teclea rápido producen un formulario lleno con el estado equivocado y ningún error por ningún lado.

AbortError se atrapa y se ignora a propósito. Cancelar es algo que este código hizo adrede, así que mostrárselo al usuario como una falla sería reportar tu propia limpieza como un error.

Los campos

Quita todo lo que no sea dígito a la entrada, tópalo en cinco y deja que el navegador ayude: inputMode saca el teclado numérico en un teléfono, y autoComplete permite que una dirección guardada llene el campo por ti.

AddressFields.jsx

export function AddressFields() {
  const [code, setCode] = useState("");
  const [settlementId, setSettlementId] = useState("");
  const { status, estado, municipio, colonias } = usePostalCode(code);

  return (
    <fieldset>
      <label htmlFor="cp">Código postal</label>
      <input
        id="cp"
        name="postal_code"
        inputMode="numeric"
        autoComplete="postal-code"
        maxLength={5}
        value={code}
        onChange={(event) => {
          setCode(event.target.value.replace(/\D/g, ""));
          setSettlementId("");
        }}
        aria-describedby={status === "unknown" ? "cp-error" : undefined}
      />
      {status === "unknown" && (
        <p id="cp-error" role="alert">
          No encontramos ese código postal.
        </p>
      )}

      <label htmlFor="estado">Estado</label>
      <input id="estado" name="state" value={estado} readOnly />

      <label htmlFor="municipio">Municipio</label>
      <input id="municipio" name="municipality" value={municipio} readOnly />

      <label htmlFor="colonia">Colonia</label>
      <select
        id="colonia"
        name="settlement_id"
        value={settlementId}
        disabled={colonias.length === 0}
        onChange={(event) => setSettlementId(event.target.value)}
      >
        <option value="">
          {status === "loading" ? "Buscando…" : "Elige tu colonia"}
        </option>
        {colonias.map((colonia) => (
          <option key={colonia.id} value={colonia.id}>
            {colonia.name} · {colonia.settlement_type.name}
          </option>
        ))}
      </select>
    </fieldset>
  );
}

El select envía settlement_id y no el nombre de la colonia. Ese id sigue apuntando a la misma colonia después de actualizar el catálogo, y un nombre es una cadena que alguien puede reescribir sin avisarte. Imprime el nombre en la etiqueta, claro; guarda el id.

Mostrar el tipo de asentamiento junto al nombre vale los cuatro caracteres. Un mismo código postal puede cubrir una Colonia y un Fraccionamiento con nombres que se leen casi igual, y el tipo es lo único en pantalla que los separa.

Antes de publicarlo

Tres cosas con las que la gente tropieza

  • Mantén el código en el estado como cadena.

    En el momento en que se vuelve número, 06600 es 6600 y el cero inicial se fue para siempre. El filtro de dígitos de arriba lo mantiene como cadena a propósito; que también lo sea con lo que lo envíes.

  • La colonia elegida se limpia en cada tecla, y es a propósito.

    Esa es la segunda línea de onChange. Un settlementId que quedó del código anterior es un id válido que apunta a una colonia de otro municipio, así que el formulario lo enviaría sin quejarse y el pedido se iría al lugar equivocado.

  • No bloquees el formulario por la consulta.

    Si el proxy está caído, el estado cae en error y el cliente debería poder escribir su dirección y terminar la compra de todos modos. Un formulario de dirección que no se puede enviar porque falló una consulta es peor que uno que nunca tuvo consulta.

Conecta el proxy y mira cómo se llena solo el formulario.

100 solicitudes al mes, gratis, sin tarjeta. Con debounce, son 100 direcciones: de sobra para dejar bien el formulario antes de que se acerque a un cliente.

curl -s https://api.postalkit.mx/v1/postal-codes/06600 -H "Authorization: Bearer YOUR_API_KEY"