TypeScript、react-hook-form、zod：至福のフォーム

<font color="Grey">*[TypeScript, react-hook-form, zod: blissful forms](https://aiven.io/blog/typescript-react-hook-form-zod)の翻訳です。*</font>

2023年8月8日

# TypeScript, react-hook-form, zod: 至福のフォーム

Aivenのオープンソースエンジニアはどのようにフォームを作成し、信頼性が高く、コンポーザブルで楽しいWebフォームを作成しているか

数ヶ月前、Klawはフロントエンドの書き換えを開始し、Angular 1からReactに移行した。今日のフロントエンドの世界には、これほど多種多様な素晴らしいツールがあるのですから。私たちが最初に決断しなければならなかったことのひとつは、フォームをどうするかということでした。


## すべてはフォームについて


[Klaw](https://klaw-project.io)は、Apache Kafka®トピック用のガバナンス・ツールです。このツールの目的は、4つの目の原則を適用することで、クラスタ上の全てのオペレーションに安全なレイヤーを提供することです。つまり、操作を直接実行する代わりに、ワークフローは操作のリクエストを作成し、それを実行する前にチームの他のメンバーがレビューして承認することになります。ソース管理とコードレビューの作業に少し似ている！


したがって、Klawはリクエストの作成に重点を置いています。トピック、コネクター、スキーマ、サブスクリプションの作成、削除、プロモートのリクエストです。つまり、Klawには多くのフォームがあり、ユーザーが実行したい操作の種類ごとに1つずつ、強力なバリデーションを実装することでユーザーが使いやすいように調整されています。そしてKlawはオープンソースのプロジェクトであるため、そのすべてが将来の機能のために拡張可能である必要があり、また貢献したい人にとって理解しやすいものである必要があります。


したがって、以下のようなフォームを作成するツールが必要です：


* 信頼性： **Klawのコア機能のドライバーとして、厳密な型安全性と確かな検証機能が必要です。
* コンポーザブル:** 複雑なフォームがあるため、自由に構築できるシンプルなビルディング・ブロックが必要です。
* ユーザーと開発者は、私たちのフォームに多くの時間を費やすでしょう。良い時間であるに越したことはありません！


ありがたいことに、新しいKlawフロントエンドはTypeScriptを使用しているため、`zod`と`react-hook-form`という強力なコンビを利用することができます。


## `zod`とは？


[`zod`](https://zod.dev/?id=introduction) は "TypeScript-first schema declaration and validation library" である。


`zod`は、開発者がオブジェクトの表現(「スキーマ」)を構築し、そこからTypeScriptの型を推測することを可能にするプリミティブのライブラリである。また、与えられたオブジェクトに格納された値をスキーマに照らして検証するツールも提供する。


多くの用途があるが、特にフォームのバリデーションに適しており、`react-hook-form`の作成者はそのことをよく知っている。


## react-hook-form`とは？


[`react-hook-form`](https://react-hook-form.com/)は、"パフォーマンスが高く、柔軟で拡張可能なフォームと使いやすいバリデーション" を提供します。


`react-hook-form`は [uncontrolled components](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable) アプローチをデフォルトとし、HTML標準に従ってフォームを作成するフォームライブラリである。react-hook-form`を使用すると、HTML標準に準拠しているためフォームにアクセスしやすく、ライブラリは使い慣れたAPIを提供する。しかし、非常に寛容で、制御された入力のサポートを含む、柔軟で拡張可能なフックツールボックスを備えている。TypeScriptの強力なサポートにより、開発者はAPIを好きなように使うことができる。


これは私たちにとって、さまざまな意味で重要なことです：


* フォームの構成要素を簡単に書くことができる。
* 入力コンポーネントを制御するかしないかを自由に選択できる。
* そして最も重要なことは、[resolver API](https://react-hook-form.com/api/useform/#resolver)を活用することで、`zod`を検証ツールとして使えることである。


## どう使うか


Klawチームは、`react-hook-form`と`zod`を別々のファイルに分け、フォームをレンダリングするときに一緒にすることにした。


### `react-hook-form`: ビルディングブロックを `Form.tsx` にエクスポートする。


`Form.tsx`は、`react-hook-form`でフォームを構築するために必要なすべてのブロックを定義してエクスポートし、バリデーションのための`zod`の統合を考慮に入れています。私たちは内部的に[Aiven Console](https://console.aiven.io)を構築する際にこのアプローチを使用しており、Klawでも、そうでなければ不整合につながる可能性のあるパターンをカプセル化するという明確な目的のために実装しました。例えば、バリデーションはいつ実行されるのでしょうか?この決定は`Form.tsx`で一度行われ、アプリケーション内のすべてのフォームに適用されます。この意見を取り入れたアプローチにより、フォームを構築する単一の方法を提供することで、開発者にわかりやすい体験を提供することができます。


### ブループリントuseForm


`useForm` フォームが保持する値を定義し、[それらの値を検証する方法です](https://github.com/aiven/klaw/blob/09570e7f5903d77d81ca522c499bcedfad6cb132/coral/src/app/components/Form.tsx#L70-L81) `react-hook-form`のネイティブフック `useForm` を独自のカスタムフックでラップします。これにより、さまざまなパラメータに意味のある名前をつけたり (たとえば、リゾルバの代わりにスキーマを指定するなど)、すべてのフォームで共有したいデフォルト値を設定したり (たとえば、バリデーション `onTouched` など)できるようになります。これは、フォームの処理と検証に必要な値とメソッドを保持するオブジェクトを、渡したスキーマに従って型付けして返します。


### 土台となるものフォーム


[フォームのDOM構造を保持する](https://github.com/aiven/klaw/blob/09570e7f5903d77d81ca522c499bcedfad6cb132/coral/src/app/components/Form.tsx#L90-L107)。これは `react-hook-form` の `FormProvider` でラップされた HTML フォーム要素を返す単純なコンポーネントである。FormProvider` は `useForm` から返されたオブジェクトの値を props として受け取る。


## ブリック: 入力コンポーネント


フォームに求めるさまざまな値やユーザー体験を表現するために、さまざまな入力コンポーネントを定義します。これらはフォームコンポーネントの子としてレンダリングされます。これらはすべて `useFormContext` フックを呼び出すことで親のフォームに登録され、その戻り値を使って自分自身を登録します。


* [単純入力は制御されません](https://github.com/aiven/klaw/blob/09570e7f5903d77d81ca522c499bcedfad6cb132/coral/src/app/components/Form.tsx#L148-L176)。[これらは `register` メソッドに `name` を指定して呼び出す](https://github.com/aiven/klaw/blob/09570e7f5903d77d81ca522c499bcedfad6cb132/coral/src/app/components/Form.tsx#L159) ことによって親フォームに登録されます。
* [より複雑な入力は `react-hook-form` `Controller` コンポーネントでラップされます](https://github.com/aiven/klaw/blob/09570e7f5903d77d81ca522c499bcedfad6cb132/coral/src/app/components/Form.tsx#L329-L380)。フォームコンテキストから `control` の値を `control` プロパティとして `Controller` に渡すことで、親フォームに登録される。


## `zod`: スキーマとバリデーションによるセーフティネットの定義


`zod`を使用して、各フォームのシェイプ、型、バリデーションを定義するので、関連するフォームが定義されている近く、通常は[`form-schemas`フォルダ](https://github.com/aiven/klaw/tree/09570e7f5903d77d81ca522c499bcedfad6cb132/coral/src/app/features/topics/schema-request/form-schemas)に配置します。`TopicSchema.tsx`は、`topic-schema-request-form.ts`で定義されたスキーマと型を使用してフォームをレンダリングする。


### スキーマと推論される型


フォームスキーマは通常、一連のキーと値のペアを保持するオブジェクトである。zod` では `infer` メソッドを使って、このスキーマから TypeScript の型を推論することができます。


![コード環境のアニメーションGIF。カーソルがtopicRequestSchemaというオブジェクトの上を転がり、IDEがその型を推測できることを示すモーダルウィンドウが表示される](https://cdn.sanity.io/images/sczeoy4w/production/c508127fbdc5d895f26a4951e8440386d43f8f15-860x404.gif?q=80&fit=min&auto=format&dpr=1.5)


### バリデーション


バリデーションはスキーマ定義の不可欠な一部として行われる。これは非常に柔軟で、必要なルールやその複雑さに応じて、さまざまな方法で行うことができます。


単純なバリデーションのために、`zod` は `.min`, `.max`, `.optional`, `.required` といった一連のメソッドを提供している。これらのメソッドには常に 2 番目のパラメータが渡され、ディスパッチするエラーメッセージを指定することができる。


![zodの.minメソッドと.maxメソッドを示すコード環境のスクリーンショット。どちらもエラーメッセージ付きで実装されている](https://cdn.sanity.io/images/sczeoy4w/production/0cbccdc2a16a27d64fc9a48244dd00af32d7c1b5-1366x214.png?q=80&fit=min&auto=format&dpr=1.5)


より複雑なケース、例えば値が他のフィールドの値を知っていることに依存している場合、 `zod` は `refine` メソッドと `superRefine` メソッドを提供します。これらのメソッドは関数を引数として取り、その関数に特定のケースを処理するためのカスタムロジックを含めることができる。例えば、Klaw でトピックリクエストを作成する際に、 `replicationFactor` の値が `maxReplicatioFactor` の値より大きくならないようにすることができる。次に `addIssue` を使って、この場合にディスパッチしたい正確な種類のエラーを作成します。


![zodのrefineメソッドとsuperRefineメソッドを使用した、より複雑な検証ケースを示すコード環境の画像。superRefineを呼び出すには、上で説明したように、別のメソッドでrefinement自体を定義する必要があります](https://cdn.sanity.io/images/sczeoy4w/production/99cc583f93cb3ab87a14e743e57907a3434eecd9-1722x1566.png?q=80&fit=min&auto=format&dpr=1.5)


## ついに一緒に: フォームのレンダリング


これで  

自由に構成できるフォームと入力コンポーネント

フォームスキーマ、タイプ、バリデーション


そして、ユーザーに望むUIをレンダリングするために、これらを一緒に使うときが来ました！これがその方法だ：


1.`useForm`、`Form`、必要な入力を `Form.tsx` からインポートする。
2.スキーマと推論される型を定義されている場所からインポートする。
3.推論された型をジェネリック型の引数として `useForm` を呼び出し、スキーマ (およびオプションのデフォルト値) を渡す。
4.`useForm` の返された値を `Form` コンポーネントに props として渡す。
5.フォームスキーマの正しいフィールドに対応する `name` プロップを渡して、必要な入力を `Form` の子としてレンダリングする。


概念的には次のようになります：



```
react-hook-form
zod
react-hook-form + zod

// NameForm.tsx

import { useForm, Form, TextInput } from "src/app/components/Form"
import { formSchema, FormSchema } from "src/app/components/name-form.ts"

// …


const form = useForm<FormSchema>({
  schema: formSchema,          
  defaultValues,
});


<Form
  {...form}
>
  <TextInput<FormSchema> name={'name-in-schema-type'} /> -> only names in FormSchema allowed as name for TextInput
</Form>
```

これで目的を達成できたので、もう安心です。私たちのフォームは


* 信頼性: フォームはエンドツーエンドで型付けされ、堅牢なバリデーションが行われます。
* コンポーザブル：フォームの作成は、ビルディング・ブロックを組み合わせることで完結します。
* 喜び: フォームを自信をもって迅速に作成できることは、私たちのチームにとって永遠の喜びです！


## 欠点


しかしもちろん、あらゆる技術的選択にはトレードオフがあり、これも例外ではない。


**制御されていないコンポーネントと制御されたコンポーネントの混合：***。


複雑な入力コンポーネントの多くは制御する必要があります (`Controller` でラップするか、明示的に `value` プロパティを設定します)。これはライブラリの基本理念である "HTML標準 "に反しており、複雑さを増しています。この複雑さは当然バグにつながります。[このようなバグ](https://github.com/aiven/klaw/pull/1045)。


**ちょっとした定型文:**


新しい入力タイプを`Form.tsx`に追加すること、すべてのフォームのためにスキーマと検証ルールを書くこと、正確にすべての入力を入力するために適切な構文を使用することを忘れないこと、このすべてが退屈に感じるかもしれません。このアプローチが強い関心分離を強制するのとは対照的に、コロケーションの甘い快楽に憧れるかもしれない。そしてそれは正しいかもしれない！これは奇跡の解決策ではなく、あなたの目的に合うか合わないかだけの解決策なのだ。


**複雑なUIからは逃れられない。


私たちのアプローチやビルディング・ブロックがシンプルであればあるほど、私たちが構築するUIは時として避けられないほど複雑になります。私たちのACLフォームがそうで、非常に動的で、多くの要因によって有効になったりならなかったりするフィールドがあります。このため、[興味深いスキーマとバリデーション](https://github.com/aiven/klaw/tree/09570e7f5903d77d81ca522c499bcedfad6cb132/coral/src/app/features/topics/acl-request/form-schemas)、これらのフォームのために特別に設計された[カスタムフィールドのホスト](https://github.com/aiven/klaw/tree/09570e7f5903d77d81ca522c499bcedfad6cb132/coral/src/app/features/topics/acl-request/fields)、[3000行以上の長さのテストファイル](https://github.com/aiven/klaw/blob/09570e7f5903d77d81ca522c499bcedfad6cb132/coral/src/app/features/topics/acl-request/TopicAclRequest.test.tsx)が必要になります。


## Klawへの貢献


幸いなことに、これらの欠点から逃れることはできなくても、反復し、改善することによって、それを軽減することができる！[貢献したければ、[リファクタリング課題を拾ってください](https://github.com/aiven/klaw/issues/1142)！私たちはあらゆるPRを歓迎します。もしこの投稿に興味を持たれたなら、私たちの[good first issue](https://github.com/aiven/klaw/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)をご覧ください。[投稿ガイドライン](https://github.com/aiven/klaw/blob/main/CONTRIBUTING.md)に必ず目を通してください！