Skip to content

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.

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.

Wire the proxy up and watch the form fill itself.

100 requests a month, free, no card. Debounced, that is 100 addresses — plenty to get the form right before it goes near a customer.

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