🧩

フォームライブラリに依存しないReactコンポーネント設計

2023/12/30に公開

背景

React ではフォームライブラリを利用する場合、ナイーブに実装するとフォームの UI とフォームライブラリが密結合になります。
これは特定のフォームライブラリに限った話ではなく、React Hook Form, Formik, React Final Form といった主要なフォームライブラリ全てで当てはまる問題です。

例えば React Hook Form では、フォーム全体の設定をuseFormで行い、各属性ではregister, Controller, useControllerを使って UI と React Hook Form を接続します。
つまりフォームコンポーネントは最外(useForm部分)と最内(registerなどの部分)でフォームライブラリに依存することになります。

const Form: React.FC = () => {
  const { control, handleSubmit } = useForm({
    defaultValues: {
      username: "",
    },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <Grid container direction="column" spacing={1}>
        <Grid item>
          <Controller
            control={control}
            name="username"
            render={({ field }) => <TextField label="username" {...field} />}
          />
        </Grid>
      </Grid>
      <Button type="submit">Submit</Button>
    </form>
  );
};

この「フォームコンポーネントが、フォームの設定を行う最外部分と UI と接続する最内部分でフォームライブラリに依存する」という特徴は、他のフォームライブラリでも見られます。
Formik の場合は最外で<Formik />useFormikを使い、最内で<Field />, useFieldなどを使います。
React Final Form でも<Form />, <Field />を同様に使います。

そこで中間部分を UI テンプレートとして分離することで、フォームコンポーネントをフォームライブラリに依存しないようにできます。

成果物

フォームライブラリへの依存を無くした例がこちらです。
最内での依存関係を逆転させたFormTemplateコンポーネントを用意し、各フォームライブラリを利用する側が DI します。
これによってフォームの UI をフォームライブラリから独立・共通化できています。

設計

依存関係のグラフはこんな感じです。

まずフォームライブラリに依存しているのはFormFieldのみです。
Formはフォーム全体のコンポーネントで、フォームライブラリの設定を行うために依存します。
Fieldはフォームライブラリと UI の接続のためのラッパーコンポーネントで、React Hook Form のuseControllerや Formik, React Final Form のFieldを利用します。

フォームの UI であるFormTemplateは依存関係の逆転によってFieldのことは知りません。
React.FC<FieldProps>のインスタンスであることだけ知っていれば描画できるからです。
インスタンスについてはFormから props として受け取ります(DI ライブラリや React の context でも良いと思います)。

実装

構成は以下のようになっています。

├── form-libraries/
│   └── react-hook-form/
│       ├── ErrorMessage.tsx
│       ├── Form.tsx
│       └── TextField.tsx
├── form-ui/
│   ├── ComponentProps.ts
│   └── FormTemplate.tsx
└── schema/
    └── user.ts

前述のFieldPropsは入力欄以外にエラーメッセージも分離する必要があったためComponentPropsとなっています。
ErrorMessage, TextFieldが前述のFieldです。

またバリデーションについてもフォームライブラリから分離しています。
今回は Zod を利用していますがここはなんでも良いと思います。

フォーム UI

FieldProps

依存関係を逆転するためのインターフェース定義です。
ここでフォームライブラリに依存すると全てが終わりですが、調査の結果nameが存在すれば十分なことが分かりました。
ここでは入力欄とエラーメッセージの Props を用意しました。

src/form-ui/ComponentProps.ts
export type TextFieldProps = MuiTextFieldProps & { name: string };
export type ErrorMessageProps = { name: string };

FormTemplate

フォーム UI を分離したコンポーネントです。
props でコンポーネントの実装を受け取り、描画します。

src/form-ui/FormTemplate.tsx
type FormTemplateProps = {
  onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
  TextField: React.FC<TextFieldProps>;
  ErrorMessage: React.FC<ErrorMessageProps>;
};

const FormTemplate: React.FC<FormTemplateProps> = ({
  onSubmit,
  TextField,
  ErrorMessage,
}) => {
  return (
    <form onSubmit={onSubmit}>
      <Grid container direction="column" spacing={1}>
        <Grid item>
          <TextField name="firstName" label="First name" />
          <ErrorMessage name="firstName" />
        </Grid>
        <Grid item>
          <TextField name="lastName" label="Last name" />
          <ErrorMessage name="lastName" />
        </Grid>
      </Grid>
      <Button type="submit">Submit</Button>
    </form>
  );
};

フォームライブラリ

ここでは React Hook Form の例を紹介します。
リポジトリでは Formik、React Final Form についても実装しています。

TextField

useFormContext, useControllerを使って接続するだけです。

src/form-libraries/react-hook-form/TextField.tsx
const TextField: React.FC<TextFieldProps> = (props) => {
  const { control } = useFormContext();
  const { field } = useController({
    control,
    name: props.name,
    defaultValue: props.defaultValue,
  });

  return <MuiTextField {...props} {...field} />;
};

ErrorMessage

useFormContext, ErrorMessageを使って接続するだけです。

src/form-libraries/react-hook-form/ErrorMessage.tsx
const ErrorMessage: React.FC<ErrorMessageProps> = ({ name }) => {
  const {
    formState: { errors },
  } = useFormContext();

  return (
    <HookFormErrorMessage
      name={name}
      errors={errors}
      render={({ message }) => <Typography color="error">{message}</Typography>}
    />
  );
};

Form

useFormを使ったフォームの設定、props を使った DI、FormTemplateの描画をします。

src/form-libraries/react-hook-form/Form.tsx
const Form: React.FC = () => {
  const methods = useForm<User>({
    defaultValues: {
      firstName: "",
      lastName: "",
    },
    resolver: zodResolver(userSchema),
  });

  return (
    <FormProvider {...methods}>
      <FormTemplate
        onSubmit={methods.handleSubmit((data) => console.log(data))}
        TextField={TextField}
        ErrorMessage={ErrorMessage}
      />
    </FormProvider>
  );
};

まとめ

依存関係の逆転や DI といった普遍的なテクニックを React コンポーネントに適用し、フォームライブラリとフォーム UI の疎結合を実現しました。
実装についてはこちらのリポジトリにあります。

https://github.com/YunosukeY/clean-react-form-architecture

Discussion