🍣

[Next.js13]郵便番号を入力したとき、対応する住所が複数あったらダイアログを表示してユーザーに選択させる

2023/06/05に公開

やること

↓これ

技術スタック

  • Next.js13.4
    • AppRouter
  • ReactHookForm ver7.44.2
  • zod ver3.21.4
  • jotai ver2.1.0

ロジック

以下イメージです。
アドレスフォーム

  • 郵便番号を入力する
  • 7文字に達する
  • APIを叩いて住所リストを取得する
    • 使用したAPIは→ https://zipcloud.ibsnet.co.jp/api/search
  • 候補が1つならフォームにセット
    • 終了
  • 候補が2つ以上ならダイアログを表示
  • ユーザーが候補から1つを選ぶ
  • 候補をフォームにセット
    • 終了

next-template/index.tsx at develop · ampersand-github/next-template · GitHub

"use client";
export const AddressForm = () => {
  // ダイアログの制御
  const [_, setIsOpen] = useAtom(isAddressSelectDialogOpenAtom);
  // 住所の候補リスト(ダイアログに表示するため)
  const [candidate, setCandidate] = useAtom(candidateAddressAtom);
  // ダイアログからユーザーが選択した住所
  const [selectedAddress] = useAtom(selectedAddressAtom);
  const [isLoading, setIsLoading] = React.useState(false);
  // formの定義
  const form = useForm<z.infer<typeof addressFormSchema>>({
    resolver: zodResolver(addressFormSchema),
    defaultValues: initialAddress,
  });
  
  // ユーザーが住所を選択したとき、選択した値をフォームにセットする
    useEffect(() => {
    form.setValue("prefecture", selectedAddress?.prefecture || "");
    form.setValue("city", selectedAddress?.city || "");
    form.setValue("town", selectedAddress?.town || "");
  }, [form, selectedAddress]);
}

  // 郵便番号から住所を取得する
  // 郵便番号と住所が1:1の場合、取得した住所をフォームにセットする
  // 郵便番号が1:Nの場合、ダイアログを開いてユーザーに住所を選択させる
  const autofillFromZipcode = async (postalCode: string) => {
    setIsLoading(true);
    const results = await fetchCandidateAddress(postalCode);
    if (results.length === 1) {
      form.setValue("prefecture", results[0].prefecture);
      form.setValue("city", results[0].city);
      form.setValue("town", results[0].town);
    }
    // 
    if (results.length >= 2) {
      setCandidate([results[0], ...results.slice(1)]);
      setIsOpen(true);
      // ここでダイアログが開く
      // 選択すると自動でフォームに入力される
    }

    setIsLoading(false);
  };
  
    // 送信
  const onSubmit = (values: z.infer<typeof addressFormSchema>) => {
    console.log(values);
    toast({ description: "送信しました", variant: "success" });
  };

  return (
    <Form {...form}>
      {isLoading ? (
        <div className="flex justify-center">
          <Loader2 className="r-2 h-8 w-8 animate-spin " />
        </div>
      ) : (
        <>
          <AddressSelectDialog items={candidate} />
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
            <FormField
              control={form.control}
              name="postalCode"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>郵便番号</FormLabel>
                  <FormControl>
                    <Input
                      type="text"
                      autoComplete="postal-code"
                      inputMode="numeric"
                      placeholder="1234567"
                      maxLength={7}
                      {...field}
                      value={field.value || ""}
                      onChange={async (
                        e: React.ChangeEvent<HTMLInputElement>
                      ) => {
                        if (isNumeric(e.target.value)) {
                          // 数値のみ反映させる
                          field.onChange(e.target.value);
                          // 7字になったら郵便番号を検索する
                          if (e.target.value.length === 7)
                            await autofillFromZipcode(e.target.value);
                        }
                      }}
                    />
                  </FormControl>
                  <FormDescription>郵便番号の入力は必須です</FormDescription>
                  <FormMessage />
                </FormItem>
              )}
            />
	    // 都道府県(略)
	    // 市区町村(略)
            // 町名以下(略)
            // 建物名・階層・部屋情報(略)
            <Button type="submit">Submit</Button>
          </form>
        </>
      )}
    </Form>
  );

住所選択ダイアログ

  • 引数で住所リストを受け取ってラジオボックス形式で表示
  • 選択したらJotaiで値を保存するuseAtom(selectedAddressAtom)

next-template/index.tsx at develop · ampersand-github/next-template · GitHub

"use client";

type props = {
  items: [IAddress, ...IAddress[]];
};

export const AddressSelectDialog = ({ items }: props) => {
  const [isOpen, setIsOpen] = useAtom(isAddressSelectDialogOpenAtom);
  const [_, setSelected] = useAtom(selectedAddressAtom);
  const handleChange = async (data: any) => {
    console.log(data);
    setSelected(items[Number(data)]);
    setIsOpen(false);
  };

  return (
    <Dialog modal={true} open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
      <DialogContent className="mt-8 max-h-[calc(100%_-_64px)] max-w-[calc(100%_-_64px)] overflow-y-auto">
        <DialogHeader>
          <DialogTitle>住所選択</DialogTitle>
          <DialogDescription>
            以下のリストから住所を選択してください。選択した住所は変更できます。
          </DialogDescription>
        </DialogHeader>

        <RadioGroup
          onValueChange={handleChange}
          className="flex flex-col space-y-1"
        >
          {items.map((item, index) => {
            const value = `${item.prefecture} ${item.city} ${item.town}`;
            return (
              <div className="flex items-center space-x-2" key={index}>
                <RadioGroupItem value={String(index)} id={String(index)} />
                <Label htmlFor={String(index)}>{value}</Label>
              </div>
            );
          })}
        </RadioGroup>
      </DialogContent>
    </Dialog>
  );
};

ポイント

フォームと住所選択ダイアログを分離しています。
状態管理を色々試していましたが、なんやかんやでjotaiに落ち着きました。
AppRouterで使用できる状態管理ツールかつ、軽いもので選定しました。
参考:【jotai】App Routerでの状態管理
(なぜjotaiなら動くのかわかってないので誰か教えてください)

Discussion