フォームライブラリに依存しないReactコンポーネント設計
背景
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 をフォームライブラリから独立・共通化できています。
設計
依存関係のグラフはこんな感じです。
まずフォームライブラリに依存しているのはFormとFieldのみです。
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 を用意しました。
export type TextFieldProps = MuiTextFieldProps & { name: string };
export type ErrorMessageProps = { name: string };
FormTemplate
フォーム UI を分離したコンポーネントです。
props でコンポーネントの実装を受け取り、描画します。
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を使って接続するだけです。
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を使って接続するだけです。
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の描画をします。
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 の疎結合を実現しました。
実装についてはこちらのリポジトリにあります。
Discussion