レンダリングとメモ化についてようやく理解したので書く
なぜこの記事を書こうと思ったのか
Reactを学び始めてから、props の渡し方、配列処理のメソッド、ユーティリティ関数の使い方、カスタムフック、定数の扱い方、コンポーネントの切り分け、さらにはアトミックデザインといった多くの概念に触れ、理解する必要がありました。現場での約1年間の経験を通じて、これらの要素がようやく整理でき、ある程度の自信を持って活用できるようになりました。
特に、長い間課題だった「レンダリングとメモ化」に関する理解も深まり、自分なりの言葉でまとめられる段階に来ました。この記事では、レンダリングの発生条件やメモ化の重要性について、自分の経験を元に記録し、他の人にも伝わりやすい形で書いてみようと思います。
レンダリングって?
レンダリングは、Reactコンポーネントが変更された状態(たとえば、props や state の変化)を反映し、UIが更新されるプロセスのことです。
Reactでは、高速でレンダリングが行われます。
稀に「レンダリング = 悪」という誤解を持つ場合がありますが、これは間違いです。レンダリングはReactの基本機能であり、UIを最新の状態に保つために欠かせないプロセスです。
ただし、必要以上にレンダリングが多発するとパフォーマンスに影響が出ることがあるため、「レンダリングの回数を最適化」することが重要です。この点を開発者は念頭に置いておくべきです。
レンダリングの発生条件は?
Reactコンポーネントのレンダリングは、以下の条件で発生します。
コンポーネント内部で useState で定義した値が変更されるとき
- コンポーネントのstateが更新されると、そのコンポーネントは再レンダリングされます。
親コンポーネントで useState で定義した値が変更されたとき
- 親コンポーネントが再レンダリングされると、子コンポーネントも再レンダリングされることがあります。これは、親から子に渡される props に変化があった場合に起こります。
親コンポーネントから子コンポーネントに渡された props が変更されたとき
- 親コンポーネントが再レンダリングされ、props として渡されている値が変更されると、子コンポーネントも再レンダリングされます。
よく色々な記事などで見かけるのですが、「ここだけ」抑えておくことが出来ればメモ化に対する向き合い方がわかってきます。
実際にレンダリングを起こしてみる。
簡単なサンプルコードです。
import { ChangeEvent, FC, useState } from "react";
const MemoRender: FC = () => {
// 文字入力を管理するinputのuseState
const [input, setInput] = useState<string>("");
const onChangeInput = (e: ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setInput(v);
};
console.log(input)
return (
<div style={{padding: "3rem"}}>
<h1>これは親コンポーネントです</h1>
<input type="text" value={input} onChange={onChangeInput} />
</div>
);
};
export default MemoRender;
このコード内では、inputの入力値が更新されるたびにuseStateの値が更新されることにより、レンダリングが発生しているので、value値が入れ替わるたびにconsole.logが表示されます。
これは先ほどのレンダリングのケースとして申した、「コンポーネント内部で useState で定義した値が変更されるとき」に該当するものです。
子コンポーネントを用意してみる
受け取ったstring型を展開するだけのコンポーネントです。(styleに関しては今回適当なので僕の嫌いなinline-styleで記入しています。)
このコンポーネントでも、レンダリングが発生するたびにconsole.logが出力されます。
import { FC } from "react";
type MemoRenderChildProps = {
message: string;
};
const MemoRenderChild: FC<MemoRenderChildProps> = ({ message }) => {
console.log(`${message}: がレンダリングされました`);
return <div style={{ border: "1px solid black", padding: "2rem" }}>{message}</div>;
};
export default MemoRenderChild;
先ほど作成した親コンポーネント内で配列を定義して、小コンポーネントをmap関数を使用して展開します。
import { ChangeEvent, FC, useState } from "react";
import MemoRenderChild from "./MemoRenderChild";
const MemoRender: FC = () => {
// 文字入力を管理するinputのuseState
const [input, setInput] = useState<string>("");
const onChangeInput = (e: ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setInput(v);
};
console.log(input);
const messageArr: string[] = ["大将", "中将", "少将", "富豪", "貧民"];
return (
<div style={{ padding: "3rem" }}>
<h1>これは親コンポーネントです</h1>
<input type="text" value={input} onChange={onChangeInput} />
<h2>これより下で小コンポーネントを展開します</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{messageArr.map((message, index) => (
<MemoRenderChild key={index} message={message} />
))}
</div>
</div>
);
};
先ほどお伝えしたレンダリングの発生条件
「親コンポーネントで useState で定義した値が変更されたとき」を実行してみます。
親コンポーネント内でのuseStateが更新された時に、子コンポーネントもレンダリングが発生する。ということです。
実際に親コンポーネントのinputを更新してみます。すると....
「親のuseStateを変更した際に、小コンポーネント内でのレンダリングが発生している」ので、実際に小コンポーネント内に書いてあるconsole.log()が発火しているのがわかるかと思います。
こんなケースでもレンダリングは起きている
少しだけ親コンポーネントの配列を減らしたりと変更しました。
子コンポーネントではinputの値も受け取るようになっています
import { ChangeEvent, FC, useState } from "react";
import MemoRenderChild from "./MemoRenderChild";
const MemoRender: FC = () => {
// 文字入力を管理するinputのuseState
const [input, setInput] = useState<string>("");
const onChangeInput = (e: ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setInput(v);
};
console.log(input);
const messageArr: string[] = ["大将", "中将", "少将"];
return (
<div style={{ padding: "3rem" }}>
<h1>これは親コンポーネントです</h1>
<input type="text" value={input} onChange={onChangeInput} />
<h2>これより下で小コンポーネントを展開します</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{messageArr.map((message, index) => (
<MemoRenderChild key={index} message={message} input={input} />
))}
</div>
</div>
);
};
export default MemoRender;
小コンポーネント
import { FC } from "react";
type MemoRenderChildProps = {
message: string;
input: string;
};
const MemoRenderChild: FC<MemoRenderChildProps> = ({ message, input }) => {
console.log(`${message} + ${input}が入力されている`);
return (
<>
<div style={{ border: "1px solid black", padding: "2rem" }}>{message}</div>
<div>{input}が入力されている</div>
</>
);
};
export default MemoRenderChild;
先ほどお伝えした通り、親コンポーネント内のinputの内容を更新することで小コンポーネントのレンダリングは発生します。
また、inputの内容をPropsで引き渡しているので、「親コンポーネントから子コンポーネントに渡された props が変更されたとき」に該当するケースとして小コンポーネント内でレンダリングが発生しています。
レンダリングの発生条件が整ったところでメモ化について
Reactには3種類のメモ化が存在する。
useMemo
- 定数内の処理結果などをメモ化する
計算結果をメモ化する。 たとえば、APIから取得したデータをフィルタリングして length を計算する場合、useMemo を使って再計算を防ぐことができます。第二引数の依存配列が変更されない限り、計算結果はメモ化され、再レンダリング時にも再計算されません。
const filteredData = useMemo(() => {
return data.filter(item => item.isActive).length;
}, [data]);
useCallback
- 関数の再生成をメモ化する
useCallback は、関数の再生成を防ぐためにメモ化します。関数が毎回再生成されると、親から渡された props の変化として子コンポーネントが再レンダリングされることがあります。それを防ぐために、useCallback で関数をメモ化することで、依存する値が変わらない限り同じ関数を再利用します。useCallback も、依存配列が変わらない限り、同じ関数インスタンスが再利用されます。
特に、React.memo と併用することで、子コンポーネントの不要な再レンダリングを抑制する効果があります。
const handleClick = useCallback(() => {
console.log("Button clicked");
}, []);
React.Memo
- コンポーネントをメモ化する
コンポーネントの再レンダリングを防ぐためにメモ化します。props に変更がない限り、再レンダリングを防ぐことができます。これにより、特に親コンポーネントが頻繁に再レンダリングされる場合に、子コンポーネントの再レンダリングを抑制する効果があります。
また、メモ化されたコンポーネントの子コンポーネントは、内部にuseStateやuseEffectなどを持っていない場合親側のPropsが変更されない限りはレンダリングの対象から外されます。
// 親コンポーネントの値が更新されても、このCardに渡されるPropsが変更されない限り
// レンダリングが発生しなくなる
//React.memoのあとは()でラップする必要があるため注意!
const Card:FC = React.memo(({ id, data }) => {
return (
<div>
<p>{data}</p>
</div>
);
});
memoの使い所
useMemoと、React.memoはそのまま単体でも使用できる、ということがわかったと思うのですが、
useCallbackに関しては「React.memo」をセットで使わなくてはあまり効果がありません。
なぜか
useCallbackは、関数の再生成を防ぐためのフックです。Reactコンポーネントが再レンダリングされるたびに、新しい関数オブジェクトが生成されることを防ぎ、特定の依存関係が変わらない限り、同じ関数を再利用します。しかし、useCallback単体で使用したとしても、その関数を渡される子コンポーネントが毎回再レンダリングされているのであれば、あまり効果を発揮しません。
先ほど説明した通りで、親コンポーネントが再レンダリングされると、子コンポーネントにも再レンダリングのトリガーがかかります。ここでReact.memoを使うと、子コンポーネントはプロパティ(props)が変更されない限り再レンダリングされなくなります。useCallbackを使用して同じ関数を渡すことで、子コンポーネントに毎回新しい関数が渡されることを防ぎ、React.memoの効果を最大限に活かせます。
つまり、useCallbackとReact.memoはセットで使うことで、関数とコンポーネントの無駄な再レンダリングを防ぎ、パフォーマンスを最適化できます。
↑が若干理解難しいので補足
「親コンポーネントで定義された関数が useCallback でメモ化されておらず、React.memo でラップされた子コンポーネントに props として1つだけ渡されています。この状況で、親コンポーネントが再レンダリングされた際、子コンポーネントも再レンダリングされる??」
回答 -> 「際レンダリングされる」
React.memoでラップされた子コンポーネントが親コンポーネントから関数を props として受け取っている場合、その関数が useCallback でメモ化されていないと、親コンポーネントが再レンダリングされるたびに新しい関数が子コンポーネントに渡されます。
この場合、React.memo は関数の再生成を検出できないため、毎回違う props(新しい関数オブジェクト)として認識し、子コンポーネントが再レンダリングされます。
Reactにおける再レンダリングは「差分」を検出して最適化されますが、関数の場合はその挙動が異なります。JavaScriptでは、関数はオブジェクトの一種なので、たとえ同じロジックの関数であっても、新しく生成されるたびに違うオブジェクトとして扱われます。
React.memo は、props の浅い比較(shallow comparison)を行います。関数が新しいオブジェクトとして生成されていると、毎回異なる props として扱われてしまい、React.memo は「違う props が渡された」と判断して再レンダリングを行います。このため、useCallback を使って関数をメモ化しない限り、関数の差分は検出されず、子コンポーネントの再レンダリングが発生します。
つまり、関数自体が再生成されているため、Reactはそれを「差分」として認識せず、毎回新しい props として扱ってしまうのです。この点が、useCallback が重要になる理由ですね。
最後に
- レンダリング自体はReactの立派な「機能」なので、決してあってはならないということではありません。
- 大事なことは、レンダリング数をより適正化するためにmemo化を活用しましょうということです。
- 過剰にメモ化を行なってしまうと無駄なキャッシュが働きすぎるためパフォーマンスが低下する可能性があります。
- 最初からメモ化!!メモ化!!というよりはチーム内などで、「ここの処理重そうだからメモ化しよう」などの話し合いをしてみたりするのも手かなと思います!
ありがとうございました!