📝

Reactのメモ化と、メモ化できないケースについて

14 min read

寒空のなか商戦に駆り出されているゆきだるまのみなさん、ことしもおつかれさまです。
この記事は、Money Forward Engineering Advent Calendar 2021 24日目の記事です。

私は、クラウド会計ソフトの画面をなんとかする仕事をしています。
React や TypeScript を使ってがんばっています。

この記事について

この記事では、 React を使う話でたまに出てくる「メモ化」について書きたいと思います。
また、標準で使える useMemo などメモ化のためのフックは便利ですが、使えそうで使えない状況もあるようなので、一緒にここでまとめたいと思います。

新しい技術の話でもなければ、会社での独自の取り組みでもないアドベントカレンダーらしからぬ話ですが、ここ2年くらいずいぶん苦労したわりにあまり欲しい情報の記事がすぐ見つからず、もしかしたら有益かもしれないと思ったので書いておきます。

メモ化するフック

React (執筆時点でv17.0.2)を npm などでインストールすると、そのまま useCallbackuseMemo を使うことができます。

https://ja.reactjs.org/docs/hooks-reference.html

このリファレンスによれば、それぞれ「メモ化されたコールバックを返します」「メモ化された値を返します」とあります。

この記事はどちらかというと useMemo を前提に書きますが、 useCallback も「 useMemo(() => fn, deps) と等価です」とのことなので、大体同じことが言えると思います。

メモ化ってなんでしたっけ

フック API リファレンスにおいて、メモ化については Wikipedia の記事が貼ってあり、以下のように説明されています。

メモ化(英: Memoization)とは、プログラムの高速化のための最適化技法の一種であり、サブルーチン呼び出しの結果を後で再利用するために保持し、そのサブルーチン(関数)の呼び出し毎の再計算を防ぐ手法である。
https://ja.wikipedia.org/wiki/メモ化

メモ化によって何が起きるのかを知るため、 React, TypeScript と useMemo でゆきだるまをおどらせてみます。

あたまのてっぺんで円をえがくタイプのおどりです。
ゆきだるまの横幅 width と身長 height 、えがく円の半径 radius 、そこに円を一周するまでのフレームの数 frame がわかれば動きが決まるので、1周分の frame ごとの動き( div 要素の style 属性)を convertToYukidarumaMotions で作っています。

width , height , radiususeState になっていて後から変更される可能性がありますが、逆に3つとも変わらなければ yukidarumaMotions はずっと最初の値のままでよいことになります。

そこで、 yukidarumaMotionsuseMemo を使っています。
第2引数 [width, height, radius] を前回の描画と比較して、同じだったら前回の yukidarumaMotions をそのまま返し、違っていたら第1引数の関数を実行して計算し直し、その結果を返します。

「あたまのてっぺんで円をえがいておどれ」とだけ言われたゆきだるまが、すぐにおどれるように、あらかじめこまかな体の動かし方を考えてメモに書いて、ずっとそれを読んでおどっているというイメージを私は持っています。

このように、メモ化は一度計算した結果をどこかに書いておいて、それを後で読むという技術です。

メモ化のメリット

前述のおどるサンプルから useMemo を外してコンソールをみると、 console.log('useMemoによって、ここは一度しか実行されない') が何度も呼ばれていることがわかると思います。
このサンプルだと useMemo がなくてもあまり支障なく動くと思いますが、 useMemo によって計算の回数が大きく減っていて、状況によっては動作が軽くなったりすると思います。

また、 useMemouseCallback が第2引数を前回と同じと判定したとき、返す値の参照が前回と同じになります。
これと合わせて、依存しているコンポーネントに React.memo を第2引数なしで使っていると、 props の各プロパティの参照が前回と一致するとき再描画されなくなります。
再描画されないということはそのコンポーネントの中の諸々の処理がすべて実行されないので、状況によってかなり速くなりそうです。

https://ja.reactjs.org/docs/react-api.html

useEffect も、第2引数の各要素が前回から変化した時だけ第一引数の関数が実行される他、 useRef などで前回の状態を保存しておけば、前回から特定の状態が変化したときだけ動作する処理を自分で作ることもできます。
useMemo は、その値を得るまでの時間よりも、値が同じであった場合に React が後の処理を減らせるために、値をできるだけ同じにする目的で利用することの方が多いのではないでしょうか。

なお、参照を前回描画から維持しないと依存するコンポーネントの再描画が起きてしまいますが、 DOM の再操作まで行われてしまうかどうかはまた別で、 React がコンポーネントの戻り値を前回と比較して、最低限の DOM の再操作を行います。

https://ja.reactjs.org/docs/rendering-elements.html#react-only-updates-whats-necessary

DOM 操作の時間が特に長いと言われているので、再描画されても DOM の再操作に至りにくいコンポーネントならそこまでメモ化しなくてもよいかもしれません。

総じて、 React のメモ化のメリットはほぼ「速くなる」ことになると思います。
Wikipediaとそこで参照されている論文 によれば、メモ化は速くなる以外のメリットがあるケースもあるようですが、 React を使う上ではあまりなさそうに見えるので割愛します。

Reactでメモ化できないケース

メモ化できないケース、ないし useMemo が使えないケースがありそうなので、以下で書きたいと思います。網羅的に紹介するというよりは、私が仕事でよく遭遇したものを紹介します。

その1 意味上の保証がほしいとき

useMemo のリファレンスを読むと、以下のように書かれています。

useMemo はパフォーマンス最適化のために使うものであり、意味上の保証があるものだと考えないでください。将来的に React は、例えば画面外のコンポーネント用のメモリを解放するため、などの理由で、メモ化された値を「忘れる」ようにする可能性があります。useMemo なしでも動作するコードを書き、パフォーマンス最適化のために useMemo を加えるようにしましょう。
https://ja.reactjs.org/docs/hooks-reference.html#usememo

React がたわむれに前回の値を忘れて参照が新しくなってしまったとき、もしくはコードから useMemo を消したとき、遅くなる以外に動きが変わったらダメということになりそうです。

具体的にどのようなケースで useMemo を使うべきでないのかというと、私の仕事では、主にフォームなど入力を伴うところでよく出てくる印象でした。
たとえば、フォームのコンポーネント Form とフォームに依存する(外側の)コンポーネント App があったとして、フォームにいま何が入力されているかの情報は Form の状態に入っているが、あるとき App がフォームの入力を変えることができる、といったケースです。
初期値にサーバから得られた値を使うとか、「フォームをリセット」ボタンがフォームの外側にある場合はそのようになると思います。

色々な書き方ができそうですが、たとえば Form を以下のように書けると思います。

/** テキスト入力の情報。 */
type Text = {
  value: string;
}

/** フォーム。 */
const Form: VFC<{text: Text}> = (props) => {
  const previousPropsRef = useRef<{text: Text} | undefined>(undefined)
  const [text, setText] = useState<Text>({value: ''})
  if (props.text !== previousPropsRef.current?.text) {
    setText({value: props.text.value})
  }
  previousPropsRef.current = props
  return (
    <form>
      <input
        value={text.value}
        onChange={
          ({currentTarget: {value}}) => {
            setText({value})
          }
        }
      />
    </form>
  )
}

/** フォームのあるアプリケーション。 */
const App: VFC<{}> = () => {
  const text: Text = {value: 'ゆきだるま'}
  return (
    <Form text={text} />
  )
}

Formprops.text が新しい値になったら、 App からテキストの内容を指定されたとみなし、 Form の状態の text.value を更新します。

App で宣言している text がフォームの初期値だとすると、このままでは不具合になる可能性があります。以下の例では、唐突ですが App に「888msごとにうごく8」を追加しました。オブジェのようなものです。

/** フォームのあるアプリケーション。 */
const App: VFC<{}> = () => {
  const [count, setCount] = useState<number>(0)
  useEffect(() => {
    const timerId = setInterval(() => {setCount((prev) => prev < 7 ? prev + 1 : 0)}, 888)
    return () => {
      clearInterval(timerId)
    }
  }, [])
  const text: Text = {value: 'ゆきだるま'}
  return (<>
    <div className="count">{'_______8_______'.slice(7-count, 15-count)}</div>
    <Form text={text} />
  </>)
}

これが追加されたことで、888msごとに App が再描画されるようになりました。
すると、何を入力しても1秒ももたず ゆきだるま に戻ってしまう狂気のフォームができあがります。

それが仕様ならよいのですが、「ゆきだるま」はあくまで初期値で、フォームに関係ない再描画のたびにフォームを書き換えたくないということも多いと思います。

ここで、先ほどの textuseMemo を使ってみます。

/** フォームのあるアプリケーション。 */
const App: VFC<{}> = () => {
  const [count, setCount] = useState<number>(0)
  useEffect(() => {
    const timerId = setInterval(() => {setCount((prev) => prev < 7 ? prev + 1 : 0)}, 888)
    return () => {
      clearInterval(timerId)
    }
  }, [])
  const memoizedText = useMemo<Text>(() => ({value: 'ゆきだるま'}), [])
  return (<>
    <div className="count">{'_______8_______'.slice(7-count, 15-count)}</div>
    <Form text={memoizedText} />
  </>)
}

すると、入力してから時間が経っても ゆきだるま には戻らなくなります。見た目の上では仕様通りです。

しかし、

useMemo なしでも動作するコードを書き、パフォーマンス最適化のために useMemo を加える

という観点からすると正しくない使い方ということになりそうです。
useMemo がないと動作が変わってしまう上に、速くなることを期待している場面でもありませんでした。

よって、このような状況では以下のように useMemo 以外の方法で参照を維持します。

/** フォームのあるアプリケーション。 */
const App: VFC<{}> = () => {
  const [count, setCount] = useState<number>(0)
  useEffect(() => {
    const timerId = setInterval(() => {setCount((prev) => prev < 7 ? prev + 1 : 0)}, 888)
    return () => {
      clearInterval(timerId)
    }
  }, [])
  const textRef = useRef<Text>({value: 'ゆきだるま'})
  return (<>
    <div className="count">{'_______8_______'.slice(7-count, 15-count)}</div>
    <Form text={textRef.current} />
  </>)
}

useRef 以外でも、再描画させたいときなどは必要に応じて useState でも良いと思います。

上に挙げた3つの方法を CodePen で動かしたものを貼っておきます。

実際のところ、上の例において動作確認した範囲では useMemo も正しく動く上に、多くのケースでコード量も短くて済みます。
それでも useMemo を避けるのですが、社会の不合理なルールにしばられて生きるとき特有の気分にはなります。

なお、 React.memo にも useMemo とほぼ同様の説明があります。

これはパフォーマンス最適化のためだけの方法です。バグを引き起こす可能性があるため、レンダーを「抑止する」ために使用しないでください。
https://ja.reactjs.org/docs/react-api.html#reactmemo

その2 配列だったとき

本来、配列だからといってメモ化したらダメということはないと思うのですが、 useMemo ではやりづらいことがありました。

const arr = useMemo(() => str.split(','), str) のように配列全体の参照をメモ化するぶんには useMemo で問題ないのですが、配列の各要素を個別にメモ化したい場合が面倒です。

ドキュメントには以下のように書かれています。

フックをループや条件分岐、あるいはネストされた関数内で呼び出してはいけません。
https://ja.reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

つまり、 const arr = baseArr.map((item) => useMemo(() => item + 'foo', [item])) などのように、フックをループの中で使うことはできません。
また、配列の各要素は再描画ごとに増減したり順番が入れ替わったりする可能性があり、 React もそれを考慮して、ループでリスト状に表示するコンポーネントにはキーを指定します。

https://ja.reactjs.org/docs/lists-and-keys.html

これらのことから、まず配列状のデータはインデックスとキーから各要素を参照できるようになっていると便利です。
Redux では状態を正規化する手法が紹介されています。

https://redux.js.org/usage/structuring-reducers/normalizing-state-shape#designing-a-normalized-state

私の仕事でも、まず配列を Redux の使用方法と同様にして管理しました。

/**
 * generics 型引数において、対象のオブジェクトの型 TargetObject に ID を持つ制約を与える。
 * ID となるプロパティ名は IdName で指定できる。
 */
export type ObjectWithId<
  TargetObject extends TargetObject[IdName] extends number
    ? any & {[id in IdName]: number}
    : any & {[id in IdName]: string},
  IdName extends keyof TargetObject
> = TargetObject[IdName] extends number
  ? any & {[id in IdName]: number}
  : any & {[id in IdName]: string}

/**
 * ID を持ったオブジェクトの配列を正規化したもの。
 */
export type NormalizedArray<
  TargetObject extends ObjectWithId<TargetObject, IdName>,
  IdName extends keyof TargetObject
> = {
  /** ID の配列。 */
  allIds: TargetObject[IdName][];
  /** ID をキーとしたオブジェクトのマップ。 */
  byId: TargetObject[IdName] extends number
    ? {[id: number]: TargetObject}
    : {[id: string]: TargetObject};
}

// 使用例

type Foo = {
  id: string;
  name: string;
}

const foos: NormalizedArray<Foo, 'id'> = {
  allIds: ['8', '12', '24'],
  byId: {
    '8': {id: '8', name: 'ゆきだるま'},
    '12': {id: '12', name: 'となかい'},
    '24': {id: '24', name: 'もみのき'},
  },
}

あとは、前述の「意味上の保証がほしいとき」の例と同様に、 useRef を使って前回の描画の値を保持します。
仮に配列 foos から生成できる配列 bars をメモ化したいとして、今度は foosbarsuseRef で保持します。
そしてループの中で各要素を個別に比較し、 foos.byId[id] に変更がなければ前回描画の previousBarsRef.current.byId[id] を返します。

type Foo = {
  id: string;
  name: string;
}
type Bar = {
  id: string;
  firstName: string;
  lastName: string;
}

// 以下はReactコンポーネント内で書く

const previousFoosRef = useRef<NormalizedArray<Foo, 'id'> | undefined>(undefined)
const previousBarsRef = useRef<NormalizedArray<Bar, 'id'> | undefined>(undefined)
const [foos, setFoos] = useState<NormalizedArray<Foo, 'id'>>({allIds: [], byId: {}})
const bars: NormalizedArray<Bar, 'id'> = {
  allIds: foos.allIds,
  byId: foos.allIds.reduce((accumulator, id) => ({
    ...accumulator,
    [id]:
      foos.byId[id] === previousFoosRef.current?.byId[id] &&
      previousBarsRef.current?.byId[id]
        ? previousBarsRef.current.byId[id]
        : {
          id,
          firstName: foos.byId[id].name.slice(0, 2),
          lastName: foos.byId[id].name.slice(2),
        },
  }), {}),
}
previousFoosRef.current = foos
previousBarsRef.current = bars

面倒ですが、これで配列の個別の要素をメモ化できます。

配列かつ「意味上の保証がほしいとき」という状況もあると思いますが、 useMemo を使っていないので、この処理をそのまま使えると思います。

まとめ

以下の通りまとめます。

  • React は標準のフック useMemo , useCallback を使ってメモ化できます。
  • React におけるメモ化とは、概ね高速化のために前回描画したときの値を使うことです。
  • 意味上の保証がほしいときや配列の要素を保持したいときは React の標準のフックでメモ化できないので、対策が必要です。

Money Forward Engineering Advent Calendar 2021 も、明日で最後の25日目です。
満を持して弊社 CTO の中出です。ご期待ください。

補足

たくさんの方に記事を読んでいただき、大変ありがとうございます。
Twitter やはてなブックマークでコメントをいただき、補足が必要と思いましたので追記します。
認識の異なることなどありましたら、コメント等でご指摘いただけますと幸いです。

描画中に setXxxx して大丈夫?

今回 意味上の保証がほしいとき で紹介したケースは、 React のドキュメントにおける getDerivedStateFromProps のフック化 とほぼ同様の話であると思います。同ドキュメントのサンプルコードも描画中(=関数コンポーネントが値を返すまでの同期的なタイミング)に setXxxx しているように見えるので、 推奨こそされていない かもしれませんが想定された使用方法だと考えています。

function ScrollView({row}) {
  const [isScrollingDown, setIsScrollingDown] = useState(false);
  const [prevRow, setPrevRow] = useState(null);

  if (row !== prevRow) {
    // Row changed since last render. Update isScrollingDown.
    setIsScrollingDown(prevRow !== null && row > prevRow);
    setPrevRow(row);
  }

  return `Scrolling down: ${isScrollingDown}`;
}

useEffect で setXxxx したほうがいいのでは?

useEffectsetXxxx を実行した場合、古い props に由来する状態で一度 DOM 操作を終えた後に setXxxx によって状態を更新することになります。状況によっては、たとえばテキスト入力が一瞬空になった後また文字列が入る、といった画面のチラつきが起こることがありそうです。

また、 useEffect のリファレンスに描画中(=レンダーフェーズ)に行ってはいけないことについて言及されていますが、ここに setXxxx などによるコンポーネントの状態の変更は含まれないと解釈しています。

DOM の書き換え、データの購読、タイマー、ロギング、あるいはその他の副作用を、関数コンポーネントの本体(React のレンダーフェーズ)で書くことはできません。それを行うと UI にまつわるややこしいバグや非整合性を引き起こします。
https://ja.reactjs.org/docs/hooks-reference.html#useeffect

value が text プロパティの中に入っているのはなぜ?

意味上の保証がほしいとき のサンプルコードにおいて、 Form コンポーネントが value しか必要としていないにも関わらず、 props{text: {value: string}} というやや複雑な型になっていました。

これは、 App から Form に与える value を前回と変化させずに、その value を使って Form の状態を新たに更新する処理があることを想定していたためでした。ただ、元のサンプルコードにはそのような処理がなかったので、わかりにくかったかもしれません。

以下のように、ボタンをクリックすると入力が初期値に戻る機能を持つフォームが典型的だと思います。

useState を使って「入力が初期値に戻るボタン」を追加しつつ、 1. は試しに propsvalue を直接受け取ってみました。 2. は元のサンプルコードと同様です。

すると 1. はボタンをクリックしても初期値に戻せなくなってしまっています。
AppFormprops.value をどのように生成したとしても、同じ内容の string (などのプリミティブ)同士となった場合は新旧が等しいと判定され、 Form の状態は更新されません。
2. のように、 Form の状態を更新したいときだけ、オブジェクトリテラルで新しく生成した値を props.text に設定すると、 value が同じであっても更新できます。

私が調べた範囲ではこのような方法を推奨している情報が見当たりませんでしたが、私の仕事では悩んだ末このように対処しました。

変更履歴

  • 2021-12-27: 補足 を追加しました。

Discussion

ログインするとコメントできます