はじめに
こんにちは! 開発 2 部の小林です。
バイセルでは最新の機能を積極的に使える環境が整っており、各プロジェクトのエンジニアが日々最新の技術を調査し、社内で共有を行なっています。
今回は技術調査の一環として、react-hook-form(以後 RHF とする)の最新バージョンで追加予定の Form コンポーネント(執筆時点ではプレリリース)の調査を行いましたので内容を共有します。
対象読者
本記事は以下のエンジニアを対象にしています。
- RHF でフォームを作成したことがあるエンジニア
- RHF の最新機能に興味のあるエンジニア
もし、RHF に馴染みのない方は、先に公式を読まれることをお勧めいたします。
https://react-hook-form.com/get-started/
Form コンポーネントとは
RHF で現在プレリリース中の 7.44.x 版で導入予定の最新機能で、Form の submit 処理でよく記述される処理を共通化すること & Web 標準 API を使用することでプログレッシブエンハンスメントを向上させることを目的とした新しいコンポーネントです。
RFC
より詳しく知るために RFC を一部抜粋して紹介します。
https://github.com/react-hook-form/react-hook-form/discussions/9856
動機
以下の動機が書かれていました。
- 開発者体験の向上
- 標準的な Web API を活用する
- プログレッシブエンハンスメントに対応
- 90%のユースケースに対応し、かつカスタム onSubmit コールバックを可能にする
- コードベースに一貫性を持たせ、良いパターンを確立することで、以下のような繰り返しのコードを減らすことができる
const { handleSubmit } = useForm() const onSubmit = async (data) => { try { await fetch('/api', { headers: { 'content-type': 'application/json', }, body: JSON.stringify(data), }) // error handling and status check // success handling } catch (e) { // error handling } } <form onSubmit={handleSubmit(onSubmit)} />
確かに onSubmit 内で try ~ catch を書くのは 多くの場合に共通化できる処理ですね。
また、プログレッシブエンハンスメントという言葉も出てきました。
プログレッシブエンハンスメントとは
MDN には以下のように記載されています。
プログレッシブエンハンスメント (Progressive enhancement) とは、可能な限り多くのユーザーに不可欠なコンテンツと機能のベースラインを提供することを中心とした設計哲学であり、必要なすべてのコードを実行できる最新のブラウザーのユーザーに限り、最高の体験を提供します。
https://developer.mozilla.org/ja/docs/Glossary/Progressive_Enhancement
つまり、使用できる機能は最大限提供しつつ、機能が制限されているユーザーには最低限必要不可欠な操作方法を提供する設計哲学です。
今回の Form コンポーネントに関するプログレッシブエンハンスメント観点とは、SSR などのようにサーバ側でレンダリングされた HTML がブラウザーに提供された時は、 JavaScript(以後 js とする) が読み込まれるまで Form の機能が制限されてしまいますが、最低限ユーザーが意図した操作ができるようにすることです。
というのも、従来通り RHF の handleSubmit を使用する場合、ブラウザー側で js を読み込み、Form に onSubmit のイベントハンドラーを設定してからでないと、意図した Submit 処理を実行することができないためです。
上記の場合、CSR の時は js が読み込まれないとそもそも画面が表示できないので問題ないですが、サーバ側でレンダリングされた HTML がブラウザーに提供された時は、画面が表示された後に js が読み込まれる状態になるので、ブラウザー側で js が off になっている時や、ネットやマシンスペックが弱い環境にいるユーザーは js が読み込まれる間、意図した submit 処理ができなくなります。
デメリット・制限
デメリット・制限について以下のような記述がありました。
デメリット
- 特定のユースケースのみを抽象化する
- バンドルサイズが大きく上昇してしまう(約 5% - ライブラリ全体で約 10kb に近くなる)
制限
- プログレッシブエンハンスメントはネイティブアプリには適用されない
- 今回の変更ではデータ変換については考慮しない
新しいコンポーネントを追加するので、どうしてもバンドルサイズが上昇するようです。
また、データ変換については考慮しないとのことでしたが、最新の7.44.0-rc.0では、handleSubmit を使用した場合は、以下のように useForm の第 3 型引数に変換後の型を渡す事で、適切に型推論された変換後の値を受け取れるようになっていました。
UseFormReturn<TFieldValues, TContext, TTransformedValues>
データ変換とは、RHF が提供している resolver とバリデーションライブラリを組み合わせた時に使うことができ、Form で管理している値に変換処理を挟み込み、handleSubmit や getValues で受け取れる機能です。( Zod で言うとtransform API)
https://github.com/react-hook-form/resolvers
上記のバリデーションライブラリとして、バイセルでは Zod を使用しており、使用例について書いた記事があるので、ぜひ参考にしてみてください。
実務で使った React-Hook-Form × Zod の事例紹介
また、過去の記事で Output Schema に型にうまく推論されず型不整合が起きる件について取り上げましたが、このバージョンがマージされれば解消されます!
React Hook Form + Zod における型不整合の解消
実際に使ってみたところ、ちゃんと推論されていました。
デモはこちら
const zipCodeSchema = z .object({ firstThreeDigits: firstThreeDigitsSchema, lastFourDigits: lastFourDigitsSchema }) .transform(({ firstThreeDigits, lastFourDigits }) => ({ zipCode: firstThreeDigits + lastFourDigits })); type InputSchema = z.input<typeof zipCodeSchema>; type OutputSchema = z.output<typeof zipCodeSchema>; // ...省略 const { handleSubmit, } = useForm<InputSchema, any, OutputSchema>({ resolver: zodResolver(zipCodeSchema) }); // ...省略 <form onSubmit={handleSubmit((data) => { console.log(data); // { zipCode: string } = OutputSchema })} >
しかし、Form コンポーネント の fetcher を使用した場合では、変換後の値を取得できましたが、型が InputSchema で推論されてしまいました。
デモはこちら
// ...省略 const { control } = useForm<InputSchema, any, OutputSchema>({ resolver: zodResolver(zipCodeSchema) }); // ...省略 <Form<InputShema, OutputSchema> control={control} action="test" fetcher={(_, { values }) => { console.log(values); // { firstThreeDigits: string, lastFourDigits: string } = InputSchema })} >
Form コンポーネントの型引数である FormProps 型では、fetcher 型は以下のようになっています。
export type FormProps< TFieldValues extends FieldValues, TTransformedValues extends FieldValues | undefined = undefined > = { // ...省略 fetcher: ( action: string, payload: { values?: TFieldValues method: string event?: React.BaseSyntheticEvent formData: FormData formDataJson: string }, ) => Promise<void> | void } export function Form< T extends FieldValues, U extends FieldValues | undefined = undefined >(props: FormProps<T, U>)
fetcher の values が TFieldValues になっていることが問題のようです。
各 API の使い方
公式に予定されている API が記載されていたのでざっと紹介します。
https://react-hook-form.com/api/useform/form/
<Form action="/api" method="post" // default to post onSubmit={() => {}} // function to be called before the request onSuccess={() => {}} // valid response onError={() => {}} // error response validateStatus={(status) => status >= 200} // validate status code />
Web 標準 API のように action や method を受け取ることができます。
ただ、method の値に関して、Form コンポーネントは default で post として送信されますが、Web 標準 API は get として送信されるようになっており、default で送信される method が異なっています。
プログレッシブエンハンスメントを考慮する場合は、実装に合わせてできるだけ引数は省略しないで書いてあげたほうが良いと思いました。
https://developer.mozilla.org/ja/docs/Web/HTML/Element/form
props
control
管理しているデータを紐づけるために、useForm の返り値の control を渡す必要があります。
FormProvider で Form コンポーネントをはさんでいるときは、省略が可能なようです。
公式の例
<Form control={control} />
RFC の例
const { control } = useForm({ // ...省略 }) <Form action="/api" control={control} />
children
通常の React コンポーネントと同様で、子要素をレンダリングします。
render
レンダープロップスとしても使用できるので、RHF の Controller のように、別のライブラリと併用できます。
Radix UIのような、ヘッドレスコンポーネントとも簡単に連携できますね。
プログレッシブエンハンスメントを意識しないといけない場合は、HTML 出力後に Web 標準 API として単体で機能するかを確認する必要があります。
<Form render={({ submit }) => <View />} />
当たり前ではありますが、引数として render が渡された時は、子要素は無視されます。
onSubmit
バリデーション成功後 ~ submit 前の間に実行されるハンドラーを設定できます。 Form で管理する値を引数として受け取り、任意の処理を実行できます。
<Form onSubmit={(data) => mutation(data)} />
onSuccess
リクエストが成功した後に実行されるハンドラーを設定できます。 サーバー側からの response オブジェクトを引数として受け取り、任意の処理を実行できます。
<Form onSuccess={({ response }) => {}} />
onError
リクエストが失敗した後に実行されるハンドラーを設定できます。 サーバー側からの response オブジェクトを引数として受け取り、任意の処理を実行できます。
<Form onError={({ response }) => {}} />
リクエスト処理が失敗した時は、formState の isSubmitSuccessful が false になります。
headers
リクエストのヘッダーを指定できます。 オブジェクト形式で受け取り、value には文字列のみ指定可能なようです。
<Form headers={{ accessToken: 'xxx', // Json content type // will stringify form data 'Content-Type': 'application/json', }} />
実装を見ると、こちらに Content-Type を指定した場合でも、別途 encType を指定すると Content-Type が上書きされてしまうので注意が必要です。
validateStatus
status コードを引数として受け取り、成功とする status code を判断するハンドラーを設定できます。
省略した場合は、200 系のみですが、その他の成功とする status code を指定できます。
<Form validateStatus={(status) => status === 200} />
fetcher
submit/try の処理を全て代替する、任意のハンドラーを設定できます。
第 1 引数には、設定した action を受け取り、第 2 引数には以下の値を受け取ります。
{ method, // post or get values, // RHFのFormで管理している値 event, // React.BaseSyntheticEventの型を持つ値 formData, // new FormDataされた値 formDataJson, // valuesをjson.stringifyした値 }
GraphQL や SWR のような Web 標準 API の fetch API 以外を使用してリクエストを送りたい場合はこの API を使用します。
// with server state library <Form fetcher={ (action, { values }) => axios(action, values})} /> // with custom axios <Form fetcher={ (action, { values }) => mutation(values)} />
実際に使ってみる
プログレッシブエンハンスメント観点も試したいので、今回は Next.js を使って簡単なフォームを作成します。
デモコードはこちら
Form コンポーネントに内包されている Web 標準の fetch API で post するパターン
Form コンポーネントのコードを見る限り、props に fetcher を渡していない場合は、Web 標準の fetchAPI を使用するようなので、まずこのパターンを試します。
Form コンポーネントを使用して、名前と年齢を入力するだけのフォームを作成します。
import { Form, useForm } from 'react-hook-form' type FormSchema = { username: string age: number } const App = () => { const { control, register } = useForm<FormSchema>() return ( <Form action="/api/test" method="post" encType="application/json" headers={{ 'Content-Type': 'application/json', }} control={control} onSuccess={(response) => { console.log('onSuccess', response) }} onError={(response) => { console.log('onError', response) }} onSubmit={(data) => { console.log('onSubmit', data) }} > <fieldset> <label> 名前: <input type="text" {...register('username')} /> </label> </fieldset> <fieldset> <label> 年齢: <input type="number" {...register('age')} /> </label> </fieldset> <button type="submit">送信</button> </Form> ) } export default App
次に Next.js のAPI Routesを使用して、post 先の/api/test
のハンドラーを作成します。
export default function handler(req: NextApiRequest, res: NextApiResponse) { console.log(req.body) res.status(200).json({ username: req.body.username, age: req.body.age }) }
一旦以下のようなフォームが表示できたので、適当な値を入力して post してみます。
すると、以下のようにログが表示され、無事 post できたことが確認できました!
// フロント側 onSubmit {username: 'kyousay', age: '30'} onSuccess {response: Response} // API側 { username: 'kyousay', age: '30' }
ちゃんとonSubmitは、post 前に発火されています。
また、encType だけにapplication/json
を設定した状態では、値がmultipart/form-data
として post されてしまったので、400 Invalid JSON
になってしまい若干詰まりました。
headers APIの Content-Type に json を含んだ文字列を入力しないと、json 化されたデータを requestBody に設定できませんでした。
型チェックの段階で気づかせてほしいですね。
(headers に指定可能なキーにも型推論が効かないのでどうにかしたいところです。)
Form コンポーネントの fetcher API を使って post するパターン
次に Form コンポーネントのfetcher APIを使用して post するパターンを試します。
まずはコンポーネントを用意します。
先程使用したコンポーネントを再利用して、Form に fetcher 関数を渡します。
リクエストには、RFC でも取り上げられていた axios を使います。
// ...省略 <Form action="/api/test" method="post" encType="application/json" control={control} fetcher={(action, { values }) => { // onSubmitハンドラーの処理 console.log(action, values) axios.post(action, JSON.stringify(values), { headers: { 'Content-Type': 'application/json', }, }).then(() => { // onSusscessハンドラーの処理) }).catch(() => { // onErrorハンドラーの処理 }) }} >
fetcher を渡すと、headers や、onSubmit のような一部のハンドラーが渡せなくなるので注意が必要です。
もし渡せなくなった props と同様の処理を行いたい場合は、fetcher 関数内で自分でロジックを書くことになります。
API Routesのハンドラーはこのまま流用できるので、変更しないで試します。
post すると以下のようにログが表示されて、正常に機能していることが確認できました。
// フロント側 /api/test, {username: 'kyousay', age: '30'} // API側 { username: 'kyousay', age: '30' }
fetcher API の第一引数として action を受け取れるので、axios や swr などではキーとして扱えて便利です。
気になった点としては、fetcher API の使用有無で、post された値を parse する必要があるかどうかが変わるように、静的生成された時と互換性があるようにサーバー側も実装しないといけないのが大変そうだなと思いました。
プログレッシブエンハンスメント観点の確認
プログレッシブエンハンスメント観点の確認を行うため、Next.js で build を行い、静的に生成された HTML のソースを見てみます。
next build && next start を行い、ソースを確認すると以下のようにちゃんと form が生成されていました。
<form action="/api/test" method="post" encType="application/json"> <!-- 省略 --> </form>
Web 標準 API の form として正しく動作するように、action, method などが設定されていれば、例え js が off の環境であろうと、最低限 form の submit 処理をユーザーに提供できます。
わかったこと
GraphQL ではメリットが薄い
調べていて、Rest API ではプログレッシブエンハンスメント観点や、処理の共通化の観点でとても便利だと感じましたが、GraphQL を使う場合はあまりメリットがないと考えました。
そもそも GraphQL だと下記のような特徴があり、プログレッシブエンハンスメント対応はほぼ不可能です。
- 成功しても、失敗しても status code 200 が返ってくる(仕様ではないですが、ほぼほぼのライブラリがこの仕様です)
- query, mutation に関わらず、POST リクエスト
- ひとつのエンドポイントしか持たず、type システムによってリクエストを判定する
また、fetcherを使用することで、一応 Form コンポーネントを GraphQL と組み合わせをすることは可能ですが、handleSubmit を使った従来通りの方法とあまり変わらないため、特段使用するメリットがないなと感じました。
さらに、RFC のデメリット・制限の紹介でも書きましたが、現状 Form コンポーネントの fetcher を使用した際に、変換後の型推論をしてもらえない状態なので、現状では handleSubmit を使用するほうが良いと考えました。
// handleSubmitパターン const { handleSubmit } = useForm() const onSubmit = (data) => { // 任意の処理 } return <form onSubmit={handleSubmit}>// ...省略</form> // Formコンポーネントパターン const { control } = useForm() const onSubmit = (_, { values }) => { // 任意の処理 } return <Form fetcher={onSubmit}>// ...省略</Form>
プログレッシブエンハンスメント観点
今回一番勉強になったのは、プログレッシブエンハンスメント観点でした。
正直意識できていなかった観点で、パフォーマンス最適化の手法として、サーバー側でレンダリングすることがメジャーな選択肢の一つとなっている、昨今のフロントエンドにおいてとても重要な観点だと感じました。
また、RHF だけでなく、他の多くのライブラリが抱える共通の課題なのかなとも感じました。
まとめ
今回調べてみて、残念ながら筆者のプロジェクトでは Graph を使用しているため、正式リリースされた後にすぐ使用はできなさそうだと判断しました。
しかし、RFC を読み、機能の背景・解決したい課題を理解することで、プログレッシブエンハンスメントのような、日常の業務で活かせる新しい観点を学ぶことができました。
また、調べていく過程でもっとこうした方が使い勝手が良いのではという発見が多々ありましたので、これを機に issue や PR を作成してコミュニティに貢献していこうと思います。
もし機会があれば、また新しい機能の紹介記事も書こうと思うのでお楽しみに!
BuySell Technologies ではエンジニアを募集しています。こういった取組に興味がある方はぜひご応募をお願いします!