Reactのメモ化と、メモ化できないケースについて
寒空のなか商戦に駆り出されているゆきだるまのみなさん、ことしもおつかれさまです。
この記事は、Money Forward Engineering Advent Calendar 2021 24日目の記事です。
私は、クラウド会計ソフトの画面をなんとかする仕事をしています。
React や TypeScript を使ってがんばっています。
この記事について
この記事では、 React を使う話でたまに出てくる「メモ化」について書きたいと思います。
また、標準で使える useMemo
などメモ化のためのフックは便利ですが、使えそうで使えない状況もあるようなので、一緒にここでまとめたいと思います。
新しい技術の話でもなければ、会社での独自の取り組みでもないアドベントカレンダーらしからぬ話ですが、ここ2年くらいずいぶん苦労したわりにあまり欲しい情報の記事がすぐ見つからず、もしかしたら有益かもしれないと思ったので書いておきます。
メモ化するフック
React (執筆時点でv17.0.2)を npm などでインストールすると、そのまま useCallback と useMemo を使うことができます。
このリファレンスによれば、それぞれ「メモ化されたコールバックを返します」「メモ化された値を返します」とあります。
この記事はどちらかというと 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
, radius
は useState
になっていて後から変更される可能性がありますが、逆に3つとも変わらなければ yukidarumaMotions
はずっと最初の値のままでよいことになります。
そこで、 yukidarumaMotions
に useMemo
を使っています。
第2引数 [width, height, radius]
を前回の描画と比較して、同じだったら前回の yukidarumaMotions
をそのまま返し、違っていたら第1引数の関数を実行して計算し直し、その結果を返します。
「あたまのてっぺんで円をえがいておどれ」とだけ言われたゆきだるまが、すぐにおどれるように、あらかじめこまかな体の動かし方を考えてメモに書いて、ずっとそれを読んでおどっているというイメージを私は持っています。
このように、メモ化は一度計算した結果をどこかに書いておいて、それを後で読むという技術です。
メモ化のメリット
前述のおどるサンプルから useMemo
を外してコンソールをみると、 console.log('useMemoによって、ここは一度しか実行されない')
が何度も呼ばれていることがわかると思います。
このサンプルだと useMemo
がなくてもあまり支障なく動くと思いますが、 useMemo
によって計算の回数が大きく減っていて、状況によっては動作が軽くなったりすると思います。
また、 useMemo
や useCallback
が第2引数を前回と同じと判定したとき、返す値の参照が前回と同じになります。
これと合わせて、依存しているコンポーネントに React.memo を第2引数なしで使っていると、 props
の各プロパティの参照が前回と一致するとき再描画されなくなります。
再描画されないということはそのコンポーネントの中の諸々の処理がすべて実行されないので、状況によってかなり速くなりそうです。
useEffect
も、第2引数の各要素が前回から変化した時だけ第一引数の関数が実行される他、 useRef
などで前回の状態を保存しておけば、前回から特定の状態が変化したときだけ動作する処理を自分で作ることもできます。
useMemo
は、その値を得るまでの時間よりも、値が同じであった場合に React が後の処理を減らせるために、値をできるだけ同じにする目的で利用することの方が多いのではないでしょうか。
なお、参照を前回描画から維持しないと依存するコンポーネントの再描画が起きてしまいますが、 DOM の再操作まで行われてしまうかどうかはまた別で、 React がコンポーネントの戻り値を前回と比較して、最低限の DOM の再操作を行います。
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} />
)
}
Form
の props.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秒ももたず ゆきだるま
に戻ってしまう狂気のフォームができあがります。
それが仕様ならよいのですが、「ゆきだるま」はあくまで初期値で、フォームに関係ない再描画のたびにフォームを書き換えたくないということも多いと思います。
ここで、先ほどの text
に 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 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 もそれを考慮して、ループでリスト状に表示するコンポーネントにはキーを指定します。
これらのことから、まず配列状のデータはインデックスとキーから各要素を参照できるようになっていると便利です。
Redux では状態を正規化する手法が紹介されています。
私の仕事でも、まず配列を 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
をメモ化したいとして、今度は foos
と bars
を useRef
で保持します。
そしてループの中で各要素を個別に比較し、 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 したほうがいいのでは?
useEffect
で setXxxx
を実行した場合、古い 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.
は試しに props
で value
を直接受け取ってみました。 2.
は元のサンプルコードと同様です。
すると 1.
はボタンをクリックしても初期値に戻せなくなってしまっています。
App
で Form
の props.value
をどのように生成したとしても、同じ内容の string (などのプリミティブ)同士となった場合は新旧が等しいと判定され、 Form
の状態は更新されません。
2.
のように、 Form
の状態を更新したいときだけ、オブジェクトリテラルで新しく生成した値を props.text
に設定すると、 value
が同じであっても更新できます。
私が調べた範囲ではこのような方法を推奨している情報が見当たりませんでしたが、私の仕事では悩んだ末このように対処しました。
変更履歴
- 2021-12-27: 補足 を追加しました。
Discussion