モノレポ内でのReact Hook Formの責務分離の設計

目次

はじめに

こんにちは!ハイヤールーの共同創業者の谷合です。 ハイヤールーアドベントカレンダーの 11 日目を担当します!

現在ハイヤールーではフロントエンドのリアーキテクチャプロジェクトを進めています。 リアーキテクチャの背景についてはこちらの「Web フロントエンドのリアーキテクチャに向けた課題整理と解決の道筋」にまとめておりますので、ご参照ください。

このプロジェクトの中で、重要な設計の一つとしてコンポーネントの責務分離を徹底しています。 (詳しくは僕が以前書いた「フロントエンドのコンポーネントの責務分離のための方針とその効果について」を参照してください。)

また、弊社ではフォームを利用するページでは、React Hook Form(以後 RHF)を利用しています。以前のアーキテクチャでは React のコンポーネントに RHF の実装も含まれている状態となっていたため、移行の際にこのコンポーネントも Presentational 層と Container 層に分解する必要がありました。

今回はその際に考慮したポイントを 2 つご紹介したいと思います。 かなりシンプルに分解できたので、少しでも参考になればと思います。

TL;DR

  • Vlidation Schema を zod を使って分離したおかげで、UI コンポーネント以外の Package からの参照を可能にした
  • Validation Schema を切り離したおかげで、フォームの UI コンポーネントを共通化し、利用する側は必要な Props を渡すだけになった
  • その結果コードの記述量が減り、可読性が高まったのとレビューで共通部分の議論をしなくて済むようになった
  • 今後の展望として、切り離された Validation Schema の参照スコープを制限できるようにしたい

1. Validation Schema を切り分けておく

そもそもなぜ切り分けておくべきか

以前の状態は以下のような状態になっていました。

このコンポーネントの中に RHF の useForm などのロジックも含まれた状態となっており、Controller が持つ rules に直接 Validation Schema も記載されていました。

<Controller
  name="name"
  control={control}
  rules={{
    required: {
      value: true,
      message: "タイトルは必須です。",
    },
    maxLength: {
      value: 100,
      message: "タイトルは100文字以内で入力してください。",
    },
  }}
  render={({ field }) => (
    <TextField
      {...field}
      id="name"
      label={"テストタイトル"}
      type="text"
      error={Boolean(errors.name)}
      color="primary"
      fullWidth
      helperText={errors.name?.message}
      InputLabelProps={{
        shrink: true,
      }}
      required
      variant="outlined"
    />
  )}
/>

この状態の場合、Presentational 層から Validation Schema を切り離せなくなり、使い回せないコンポーネントとなってしまうばかりか、Validation も UI に固定されてしまうため都度定義が必要となってしまったり、別のファイルからの参照が難しいという問題もありました。

例えばですが、ゆくゆくは UI 側の入力に加えて GraphQL からサーバーにリクエストを送る前にも再度確認できるようにしたいと思っていますが、それを現状の設計で行おうとすると、別途 Validation を定義する必要があります。

しかし(ほぼ)同じリクエストを別で定義するのは無駄なので避けたいと思っていました。

具体的にどのように切り分けたのか

具体例を説明する前に背景情報について述べておきます。

まず、弊社では JSON を Parse する用途でzodというライブラリを利用していました。 こちらは RHF と組み合わせることで(Schema Validation)Validation を定義することができるため、今回は zod を利用する形で Validation Schema を定義することにしました。

また、今回のリアーキテクチャのタイミングで pnpm を利用したモノレポの構成を利用しているため、この Validation を管理するディレクトリも別 Package として切り出しています。

Monorepo についての詳しい内容は Himenon の「pnpm と dependency-cruiser で Web フロントエンドの Monorepo を支える」を参照ください。

ではさっそく、どのように切り分けたのかについて具体的なコードを交えて解説していきます。下のコードは Validation Schema を分離したコードになります。

もともと Controller に直接定義されていた Schema は以下のように TestFromSchema.ts として別ファイルに切り出す事ができました。

// TestFromSchema.ts
export const useTestFormSchema = () => {
  return z.object({
    title: z
      .string()
      .min(1, { message: "タイトルは必須です。" })
      .max(100, { message: "タイトルは100文字以内で入力してください。" }),
  });
};

export type TestFormSchema = z.infer<ReturnType<typeof useTestFormSchema>>;

また、Schema の型情報も TestFormSchema として export して他でも利用できるようにしています。

以下のコードでは、実際に useForm にて定義した Schema を利用している部分になります。RHF には zodResolver という関数が存在するため、それを利用してフォームに Validation Schema を接続します。

const validateSchema = TestForm.useTestFormSchema();
const methods = useForm<TestForm.TestFormSchema>({
  resolver: zodResolver(validateSchema),
  defaultValues: {
    name: props.name,
  },
});

どのようなメリットがあったか

まず第一に、Validation Schema を切り出したことによって他のファイル(や package)からこの Schema 自体やフォームの型定義を参照できるようになりました。

例えば、複数のステップがあるフォームなどで一時的に State 管理する場合の型として利用したり、Submit 時の関数の引数の型を Container 層で定義する場合などに利用できるようになります。

次に UI コンポーネントから RFH の Controller 経由で定義していた Validation Schema を切り出すことができたため、フォームのコンポーネントを共通化する準備が整いました。こちらに関しては次のセクションで詳しく説明させていただくため、詳細は割愛します。

また今後の展望として、参照される Package やファイルのスコープを制限することも検討しています。 再利用性を考えたときに不必要に参照可能なスコープを広げた結果、循環参照が生まれたり参照が増えることでやりすぎた共通化問題が発生する原因となります。

それらを防ぐために dependency-cruiser などを利用してスコープを制限するなどの検討とあわせて設計する必要がありますが、分離したことによってそういったことも考えられるようになりました。

RFH を利用するコンポーネントの責務分離と共通化

なぜやるか

当たり前ですが共通コンポーネントを切り分けておくことで、次回も同じ定義をせずに済むということに尽きます。

テキストフォームもセレクターも name 属性や defaultValue などが異なるだけで、ほとんど毎回同じようなコンポーネントが定義されていましたが、共通コンポーネントとして切り出せなかったのは、前項で述べた Validation の Schema が UI に固定されたことが原因でした。

それを今回分離できたことにより、UI の共通化も必然的に進められるようになりました。

具体的にどのように切り出したのか

さっそく具体例を見ていきます。

今回 UI コンポーネントの切り出しにあたって、ログラスYuito Sato さんの「React Hook Form を 1 年以上運用してきたちょっと良く使うための Tips in ログラス(と現状の課題)」を参考にさせていただきました。

// InputControlTextField.tsx
export type InputControlTextFieldProps = TextFieldProps & {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  control?: Control<any>;
};

const InputControlTextField: React.FC<
  InputControlTextFieldProps & { name: string }
> = ({ defaultValue, ...props }) => {
  const {
    field,
    fieldState: { error },
  } = useController({
    name: props.name,
    control: props.control,
    defaultValue: defaultValue,
  });

  return (
    <TextField
      {...props}
      onChange={(event) => {
        props.onChange?.(event);
        return field.onChange(event);
      }}
      value={field.value}
      inputRef={field.ref}
      size="small"
      error={Boolean(error?.message)}
      helperText={error?.message ?? props.helperText}
    />
  );
};

まずは useController を利用したケースです。

Props として親コンポーネントから、Control を受け取るようにしています。

余談ですが、もともとこの Props は Generics を使って型定義をしておりましたが、かなり複雑になってしまったというのと、弊社のフロントエンドエンジニア以外のエンジニアもコードを書くということもあったので、戦略的に any を使っています。

これ以外のケースでも、このように一気に最適な状態にすべきかというのは、リアーキテクチャ時にかなり気をつけていて、いきなり複雑度をあげることによって、経験の少ない人がコードを書けなくなりチームの開発スピードが落ちてしまうのは本末転倒なため、会社全体のフロントエンドのレベルが上がったときに徐々に良くしていきたいという理由で戦略的に目をつぶりました。

さて本題に戻ります。

このコンポーネントを使う際は、以下のようにコンポーネントに渡す Props を定義して利用します。エラー発生時の文言表示などはすでに InputControlTextField.tsx 側に定義されているので、利用する側はこれだけで OK です。

また、Validation の Schema は前項で切り出し RFH に接続されているので、特段こちらではすることはありません。

// Form.tsx

const nameFieldProps: InputControlTextFieldProps = {
  control: method.control,
  fullWidth: true,
  label: t("タイトル"),
  InputLabelProps: {
    shrink: true,
  },
  required: true,
};

<InputControlTextField name="name" {...nameFieldProps} />;

こうすることで、毎回フォームを利用したい場合は、name 属性と必要な Props を渡すだけで良くなったため、再利用性が高まったのと利用する側の可読性も高くなりました。

また InputControlTextField.tsx を直接呼び出さないケースについても触れておきます。

例えば以下のように別途処理を行うコンポーネントを切り出して、その中で呼び出したいケースなどでは、以下のような形で useFormContext 経由で Control を渡すようにしています。

// TitleFormField.tsx
const method = useFormContext();
return <InputControlTextField {...props} control={method.control} fullWidth label={props.label || "タイトル" />;

コンポーネントの参照が深くなると、Props Drilling が発生してしまうため、こうしたケースの場合、大本の呼び出し側で FormProvider を利用し、子コンポーネントのどこからでも呼び出せるようにしておくと便利です。

<FormProvider {...methods}>
  <ChildComponent>
    <GrandChildComponent />
  </ChildComponent>
</FormProvider>

InputControlTextField 自体も useFormContext を前提に実装することも検討しましたが、Context を利用するケース以外の使い方の方が多かったため、今回はこのような形を取りました。

ここでは長くなってしまうため割愛しますが、TextField 以外もセレクターやチェックボックスなども同様の形で切り出しています。そのためそれらのパーツを利用する際は Control と name 属性と各コンポーネントが必要としている Props を渡すことで利用可能な状態となっています。

どのようなメリットがあったか

まずフォームを利用する際のコードの記述量が圧倒的に減りました。基本的には必要な Props を親コンポーネントで定義し、name 属性を渡すだけでパーツが使えるため可読性も非常に高くなりました。

また共通コンポーネントとして切り出しているため、万が一そのファイルに変更があった場合も見落としにくく、レビュー時に議論できる様になったため、フォーム周りの実装が荒れにくくなったと感じます。

今までは UI コンポーネントごとにフォームの定義がされていたため、他のファイルと異なる使い方をしていても、レビューで見過ごすことがありましたが、基本的にはインターフェースを定義したことによって、多少他のファイルと異なったとしても大きく思想がずれないということが一番大きな恩恵かと思います。

おわりに

React Hook Form を使ったコンポーネントの責務分離の設計時に検討したことと、その背景についてまた、zod を使った Validation Schema の分離と、フォームの UI コンポーネントの共通化をどのように行ったのかというのを実際のコードを交えながら紹介しました。

また、参照可能なスコープの制限などのまだ取り入れられていない部分の展望についても一部紹介しました。まだまだ紹介しきれてない Tips などもいくつかあるため、それらを含めた別の記事で触れたいと思います。

明日はいっちー(@icchy_san)の「データベース実行環境を支える技術」です!