Reactでフォーム処理の関心事をカスタムフックに切り出す
この記事について
実際にフォームを使用するときは、バリデーションライブラリと合わせて実装することが多いと思います。
こちらの記事で再描画を抑えるためにはReactHookForm(以下 RHF)を使用するのが良いと投稿しました。
今回の記事では、タイプセーフなバリデーションライブラリ「Zod」とRHFを使用した実践的なフォームを作成していきます。
RHFとZodは以下のresolverを使用すると簡単に組み合わせることができます。
import { zodResolver } from "@hookform/resolvers/zod"
ですが愚直に実装しても、1つのコンポーネントにすべての関心事が詰め込まれ、ファットなコンポーネントになってしまうでしょう。
今回は、フォーム処理における関心事をカスタムフックに切り出し、交換可能(プラガブル)なフックを作成していきます。
画面
以下のようなフォーム画面を実装しています。
InputFieldコンポーネント
以降で使用しているInputFieldのコンポーネントです。
基本的にはinputタグをラップしつつ、errors
がある場合はその内容を列挙しているだけなので、読み進める分には飛ばしてOKです。
InputField.tsx
export type InputFieldProps = Omit<JSX.IntrinsicElements["input"], "ref"> & {
errors?: string[]
inputRef?: React.Ref<HTMLInputElement>
}
export const InputField = (props: InputFieldProps) => {
const { errors, inputRef, ...others } = props
return (
<div>
<input {...others} ref={inputRef} />
{errors?.map((x) => (
<p>
<small>{x}</small>
</p>
))}
</div>
)
}
ReactHookFormとZodを使った実装(愚直に実装)
一枚のindex.tsx
にフォームに関する処理を実装していきます。
$ tree page1
page1
└── index.tsx
長いので斜め読みでOKです。
import { zodResolver } from "@hookform/resolvers/zod"
import {
SubmitErrorHandler,
SubmitHandler,
useForm,
UseFormRegisterReturn,
} from "react-hook-form"
import { z } from "zod"
import { InputField } from "../../../components/InputField"
const schema = z.object({
name: z.string().min(5),
email: z.string().email(),
})
type FormData = z.infer<typeof schema>
const defaultValues: FormData = { name: "", email: "" } as const
const Page = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
mode: "onChange",
resolver: zodResolver(schema),
defaultValues,
})
const handleValid: SubmitHandler<FormData> = (data, event) => alert("OK")
const handleInvalid: SubmitErrorHandler<FormData> = (errors, event) =>
alert("INVALID")
return (
<form onSubmit={handleSubmit(handleValid, handleInvalid)} noValidate>
<div>名前:</div>
<InputField
{...convert(register("name"))}
errors={resolve(errors.name)}
/>
<div>メール:</div>
<InputField
{...convert(register("email"))}
errors={resolve(errors.email)}
/>
<button>submit</button>
</form>
)
}
// InputField.tsxはrefの代わりにinputRefを定義しているので、ref->inputRefにセットし直します。
function convert({ ref, ...others }: UseFormRegisterReturn) {
return { inputRef: ref, ...others }
}
function resolve(field?: { message?: string }) {
return field?.message ? [field?.message] : undefined
}
export default Page
この時点でも見通しが悪いですが、実際にはもっと沢山の処理が入ってくるので、これからもっと可読性の低下が予想されます。
カスタムフックで処理を切り分ける
フォームに関する処理をuseUserForm.ts
に切り出します。
これによりPageコンポーネントの見通しは改善されたと思います。
$ tree page2
page2
├── hooks
│ └── useUserForm.ts
└── index.tsx
import { InputField } from "../../../components/InputField"
import {
SubmitErrorHandler,
SubmitHandler,
UserForm,
useUserForm,
} from "./hooks/useUserForm"
const Page = () => {
const { handleSubmit, errors, fieldValues }: UserForm = useUserForm()
const handleValid: SubmitHandler = (data, event) => alert("OK")
const handleInvalid: SubmitErrorHandler = (errors, event) => alert("INVALID")
return (
<form onSubmit={handleSubmit(handleValid, handleInvalid)} noValidate>
<div>名前:</div>
<InputField {...fieldValues.name} errors={errors.name} />
<div>メール:</div>
<InputField {...fieldValues.email} errors={errors.email} />
<button>submit</button>
</form>
)
}
export default Page
ポイントとしてはuseUserForm
の「実装」に依存するのではなく、タイプであるUserForm
に依存した実装にしています。
そうすることで、UserForm
の制約を守っている限り、Pageコンポーネント自体の実装に影響はありません。
※UserForm
はhooks/useUserForm.ts
でexportされているuseUserForm()のReturnTypeです。
フォームの処理に用事がある人は、こちらのカスタムフックを修正してください。
hooks/useUserForm.ts
import { zodResolver } from "@hookform/resolvers/zod"
import {
SubmitErrorHandler as SubmitErrorHandlerOriginal,
SubmitHandler as SubmitHandlerOriginal,
useForm,
UseFormRegisterReturn,
} from "react-hook-form"
import { z } from "zod"
const schema = z.object({
name: z.string().min(5),
email: z.string().email(),
})
type FormValues = z.infer<typeof schema>
const defaultValues: FormValues = { name: "", email: "" } as const
export type UserForm = ReturnType<typeof useUserForm>
export type SubmitHandler = SubmitHandlerOriginal<FormValues>
export type SubmitErrorHandler = SubmitErrorHandlerOriginal<FormValues>
export const useUserForm = () => {
const {
register,
handleSubmit: handleSubmitOriginal,
formState: { errors },
} = useForm({
mode: "onChange",
resolver: zodResolver(schema),
defaultValues,
})
const handleSubmit = (
onValid: SubmitHandler,
onInvalid: SubmitErrorHandler
) => handleSubmitOriginal(onValid, onInvalid)
return {
handleSubmit,
errors: {
name: resolve(errors.name),
email: resolve(errors.email),
},
fieldValues: {
name: convert(register("name")),
email: convert(register("email")),
},
}
}
// InputField.tsxはrefの代わりにinputRefを定義しているので、ref->inputRefにセットし直します。
function convert({ ref, ...others }: UseFormRegisterReturn) {
return { inputRef: ref, ...others }
}
function resolve(field?: { message?: string }) {
return field?.message ? [field?.message] : undefined
}
hooks/useUserForm.ts
はフォームに関係のない改修では確認する必要がないので、「その他の処理」を追加することも容易になりました。
カスタムフックをMockに切り替える
プロジェクトの開発初期やユニットテストを行う場合、自分に都合の良い振る舞いを行うよう処理を差し替えたいときがあります。
フォームの処理はUserForm
の制約に沿っている限り、簡単にモックオブジェクトに切り替えることができます。
$ tree page3
page3
├── hooks
│ ├── useUserForm.ts
│ └── useUserFormMock.ts // これに切り替える
└── index.tsx
UserForm
に則り、自分に都合の良いカスタムフックを作成します。
import { UserForm } from "./useUserForm"
// ⭐
export const useUserForm = (): UserForm => {
const handleSubmit = (onValid: any) => () => {
onValid()
return Promise.resolve()
}
return {
handleSubmit,
errors: {
name: undefined,
email: undefined,
},
fieldValues: {
name: { ...mockFieldValue, name: "name" },
email: { ...mockFieldValue, name: "email" },
},
}
}
const mockFieldValue = {
name: "mock",
onChange: () => Promise.resolve(),
onBlur: () => Promise.resolve(),
inputRef: () => {},
}
最後にUserForm
の実装をuseUserFormMock
に差し替えます。
import { InputField } from "../../../components/InputField"
import {
SubmitErrorHandler,
SubmitHandler,
- useUserForm,
UserForm,
} from "./hooks/useUserForm"
+ import { useUserForm } from "./hooks/useUserFormMock"
const Page = () => {
const { handleSubmit, errors, fieldValues }: UserForm = useUserForm()
const handleValid: SubmitHandler = (data, event) => alert("OK")
const handleInvalid: SubmitErrorHandler = (errors, event) => alert("INVALID")
return (
<form onSubmit={handleSubmit(handleValid, handleInvalid)} noValidate>
<div>名前:</div>
<InputField {...fieldValues.name} errors={errors.name} />
<div>メール:</div>
<InputField {...fieldValues.email} errors={errors.email} />
<button>submit</button>
</form>
)
}
export default Page
このようにどのような入力値でも成功時の処理(都合の良いMockの処理)が実行されています。
Source
使用したコードは以下に格納しています。
まとめ
フォームに関する処理をカスタムフックに切り出すことができました。
PageコンポーネントではZodとRHFをimport
しておらず、useUserForm
の中にそれらの実装を閉じ込めています。
ゆえに、もしバリデーションのライブラリをZodではなくyupに切り替えたとしても、exportしている制約を変更しない限りuseUserForm
を変更するだけで修正が完結します。
これが交換可能なコンポーネントの強みであり、変更に強い理由です。
大規模になるとinterfaceを定義し更に独立性を高め、DIコンテナなどを使用し、アプリケーションの上位のところで依存性を管理することもあると思います。
また、アグリゲーションレイヤー(BFFなど)で管理するため、フロントエンドではここまで求めない事もあると思います。
システムの規模を把握した上で適切な設計を行うと良いでしょう。
Discussion
良い