🏡

Remix + Conform で郵便番号から住所を補完するフォームを作る

2024/02/24に公開

これはなに?

Remixと、とっても使いやすいフォームバリデーションライブラリ Conform とで、郵便番号から住所を補完するフォームを作りたかったのです。

よくあるこういうやつです。日本の郵便番号から住所を補完します。

conform の Intent button の利用が必要でちょっとハマったのですが、conform 作者の方に X でフォローいただいて解決できました。最終的なものと、解決までにやったことをまとめます。

できあがりのデモ

デモがこちらです。
https://www.techtalk.jp/demo/conform/value

コードの解説

以下解説です。

「住所検索」ボタン付きの登録フォーム

フォーム部分のJSXだけを抜粋するとこんなコードになります。

<Form method="POST" className="flex flex-col gap-4" {...getFormProps(form)}>
  <div>
    <Label htmlFor={zip1.id}>郵便番号</Label>
    <HStack>
      <div>
        <Input className="w-16" {...getInputProps(zip1, { type: 'tel' })} />
        <div className="text-sm text-destructive">{zip1.errors}</div>
      </div>
      <div>-</div>
      <div>
        <Input className="w-24" {...getInputProps(zip2, { type: 'tel' })} />
        <div className="text-sm text-destructive">{zip2.errors}</div>
      </div>
      <Button
        type="button"
        variant="outline"
        className="whitespace-nowrap"
        onClick={() => fillAddressByPostalCode()}
      >
        住所検索
      </Button>
    </HStack>
  </div>

  <div>
    <Label htmlFor={prefecture.id}>都道府県</Label>
    <Input {...getInputProps(prefecture, { type: 'text' })} />
    <div className="text-sm text-destructive">{prefecture.errors}</div>
  </div>

  <div>
    <Label htmlFor={city.id}>市区町村</Label>
    <Input {...getInputProps(city, { type: 'text' })} />
    <div className="text-sm text-destructive">{city.errors}</div>
  </div>

  <div>
    <Label htmlFor={street.id}>番地</Label>
    <Input {...getInputProps(street, { type: 'text' })} />
    <div className="text-sm text-destructive">{street.errors}</div>
  </div>

  <Button className="mt-2 w-full">登録</Button>
</Form>

conform で作るフォームは簡潔に書けて、見通しよくていいですね!

Label, Input, Button などの UIコンポーネントは shadcn-ui を使っています。
getFormPropsgetInputProps は conform のユーティリティ関数です。

住所検索ボタンの onClick で呼び出す fillAddressByPostalCode 関数で郵便番号から住所を補完する処理をします。

郵便番号から住所を取得して、フォームに反映する

fillAddressByPostalCode 関数の説明に行く前に、まずは下準備として郵便番号の文字列から住所を返す関数 lookupAddress を作ります。

今回は商用でも無料で、無制限に使える 郵便番号 REST API を使います。
このAPI はYuki Matsuzaka さんが個人で開発されご提供されています。ありがとうございます!

const lookupAddress = async (postalCode: string) => {
  const res = await fetch(`https://postcode.teraren.com/postcodes/${postalCode}.json`).catch(() => null)
  if (!res || !res.ok) return null
  return (await res.json()) as {
    prefecture: string
    city: string
    suburb: string
    street_address: string | null
  }
}

さて、それでは肝心の fillAddressByPostalCode 関数です。
これはフォームと同じ route 関数コンポーネントの中で定義しています。

// value はレンダリングのときに都度参照していないと更新されず undefined になるので、ここで参照しておく
// ref: https://github.com/edmundhung/conform/pull/467
const postalCode = `${zip1.value}${zip2.value}`

const fillAddressByPostalCode = async () => {
  const address = await lookupAddress(postalCode) // 郵便番号から住所を取得
  if (!address) return

  // 住所をフォームに反映する
  form.update({
    name: prefecture.name,
    value: address.prefecture,
  })
  form.update({
    name: city.name,
    value: `${address.city}${address.suburb}`,
  })
  form.update({
    name: street.name,
    value: address.street_address ?? '',
  })
  toast.info('郵便番号をもとに住所を更新しました')
}

form.update は conform の Submittion Intent における Form Controls 機能 を実現するヘルパ関数です。

説明文

Conform utilizes the submission intent for all form controls, such as validating or removing a field. This is achieved by giving the buttons a reserved name with the intent serialized as the value. To simplify the setup, Conform provides a set of form control helpers, such as form.validate, form.reset or form.insert.

日本語訳

Conformは、バリデーションやフィールドの削除など、すべてのフォームコントロールにサブミッションインテントを利用します。これは、インテントを値としてシリアライズした予約名をボタンに与えることで実現されます。設定を簡単にするために、Conformはform.validate、form.reset、form.insertなどのフォームコントロールヘルパーを提供しています。

このヘルパ関数は useForm をして取得した form 変数に生えているもので、プログラマティックにフォームの値を更新することができます。今回は form.update を使ってコントロールの値を更新しました。

注意点としては form.update 関数に渡す name プロパティは、フィードの name プロパティと同じものを指定しなければなりません。今回は同じく useForm で取得した prefecture, city, street それぞれのフィールド変数に生えている name プロパティを使います。まあ、変数名と同じものが入っているので、別に文字列で渡してもいいんですが。

以上で郵便番号から住所を補完するフォームの完成です。
フォームの送信を受け取る action などは通常どおりなのでここでは省略します。

ハマったポイントと解決策

最後のコードの冒頭、変数 postalCode に代入している部分のコメントにもちょっと書いていますが、実装する中で2点ほどハマったポイントがありました。どちらも conform の Intent Buttonにまつわる部分でした。以下詳細です。

ハマり1: ボタンのクリックハンドラで、入力された値を取得できない

最初「住所検索」ボタンをクリックされたときのイベントハンドラから呼び出す fillAddressByPostalCode の中で以下のように直接フィールドメタデータの value を参照していました。

// 郵便番号から住所を取得
const fillAddressByPostalCode = async () => {
  const address = await lookupAddress(`${zip1.value}${zip2.value}`)
    ...
}

これが undefined になっちゃって困っていたんです。Form の中で表示するようにすると値は入ってるんですが、lookupAddress の中で出すときは undefined。なんか動作が不定な感じです。

というわけで X でボヤいたところ 作者の Edmund さんからメンションいただきました。

https://twitter.com/_edmundhung/status/1760031090629374005

その内容を参考に、以下のコードをコンポーネントがレンダリングされる都度実行される形にしてそれを使うようにしたら解決したのでした。

// value はレンダリングのときに都度参照していないと更新されず undefined になるので、ここで参照しておく
// ref: https://github.com/edmundhung/conform/pull/467
const postalCode = `${zip1.value}${zip2.value}`

原因はこのプルリクで説明されているとおりでした。
https://github.com/edmundhung/conform/pull/467

Conform uses useSyncExternalStore with subjects tracked by a proxy to achieve fine-grained subscription. This works fine generally.. but not in a callback the way you might want!
...
To solve this, a kinda awkward approach is to explicitly subscribe it during render:

日本語訳

Conformはきめ細かいサブスクリプションを実現するために、プロキシによって追跡されたサブジェクトでuseSyncExternalStoreを使用します。これは一般的には問題なく動作しますが、あなたが望むようなコールバックでは動作しません!
...
これを解決するには、レンダリング中に明示的にサブスクライブするというちょっと厄介な方法がある:

useSyncExternalStore という hook が React にあることを初めて知ったのですが、DOM で管理されてる入力値をこの hook を使って読み取れるようにしているが、レンダリングの都度参照するようにしないと更新されないので undefined になっちゃう、ということのようですね。うーん、むずかしい!

というわけで当面の回避策としてレンダリング中に郵便番号を変数にいれることで subscribe して更新されるようにしつつ、その変数をクリックハンドラで使うようにすることで一旦は大丈夫でしょう。

なお、このプルリクには2パターンの根本的な解決策が検討されています。

ひとつめはクリックハンドラでも常に更新されている fields.[name].latest.value というのを追加するという案。

もうひとつは useExternalState を使わずにそのまま fields.[name].value が常に更新されるようにする(ただし、Suspence 周りで影響がある?)ということのようです。

ちょっとティアリングというのがなにかよくわかってなくて、難しくてわかりませんでした。ただ、フォームを Suspence で出すことはなさそうな気がするし、使う側として似たようなものが2個あるのはちょっとややこしいな、と感じたので 2. の常に更新されるほうが都合がいいな、と感じたのでわからないけれどもその旨だけコメントしておいています。うーん、React と DOM、奥が深いですね。。

ハマり2: react@canary では console に警告が出る

サーバサイドレンダリングをする Remix では、ブラウザ拡張が HTML を書き換えることで hydration error が出るケースがあります。こういうやつです。

リンク先を見ると Hydration Errorというのがわかります。

これは React 本体の問題で、すでに最新ブランチでは解消されているんですが、もう2年ぐらい React 18.2 のままで全然次のがでてこない現状があります。今年中にどうも React 19 は出るっぽいのですが。

というわけで私はこの hydration error の解消のためにいつも react@canary を使っています。nextjs app router などでも普通に canary を使ってるようで、特に問題もないので、、、と思ったんですが、実は react@canary で development モードのときにだけ、form.updateの対象になったフィールドで conform の getInputProps でこんな警告が出るんです。

key= はスプレッド構文で指定しないでね、っていうことのようです。
というわけで最初は getInputProps のあとに、以下のように追加で field メタデータの initialValue を key に指定するようにしてみました。

<Label htmlFor={prefecture.id}>都道府県</Label>
<Input {...getInputProps(prefecture, { type: 'text' })} key={prefecture.initialValue} />

これで警告でなくなって動きはしたものの、1回郵便番号補完をしたあとで、住所をちょっと変更してから、再度郵便番号補完をしても、値が更新されないという現象になって困ってしまっていました。もうちょいなのに!

でも当然なんですよね。1回目は郵便番号から住所取得後 update で initialValue が更新されるので別コンポーネントとしてレンダリングされて更新されるものの、2回目の update では initialValue が行員されないので、コンポーネントが更新されないと。

というわけで、郵便番号補完をする都度変わる ID を useState とかで作ってそれを key にする?などいろいろ考えたんですが、新しい変数足すのも嫌だしなにかいい方法ないかな、と思ってそれを X でボヤいたところ、またも Edmund さんが助けてくださいました。

https://twitter.com/_edmundhung/status/1760796808333189161

結局 getInputProps で key も返してるのだから、追加で入れてる key= の指定をやめれば動くよ、と。
この key は update の都度 conform の内部で生成されていたので、それが必要なものだったというわけです。

react@canary の開発環境で警告は出はするけど、それは余計なお世話じゃないかな〜ということのようです。nextjs でも出たそうで。

今回 Edmund さんには、リポジトリをわざわざ手元でクローンして動かしたうえで、直して動画にとって教えていただきました。なんてありがたいことでしょう。

conform はいいぞ

というわけで、conform は作者の方も含めてとても良いライブラリです。
便利で簡潔に書けて、作者の方も素敵です。サイコーやん?

ソースコード

上記デモのソースコード全体はこちらです。

https://github.com/techtalkjp/techtalk.jp/blob/main/app/routes/demo.conform.value.tsx

Discussion