住所入力フォームで郵便番号を入れると都道府県・市区町村が自動で入力される——よく見かけるUIですが、きちんと実装するには API の呼び出しだけでなく、入力中の連打防止・ローディング表示・エラー処理・アクセシビリティまで考慮する必要があります。
この記事では、無料で使える zipcloud API を使って住所自動入力を実装する方法を、基本から実用的な改善まで段階的に解説します。
zipcloud API の概要
zipcloud(https://zipcloud.ibsnet.co.jp/)は、日本郵便の郵便番号データをもとにした無料の郵便番号検索 API です。CORS に対応しているため、ブラウザの JavaScript から直接呼び出せます。
| 項目 | 内容 |
|---|---|
| エンドポイント | https://zipcloud.ibsnet.co.jp/api/search |
| パラメータ | zipcode(7桁の郵便番号、ハイフンあり・なし両対応) |
| レスポンス形式 | JSON |
| 利用料 | 無料(商用利用可) |
| CORS | 対応(ブラウザから直接呼び出し可能) |
| データ更新 | 日本郵便の公式データに基づき定期更新 |
// 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 が返ります。status: 200 でも results が null の場合があるため、必ずnullチェックが必要です。基本実装:郵便番号入力で住所を自動入力する
まず 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" をつけるとスマートフォンで数字キーボードが開きます(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;
});
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 で直前のリクエストをキャンセルすることで確実に防げます。
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;
}
}
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つにまとめた完全実装です。コピーしてそのまま使えます。
<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>
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(); }
});
.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 値を設定 |
ブラウザ・パスワードマネージャーが正しく補完できる |
よくある質問
results が配列で返ってくるため、複数件ある場合はプルダウンや候補リストで選択させるUIが理想的です。多くのケースでは1件だけ返ってきますが、大型ビルや団地などで複数件になることがあります。実装コストが高い場合は先頭の results[0] を使い、「正確な住所は手動で確認してください」と案内する方法もあります。useState で各フィールドの値を管理し、API 結果を setState で反映します。Vue では ref() や v-model で双方向バインディングします。AbortController は useEffect のクリーンアップ関数内で controller.abort() を呼ぶのが定石です。まとめ
郵便番号から住所を自動入力する実装のポイントをまとめます。
- zipcloud API は無料・CORS対応で、ブラウザから直接 fetch できる
resultsがnullのケースを必ずハンドリングする(存在しない郵便番号)- デバウンス(300ms待ち)で無駄なAPIリクエストを削減する
- AbortController で「直前リクエストのキャンセル」を実装してRace Conditionを防ぐ
aria-live="polite"でスクリーンリーダーへの通知を忘れない- サーバー側でも必ず入力値を検証する
fetch のローディング制御については【JavaScript】フォーム送信時にローディングアニメーションを表示する方法、フォームバリデーション全体は【JavaScript】フォームバリデーション完全ガイドもあわせてご覧ください。