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.
Por qué esto no es una caja de búsqueda
El diseño obvio es buscar nombres de colonia mientras el cliente teclea. Es la forma equivocada aquí, y las cuentas lo dicen antes que la experiencia de uso.
-
Un código postal está terminado. Un nombre nunca lo está.
Cinco dígitos son una pregunta completa que se hace exactamente una vez. «Rom» es el prefijo de algo, así que cada letra adicional vuelve a preguntar, y «Roma» a secas es una colonia en más ciudades de las que quieres meter en un menú desplegable.
-
El límite por minuto son sesenta solicitudes.
Una caja de búsqueda sin debounce es una solicitud por tecla, así que un puñado de clientes escribiendo al mismo tiempo es un 429 para todos. La consulta de arriba es una solicitud por dirección completa.
-
Y el plan gratuito son 100 solicitudes al mes.
A una solicitud por tecla eso son un par de docenas de direcciones. Con debounce, a una por código completo, son 100 direcciones, que alcanzan para construir la cosa y verla funcionar antes de pagarle a nadie.
Buscar por nombre sigue siendo lo correcto cuando el cliente de verdad no sabe su código: nada más es otra pantalla, con su propio debounce y un mínimo de dos caracteres antes de preguntar nada. Ese es el endpoint de búsqueda de colonias, y va detrás del mismo proxy que todo lo demás aquí.
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.