🖍️

Reactでフォーム処理の関心事をカスタムフックに切り出す

2022/11/06に公開約8,000字1件のコメント

この記事について

実際にフォームを使用するときは、バリデーションライブラリと合わせて実装することが多いと思います。

こちらの記事で再描画を抑えるためにはReactHookForm(以下 RHF)を使用するのが良いと投稿しました。
https://zenn.dev/ishiyama/articles/99aff84e95ef27

今回の記事では、タイプセーフなバリデーションライブラリ「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です。

index.tsx
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
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コンポーネント自体の実装に影響はありません。

UserFormhooks/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に則り、自分に都合の良いカスタムフックを作成します。

hooks/useUserFormMock.ts
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に差し替えます。

index.ts
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

使用したコードは以下に格納しています。

https://github.com/ishiyama0530/react-form-recipes/tree/main/src/pages/advanced

まとめ

フォームに関する処理をカスタムフックに切り出すことができました。
PageコンポーネントではZodとRHFをimportしておらず、useUserFormの中にそれらの実装を閉じ込めています。
ゆえに、もしバリデーションのライブラリをZodではなくyupに切り替えたとしても、exportしている制約を変更しない限りuseUserFormを変更するだけで修正が完結します。
これが交換可能なコンポーネントの強みであり、変更に強い理由です。

大規模になるとinterfaceを定義し更に独立性を高め、DIコンテナなどを使用し、アプリケーションの上位のところで依存性を管理することもあると思います。
また、アグリゲーションレイヤー(BFFなど)で管理するため、フロントエンドではここまで求めない事もあると思います。
システムの規模を把握した上で適切な設計を行うと良いでしょう。

Discussion

ログインするとコメントできます