Recoil で再レンダリングを抑えるために気をつけていること

こんにちは。梅津です。

前回書いたこちら↓の記事で Recoil を利用しているという話をしました。

今回は業務で Recoil を使っている中で「こうすれば再レンダリングを抑えられるなー」ということがわかってきたので、その紹介です。

作るものと利用するライブラリ

今回は次のようなログインフォームを例にサンプルコードを書いていきます。

サンプルコードで利用するライブラリとそのバージョンは次のとおりです。

  • React 18.2.0
  • Recoil 0.7.5
  • MUI 5.10.4

また、コードの簡略化のためカスタムフックは作らない方針でいきます。

再レンダリングを抑えるためのポイント

ポイントになるのは次の2点です。

  1. Atom を小さく分割し、枝葉のコンポーネントで購読する
  2. イベントハンドリング時に Atom の値が欲しいなら useRecoilCallback を使う

ポイント1: Atom を小さく分割し、枝葉のコンポーネントで購読する

Recoil では useRecoilStateuseRecoilValue を使って Atom の値を購読することができます。
Atom の更新がおこなわれると、その Atom を購読しているコンポーネントはすべて再レンダリングの対象となります。
その性質上、いくつものプロパティを持つ大きな Atom を複数のコンポーネントが購読してしまうと、再レンダリングされる対象が多くなってしまいます。
これは大きな Atom を Selector で分割していったとしても変わりません。(number や string といったプリミティブな値にまで分割すればメモ化されるようですが)

次に Atom は小さく分割したものの、コンポーネントツリーの親のほうで購読した場合はどうでしょう。
再レンダリング対象は親コンポーネントになるわけですから、条件によっては子のコンポーネントも再レンダリングされていくことになります。

再レンダリングを抑えるのに一番効果があるのは、Atom を小さく分割して、コンポーネントツリーの枝葉のほうのコンポーネントで購読することです。
関係するものを最小限にすることで再レンダリングを抑えることができます。

具体的には次のようなコードです。

// globalState/login.ts

import { atom, DefaultValue, selector } from "recoil"

interface EmailAtom {
  emailInputValue: string
}

export const emailAtom = atom<EmailAtom>({
  key: "emailAtom",
  default: {
    emailInputValue: "",
  },
})

interface PasswordAtom {
  passwordInputValue: string
}

export const passwordAtom = atom<PasswordAtom>({
  key: "passwordAtom",
  default: {
    passwordInputValue: "",
  },
})
// EmailInput.tsx

import React, { useCallback } from "react"
import { TextField } from "@mui/material"
import { useRecoilState } from "recoil"
import { emailAtom } from "../globalState/login"

export function EmailInput(): JSX.Element {
  // Email用のAtomへ依存
  const [{emailInputValue}, setState] = useRecoilState(emailAtom)

  const handleChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      setState((currVal) => {
        return {
          ...currVal,
          emailInputValue: event.target.value,
        }
      })
    },
    []
  )

  return (
    <TextField
      label={"email"}
      value={emailInputValue}
      onChange={handleChange}
    />
  )
}
// PasswordInput.tsx

import React, { useCallback } from "react"
import { TextField } from "@mui/material"
import { useRecoilState } from "recoil"
import { passwordAtom } from "../globalState/login"

export function PasswordInput(): JSX.Element {
  // Password用のAtomへ依存
  const [{passwordInputValue}, setState] = useRecoilState(passwordAtom)

  const handleChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      setState((currVal) => {
        return {
          ...currVal,
          passwordInputValue: event.target.value,
        }
      })
    },
    []
  )

  return (
    <TextField
      label={"password"}
      type={"password"}
      value={passwordInputValue}
      onChange={handleChange}
    />
  )
}

不要な再レンダリングを抑えるために状態を細かく分割していくというのは Context でも出てくる話です。
グローバルな状態を管理するためのツールが違っても、やることは意外と変わらないんだなと感じました。

ポイント2: イベントハンドリング時に Atom の値が欲しいなら useRecoilCallback を使う

ユーザーのアクションに合わせて Atom に保存した値をサーバーへ送信したいということがよくあります。
例えばユーザーがログインボタンを押下したときのログイン処理などです。
このとき useRecoilState などを使って Atom の値を取得してしまうと、ポイント1で述べたとおり再レンダリングの対象となってしまいます。

再レンダリングは抑えつつイベントハンドリング時に Atom の値を取得したいときは useRecoilCallback を使います。

// LoginForm.tsx

import React from "react"
import { Button, Stack, Typography } from "@mui/material"
import { EmailInput } from "./EmailInput"
import { PasswordInput } from "./PasswordInput"
import { useRecoilCallback } from "recoil"
import { emailAtom, passwordAtom } from "../globalState/login"
import { login } from "../api/login"

export function LoginForm(): JSX.Element {
  const handleSubmit = useRecoilCallback(
    ({snapshot}) =>
      async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault()

        // snapshot から Atom の値を取得できる
        const {emailInputValue} = await snapshot.getPromise(emailAtom)
        const {passwordInputValue} = await snapshot.getPromise(passwordAtom)

        await login(emailInputValue, passwordInputValue)
      }
  )

  return (
    <form onSubmit={handleSubmit}>
      <Stack spacing={2}>
        <Typography>ログイン情報を入力してください</Typography>
        <EmailInput/>
        <PasswordInput/>
        <Button type={"submit"} variant={"contained"}>
          ログイン
        </Button>
      </Stack>
    </form>
  )
}

useRecoilCallback は再レンダリングを抑える以外にも使いみちがあるようです。
詳しくは 公式ドキュメント に記載がありますので、一度目を通してみるのをおすすめします。

おわりに

いかがだったでしょうか。
Recoil 自体は Atom の粒度について言及していないので、どういう方針で実装するか迷う時期がありました。
ちょっとした実験をしてみた結果、 Atom を小さくしたほうが再レンダリングを抑えられることがわかってからは、なるべく Atom を小さくする方針で実装しています。

ところで私が現在携わっている社内プロダクトでは、今回のようにフォームの作成に Recoil を使ってきました。
なのですが、「そもそもフォームを作るならそれに特化したライブラリに移行したほうが保守やパフォーマンスの観点から良いのでは?」という流れになっており、react-hook-form などへの移行を考えています。
今後のフォーム作りがどう変わっていくのか。次の機会に紹介できたらと思います。

それでは、また。

参考・関連リンク