こんにちは!ミラティブのフロントエンドエンジニア どじねこ です。
今回は、ミラティブを支える社内向けの管理システムにおいて、機能開発の体験を改善する取り組みを「MUI と Zod、React Hook Form の活用術」としてご紹介させていただきます。
前提
ミラティブでは、他の多くの Web サービスと同様に配信者さんの登録した情報や配信の履歴を管理する社内向けのシステムが存在しています。 特に管理システムのフロントエンドにおいては、その特性上入力フォームの実装がその大半を占めています。 日々の機能開発においては、そうした管理システムに実装された入力フォームの新規実装や機能拡張が行われています。
課題
一般的な入力フォームでは、画面の初期描画の際にすでに設定済みの値をフォームに適用する初期化処理や、入力された内容を検証して必要に応じてエラー表示するバリデーション処理が必要です。
ミラティブの管理システムに実装された入力フォームには、先に挙げたような入力検証やエラー表示の処理が都度個別に実装されていました。そのため入力項目の量によってはかなり複雑な実装を要求されることが課題となっていました。
ミラティブではフロントエンドエンジニアのチーム運用はまだ始まったばかりで道半ばであり、バックエンドエンジニアがフロントエンドの機能実装を担当することも多くあります。そこで、中長期的に開発スピードを減退させないため、初期化やバリデーションの複雑性を何らかの仕組みによって解決し、実装をより簡潔に行える環境を整えることが必要でした。
採用技術
対象の管理システムに実装されている入力フォームの半数以上は React を使った実装となっており、今後行われる実装においても React を使って実装する方針です。 そのため React と親和性のあるライブラリを採用することにしました。今回採用した技術のうち、特に大きな要素を以下に列挙します。
MUI
MUI は Google の提唱する Material Design を実装した UI ライブラリです。React コンポーネントを配置するだけでほとんどのデザインルールを踏襲できるように設計されています。
改修対象のシステムではフロントエンドに長けたエンジニア以外も開発をすることがあります。 そのためより簡潔に機能を実装できるよう UI 自体の構築には MUI を採用しました。
Zod
Zod はデータの入力検証ライブラリです。入力規則の定義をスキーマと呼ばれる単位で宣言し、それを元に入力値が規則に沿っているかを検証できます。TypeScript First で開発されており、スキーマから期待する TypeScript の型情報を得られるなどのサポートが組み込まれています。
後述する React Hook Form との親和性が高く、採用事例やドキュメントも多くあることから、実装者の学習負荷を低減しつつ、簡潔に入力内容のバリデーションが実装できるように採用しました。
React Hook Form
React Hook Form は React Hooks を使った入力フォームの入力内容の検証サポートするライブラリです。Zod などの入力検証ライブラリとの連携が組み込まれています。
宣言的に入力内容の検証処理を実装できるため、既存の入力フォームの実装に比較して記述量を大きく抑えることができるよう採用しました。
実装者の負担を軽減するための取り組み
課題を解決するための改修にあたっては、特定のライブラリを使った実装に置き換えるだけではなく、フロントエンドに長けた実装者以外でも、簡単に機能開発に取り組める環境を整備する必要がありました。ここではそれらを実現するに当たり考慮した点をご紹介します。
React Hook Form における useController の活用
React における入力フォームの状態の管理手法は大きく以下の 2 つに分けられます。
- 制御コンポーネント: フォームの状態の管理を DOM から React コンポーネントで管理する手法
- 非制御コンポーネント: フォームの状態を React コンポーネントが管理せず DOM から読み取る手法
多くの場合は、制御コンポーネントによる制御で十分なパフォーマンスを発揮しながら、React のライフサイクルをそのまま使用できるため、学習コストが抑えられます。React Hook Form では制御コンポーネントで実装された入力フォームの状態を制御するための React Hooks である useController が用意されています。
一方で、高度なパフォーマンスチューニングを必要とする場面では、意図的に非制御コンポーネントを選択する場合もありますがその場合は ref を参照する必要があるなど若干のコツや練度が必要です。
React Hook Form では非制御コンポーネントの場合でも簡単に入力フォームの状態を取得するための実装として、基本の React Hooks である useForm 内に register 関数が用意されています。
今回は、より利便性を重視して、初学者でも簡単に使用できるよう制御コンポーネントによる実装と useController を使用する手法を採用することにしました。ここでは例として 2 つのテキストフィールドを持つ入力欄を useController を使用して実装する例を示します。
import React, { FC } from "react"; import { FieldValues, useController, UseControllerProps, useForm, } from "react-hook-form"; import { Button, Card, CardContent, Stack, TextField as TextFieldMui, TextFieldProps, } from "@mui/material"; // MUI の TextField を React Hook Form に対応させる実装 const TextField = <T extends FieldValues>( props: UseControllerProps<T> & TextFieldProps ) => { const { field } = useController<T>({ name: props.name, control: props.control, }); return <TextFieldMui {...field} label={props.label} />; }; // サンプルフォームの入力項目 type FormValues = { firstName: string; lastName: string; }; // React Hook Form に対応した TextField を使用するサンプルフォーム const SampleForm: FC = () => { const { handleSubmit, control } = useForm<FormValues>({ defaultValues: { firstName: "らびっと", lastName: "ミラティブ", }, }); const onSubmit = (data: FormValues) => { console.log(data); }; return ( <Card> <CardContent> <form onSubmit={handleSubmit(onSubmit)}> <Stack spacing={2}> <TextField name="firstName" label="名" control={control} /> <TextField name="lastName" label="姓" control={control} /> <Button type="submit" variant="contained"> Submit </Button> </Stack> </form> </CardContent> </Card> ); };
React Hooks による簡略な実装手段の提供
ここまで挙げた技術のうち、特に Zod と React Hook Form の組み合わせは、フロントエンドに慣れていない初学者のメンバーにとってはまだ十分に複雑な仕組みとなる可能性がありました。 そこで Zod のスキーマと デフォルトの値を渡すだけで簡単に React Hook Form を利用できるカスタムフックとして useRHF を用意しました。
利用イメージは以下のようなものになります。
import { useRHF } from "./useRHF"; interface ComponentProps { onSubmit: () => void; } const Component = (props: ComponentProps) => { const { handleSubmit, register } = useRHF({ schema: z.object({ username: z.string() }), defaultValues: { username: "" }, }); return ( <form onSubmit={handleSubmit(props.onSubmit)}> <input {...register("username")} /> <button type="submit" /> </form> ); };
漸次的な移行のサポート
置き換えの対象となっていたシステムは、2021 年頃から React による機能実装が行われはじめ、実装されているコードの量は多くある状態でした。すべての React コンポーネントを一度に React Hook Form を活用した実装に切り替えることは分量的に難しいものとなっていました。
そのため、従来からある React コンポーネントに対して、React Hook Form の useController フックで使用される control を渡せるよう機能を拡張しました。具体的には control が渡されている場合にのみ React Hook Form に対応しているコンポーネントとして動作させることで、従来どおりの使い勝手と互換性を維持しつつ徐々に機能を移行できるように再設計しています。
参考までに以下に例として一部を抜粋します。
// 従来の入力フォームのコンポーネント const TextFieldWithoutRHF = () => { /* 中略 */ }; // 従来の入力フォームを React Hook Form に対応させたコンポーネント const TextFieldWithRHF = <T extends FieldValues>( props: ComponentProps<typeof TextFieldWithoutRHF> & { name: Path<T>; control: Control<T>; } ) => { const { name, control, ...otherProps } = props; const { field, formState: { errors }, } = useController<T>({ name, control }); return <TextFieldWithoutRHF name={name} {...otherProps} {...field} />; }; // 外部から利用できる唯一のコンポーネントのインターフェースは変化させない export const TextField = <T extends FieldValues>( props: ComponentProps<typeof TextFieldWithoutRHF> & { name: Path<T>; control?: Control<T>; } ) => { const { control } = props; if (control) { // control を含む場合は React Hook Form を使用するコンポーネントとして動作する return <TextFieldWithRHF {...props} control={control} />; } // control がない場合は従来通りの挙動をするコンポーネントとして動作する return <TextFieldWithoutRHF {...props} />; };
効果
MUI、React Hook Form、Zod を組み合わせて入力フォームを実装できるような基盤を整えたことで大きく 2 つの効果を得られました。
効果 1: 画面の実装コストを大きく低減できた
MUI 自体に多くのコンポーネントがデフォルトで備わっており、コンポーネントカタログと実装例が公式サイトで多く提供されているため、画面の構築にかかるコストを従来に比較して大きく低減できました。フロントエンドエンジニアでなくとも、同程度の品質の UI や体験をより多く実現できるようになりました。
効果 2: 入力規則を宣言的に実装できるようになった
React Hook Form と Zod を組み合わせたことで、入力規則を宣言的に実装できるようになりました。基本的な入力規則に関する実装や、エラーメッセージの制御を Zod に移譲しつつ、React Hook Form がフォーム全体の状態を制御してくれるようになり、従来の実装に比較してほとんどの入力フォームで半分以下の行数で実装できるようになりました。
まとめ
今回は MUI と Zod、 React Hook Form を使った入力フォーム実装の改善例を紹介しました。
導入した 3 つのライブラリによる入力フォームの新規実装や移行はまだ始まったばかりで、全体の 1/4 程度が新しい仕組みによって実装されている状態です。 一方で従来の実装との互換性をコンポーネントごとに維持する方策を取れたため、緩やかではありますが確実に以前よりメンテナンスがしやすい環境へと歩みを進めることができています。
今後も Web フロントエンドの改善を波及させることで、Mirrativ をご利用のみなさまのより良い体験を素早く提供できるよう、チーム一同改善に努めていきたいと感じました。
We are hiring!
ミラティブではユーザへ価値提供するために部署を横断して全体最適を目指せるエンジニアを絶賛募集中です。