React Hook Form のフォーム配線、Zod の型 + バリデーション、jpzip-js のルックアップを組み合わせて、郵便番号から住所を自動入力する典型構成を作ります。アクセシビリティと二重 lookup 抑制まで含めて、そのまま production に置ける形で書きます。
#TL;DR
- 責務分離: 構文バリデーション(7 桁数字)は Zod、住所の自動入力(zipcode → 住所のルックアップ)は jpzip-js、フォーム状態は React Hook Form という三層に切り分ける
- Zod の async refine で実在性チェックを書かない。同期スキーマで構文だけ通し、
lookupは onBlur ハンドラで呼ぶ setValue('prefecture', ..., { shouldValidate: true })で Zod の再バリデーションを走らせると、空フィールドエラーが自動的に解ける- 直近成功した zipcode を
useRefで持って二重 lookup を抑制。ユーザーが手で町域を編集した後の onBlur で書き換えが巻き戻るのを防ぐ aria-busyとaria-live="polite"の 2 つで スクリーンリーダー対応 が完了する- サーバー側でも
lookupを呼ぶ。クライアント側の自動入力は UX 補助で、信頼できる入力ではない
#なぜこの構成か
「React で住所自動入力フォーム」と検索すると、useState + useEffect で fetch する素朴な実装が多数ヒットします。動きはしますが、バリデーション・型推論・再レンダー範囲・テスタビリティのどれをとっても本構成の方が綺麗です。
| 観点 | useState + useEffect | React Hook Form + Zod + jpzip |
|---|---|---|
| 型推論 | 手書きの interface に頼る | Zod スキーマから z.infer で自動 |
| バリデーション | submit 時に自前で書く | zodResolver で submit / blur / change の全タイミングを統一 |
| 再レンダー範囲 | 親が再レンダー → 全フィールドが再レンダー | フィールド単位(register ベースなら zipcode の onBlur で再レンダーするのは zipcode フィールドのみ) |
| エラー表示の局所化 | errors.zipcode && <span>...</span> を手で書く | formState.errors.zipcode を読むだけ |
| テスト容易性 | act のラップが煩雑 | RHF の <FormProvider> で済む |
| async lookup の重複抑制 | 自分で AbortController を握る | onBlur 内で useRef 1 行 |
非同期で「住所が後から埋まる」という体験は、フォームライブラリと相性が悪く見えますが、実は React Hook Form の setValue が shouldValidate / shouldDirty / shouldTouch のフラグを渡せるおかげで、副作用としての自動入力を Zod のバリデーション結果に綺麗に反映できます。
#統合手順
#1. 依存のインストール
npm install react-hook-form zod @hookform/resolvers @jpzip/jpzip
@hookform/resolvers は RHF と Zod を繋ぐアダプタです。@jpzip/jpzip は zero runtime deps なので、追加されるのは実質 RHF + Zod + jpzip の 3 つです。
#2. Zod スキーマで住所モデルを定義
import { z } from 'zod';
export const addressSchema = z.object({
zipcode: z
.string()
.regex(/^\d{7}$/, '7 桁の数字で入力してください'),
prefecture: z.string().min(1, '都道府県を入力してください'),
city: z.string().min(1, '市区町村を入力してください'),
town: z.string().min(1, '町域・番地を入力してください'),
});
export type AddressFormValues = z.infer<typeof addressSchema>;
ポイント:
async refineでlookupを呼ばない。Zod の async バリデーションは submit / blur 時にしか走らず、「ユーザーが入力した瞬間に住所を埋める」という UX とは噛み合いません。実在性チェックはサーバー側に寄せますregex(/^\d{7}$/)は 構文チェックのみ。ハイフン入りの231-0017をそのまま受け取りたい場合は、後段のsetValueAsで削ぎ落とします
#3. useForm + zodResolver で配線
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { addressSchema, type AddressFormValues } from './address-schema';
export const AddressForm = () => {
const {
register,
setValue,
handleSubmit,
formState: { errors },
} = useForm<AddressFormValues>({
resolver: zodResolver(addressSchema),
mode: 'onBlur',
defaultValues: { zipcode: '', prefecture: '', city: '', town: '' },
});
// 次のステップで埋める
const onPostalBlur = async (_e: React.FocusEvent<HTMLInputElement>) => {
/* TODO */
};
return (
<form onSubmit={handleSubmit((v) => console.log(v))} className="h-adr">
<label>
郵便番号
<input
{...register('zipcode', {
setValueAs: (v: string) => v.replace(/[^\d]/g, ''),
})}
onBlur={onPostalBlur}
inputMode="numeric"
maxLength={8}
/>
{errors.zipcode && <span role="alert">{errors.zipcode.message}</span>}
</label>
{/* 都道府県・市区町村・町域は次のステップで埋める */}
</form>
);
};
setValueAs で 231-0017 のハイフンを削ぎ落としているので、フォーム状態としては常に 7 桁数字が保持されます。maxLength={8} はハイフン 1 文字ぶんの余裕です。
#4. onBlur で jpzip.lookup を呼び setValue で埋める
import { lookup } from '@jpzip/jpzip';
const onPostalBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
const raw = e.target.value.replace(/[^\d]/g, '');
if (raw.length !== 7) return;
const entry = await lookup(raw);
if (!entry) {
setStatus('該当する郵便番号が見つかりません');
return;
}
setValue('prefecture', entry.prefecture, { shouldValidate: true });
setValue('city', entry.city, { shouldValidate: true });
setValue('town', entry.towns[0]?.town ?? '', { shouldValidate: true });
setStatus('住所を取得しました');
};
shouldValidate: true を渡すと、Zod の prefecture・city・town の min(1) ルールが即時再評価されるので、setValue 直後に空フィールドエラーが自動で解けます。
複数町域(towns.length > 1)が返るケースは、towns[0] を採用するか、ユーザーに選択させる <select> を出すかをここで分岐します。今回は先頭採用で進めます。
#5. 二重 lookup を useRef で抑制
onBlur は同じ値でもフォーカスが外れるたびに走るので、直近成功した zipcode を覚えておきます。
import { useRef } from 'react';
const lastLookedUp = useRef<string>('');
const onPostalBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
const raw = e.target.value.replace(/[^\d]/g, '');
if (raw.length !== 7) return;
if (raw === lastLookedUp.current) return; // 同じ値なら何もしない
lastLookedUp.current = raw;
// ... lookup と setValue
};
L1 LRU が効いているので 2 回目以降の lookup 自体は 約 0.3 ms で返りますが、setValue の再発火を避けるのが本来の目的です。ユーザーが町域を手で書き換えた後、もう一度郵便番号にフォーカスして外しただけで書き換えが巻き戻ると、入力者は混乱します。
#6. アクセシビリティ属性を仕込む
const [isLooking, setIsLooking] = useState(false);
const [status, setStatus] = useState<string>('');
const onPostalBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
// ... 早期 return 群
setIsLooking(true);
try {
const entry = await lookup(raw);
// ... setValue 群
} finally {
setIsLooking(false);
}
};
return (
<form onSubmit={handleSubmit((v) => console.log(v))} className="h-adr">
<label>
郵便番号
<input
{...register('zipcode', { setValueAs: (v: string) => v.replace(/[^\d]/g, '') })}
onBlur={onPostalBlur}
aria-busy={isLooking}
inputMode="numeric"
maxLength={8}
/>
</label>
<output role="status" aria-live="polite">{status}</output>
{/* prefecture / city / town の input 群 */}
</form>
);
aria-busy={isLooking}— スクリーンリーダーが「ビジー状態」を読み上げ、補助技術側で待ちを表現できる<output role="status" aria-live="polite">— 「住所を取得しました」「該当する郵便番号が見つかりません」を非侵襲的に読み上げるinputMode="numeric"— モバイルで数字キーボードを開く(視覚補助とは別だが UX のため)
#7. サーバー側で再検証
クライアント側 lookup はあくまで自動入力の UX 補助です。送信時にユーザーが住所を手で書き換えている可能性があるので、サーバー側で必ず再度 lookup を呼んで一致を確認します。
// app/api/address/route.ts (Next.js Route Handler の例)
import { lookup } from '@jpzip/jpzip';
import { addressSchema } from '@/lib/address-schema';
export async function POST(req: Request) {
const body = addressSchema.parse(await req.json());
const entry = await lookup(body.zipcode);
if (!entry) {
return Response.json({ error: 'invalid zipcode' }, { status: 422 });
}
if (entry.prefecture !== body.prefecture || entry.city !== body.city) {
return Response.json({ error: 'address mismatch' }, { status: 422 });
}
// 永続化
return Response.json({ ok: true });
}
@jpzip/jpzip は Edge runtime 互換なので、export const runtime = 'edge' を付けてもそのまま動きます。
#ハマりやすい所
setValueのshouldValidateを忘れる: 付け忘れると、setValue直後にエラー表示が古いまま残ります。prefectureの min(1) ルールに引っかかったまま「入力されているのに赤くなる」状態になりますmode: 'onChange'を選びがち: 入力中に毎キーストロークでバリデーションが走ると、zipcodeの regex エラーがチラつきます。onBlurが UX 上の正解ですControllerで実装する: 素の<input>ならregisterで十分です。Material UI / Mantine などのカスタムコンポーネントを使う場合だけControllerに切り替えます- 複数町域の扱いを決め忘れる:
towns.length > 1の郵便番号(企業向け大口や一部の地域)で先頭採用すると、業務によっては誤入力につながります。ECサイトなら問題なし、行政手続きなら選択 UI が必要、と要件に応じて判断します - submit 時の再 lookup を忘れる: クライアント側のキャッシュ済み住所をサーバー側がそのまま信用すると、ユーザーが手で書き換えた住所が DB に入ります。サーバー側 lookup は必須です
- SSR でフォームを初期描画する: Next.js App Router で
'use client'漏れがあるとuseFormが SSR 側で実行されてエラーになります。AddressFormコンポーネントの先頭に'use client'を付けます
#動作確認
Vitest + React Testing Library で onBlur → lookup → setValue の経路をテストします。
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AddressForm } from './AddressForm';
vi.mock('@jpzip/jpzip', () => ({
lookup: vi.fn(async (zip: string) => {
if (zip === '2310017') {
return {
prefecture: '神奈川県',
city: '横浜市中区',
towns: [{ town: '本町' }],
};
}
return null;
}),
}));
describe('AddressForm', () => {
beforeEach(() => vi.clearAllMocks());
it('fills address fields after onBlur with a valid zipcode', async () => {
const user = userEvent.setup();
render(<AddressForm />);
const zip = screen.getByLabelText('郵便番号');
await user.type(zip, '231-0017');
await user.tab(); // フォーカスを外して onBlur 発火
await waitFor(() => {
expect((screen.getByLabelText('都道府県') as HTMLInputElement).value).toBe('神奈川県');
});
expect(screen.getByRole('status')).toHaveTextContent('住所を取得しました');
});
it('shows error status when zipcode is not found', async () => {
const user = userEvent.setup();
render(<AddressForm />);
await user.type(screen.getByLabelText('郵便番号'), '0000000');
await user.tab();
await waitFor(() => {
expect(screen.getByRole('status')).toHaveTextContent('該当する郵便番号が見つかりません');
});
});
});
テストで使う郵便番号は 横浜市庁舎の 231-0017 に固定すると、見直し時に「これはなんの番号だっけ?」と迷いません。実 lookup を叩きたくないので vi.mock で @jpzip/jpzip 全体を差し替えています。MSW を使う場合は https://jpzip.nadai.dev/p/231.json をスタブします。
#まとめ
React Hook Form + Zod + jpzip は、「同期バリデーション」「非同期 lookup」「フォーム状態」の三役を綺麗に分担できる組み合わせです。Zod の async refine に lookup を押し込まず、onBlur ハンドラに置く判断さえできれば、残りはほぼ機械的に組み立てられます。
useRef で二重 lookup を抑制し、aria-busy と aria-live でスクリーンリーダー対応を済ませ、サーバー側で再 lookup する。3 点を押さえると、production フォームとしての品質に届きます。
関連:
- jpzip の全体像 — なぜ CDN 静的配信モデルなのか
- 120,677 件の配信設計 — L1 LRU が
setValue連発に強い理由 - Yubinbango から jpzip-js への移行 — class 属性ベースのレガシーフォームを残したまま移行する場合