はじめに
前回 の続きです。
0. 今回やること
レンダリングが無駄に行われてしまうとパフォーマンスが悪くなるのでなるべく避けたいのでその手法について見ていきます。
1 useEffect
以降に言及するHooksとは違い無駄な再レンダリングの抑制ということが主目的ではないですが、React Hooks を使う上で避けては通れないというより必ず使うことになる Hook です。
基本的にはドキュメント にあるように主たる目的としてはある処理やDOMの変更をレンダリング完了後まで遅延させることであり、初回以降は第2引数の依存配列に変更があった場合のみその処理を実行することで、結果無駄な再レンダリングを抑制することになるということのようです。
例えば以下のような使い方があります。
const [tasks, setTasks] = useState([])
const TaskList = () => {
const [flag, setFlag] = useState(false)
// APIから既存のタスクを取得
const fetchTodoListAPI = async (data) => {
const response = await axios.get(URL,data);
setTasks(response)
setFlag(false)
}
// 本来は別コンポーネントに分けたりするべき
const AddTaskAPI = async (data) => {
const response = await axios.post(URL,data)
setFlag(True)
}
// 初回レンダリング時に自動でAPIを叩き、タスクを取得して一覧表示させたい
// 第2引数を空配列にすることでマウント時とアンマウント以外では自動で実行されない
useEffect(() => {
fetchTodoListAPI()
}, [])
}
ここで注意しなければいけないのはuseEffect内でStateの変更を行う処理は定義しないということです。
useEffectの役割は改めてになりますが、処理の遅延です。
つまり、定義した処理は初回は必ず実行されることになり、そこでStateの変更を行うことを定義してしまうと無限ループに陥ってしまうからです。
なので前回のコードは修正しないといけません。
前回のコード
const SearchBookContainer = () => {
const [books, setBooks] = useState([]);
const [defaultBooks, setDefaultBooks] = useState([]);
const [filterFlag, setFilterFlag] = useState(false);
const { control } = useForm();
// const classes = useStyles();
const baseUrl = GBAParams.ROOT_URL;
console.log(baseUrl);
const searchTitle = async (data) => {
const params = {
// 完全一致で探したい
q: `${GBAParams.QUERY_TITLE}${data.title}`,
// filter: `paid-ebooks`,
Country: "JP",
maxResults: 40,
orderBy: "newest",
// startIndex: 0,
printType: "books",
};
console.log(params);
try {
const response = await axios.get(baseUrl, { params: params });
console.log(response.data.items);
console.log(response.data);
console.log(response);
const filter_items = response.data.items
// 刊行順にソート
const filtered_items = filter_items.sort(function (a, b) {
if (a.volumeInfo.publishedDate < b.volumeInfo.publishedDate) {
return -1;
} else {
return 1;
}
});
// 最終的に描画する部分
setBooks(filtered_items)
setDefaultBooks(filtered_items);
} catch (error) {
console.log(error.response);
}
};
const handleFilter = () => {
if (!filterFlag) {
// 期間限定試し読みなどを省く
const filter_items = books.filter(
(book) => book.volumeInfo.seriesInfo !== undefined
)
// 刊行順にソート
const filtered_items = filter_items.sort(function (a, b) {
if (a.volumeInfo.publishedDate < b.volumeInfo.publishedDate) {
return -1;
} else {
return 1;
}
});
console.log(filtered_items)
setBooks(filtered_items)
setFilterFlag(true)
} else if (filterFlag === true) {
setBooks(defaultBooks)
setFilterFlag(false)
}
}
// Stateの更新を伴う処理を行っているので間違い、削除してください
useEffect(() => {
setBooks(books);
}, [books]);
2. レンダリングを最適化したいとき
レンダリングがどのようなものかは前回軽く押さえましたが、じゃあ再レンダリングって実際はどのようなときに起こるのかというと主に以下のような場合に起きるみたいです。
- props(コンポーネントに渡された引数)か state に変更があった場合
- 子コンポーネントの場合、親コンポーネントが再レンダリングされた場合
- コンポーネント内に関数を使用している場合やコールバック関数を props として受け取っている場合
- 比較されたコンポーネントが等価でないと判断された場合
1 つ目に関してはレンダリングの主目的でもありますので、当然といえば当然ですね。
2 つ目が曲者、これがこのあたりの話をややこしくしている原因だと思いました。
これは、上位コンポーネントがレンダリングされた場合、子コンポーネントは問答無用で再レンダリングされるということになります。
3 つ目と 4 つ目も曲者で、React では関数はレンダリングされるたびに再生成されます。
すると、関数の内容が同じだとしても再生成されているので React はそれを等価とせず、コンポーネントが変更されたものとして検知し、レンダリングを始めてしまうということなのです。
つまり上記 4 点からわかることはレンダリングの最適化を図るにはそもそも React のコンポーネント設計をしっかりとできないといけないということになります。
1 つ目はともかく、2 つ目以降はコンポーネントの親子関係はもちろん、props のやりとりはあるのか?、コンポーネントの親子関係に即してきちんと関数定義できているか……etc と気を配ることが多いからです。
現に私もできていないのでこうして頭を悩ますことになっているわけです。
ちなみに、React Hook Form を使っている場合は、Redux Form の代替として使っている関係上、props のやり取りをグローバルに行ってしまうので、必然的に再レンダリングが発生してしまうようです。
※参考: FormContext パフォーマンス
上記参考リンクにあるようにそれを回避する手段もありますが今回は省いてレンダリングを許容することにしていますのであしからず……私にはまだ難しいです。
閑話休題。
なので、React.memo、useCallback、useMemo を使った無駄な再レンダリングの回避(界隈ではチューニングなんて呼ばれているみたいなので、以後それに倣います)は、使う場面や状況を使用者が的確に判断的な糸いけないという分野になるので、必ずしも使うべきものではないということになります。
しかし、現実はチューニングされていれば、されているほど負荷は最適化されていくので逃げられないものになるわけですね。
じゃあ、チューニングをしたほうがいい場面っていつなの? というと以下の場面が挙げられるようです。
- 負荷の高い処理を伴うコンポーネントがある
- 頻繁に再レンダリングされるコンポーネントがある
この 2 つの場面でじゃあ React.memo
、useCallback
、useMemo
ってどうなっているの? というのをドキュメントや参考記事を見ながら見ていきたいと思います。
2.1 チューニングって何をやっているの?
一言でいうと「メモ化」を行っているということになるようです。
具体的には
処理結果をキャッシュにして保持し、同様の処理結果が見込まれ、その値が必要な際、場合再処理せずキャッシュから利用できるようにする
ということになるようです。
それぞれ React.memo
がコンポーネントのメモ化、useCallback
が関数のメモ化、useMemo
が値のメモ化になります。
今回はこちらの記事のサンプルコードをお借りして挙動を確認して行きたいと思います。
2.2 React.memo
まずは以下のように React.memo
を使わないで書いてみます。
コードの方は参考元のものをアロー関数での書き方に置き換えたものを使用させて頂きました。
import React, { useState } from "react";
const Child = (props) => {
console.log("render Child");
return <p>Child: {props.count}</p>;
};
const App = () => {
console.log("renger App");
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
return (
<React.Fragment>
<button onClick={() => setCount1(count1 + 1)}>countup App count</button>
<button onClick={() => setCount2(count2 + 1)}>countup Child count</button>
<p>App: {count1}</p>
<Child count={count2} />{" "}
</React.Fragment>
);
};
export default App;
挙動は以下の通りです。
それぞれのコンポーネントの props や State が更新されると再レンダリングされ、App コンポーネントが更新されると、一見変更のない子コンポーネントまで更新されてしまっているのがわかると思います。
では、React.memo を使うとどうなるのか見てみましょう。
import React, { useState } from "react";
const Child = React.memo((props) => {
console.log("render Child");
return <p>Child: {props.count}</p>;
});
const App = () => {
console.log("render App");
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
return (
<React.Fragment>
<button onClick={() => setCount1(count1 + 1)}>countup App count</button>
<button onClick={() => setCount2(count2 + 1)}>countup Child count</button>
<p>App: {count1}</p>
<Child count={count2} />{" "}
</React.Fragment>
);
};
export default App;
挙動は以下の通りです。
APP コンポーネントの再レンダリングに伴って子コンポーネントが再レンダリングされていないことがわかると思います。
ここから分かる通り、React.memo
を使うにはコンポーネントに props を引数として与える必要があり、それを元にレンダリングの際に前後のコンポーネントを等価かどうか比較をして、キャッシュされたものを使うのか再レンダリングするのかを判断するようです。
以下ドキュメントより、注意事項です。
React.memo は props の変更のみをチェックします。React.memo でラップしているあなたのコンポーネントがその実装内で useState や useContext フックを使っている場合、state やコンテクストの変化に応じた再レンダーは発生します。
デフォルトでは props オブジェクト内の複雑なオブジェクトは浅い比較のみが行われます。比較を制御したい場合は 2 番目の引数でカスタム比較関数を指定できます。(下記のコード参照)
const MyComponent = React.memo((props) => {
/* render using props */
})
const areEqual = React.memo((prevProps, nextProps) => {
/*
nextProps を render に渡した結果が
prevProps を render に渡した結果となるときに true を返し
それ以外のときに false を返す
*/
})
const RenderContent = () => {
return(
// ...............
)
}
export default RenderContent
同じようにレンダリングコストが高い、あるいは頻繁にレンダリングが起きる場合も見てみましょう。
// レンダリングコストが高い場合
import React, { useState } from "react";
const Child = React.memo((props) => {
let i = 0;
// この行の処理がレンダリングの度に入るのでコストが高いということになる
while (i < 1000000000) i++;
console.log("render Child");
return <p>Child: {props.count}</p>;
});
const App = () => {
console.log("render App");
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
return (
<>
<button onClick={() => setCount1(count1 + 1)}>countup App count</button>
<button onClick={() => setCount2(count2 + 1)}>countup Child count</button>
<p>App: {count1}</p>
<Child count={count2} />
</>
);
};
export default App;
// 親コンポーネントが頻繁に再レンダリングされることが想定される場合
import React, { useState, useEffect, useRef } from "react";
const Child = React.memo(() => {
console.log("render Child");
return <p>Child</p>;
});
const App = () => {
console.log("render App");
const [timeLeft, setTimeLeft] = useState(100);
// App: {timeLeft}への参照
const timerRef = useRef(null);
// timeLeftRef.currentへの参照
const timeLeftRef = useRef(timeLeft);
useEffect(() => {
timeLeftRef.current = timeLeft;
}, [timeLeft]);
const tick = () => {
if (timeLeftRef.current === 0) {
clearInterval(timerRef.current);
return;
}
setTimeLeft((prevTime) => prevTime - 1);
};
const start = () => {
timerRef.current = setInterval(tick, 10);
};
const reset = () => {
clearInterval(timerRef.current);
setTimeLeft(100);
};
return (
<>
<button onClick={start}>start</button>
<button onClick={reset}>reset</button>
<p>App: {timeLeft}</p>
<Child />
</>
);
};
export default App;
いずれも子コンポーネントの再レンダリングが抑えられていることが確認できます。
useRef に関してはこちらやこちらを参照してください。
基本的には DOM ノードへの参照、特に今回のような current 属性を扱うときに使用する場合があるみたいです。
DOM に関しては私はまだ門外漢なので今回はこれ以上掘り下げないことにします。
さて、React.memo を適切に使うと無駄なレンダリングを抑制できることがわかりましたが先にも書いたようにコンポーネント内でコールバック関数を使った場合はその限りではありません。
なのでその場合は useCallback を使い、コールバック関数をメモ化しないといけません
2.3 useCallback
import React, { useState } from "react";
const Child = React.memo((props) => {
console.log("render Child");
return <button onClick={props.handleClick}>Child</button>;
});
const App = () => {
console.log("render App");
const [count, setCount] = useState(0);
// このコールバック関数が問題
const handleClick = () => {
console.log("click");
};
return (
<>
<p>Counter: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment count</button>
<Child handleClick={handleClick} />
</>
);
};
export default App;
React.memo が使われているのに、App コンポーネントがレンダリングされるたびに子コンポーネントがレンダリングされていることが確認できると思います。
なので、このコールバック関数をメモ化することになります。
import React, { useState, useCallback } from "react";
const Child = React.memo((props) => {
console.log("render Child");
return <button onClick={props.handleClick}>Child</button>;
});
const App = () => {
console.log("render App");
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("click");
}, []);
return (
<>
<p>Counter: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment count</button>
<Child handleClick={handleClick} />
</>
);
};
export default App;
先ほどと違い、App コンポーネントが再レンダリングされても子コンポーネントの再レンダリングが起きていないことが確認できます。
useCallback の引数にはコールバック関数ともしそのコールバック関数が依存している要素がある場合それを指定する必要があります。
依存する要素というのは例えば変数をコールバック関数内で用いている場合、その変数がそれにあたります。
簡単な例だと、参考記事にあるようなものの他に
const [name, setName] = useState("");
// SetName()はnameによって出力が変わる
const test = () => {
name = "sample";
setName(name);
};
こういうのが依存関係にあると言えるかと思います。
なのでこれをメモ化する際には
const [name, setName] = useState("");
// 第2引数にnameを指定する
const test = useCallback(() => {
name = "sample";
setName(name);
}, [name]);
と書けばいいわけです。
先述の例のように依存する要素がなければ第 2 引数は空になります。
実際には name に当たる部分はフォームからの入力要素や API から引き出したデータを格納したりするので、使う機会は結構あるのかなと感じます。
ではもう一つ例を見てみます。
import React, { useState, useCallback } from "react";
const Child = React.memo((props) => {
console.log("render Child");
// ここにhandleClick()を渡したい
return <button onClick={props.handleClick}>Child</button>;
});
const App = () => {
console.log("render App");
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("click");
}, []);
return (
<>
<p>Counter: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment count</button>
{ /* propsのうちhandleClickに当たる部分を渡す書き方をしたので以下のように書く */}
<Child handleClick={handleClick} />
</>
);
};
export default App;
さて、ここまでで気づいた方もいらっしゃると思いますが、その性質上useCallback
はReact.memo
と使うことが前提であり、単独でメモ化しても意味はないようです。
ちょっと考えればReact.memo
で該当コンポーネントがメモ化されていないのではそれも納得です。
あとは、useCallback
でメモ化した関数であってもそれを生成しているコンポーネントで使用した場合は意味をなさないようです。
実際に例を見てみます。
// メモ化していないコンポーネントにuseCallbackでラップした関数を渡した場合
import React, { useState, useCallback } from "react";
// React.memo未使用
const Child = props => {
console.log("render Child");
return <button onClick={props.handleClick}>Child</button>;
};
const App = () => {
console.log("render App");
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("click");
}, []);
return (
<>
<p>Counter: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment count</button>
<Child handleClick={handleClick} />
</>
);
}
export default App
import React, { useState, useCallback } from "react";
const App = () => {
console.log("render App");
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("memonized callback");
}, []);
return (
<>
<p>Counter: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment count</button>
<button onClick={handleClick}>logging</button>
</>
);
}
export default App
それではここまでの内容を簡単にまとめると
-
React.memo
とuseCallback
は基本的にセットで扱われる(コールバック関数がなければ後者は不要) -
React.memo
を使う場合時はpropsのうちどの部分が再レンダリングの比較対象になるのかわかるように書くこと(propsに依存してないコンポーネントなら別) -
useCallback
を使う時は当該関数を生成していないコンポーネントで関数が使用されるように書くようにして、依存する要素を想定する場合はそれが明確になるような書き方をすること
と以上のようになります。
2.4 useMemo
React.memo
がコンポーネント、useCallback
が関数をメモ化するのであれば、useMemo
は関数の実行結果や値をメモ化するものになるようです。
転じて、React.memo
のようにコンポーネントをメモ化するような使い方もできるみたいです。
参考例を確認していきます。
まずはuseMemoを使わないコード
import React, { useState } from "react";
const App = () => {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
// 無駄なループを実行しているため計算にかなりの時間がかかる。
// コールバック関数なのでuseCallbackを使いたくなるが、定義したコンポーネント内では使うことができない
const double = count => {
let i = 0;
while (i < 1000000000) i++;
return count * 2;
};
// よってこちらの関数はレンダリングされる度に実行される
// しかし、このコンポーネント内ではcount1がクリックされるたびにインクリメントされるのでよってそのたびにレンダリングが起きる
// なのでdoubleCountに更新はないのでこちらの関数はそれに連動して実行させたくない
const doubledCount = double(count2);
return (
<>
<h2>Increment count1</h2>
<p>Counter: {count1}</p>
<button onClick={() => setCount1(count1 + 1)}>Increment count1</button>
<h2>Increment count2</h2>
<p>
Counter: {count2}, {doubledCount}
</p>
<button onClick={() => setCount2(count2 + 1)}>Increment count2</button>
</>
);
}
export default App
かなり重たい挙動になっているのがわかります。
では、useMemo
を使ってみましょう。
import React, { useState,useMemo } from "react";
const App = () => {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const double = count => {
let i = 0;
while (i < 1000000000) i++;
return count * 2;
};
// メモ化したい関数の実行結果と依存する配列を引数に指定する
const doubledCount = useMemo(() => double(count2), [count2]);
return (
<>
<h2>Increment count1</h2>
<p>Counter: {count1}</p>
<button onClick={() => setCount1(count1 + 1)}>Increment count1</button>
<h2>Increment count2</h2>
<p>
Counter: {count2}, {doubledCount}
</p>
<button onClick={() => setCount2(count2 + 1)}>Increment count2</button>
</>
);
}
export default App
Increment count2の更新は相変わらず重たいですが、Increment count1の挙動が軽くなったのがわかるかと思います。
useMemo
は引数にメモ化したい値を返す関数などを第1引数に指定し、第2引数にその値を出すのに必要な要素を第2引数に指定します。
特性上、useCallback
のように第2引数が空の場合はないです。
値というのは単なる計算結果に留まらず、当然関数の返り値なども含まれます。
なので、それを応用して以下のようにコンポーネントをメモ化することもできるようです。
import React, { useState, useMemo } from "react";
const App = () => {
console.log("render App");
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
// 先程までと同じ関数
const double = count => {
let i = 0;
while (i < 1000000000) i++;
return count * 2;
};
// Increment count2のカウンター部分を抜き出してコンポーネントにし、メモ化したい
// メモ化することで、Increment count1の部分が再レンダリングされてもcount2に変更がなければレンダリングされずに済む
// ということで以下の通りである。returnでjsx要素を返してやれば、あとはcount2の更新がない限りは再レンダリングを行わずに使い回される
const Counter = useMemo(() => {
console.log("render Counter");
const doubledCount = double(count2);
return (
<p>
Counter: {count2}, {doubledCount}
</p>
);
}, [count2]);
return (
<>
<h2>Increment count1</h2>
<p>Counter: {count1}</p>
<button onClick={() => setCount1(count1 + 1)}>Increment count1</button>
<h2>Increment count2</h2>
{Counter}
<button onClick={() => setCount2(count2 + 1)}>Increment count2</button>
</>
);
}
export default App
Increment count1の更新に伴って、Increment count2の再レンダリングが起きていないことが確認できると思います。
関数コンポーネント内でReact.memo
を使うことはできないのでその代替として利用することができるということになります。
3. まとめ
では改めてまとめていきます。
-
React.memo
とuseCallback
は基本的にセットで扱われる(コールバック関数がなければ後者は不要) -
React.memo
を使う場合時はpropsのうちどの部分が再レンダリングの比較対象になるのかわかるように書くこと(propsに依存してないコンポーネントなら別) -
useCallback
を使う時は当該関数を生成していないコンポーネントで関数が使用されるように書くようにして、依存する要素を想定する場合はそれが明確になるような書き方をすること -
useMemo
は関数や計算処理の実行結果の返り値をメモ化するものであり、応用でコンポーネントをメモ化することにも使えるので、関数コンポーネント内でのコンポーネントのメモ化に用いることができる -
useCallback
及びuseMemo
は依存配列を正しく指定するする必要がある(前者の場合は依存配列がない場合は空で可)
感じたこととしては以下の通りです。
-
React.memo
は分割されているコンポーネントに対して用いる(当然、importして使うこともあるので、依存するpropsが明確になるように設計したい) -
useCallback
はuseMemo
と違い、関数の再生成の抑制(useMemoの場合は値の不要な再計算の抑制になる)自体を目的とするわけでなはく、あくまでReact.memo
を利用したいがそのコンポーネントにコールバック関数が仕込まれているのでそのままでは期待している効果を得られないということへのサポーターのようなもの -
useMemo
は意外に便利
実践でバリバリ使っていくのはまだまだ難しいなと感じました。
というのも今回の例は主に計算処理でしたが、実際にはAPIを叩いたりする処理があるわけでそこにどれだけコストがあってチューニングが必要なのかどうなのかということを判断しないといけないからです。
当然依存配列もAPIから受け取ったデータとなり扱いも単なるそれとは違ってくるので。
ただ、これから隙きあらば手を出していこうと思います。
次回はFirebaseに移っていくのでDBに保存する仕組みを作ってFirebaseとの連携に移って行きたいと思います。
参考
細かい参考記事は記事中に明記してあります。
React.memo / useCallback / useMemo の使い方、使い所を理解してパフォーマンス最適化をする
React HooksのuseCallbackを正しく理解する
React hooksを基礎から理解する (useCallback編)