📝

パフォーマンスを気にするならReact Hook Formが無難

2023/12/29に公開1

最近、React のフォームライブラリを調査しました。
その中でパフォーマンスについての言及は見かけるものの、実際に計測しているものが見当たらなかったので計測してみました。
結論としては React Hook Form でなくても良いけど、パフォーマンスを気にするなら React Hook Form を選んでおくのが無難というところに落ち着きました。

要約

入力欄 10 個、CPU 6× slowdown での計測結果

ライブラリ 1 文字入力した場合の再描画
React Hook Form 8ms 前後
Formik 100ms 前後
Formik(<FastField /> 20~30ms
Formik(React.memo 20~30ms
React Final Form 50ms 前後
  • React Hook Form は高速。
  • Formik は早くない。改善は可能。
  • React Final Form はある程度早い。

React Hook Form が無難ではあるものの、CPU 6× slowdown で 100ms は通常では許容できると考え Formik を採用するのもあり。

比較したライブラリ

計測の対象にしたライブラリは以下の 3 つです。

  • React Hook Form
  • Formik
  • React Final Form

パフォーマンス以外の特徴にも触れておくと、

React Hook Form

  • TypeScript 製
  • 頻繁に更新されている
  • UI とフォームライブラリが密結合になりやすい

Formik

  • TypeScript 製
  • ある程度更新されている
  • UI とフォームライブラリを疎結合にしやすい

React Final Form

  • コードのほとんどが JavaScript で書かれている
  • 1 年以上更新されていない
  • UI とフォームライブラリが密結合になりやすい

となります。
個人的には UI から分離できる Formik の書き方が好きです。

npm trends でのダウンロード数の比較は以下のような感じでした。
React Hook Form が優勢ですが、Formik も伸びています。
React Final Form はゆるやかに使われなくなりつつあります。

https://npmtrends.com/formik-vs-react-final-form-vs-react-hook-form

パフォーマンスの基準

以下の文献によるとインタラクティブなコンテンツは 100ms 以内、可能なら 50ms 以内にフィードバックを返すことが望ましいとされています。

When the user interacts with content, it is important to provide feedback and acknowledge the user's response or interaction and to do so within 100ms, preferably within 50ms.

https://developer.mozilla.org/en-US/docs/Web/Performance/How_long_is_too_long

今回行った計測では 50ms を基準にすることにしました。

計測方法

条件

入力欄を 10 個用意し、ある入力欄に 1 文字入力するときの再描画時間を計測しました。

再描画にかかる時間は DOM の大きさによっても変わるため、単純に<input />を並べるのではなく、より実際の画面に近いものを用意する必要があります。
今回は MUI を用いて以下のような感じのフォームを用意しました。

<form>
  <Grid container>
    {range(10).map(() => (
      <Grid item>
        <TextField />
      </Grid>
    ))}
  </Grid>
</form>

ツール

計測には React Developer Tools の Profiler を用いました。
また低スペックな環境に合わせ、ブラウザの Developer Tools で CPU を 6× slowdown にしました。

計測

React Hook Form

実装

UI ライブラリを利用しているため、<Controller />を使い、制御コンポーネントとして実装することになります。

export default function App() {
  const { control, handleSubmit } = useForm();

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

https://github.com/YunosukeY/react-form-library-performance/blob/master/hook-form/src/App.tsx

計測結果

初回の描画が 256ms、その後 3 文字入力してそれぞれ 8ms 前後でした。
特徴として、フォーム全体は再描画されず、入力している欄だけが再描画されています。

Formik

実装

<Field />は使わず<Formik />のみを使いました。
<form />より内側は Formik に依存していないのが特徴です。

const Basic = () => (
  <Formik
    initialValues={initialValues}
    onSubmit={(values) => console.log(values)}
  >
    {({ values, handleChange, handleSubmit }) => (
      <form onSubmit={handleSubmit}>
        <Grid container direction="column" spacing={1}>
          {range(10).map((v) => (
            <Grid item key={v}>
              <TextField
                label={v}
                name={`f${v}`}
                value={values[`f${v}` as keyof typeof initialValues]}
                onChange={handleChange}
              />
            </Grid>
          ))}
        </Grid>
        <Button type="submit">Submit</Button>
      </form>
    )}
  </Formik>
);

https://github.com/YunosukeY/react-form-library-performance/blob/62da410298cab9291ffafe0a9658e8c3c0349164/formik/src/App.tsx

計測結果

初回は 204ms でした。
画像では分かりづらいですが、入力の度にフォーム全体が 2 回再描画され、合わせて 100ms 前後かかっています。
50ms の基準は大幅に超えています。

React Final Form

実装

<Form />, <Field />を用いました。

const MyForm = () => (
  <Form
    onSubmit={(values) => console.log(values)}
    render={({ handleSubmit }) => (
      <form onSubmit={handleSubmit}>
        <Grid container direction="column" spacing={1}>
          {range(10).map((v) => (
            <Grid item key={v}>
              <Field name={`f${v}`}>
                {({ input }) => <TextField label={v} {...input} />}
              </Field>
            </Grid>
          ))}
        </Grid>
        <Button type="submit">Submit</Button>
      </form>
    )}
  />
);

https://github.com/YunosukeY/react-form-library-performance/blob/master/final-form/src/App.tsx

計測結果

初回が 215ms、その後の入力による再描画は 50ms 強かかっています。
入力の度にフォーム全体が再描画され、50ms の基準は一応超えています。

結果

初回の描画はライブラリによらず 200ms 程度かかっていてここでは差はありません。
入力による再描画は React Hook Form のみが基準を下回り、React Final Form がほぼ基準通り、Formik は大幅に上回りました。

Formik の改善

React Final Form はパフォーマンス以外の理由で採用しないつもりでしたが、Formik はパフォーマンス以外は良いため改善を考えました。
フォーム全体が再描画されるのは防げないため、入力欄の再描画を抑える必要があります。

案 1:<FastField />

Formik にはコンポーネントを Formik に接続するための API として<Field />があり、その最適化として<FastField />があります。
どちらも UI とフォームライブラリが密結合になることに加え、<FastField />については可能なら使わないよう警告されています。

Only proceed if you are familiar with how React's shouldComponentUpdate() works. You have been warned.

No. Seriously. Please review the following parts of the official React documentation before continuing

https://formik.org/docs/api/fastfield

やばそうですが今回はこれを使っていきましょう。

実装

<TextField /><FastField />の子に移動。
value, onChangeなどを書く必要がなくなり、記述量は減ります。

const Basic = () => (
  <Formik
    initialValues={initialValues}
    onSubmit={(values) => console.log(values)}
  >
    {({ values, handleChange, handleSubmit }) => (
      <form onSubmit={handleSubmit}>
        <Grid container direction="column" spacing={1}>
          {range(10).map((v) => (
            <Grid item key={v}>
+             <FastField name={`f${v}`}>
+               {({ field }: { field: FieldInputProps<`f${typeof v}`> }) => (
+                 <TextField label={v} {...field} />
+               )}
+             </FastField>
-             <TextField
-               label={v}
-               name={`f${v}`}
-               value={values[`f${v}` as keyof typeof initialValues]}
-               onChange={handleChange}
-             />
            </Grid>
          ))}
        </Grid>
        <Button type="submit">Submit</Button>
      </form>
    )}
  </Formik>
);

https://github.com/YunosukeY/react-form-library-performance/blob/076f56e99b69272a29d2e5f5ec48bc09f52e4abb/formik/src/App.tsx

計測結果

再描画が大幅に改善されました。
相変わらず入力による更新でフォーム全体が 2 回再描画されていますが、入力していない欄は再描画されず全体で 20~30ms 程度になっています。

案 2:React.memo

<FastField />ではクラスコンポーネント時代の知識を要求されるため厳しいものがあります。
親コンポーネントが再描画されても変化のない子コンポーネントを再描画しない方法としては他に React.memo があります。

実装

今回は MUI のTextFieldをそのまま React.memo に渡します。

+ const TextField = React.memo(MuiTextField);
+
const Basic = () => (
  <Formik
    initialValues={initialValues}
    onSubmit={(values) => console.log(values)}
  >
    {({ values, handleChange, handleSubmit }) => (
      <form onSubmit={handleSubmit}>
        <Grid container direction="column" spacing={1}>
          {range(10).map((v) => (
            <Grid item key={v}>
              <TextField
                label={v}
                name={`f${v}`}
                value={values[`f${v}` as keyof typeof initialValues]}
                onChange={handleChange}
              />
            </Grid>
          ))}
        </Grid>
        <Button type="submit">Submit</Button>
      </form>
    )}
  </Formik>
);

https://github.com/YunosukeY/react-form-library-performance/blob/09b8816e76f64e2539a05003711fd95eac54aff1/formik/src/App.tsx

計測結果

こちらも再描画が大幅に改善されています。
フォーム全体が 2 回再描画され、入力していない欄は再描画されず、全体では 20~30ms 程度になっています。
とはいえ全てのフォーム、全ての入力欄コンポーネントをメモ化するのも難しいと感じました。

案 3:フォームの状態更新を遅延させる

案 1、案 2 は実装は可能ですがチーム開発での横展開、規約化、保守性を考えると採用が難しいと思いました。
別の方向性としては、フォーム全体が更新されることは受け入れ、フォームの状態更新を遅延させるというのもあります。
遅延というのはonChangeでフォームの状態を更新するのではなく、Debounce や Throttle、onBlurでの更新です。

まとめ

結論としては

  • React Hook Form は制御コンポーネントであっても高速。
  • React Final Form はある程度早い。
  • Formik は早くない。改善や回避は可能。

で、React Hook Form を使うのが無難だと思いました。
とはいえ CPU 6× slowdown の状況でも 100ms 程度ではあるため、許容できると考え Formik を採用するという選択もありだと思います。

Discussion

君津君津

2024年1月7日追記
React Final Formについて、このライブラリのパフォーマンス上の特徴であるsubscriptionの設定をしていませんでした。
設定を追加して再度計測したところ、入力していない欄は再描画されず、6msという高いパフォーマンスを確認しました。