Back to Blog
Tutorial

Auto-fill City and State from ZIP Code in React: Step-by-Step Guide

Build a smart address form with automatic city and state population using ZIP code lookup. Includes working CodeSandbox demo and production-ready React components.

Payloadic Team10 min read

Nothing frustrates users more than filling out lengthy forms. One of the best UX improvements you can make is auto-populating city and state fields when a user enters their ZIP code. In this tutorial, you'll learn how to build a smart address form in React that automatically fills in location data.

Why Auto-fill Address Forms?

The benefits of ZIP code autocomplete are significant:

  • Faster checkout: Reduce form completion time by 40-60%
  • Fewer errors: Eliminate typos in city/state names
  • Better conversions: Smoother UX leads to higher completion rates
  • Mobile-friendly: Less typing on small screens
  • Professional feel: Modern, polished user experience

According to Baymard Institute research, 18% of users abandon checkout due to a "too long or complicated" process. Smart form fields directly address this issue.

What You'll Build

By the end of this tutorial, you'll have:

  • A reusable <AddressForm> component
  • Real-time ZIP code validation and lookup
  • Automatic city/state population
  • Error handling and loading states
  • TypeScript type safety
  • Accessible form inputs

Live Demo: CodeSandbox (click to see it in action)

Prerequisites

You'll need:

  • React 18+ (works with Next.js, Vite, Create React App)
  • Basic knowledge of React hooks
  • A RapidAPI account (free tier available)
  • 10 minutes of your time

Step 1: Get Your API Key

First, get a free API key for the ZIP Code API:

  1. Go to Payloadic ZIP Code API on RapidAPI
  2. Click "Subscribe to Test"
  3. Choose the free plan (500 requests/month)
  4. Copy your API key from the code snippet

Store it in your .env file:

# .env.local (for Next.js)
NEXT_PUBLIC_RAPIDAPI_KEY=your_api_key_here

# .env (for Vite/CRA)
VITE_RAPIDAPI_KEY=your_api_key_here
REACT_APP_RAPIDAPI_KEY=your_api_key_here

Security Note: In production, proxy API requests through your backend to avoid exposing your API key. We'll show a simple version here for learning purposes.

Step 2: Create the API Service

Create a dedicated service file for ZIP code lookups:

// services/zipCodeService.ts

export interface ZipCodeData {
  zip_code: string;
  city: string;
  state: string;
  state_abbr: string;
  county: string;
  latitude: number;
  longitude: number;
}

export interface ZipCodeResponse {
  success: boolean;
  data?: ZipCodeData;
  error?: string;
}

export async function lookupZipCode(zipCode: string): Promise<ZipCodeResponse> {
  // Validate ZIP code format
  if (!/^\d{5}$/.test(zipCode)) {
    return {
      success: false,
      error: "ZIP code must be 5 digits",
    };
  }

  const apiKey = process.env.NEXT_PUBLIC_RAPIDAPI_KEY || 
                 process.env.VITE_RAPIDAPI_KEY ||
                 process.env.REACT_APP_RAPIDAPI_KEY;

  if (!apiKey) {
    return {
      success: false,
      error: "API key not configured",
    };
  }

  try {
    const response = await fetch(
      `https://zip-code-to-location3.p.rapidapi.com/v1/lookup?zipcode=${zipCode}`,
      {
        method: "GET",
        headers: {
          "X-RapidAPI-Key": apiKey,
          "X-RapidAPI-Host": "zip-code-to-location3.p.rapidapi.com",
        },
      }
    );

    if (!response.ok) {
      if (response.status === 404) {
        return {
          success: false,
          error: "ZIP code not found",
        };
      }
      throw new Error(`HTTP ${response.status}`);
    }

    const data = await response.json();
    
    return {
      success: true,
      data: {
        zip_code: data.zip_code,
        city: data.city,
        state: data.state,
        state_abbr: data.state_abbr,
        county: data.county,
        latitude: data.latitude,
        longitude: data.longitude,
      },
    };
  } catch (error) {
    console.error("ZIP code lookup error:", error);
    return {
      success: false,
      error: "Failed to lookup ZIP code. Please try again.",
    };
  }
}

Step 3: Create the Custom Hook

Extract the logic into a reusable React hook:

// hooks/useZipCodeLookup.ts
import { useState, useCallback } from "react";
import { lookupZipCode, ZipCodeData } from "../services/zipCodeService";

interface UseZipCodeLookupReturn {
  data: ZipCodeData | null;
  loading: boolean;
  error: string | null;
  lookup: (zipCode: string) => Promise<void>;
  reset: () => void;
}

export function useZipCodeLookup(): UseZipCodeLookupReturn {
  const [data, setData] = useState<ZipCodeData | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const lookup = useCallback(async (zipCode: string) => {
    // Reset state
    setError(null);
    setData(null);

    // Don't lookup if ZIP is incomplete
    if (zipCode.length !== 5) {
      return;
    }

    setLoading(true);

    try {
      const result = await lookupZipCode(zipCode);
      
      if (result.success && result.data) {
        setData(result.data);
      } else {
        setError(result.error || "Unknown error");
      }
    } catch (err) {
      setError("Network error. Please check your connection.");
    } finally {
      setLoading(false);
    }
  }, []);

  const reset = useCallback(() => {
    setData(null);
    setError(null);
    setLoading(false);
  }, []);

  return { data, loading, error, lookup, reset };
}

Step 4: Build the Address Form Component

Now create the main form component:

// components/AddressForm.tsx
import { useState, useEffect } from "react";
import { useZipCodeLookup } from "../hooks/useZipCodeLookup";

export default function AddressForm() {
  const [formData, setFormData] = useState({
    street: "",
    zipCode: "",
    city: "",
    state: "",
  });

  const { data, loading, error, lookup } = useZipCodeLookup();

  // Auto-fill city and state when ZIP code data is fetched
  useEffect(() => {
    if (data) {
      setFormData((prev) => ({
        ...prev,
        city: data.city,
        state: data.state_abbr,
      }));
    }
  }, [data]);

  const handleZipCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value.replace(/\D/g, "").slice(0, 5);
    setFormData((prev) => ({ ...prev, zipCode: value }));

    // Trigger lookup when ZIP is complete
    if (value.length === 5) {
      lookup(value);
    }
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log("Form submitted:", formData);
    // Handle form submission (e.g., send to API)
  };

  return (
    <form onSubmit={handleSubmit} className="max-w-md mx-auto space-y-4">
      <div>
        <label htmlFor="street" className="block text-sm font-medium mb-1">
          Street Address
        </label>
        <input
          type="text"
          id="street"
          value={formData.street}
          onChange={(e) =>
            setFormData((prev) => ({ ...prev, street: e.target.value }))
          }
          className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
          placeholder="123 Main St"
          required
        />
      </div>

      <div>
        <label htmlFor="zipCode" className="block text-sm font-medium mb-1">
          ZIP Code
        </label>
        <input
          type="text"
          id="zipCode"
          value={formData.zipCode}
          onChange={handleZipCodeChange}
          className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 ${
            error ? "border-red-500" : ""
          }`}
          placeholder="12345"
          maxLength={5}
          required
        />
        {loading && (
          <p className="text-sm text-gray-500 mt-1">Looking up ZIP code...</p>
        )}
        {error && <p className="text-sm text-red-600 mt-1">{error}</p>}
        {data && (
          <p className="text-sm text-green-600 mt-1">
            ✓ {data.city}, {data.state_abbr}
          </p>
        )}
      </div>

      <div className="grid grid-cols-2 gap-4">
        <div>
          <label htmlFor="city" className="block text-sm font-medium mb-1">
            City
          </label>
          <input
            type="text"
            id="city"
            value={formData.city}
            onChange={(e) =>
              setFormData((prev) => ({ ...prev, city: e.target.value }))
            }
            className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 bg-gray-50"
            placeholder="Auto-filled"
            required
            readOnly={!!data} // Lock field if auto-filled
          />
        </div>

        <div>
          <label htmlFor="state" className="block text-sm font-medium mb-1">
            State
          </label>
          <input
            type="text"
            id="state"
            value={formData.state}
            onChange={(e) =>
              setFormData((prev) => ({ ...prev, state: e.target.value }))
            }
            className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 bg-gray-50"
            placeholder="Auto-filled"
            maxLength={2}
            required
            readOnly={!!data} // Lock field if auto-filled
          />
        </div>
      </div>

      <button
        type="submit"
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium"
      >
        Continue
      </button>
    </form>
  );
}

Step 5: Add the Component to Your Page

// app/checkout/page.tsx (Next.js)
// or src/pages/Checkout.tsx (React)

import AddressForm from "@/components/AddressForm";

export default function CheckoutPage() {
  return (
    <div className="min-h-screen bg-gray-50 py-12">
      <div className="max-w-2xl mx-auto px-4">
        <h1 className="text-3xl font-bold mb-8 text-center">
          Shipping Address
        </h1>
        <div className="bg-white rounded-xl shadow-sm p-8">
          <AddressForm />
        </div>
      </div>
    </div>
  );
}

Advanced: Debounced Lookup

To avoid hitting the API on every keystroke, add debouncing:

// hooks/useDebounce.ts
import { useState, useEffect } from "react";

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

Update the form to use it:

// In AddressForm.tsx
import { useDebounce } from "../hooks/useDebounce";

export default function AddressForm() {
  const [formData, setFormData] = useState({...});
  const debouncedZipCode = useDebounce(formData.zipCode, 500); // 500ms delay

  useEffect(() => {
    if (debouncedZipCode.length === 5) {
      lookup(debouncedZipCode);
    }
  }, [debouncedZipCode, lookup]);

  // ... rest of component
}

Security Best Practice: Backend Proxy

Never expose API keys in production frontend code. Instead, proxy requests through your backend:

// app/api/zipcode/route.ts (Next.js API Route)
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const zipCode = searchParams.get("zipcode");

  if (!zipCode || !/^\d{5}$/.test(zipCode)) {
    return NextResponse.json(
      { error: "Invalid ZIP code" },
      { status: 400 }
    );
  }

  const response = await fetch(
    `https://zip-code-to-location3.p.rapidapi.com/v1/lookup?zipcode=${zipCode}`,
    {
      headers: {
        "X-RapidAPI-Key": process.env.RAPIDAPI_KEY!, // Server-side only
        "X-RapidAPI-Host": "zip-code-to-location3.p.rapidapi.com",
      },
    }
  );

  if (!response.ok) {
    return NextResponse.json(
      { error: "ZIP code not found" },
      { status: 404 }
    );
  }

  const data = await response.json();
  return NextResponse.json(data);
}

Then update your service to call your own API:

// services/zipCodeService.ts (updated)
const response = await fetch(`/api/zipcode?zipcode=${zipCode}`);

Testing the Component

Here's a Jest test example:

// components/__tests__/AddressForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AddressForm from "../AddressForm";
import * as zipCodeService from "../../services/zipCodeService";

jest.mock("../../services/zipCodeService");

test("auto-fills city and state when ZIP is entered", async () => {
  const mockLookup = zipCodeService.lookupZipCode as jest.Mock;
  mockLookup.mockResolvedValue({
    success: true,
    data: {
      zip_code: "90210",
      city: "Beverly Hills",
      state: "California",
      state_abbr: "CA",
    },
  });

  render(<AddressForm />);
  
  const zipInput = screen.getByLabelText(/zip code/i);
  await userEvent.type(zipInput, "90210");

  await waitFor(() => {
    expect(screen.getByDisplayValue("Beverly Hills")).toBeInTheDocument();
    expect(screen.getByDisplayValue("CA")).toBeInTheDocument();
  });
});

Performance Optimization

1. Cache Results

const zipCodeCache = new Map<string, ZipCodeData>();

export async function lookupZipCode(zipCode: string): Promise<ZipCodeResponse> {
  // Check cache first
  if (zipCodeCache.has(zipCode)) {
    return {
      success: true,
      data: zipCodeCache.get(zipCode)!,
    };
  }

  // Fetch from API...
  const result = await fetch(/* ... */);
  
  // Cache successful results
  if (result.success && result.data) {
    zipCodeCache.set(zipCode, result.data);
  }

  return result;
}

2. Prefetch Common ZIP Codes

If you have analytics on popular ZIP codes (e.g., 80% of users are from 10 cities), prefetch those:

const POPULAR_ZIPS = ["90210", "10001", "60601", "94102"];

useEffect(() => {
  POPULAR_ZIPS.forEach((zip) => {
    lookupZipCode(zip); // Warms up cache
  });
}, []);

Accessibility Checklist

  • ✅ Labels properly associated with inputs
  • ✅ Error messages announced to screen readers
  • ✅ Loading states communicated
  • ✅ Keyboard navigation works
  • ✅ Focus management on auto-fill
  • ✅ ARIA attributes for dynamic content
<input
  aria-describedby={error ? "zip-error" : loading ? "zip-loading" : undefined}
  aria-invalid={!!error}
  // ... other props
/>
{error && <p id="zip-error" role="alert">{error}</p>}
{loading && <p id="zip-loading" aria-live="polite">Looking up ZIP code...</p>}

Integration with Form Libraries

React Hook Form

import { useForm, Controller } from "react-hook-form";

function AddressForm() {
  const { control, setValue } = useForm();
  const { lookup } = useZipCodeLookup();

  return (
    <form>
      <Controller
        name="zipCode"
        control={control}
        render={({ field }) => (
          <input
            {...field}
            onChange={(e) => {
              field.onChange(e);
              if (e.target.value.length === 5) {
                lookup(e.target.value).then((result) => {
                  if (result.data) {
                    setValue("city", result.data.city);
                    setValue("state", result.data.state_abbr);
                  }
                });
              }
            }}
          />
        )}
      />
    </form>
  );
}

Formik

import { Formik, Field } from "formik";

<Formik
  initialValues={{ street: "", zipCode: "", city: "", state: "" }}
  onSubmit={(values) => console.log(values)}
>
  {({ setFieldValue, values }) => (
    <Form>
      <Field
        name="zipCode"
        onChange={(e: any) => {
          setFieldValue("zipCode", e.target.value);
          if (e.target.value.length === 5) {
            lookup(e.target.value).then((result) => {
              if (result.data) {
                setFieldValue("city", result.data.city);
                setFieldValue("state", result.data.state_abbr);
              }
            });
          }
        }}
      />
    </Form>
  )}
</Formik>

Real-World Examples

E-commerce Checkout

// Shopify-style checkout with ZIP autocomplete
<CheckoutStep title="Shipping Information">
  <AddressForm
    onComplete={(address) => {
      calculateShipping(address);
      setShippingAddress(address);
    }}
  />
</CheckoutStep>

Multi-tenant SaaS

// Auto-detect timezone based on ZIP code
useEffect(() => {
  if (data?.zip_code) {
    const timezone = getTimezoneFromCoordinates(
      data.latitude,
      data.longitude
    );
    setUserTimezone(timezone);
  }
}, [data]);

Common Issues & Solutions

Issue: API Key in Frontend

Problem: API key exposed in browser DevTools

Solution: Use backend proxy (shown above)

Issue: Rate Limiting

Problem: Hitting API limits with frequent lookups

Solution: Implement debouncing + caching

Issue: Invalid ZIP Codes

Problem: User enters fake ZIP like "00000"

Solution: Show error state and allow manual entry

{error && (
  <p className="text-sm text-gray-600 mt-1">
    Can't find this ZIP? You can enter city and state manually.
  </p>
)}

Conclusion

You now have a production-ready ZIP code autocomplete system that:

  • ✅ Improves user experience
  • ✅ Reduces form errors
  • ✅ Increases conversion rates
  • ✅ Works with popular form libraries
  • ✅ Handles edge cases gracefully

The Payloadic ZIP Code API makes this trivial to implement with accurate USPS data updated monthly.

Ready to implement this in your app? Get your free API key and start building better forms today.

Additional Resources

Tags:

reactzip codeformsuser experienceAPI integrationautocomplete

Ready to Try It Yourself?

Get started with Payloadic APIs and integrate phone validation or ZIP code lookup into your application today.