【JavaScript】郵便番号から住所を自動入力する実装ガイド|zipcloud API・エラー処理・デバウンス・ローディング表示まで解説

住所入力フォームで郵便番号を入れると都道府県・市区町村が自動で入力される——よく見かけるUIですが、きちんと実装するには API の呼び出しだけでなく、入力中の連打防止・ローディング表示・エラー処理・アクセシビリティまで考慮する必要があります。

この記事では、無料で使える zipcloud API を使って住所自動入力を実装する方法を、基本から実用的な改善まで段階的に解説します。

スポンサーリンク

zipcloud API の概要

zipcloudhttps://zipcloud.ibsnet.co.jp/)は、日本郵便の郵便番号データをもとにした無料の郵便番号検索 API です。CORS に対応しているため、ブラウザの JavaScript から直接呼び出せます。

項目 内容
エンドポイント https://zipcloud.ibsnet.co.jp/api/search
パラメータ zipcode(7桁の郵便番号、ハイフンあり・なし両対応)
レスポンス形式 JSON
利用料 無料(商用利用可)
CORS 対応(ブラウザから直接呼び出し可能)
データ更新 日本郵便の公式データに基づき定期更新
API レスポンスの構造(例:〒150-0043)
// GET https://zipcloud.ibsnet.co.jp/api/search?zipcode=1500043
{
  "message": null,
  "results": [
    {
      "address1": "東京都",          // 都道府県
      "address2": "渋谷区",          // 市区町村
      "address3": "道玄坂",          // 町域
      "kana1":    "トウキョウト",    // 都道府県(カナ)
      "kana2":    "シブヤク",        // 市区町村(カナ)
      "kana3":    "ドウゲンザカ",    // 町域(カナ)
      "prefcode": "13",              // 都道府県コード
      "zipcode":  "1500043"
    }
  ],
  "status": 200
}

// 該当なしの場合
{ "message": null, "results": null, "status": 200 }

// エラーの場合
{ "message": "パラメータ「郵便番号」が指定されていません。", "results": null, "status": 400 }
results が null になるケース:郵便番号が存在しない・廃止されているなど、該当データがないと results: null が返ります。status: 200 でも results が null の場合があるため、必ずnullチェックが必要です。

基本実装:郵便番号入力で住所を自動入力する

まず HTML の構造と、最もシンプルな実装を示します。

HTML(住所フォーム)
<form id="addressForm">
  <div class="field-group">
    <label for="zipcode">郵便番号 <span class="required">*</span></label>
    <div class="zip-row">
      <input
        type="text"
        id="zipcode"
        name="zipcode"
        placeholder="123-4567"
        maxlength="8"
        autocomplete="postal-code"
        inputmode="numeric"
      >
      <button type="button" id="searchBtn">住所検索</button>
    </div>
    <span id="zip-status" class="zip-status" aria-live="polite"></span>
  </div>
  <div class="field-group">
    <label for="prefecture">都道府県</label>
    <input type="text" id="prefecture" name="prefecture" autocomplete="address-level1">
  </div>
  <div class="field-group">
    <label for="city">市区町村</label>
    <input type="text" id="city" name="city" autocomplete="address-level2">
  </div>
  <div class="field-group">
    <label for="town">町域</label>
    <input type="text" id="town" name="town" autocomplete="address-level3">
  </div>
  <div class="field-group">
    <label for="address">番地・建物名</label>
    <input type="text" id="address" name="address" autocomplete="address-line1">
  </div>
</form>
inputmode=”numeric” と autocomplete について:inputmode="numeric" をつけるとスマートフォンで数字キーボードが開きます(type="number" だと郵便番号の先頭0が消えるため使いません)。autocomplete="postal-code" はブラウザの自動補完に郵便番号フィールドと認識させるための標準属性です。
基本実装(ボタンクリックで検索)
const API_BASE = 'https://zipcloud.ibsnet.co.jp/api/search';

async function searchAddress(zipcode) {
  const normalized = zipcode.replace(/-/g, ''); // ハイフン除去

  if (!/^\d{7}$/.test(normalized)) {
    return { error: '郵便番号は7桁の数字で入力してください' };
  }

  const res  = await fetch(`${API_BASE}?zipcode=${normalized}`);
  if (!res.ok) throw new Error(`HTTP error: ${res.status}`);

  const data = await res.json();
  if (data.status !== 200) return { error: data.message };
  if (!data.results)       return { error: '該当する住所が見つかりませんでした' };

  return { result: data.results[0] }; // 複数ある場合は先頭を使用
}

document.getElementById('searchBtn').addEventListener('click', async () => {
  const zipcode   = document.getElementById('zipcode').value.trim();
  const statusEl  = document.getElementById('zip-status');

  statusEl.textContent = '検索中...';

  try {
    const { result, error } = await searchAddress(zipcode);

    if (error) {
      statusEl.textContent = error;
      return;
    }

    document.getElementById('prefecture').value = result.address1;
    document.getElementById('city').value       = result.address2;
    document.getElementById('town').value       = result.address3;
    statusEl.textContent = '住所を入力しました';

    // 番地入力にフォーカスを移動
    document.getElementById('address').focus();
  } catch (err) {
    statusEl.textContent = '通信エラーが発生しました。しばらく後にお試しください。';
    console.error(err);
  }
});

ハイフン自動挿入(入力補助)

3桁入力後に自動で「-」を挿入すると、ユーザーの入力ミスが減りUXが向上します。

ハイフン自動挿入
document.getElementById('zipcode').addEventListener('input', (e) => {
  let val = e.target.value.replace(/[^\d]/g, ''); // 数字のみ抽出

  if (val.length > 3) {
    val = val.slice(0, 3) + '-' + val.slice(3, 7);
  }

  e.target.value = val;
});
IME(日本語入力)への注意:スマートフォンの数字入力では input イベントが確定前に発火することがあります。問題になる場合は compositionstart/compositionend イベントでIME変換中かどうかを判定し、変換中はハイフン挿入をスキップするように実装してください。

7桁入力で自動検索(デバウンス付き)

「検索ボタンを押さなくても7桁入力されたら自動で検索する」UI は利便性が高いですが、1文字ごとにAPIを叩くと無駄なリクエストが増えます。デバウンス(一定時間待ってから実行)で解決します。

デバウンス + 自動検索
let debounceTimer = null;

document.getElementById('zipcode').addEventListener('input', (e) => {
  const raw        = e.target.value.replace(/-/g, '');
  const statusEl   = document.getElementById('zip-status');

  clearTimeout(debounceTimer);

  if (raw.length !== 7) {
    // 7桁でなければ検索しない
    if (!/^\d+$/.test(raw) && raw !== '') {
      statusEl.textContent = '数字で入力してください';
    } else {
      statusEl.textContent = '';
    }
    return;
  }

  statusEl.textContent = '検索中...';

  // 300ms 待ってから検索(連打防止)
  debounceTimer = setTimeout(async () => {
    try {
      const { result, error } = await searchAddress(e.target.value);
      if (error) { statusEl.textContent = error; return; }

      document.getElementById('prefecture').value = result.address1;
      document.getElementById('city').value       = result.address2;
      document.getElementById('town').value       = result.address3;
      statusEl.textContent = '住所を入力しました';
      document.getElementById('address').focus();
    } catch {
      statusEl.textContent = '通信エラーが発生しました';
    }
  }, 300);
});

AbortController で直前リクエストをキャンセルする

デバウンスを使っても「前のリクエストが後のリクエストより遅く返ってくる」競合状態(Race Condition)が起きる可能性があります。AbortController で直前のリクエストをキャンセルすることで確実に防げます。

AbortController による競合防止
let controller = null; // 直前のリクエストを保持

async function searchAddressSafe(zipcode) {
  const normalized = zipcode.replace(/-/g, '');
  if (!/^\d{7}$/.test(normalized)) {
    return { error: '郵便番号は7桁の数字で入力してください' };
  }

  // 前のリクエストをキャンセル
  if (controller) controller.abort();
  controller = new AbortController();

  try {
    const res  = await fetch(
      `${API_BASE}?zipcode=${normalized}`,
      { signal: controller.signal }
    );
    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    const data = await res.json();
    if (data.status !== 200) return { error: data.message };
    if (!data.results)       return { error: '該当する住所が見つかりませんでした' };
    return { result: data.results[0] };

  } catch (err) {
    if (err.name === 'AbortError') return { aborted: true }; // キャンセルは無視
    throw err;
  }
}
AbortError は正常なキャンセル:fetch がキャンセルされると err.name === "AbortError" の例外が発生します。これはエラーではなく意図した動作なので、catch 内で AbortError を判定し、それ以外のエラーだけユーザーに通知するように実装してください。

ローディング表示とボタン制御

検索中にボタンを非活性にして二重送信を防ぎ、状態をユーザーに伝えます。

ローディング状態の制御
async function handleSearch() {
  const zipcode   = document.getElementById('zipcode').value.trim();
  const statusEl  = document.getElementById('zip-status');
  const searchBtn = document.getElementById('searchBtn');

  // ローディング開始
  searchBtn.disabled     = true;
  searchBtn.textContent  = '検索中...';
  statusEl.textContent   = '';

  try {
    const { result, error, aborted } = await searchAddressSafe(zipcode);
    if (aborted) return;

    if (error) {
      statusEl.textContent = error;
      return;
    }

    document.getElementById('prefecture').value = result.address1;
    document.getElementById('city').value       = result.address2;
    document.getElementById('town').value       = result.address3;
    statusEl.textContent = '✓ 住所を入力しました';
    document.getElementById('address').focus();

  } catch (err) {
    statusEl.textContent = '通信エラーが発生しました。しばらく後にお試しください。';
    console.error(err);
  } finally {
    // ローディング終了(成功・失敗どちらでも)
    searchBtn.disabled    = false;
    searchBtn.textContent = '住所検索';
  }
}

document.getElementById('searchBtn').addEventListener('click', handleSearch);

// Enter キーでも検索できるようにする
document.getElementById('zipcode').addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    e.preventDefault();
    handleSearch();
  }
});

完全実装コード(全機能まとめ)

ここまでの内容を1つにまとめた完全実装です。コピーしてそのまま使えます。

HTML(完全版)
<form id="addressForm">
  <div class="field-group">
    <label for="zipcode">郵便番号 <span class="required">*</span></label>
    <div class="zip-row">
      <input type="text" id="zipcode" name="zipcode"
        placeholder="123-4567" maxlength="8"
        autocomplete="postal-code" inputmode="numeric">
      <button type="button" id="searchBtn">住所検索</button>
    </div>
    <span id="zip-status" class="zip-status" aria-live="polite"></span>
  </div>
  <div class="field-group">
    <label for="prefecture">都道府県</label>
    <input type="text" id="prefecture" name="prefecture" autocomplete="address-level1">
  </div>
  <div class="field-group">
    <label for="city">市区町村</label>
    <input type="text" id="city" name="city" autocomplete="address-level2">
  </div>
  <div class="field-group">
    <label for="town">町域</label>
    <input type="text" id="town" name="town" autocomplete="address-level3">
  </div>
  <div class="field-group">
    <label for="address">番地・建物名</label>
    <input type="text" id="address" name="address" autocomplete="address-line1">
  </div>
</form>
JavaScript(完全版)
const API_BASE = 'https://zipcloud.ibsnet.co.jp/api/search';
let controller  = null;
let debounceTimer = null;

// ───── ハイフン自動挿入 ─────
document.getElementById('zipcode').addEventListener('input', (e) => {
  let val = e.target.value.replace(/[^\d]/g, '');
  if (val.length > 3) val = val.slice(0, 3) + '-' + val.slice(3, 7);
  e.target.value = val;

  // 7桁(ハイフン込み8文字)で自動検索
  if (val.replace(/-/g, '').length === 7) {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(handleSearch, 300);
  }
});

// ───── API 呼び出し ─────
async function searchAddress(zipcode) {
  const normalized = zipcode.replace(/-/g, '');
  if (!/^\d{7}$/.test(normalized)) {
    return { error: '郵便番号は7桁の数字で入力してください' };
  }
  if (controller) controller.abort();
  controller = new AbortController();

  try {
    const res  = await fetch(`${API_BASE}?zipcode=${normalized}`, { signal: controller.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();
    if (data.status !== 200) return { error: data.message };
    if (!data.results)       return { error: '該当する住所が見つかりませんでした' };
    return { result: data.results[0] };
  } catch (err) {
    if (err.name === 'AbortError') return { aborted: true };
    throw err;
  }
}

// ───── 住所をフォームに反映 ─────
function fillAddress(result) {
  document.getElementById('prefecture').value = result.address1;
  document.getElementById('city').value       = result.address2;
  document.getElementById('town').value       = result.address3;
  document.getElementById('address').focus();
}

// ───── メイン処理 ─────
async function handleSearch() {
  const zipcode   = document.getElementById('zipcode').value.trim();
  const statusEl  = document.getElementById('zip-status');
  const searchBtn = document.getElementById('searchBtn');

  searchBtn.disabled    = true;
  searchBtn.textContent = '検索中...';
  statusEl.textContent  = '';

  try {
    const { result, error, aborted } = await searchAddress(zipcode);
    if (aborted) return;
    if (error) { statusEl.textContent = error; return; }
    fillAddress(result);
    statusEl.textContent = '✓ 住所を入力しました';
  } catch (err) {
    statusEl.textContent = '通信エラーが発生しました';
    console.error(err);
  } finally {
    searchBtn.disabled    = false;
    searchBtn.textContent = '住所検索';
  }
}

document.getElementById('searchBtn').addEventListener('click', handleSearch);
document.getElementById('zipcode').addEventListener('keydown', (e) => {
  if (e.key === 'Enter') { e.preventDefault(); handleSearch(); }
});
CSS(住所フォーム)
.zip-row {
  display: flex;
  gap: 8px;
  align-items: center;
}

.zip-row input {
  width: 140px;
}

.zip-status {
  display: block;
  margin-top: 4px;
  font-size: 0.85rem;
  color: #0284c7;
  min-height: 1.2em;
}

.zip-status:not(:empty)::before {
  content: "";
}

/* エラーメッセージは赤で表示(JavaScriptでクラス切り替えする場合) */
.zip-status.error {
  color: #e11d48;
}

アクセシビリティ対応のポイント

対応項目 実装方法 効果
検索結果のスクリーンリーダー読み上げ aria-live="polite" を status 要素に追加 「住所を入力しました」などが自動読み上げされる
ボタンのラベル ボタンテキストを明確に(「検索」だけより「住所検索」のほうが分かりやすい) スクリーンリーダーで目的が伝わる
キーボード操作 Enter キーでも検索できるよう keydown で制御 マウス不要でフォーム完結
自動入力後のフォーカス移動 住所反映後に番地入力欄へ focus() キーボードユーザーがそのまま入力を続けられる
autocomplete 属性 各フィールドに適切な autocomplete 値を設定 ブラウザ・パスワードマネージャーが正しく補完できる

よくある質問

Qzipcloud API は無料で商用利用できますか?
Aはい。zipcloud は無料で商用利用可能です。ただし利用規約(https://zipcloud.ibsnet.co.jp/rule)を確認の上、過度なリクエストを行わないように注意してください。大量アクセスが必要な場合は日本郵便の公式データを自前でサーバーに持つことも検討してください。
Q郵便番号が新しくて results が null になる場合はどうすればいいですか?
Azipcloud のデータは定期更新されていますが、新設・廃止直後の郵便番号はタイムラグが生じることがあります。その場合はエラーメッセージを表示し、ユーザーに手入力を促してください。「住所が見つかりませんでした。手動で入力してください」のようなメッセージが親切です。
Q同じ郵便番号に複数の住所が返ってくる場合はどう扱えばいいですか?
Aresults が配列で返ってくるため、複数件ある場合はプルダウンや候補リストで選択させるUIが理想的です。多くのケースでは1件だけ返ってきますが、大型ビルや団地などで複数件になることがあります。実装コストが高い場合は先頭の results[0] を使い、「正確な住所は手動で確認してください」と案内する方法もあります。
QReact や Vue から同じ API を使えますか?
A使えます。fetch の呼び出し方は同じです。React では useState で各フィールドの値を管理し、API 結果を setState で反映します。Vue では ref()v-model で双方向バインディングします。AbortController は useEffect のクリーンアップ関数内で controller.abort() を呼ぶのが定石です。
Qサーバー側でも郵便番号の検証は必要ですか?
A必要です。クライアント側の検証はスキップできるため、サーバー側でも形式チェックを行ってください。住所の自動入力はあくまでUX向上のための補助機能です。フォーム送信データの検証はサーバー側が本番です。

まとめ

郵便番号から住所を自動入力する実装のポイントをまとめます。

  • zipcloud API は無料・CORS対応で、ブラウザから直接 fetch できる
  • resultsnull のケースを必ずハンドリングする(存在しない郵便番号)
  • デバウンス(300ms待ち)で無駄なAPIリクエストを削減する
  • AbortController で「直前リクエストのキャンセル」を実装してRace Conditionを防ぐ
  • aria-live="polite" でスクリーンリーダーへの通知を忘れない
  • サーバー側でも必ず入力値を検証する

fetch のローディング制御については【JavaScript】フォーム送信時にローディングアニメーションを表示する方法、フォームバリデーション全体は【JavaScript】フォームバリデーション完全ガイドもあわせてご覧ください。