フォームライブラリに依存しない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