React Hook Form(v7)を使ったコンポーネント設計案
本記事ではReact Hook Form(v7)を使ったコンポーネント設計のアイデアについて話します。
React Hook Formはその名の通り、Hooksをベースにフォームのバリデーション設定を記述できるライブラリで、特にv7で大きな変更が入りました。そのため、本記事ではv7前提であることをタイトルで明記しています。
Hooksにフォームのロジックが切り離されることにより、理論的には、TSXで記述されたView層と、バリデーションを司るロジック層を切り離して実装することができるはずです。
オンライン家庭教師マナリンクで提供しているオンライン指導の機能にてこちらの設計を実践してみたので、どなたかの参考になれば幸いです。
前提条件
- React v17
- React Hook Form v7
- Material UI v5
※View層、ロジック層という命名は適当に考えたものなので、もっと適切な命名があるかもしれません
TextAreaの例
ここでは、テキストエリアを実装する場合について考えます。
View層
まずはReact Hook Formに依存しない、シンプルなコンポーネントを実装します。また、UIフレームワークとしてMaterial UIを利用しています。
import { FormHelperText, TextareaAutosize, TextareaAutosizeProps } from '@material-ui/core';
import type { ChangeEventHandler, FocusEventHandler } from "react";
export type TextAreaProps = {
error?: string;
className?: string;
placeholder?: string;
};
export const TextArea = (
props: TextAreaProps & {
inputRef: TextareaAutosizeProps['ref'];
value: string;
onChange: ChangeEventHandler<HTMLTextAreaElement>;
onBlur: FocusEventHandler<HTMLTextAreaElement>;
}
) => {
return (
<>
<TextareaAutosize
minRows={3}
placeholder={props.placeholder}
className={props.className}
ref={props.inputRef}
value={props.value}
onChange={props.onChange}
onBlur={props.onBlur}
/>
{!!props.error && <FormHelperText error>{props.error}</FormHelperText>}
</>
);
};
propsをわざわざTextAreaProps
とそうでないものに分けていますが、これの意図は次節で明らかにします。
ロジック層
View層として定義されたシンプルなテキストエリアを、Formに特化したもの、すなわちFormのロジックを付与したものとして提供するラッパーコンポーネントを別で作成します。
import { DeepMap, FieldError, FieldValues, useController, UseControllerProps } from 'react-hook-form';
import { TextArea, TextAreaProps } from '~/components/parts/form/textarea/TextArea';
import formControlStyles from '~/components/parts/form/FormControl.module.scss';
import classNames from 'classnames';
export type RhfTextAreaProps<T extends FieldValues> = TextAreaProps & UseControllerProps<T>;
/**
* react-hook-formラッパー
*/
export const RhfTextArea = <T extends FieldValues>(props: RhfTextAreaProps<T>) => {
const { name, control, placeholder, className } = props;
const {
field: { ref, ...rest },
formState: { errors },
} = useController<T>({ name, control });
return (
<TextArea
inputRef={ref}
className={classNames(formControlStyles.formInput, formControlStyles.formTextArea, className)}
placeholder={placeholder}
{...rest}
error={errors[name] && `${(errors[name] as DeepMap<FieldValues, FieldError>).message}`}
/>
);
};
コンポーネントの命名をRhf(React Hook Formの略)がプレフィックスになるようにつけて、型なども思い切りReact Hook Formに依存させています。
CSSも、FormControl.module.scss
という名前で作成したフォームのコントロール専用のスタイルファイルからimportしたものを当てています(※classNameを受け取ってしまうと親からなんとでも見た目を変えられてしまうのが良くも悪くもあるので、そこはケースバイケースだと思います)。
useController
というフックを使えば、フォーム部品に必要ないろいろな値が手に入るので、それをほぼそのままTextAreaコンポーネントに流し込んであげればOKです。
View層のコンポーネントからExportしているTextAreaProps
型は、ロジック層のコンポーネントのPropsでも使います。これは大した目的ではなく、TextArea
コンポーネントのPropsの型を編集したときに、Formから呼び出すときの型に追加されてほしいケースがあるからです。たとえばclassName
はFormから渡して、最下層のView層までリレーされます。こういうリレーされる型をTextAreaProps
に入れています。
Form
最後に、作成したコンポーネントを実際にフォームからどう使うかを示します。
まずはFormのcontrolという変数をuseForm
フックから取得します
const {
control,
handleSubmit,
setError,
formState: { isValid },
} = useForm<NewPostInput>({
mode: 'onChange',
resolver: yupResolver(newPostSchema),
defaultValues,
});
そして、RhfTextArea
コンポーネントにはcontrolを渡します。
<RhfTextArea placeholder="投稿内容" name="body" control={control} />
これによって、ちょっとした依存注入のようなことができます。
RhfTextArea
コンポーネント側では任意のフォームのcontrolを受け取ってuseController
に渡し、そのフォームの状態が見れるわけです。
const {
field: { ref, ...rest },
formState: { errors },
} = useController<T>({ name, control });
formStateはerrors以外にも10個ほどのプロパティがあるので、あまり使うことはないかもしれませんが各コンポーネント側でもFormの状態を取得できます。
たとえばisSubmitting = true
のときはDisabledにする、とかも実装しやすいかもしれません。
export declare type FormState<TFieldValues> = {
isDirty: boolean;
dirtyFields: FieldNamesMarkedBoolean<TFieldValues>;
isSubmitted: boolean;
isSubmitSuccessful: boolean;
submitCount: number;
touchedFields: FieldNamesMarkedBoolean<TFieldValues>;
isSubmitting: boolean;
isValidating: boolean;
isValid: boolean;
errors: FieldErrors<TFieldValues>;
};
ポイント
このように切り分けるメリット
これまで説明したように、層を分けてコンポーネントを切り分けるメリットは何でしょうか。
まず一番大きいのは、「テキストエリアだけ、フォーム以外の箇所で利用することができる」です。
テキストエリアをフォーム以外の場所で利用するというとあまりイメージが湧きませんが、たとえばSelectボックスだと、何らかの一覧画面でソート順を切り替えるようなところでも使われるかもしれません。つまり、あるフォームにテキストエリアを表示するという実装は、「テキストエリアを表示する」ことと「それにフォームの目的に応じたイベント等をバインドしたり、スタイルを当てる」ことに分けることで、前者をより汎用的に利用できるようになるということです。
また、その他のメリットとして、ライブラリへの依存を整理整頓できることが挙げられます。
あらためてView層のコンポーネントを見ていただくと、Material UIとReactにのみ依存していることが分かります。
import { FormHelperText, TextareaAutosize, TextareaAutosizeProps } from '@material-ui/core';
import type { ChangeEventHandler, FocusEventHandler } from "react";
続いて、ロジック層を見ると、react-hook-form
のみに依存していることが分かります。
import { DeepMap, FieldError, FieldValues, useController, UseControllerProps } from 'react-hook-form';
import { TextArea, TextAreaProps } from '~/components/parts/form/textarea/TextArea';
import formControlStyles from '~/components/parts/form/FormControl.module.scss';
このように階層ごとに依存するライブラリの種類が分かれていると、大きめのアップデートだったり、ライブラリそのものの移行を将来的に実施する際に見るべき場所を減らすことができます。
こういう依存に気をつけないといけないと思った事例を一つ説明します。
マナリンクではReact NativeアプリでもReact Hook Formを使っているのですが、開発初期にFormikを使っていた時期がありました。こちらの記事などで説明されているように、Hooks全盛期の現代ではReact Hook Formのほうが良いだろうと、あとから採用した背景があります。
そこで、既存のフォームコンポーネントを再利用しながらReact Hook Formを使っていこうとしたところ、もっとも基底のコンポーネントだったField
というコンポーネントがwithFormikControl
という関数を使っており、依存が剥がせないことが発覚しました。
function Field(WrappedComponent: Function) {
return withFormikControl((props: any) => {
const { label, error, touched } = props;
return (
<View style={styles.container}>
{ /* 中略 */ }
</View>
);
});
}
ということで、現在このコンポーネントは徐々に依存先を減らしていく形で放置されています。
これはFormik側の仕様の特性もあると思うので仕方のないところですが、せっかくHooksに統一されたフォームライブラリを使うのであれば、(血も涙もない言い方ですが)将来的に利用をやめるようなことも考えてコンポーネント本体から切り離すように実装することがいいのかなと思っています。
以上、React Hook Form(v7)を使ったコンポーネント設計案でした。
参考文献
ぱっとググっただけでもいろいろな選択肢があるし、フロントエンドのコンポーネント設計にどの段階から本気で取り組むかは各サービスの状況にもよると思います。
ぜひ以下のような記事を見て自身に合った方法を見つけてみてください!
- https://koprowski.it/react-native-form-validation-with-react-hook-form-usecontroller/
- https://zenn.dev/erukiti/articles/webform-2021
- https://suzukalight.com/snippet/posts/2021-04-08-react-native-hook-form-yup
また、React Hook Formに限らずReactについて雑談したい方がいらっしゃいましたら喋りましょう!
オンライン家庭教師マナリンクを運営するスタートアップNoSchoolのテックブログです。 manalink.jp/ 創業以来年次200%前後で売上成長しつつ、技術面・組織面での課題に日々向き合っています。 カジュアル面談はこちら! forms.gle/fGAk3vDqKv4Dg2MN7
Discussion