Autocompleting a Mexican address from the postal code, in React
Five digits go in. Estado and municipio fill themselves, and the colonia becomes a short list to choose from instead of a text box to get wrong. The component is small; the two decisions in front of it are what make it correct.
React 19.2 · run on 2026-08-17
The API key does not go in the browser
Any key inside a React bundle is readable by anyone who opens the network tab, and it bills to you. Requests from a browser are allowed — the API sends the CORS headers for it — but that is there so your own front end can talk to a route of yours, not so a key can be shipped to every visitor.
So the component below calls your server, and your server calls us. One route is enough, and both server-side tutorials end with exactly that route: the Node one builds it with Express, and the Laravel one builds it with a week of caching behind it. Everything below assumes GET /api/postal-codes/:code exists and answers with the same body the API does.
One hook, four states
The lookup only makes sense once there are five digits, so the hook does nothing at all until there are. Below five it is idle; after that it is loading, then either ready or 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;
}
The cleanup function is doing two jobs and both matter. Clearing the timer means typing the fifth digit and then correcting it never sends the first request at all; aborting the controller means a request already in flight for the old code cannot arrive late and overwrite the answer for the new one. Without the abort, a slow response and a fast typist produce a form filled with the wrong estado and no error anywhere.
AbortError is caught and deliberately ignored. Cancelling is something this code did on purpose, so surfacing it to the user as a failure would report your own tidying up as a fault.
The fields
Strip everything that is not a digit on the way in, cap it at five, and let the browser help: inputMode gets a numeric keypad on a phone, and autoComplete lets a saved address fill the field for you.
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>
);
}
The select submits settlement_id rather than the colonia name. That id still refers to the same colonia after the catalog is refreshed, and a name is a string somebody can re-spell without telling you. Print the name on the label by all means; store the id.
Showing the settlement type beside the name is worth the four characters. One postal code can cover a Colonia and a Fraccionamiento whose names read almost identically, and the type is the only thing on screen that separates them.
Why this is not a search box
The obvious design is to search colonia names as the customer types. It is the wrong shape here, and the arithmetic says so before the user experience does.
-
A postal code is finished. A name never is.
Five digits is a complete question that can be asked exactly once. “Rom” is a prefix of something, so every additional letter asks again — and “Roma” alone is a colonia in more towns than you want to put in a dropdown.
-
The per-minute limit is sixty requests.
An undebounced search box is one request per keystroke, so a handful of customers typing at the same time is a 429 for all of them. The lookup above is one request per completed address.
-
And the free tier is 100 requests a month.
At one request per keystroke that is a couple of dozen addresses. Debounced to one per completed code it is 100 addresses, which is enough to build the thing and watch it work before you pay anyone.
Searching by name is still the right call when the customer genuinely does not know their code — it is just a different screen, with its own debounce and a minimum of two characters before it asks anything. That is the colonia search endpoint, and it belongs behind the same proxy as everything else here.
Before you ship it
Three things that catch people out
-
Keep the code in state as a string.
The moment it becomes a number, 06600 is 6600 and the leading zero is gone for good. The digit strip above keeps it a string on purpose; so should whatever you submit it as.
-
The chosen colonia is cleared on every keystroke, and that is deliberate.
That is the second line in onChange. A settlementId left over from the previous code is a valid id pointing at a colonia in another municipio, so the form would submit it without complaint and the order would go to the wrong place.
-
Do not block the form on the lookup.
If the proxy is down, the status lands on error and the customer should still be able to type their address and check out. An address form that cannot be submitted because a lookup failed is worse than one that never had a lookup.