LoginSignup
0
0

React + TypeScript: memoでコンポーネントの再レンダーをpropsが変わらないかぎり省く

Last updated at Posted at 2024-05-07

memoで包んだコンポーネントは、受け取るpropsが変わらないかぎり再レンダーされません。

本稿はReact公式サイト「memo」にもとづき、memoはどう使うのか、およびどのような場合に使うとよいのかを解説します。説明内容と順序は、公式ドキュメントにしたがいました。ただし、解説はわかりやすく改め、またコード例とサンプル(StackBlitz)はTypeScriptを加えたうえで修正した部分が少なくありません。

構文

const MemoizedComponent = memo(SomeComponent, arePropsEqual?)

コンポーネントをmemoでラップすると、メモ化されたコンポーネントが返されます。メモ化されたコンポーネントは、親コンポーネントのレンダリングにかかわらず、受け取るpropsが変わらないかぎり通常再レンダーされません。ただし、Reactがそれでもレンダリングすることはあり得ます。メモ化はパフォーマンス最適化のひとつであって、保証まではしません。

import { memo } from 'react';

const SomeComponent = memo((props) => {

});

memoの構文はつぎのとおりです。

memo(Component, arePropsEqual?) 

引数

  • Component: メモ化するコンポーネント。
    • コンポーネントそのものは変更されません。返されるのはメモ化された新たなコンポーネントです。
    • コンポーネントは関数やforwardRefによるものも含まれ、任意の有効なReactコンポーネントが渡せます。
  • arePropsEqual(省略可能): 直前と今回のふたつのpropsを引数にとる関数。戻り値はpropsが変わったかどうかを示すboolean値です。
    • trueの場合: propsは変わっていないと評価し、キャッシュされたコンポーネントのまま、再レンダーしません。つまり、コンポーネントの出力も動きも直前と同じです。
    • falseの場合: 直前とpropsが変わったと評価され、コンポーネントは再レンダーされまます。
    • 通常は、この関数を与える必要はありません。デフォルトで、ふたつのpropsの値それぞれがObject.isにより比べられます。

戻り値

第1引数のコンポーネントをメモ化した新たなReactコンポーネントです。

  • 動作は第1引数に渡したコンポーネントと変わりません。
  • ただし、親が再レンダーされたとき、渡されたpropsが直前と同じかどうかを確かめます。変わらなければ、Reactはコンポーネントを再レンダーしません。

使い方

受け取ったpropsが直前と変わっていなければコンポーネントを再レンダーしない

Reactはデフォルトでは、親コンポーネントのレンダリングが起こると、つねに子を再起的に再レンダーします。memoが返すのは、無駄な再レンダーを省くメモ化された新たなコンポーネントです。Reactは、コンポーネントの受け取ったpropsが、直前と変わっていないかを確かめます。同じであれば、再レンダーしません。

コンポーネントをmemoで包むと、返されるのがメモ化されたコンポーネントです。戻り値は、もとのコンポーネント(第1引数)と同じように使えます。

export const Greeting: FC<Props> = memo(({ name }) => {
	return <h1>Hello, {name}!</h1>;
});

Reactコンポーネントのレンダーロジックは、つねに純粋に保たなければなりませんpropsや状態およびコンテクストが変わらないかぎり、つねに同じ出力を返すということです。memoを用いることにより、Reactにコンポーネントが純粋であると伝わります。propsが直前と変わらなければ、Reactはコンポーネントを再レンダーしません。ただし、コンポーネントの状態や、用いたコンテクストが更新されたときは、再レンダリングが起こります。

つぎのコード例で、モジュールsrc/Greeting.tsxのコンポーネント(Greeting)はmemoで包みました。したがって、渡されるプロパティnameが変わらなければ再レンダーされません。レンダリングされているか確認するため、console.logを加えました。

src/Greeting.tsx
import { memo } from 'react';
import type { FC } from 'react';

type Props = {
	name: string;
};
export const Greeting: FC<Props> = memo(({ name }) => {
	console.log('Greeting was rendered at', new Date().toLocaleTimeString());
	return (
		<h3>
			Hello{name && ', '}
			{name}!
		</h3>
	);
});

親コンポーネント(App)のモジュールsrc/App.tsxのコードはつぎのとおりです。状態変数はnameaddressのふたつで、テキストフィールド(<input>要素)への入力により値が変わります。子コンポーネントGreetingに渡したプロパティは、ふたつのうちのnameです。

src/App.tsx
import { useState } from 'react';
import { Greeting } from './Greeting';

function App() {
	const [name, setName] = useState('');
	const [address, setAddress] = useState('');
	return (
		<>
			<label>
				Name{': '}
				<input
					value={name}
					onChange={({ target: { value } }) => setName(value)}
				/>
			</label>
			<label>
				Address{': '}
				<input
					value={address}
					onChange={({ target: { value } }) => setAddress(value)}
				/>
			</label>
			<Greeting name={name} />
		</>
	);
}

export default App;

つぎのサンプル001で試して、コンソール出力を確かめてみてください。nameのテキストフィールドに入力すると、子コンポーネントGreetingは再レンダーされるでしょう。けれど、addressの値は、Greetingのプロパティに含まれません。addressのテキストフィールドに入力しても、子コンポーネントの再レンダーは省かれるのです。

サンプル001■React + TypeScript: memo 01

[注記] memoはパフォーマンスを最適化するためにお使いくださいmemoなしにはコードが動かないという場合は、まず原因を探って修正するべきです。そのうえで、必要があればmemoを用いましょう。

memoはどこに使うべきか

メモ化はどこに用いるべきで、どういうときは不要か、大まかに比べるなら大きくふたつです。

  • インタラクションがさほど多くなければ、memoを使わななくて構いません。
    • ページの切り替えや画面の一部の変更で済む場合。
  • 細かなインタラクションが頻繁に生じるときは、メモ化は有効でしょう。
    • 描画エディターのアプリケーションで、図形を移動させるなど。

memoによる最適化が役立つために必要な条件は、つぎのふたつです。

  • コンポーネントがまったく同じpropsで頻繁に再レンダーされる。
  • 再レンダーするロジックの負荷が高い。

コンポーネントが再レンダーされても遅れはとくに感じられない場合、memoは要りません。さらに、レンダー中に定めたオブジェクトや関数をそのまま渡していると、コンポーネントが受け取ったpropsは毎回異なると評価され、memoは無意味です。useMemouseCallbackと併せて使うことをお考えください。

こうした場合以外は、コンポーネントをmemoでラップしても、効果はないでしょう。かといって、問題となることも考えにくいです。とりあえず、メモ化してしまうという開発チームもあります。デメリットとしては、コードが読みにくくなることです。また、レンダーごとに変わる値がひとつでもコンポーネントに含まれていれば、メモ化する意味はありません。

不要なメモ化を避けるための5つの原則

メモ化はつねに有効とはかぎりません。つぎの5つの原則にしたがえば、不要なメモ化が避けられるでしょう。

  1. コンポーネントが子コンポーネントを視覚的にラップするときは、子のJSXはchildrenとして受け取るようにします。子を包む親コンポーネントが自身の状態を更新しても、Reactは子の再レンダーは要らないとわかるからです。
  2. 状態はできるだけローカルに持ち、無闇にコンポーネントツリー上を引き上げないようにします(「React + TypeScript: コンポーネント間で状態を共有する」参照)。フォーム入力や項目へのホバーといった状態は、頻繁に変わるインタラクションです。ツリーのトップやグローバルの状態ライブラリに保持しないでください。
  3. レンダーロジックを純粋に保ちましょう。コンポーネントの再レンダーで意図しない動きになったり、表示に明らかな問題が起こるとすれば、それはバグです。メモ化で避けようとするのでなく、原因をつきとめて修正してください。
  4. 不必要に状態を更新するエフェクトは避けましょう。Reactアプリケーションでパフォーマンスの問題を引き起こす原因の多くが、エフェクトによる連鎖的な状態の更新です。コンポーネントはそのたびに再レンダーされてしまいます。
  5. エフェクトから要らない依存値は除いてください。メモ化しなくても、オブジェクトや関数をエフェクトの中あるいは外に移すだけで、簡単に解決できることもあります。

以上の原則にしたがっても、遅いと感じるインタラクションが残るかもしれません。そのような場合には、React Developer ToolsのProfilerパネルをお使いください。どのコンポーネントをメモ化すればもっとも効果的かが確かめられます。このように開発を進めることで、デバッグしやすく、わかりやすいコードが書けるでしょう。React公式サイトが推奨する理由です。将来に向けては、自動的にメモ化する研究も進められています(「React without memo」参照)。

状態が変わるとメモ化されたコンポーネントは更新される

メモ化されたコンポーネントが確かめるのは、親から渡されたpropsの直前との違いです。propsとは別に、自身の状態が変われば再レンダーされます。

前掲サンプル001のモジュールsrc/Greeting.tsxのコンポーネント(Greeting)にuseStateで状態変数(greeting)を加えました。さらに、その値は新たに定める子コンポーネント(GreetingSelector)にプロパティ(value)として渡しています。状態変数値を変える(setGreeting)のは、子コンポーネントです。

src/Greeting.tsx
// import { memo } from 'react';
import { memo, useState } from 'react';

import { GreetingSelector } from './GreetingSelector';

export const Greeting: FC<Props> = memo(({ name }) => {

	const [greeting, setGreeting] = useState('Hello');
	return (
		<>
			<h3>
				{/* Hello{name && ', '} */}
				{greeting}
				{name && ', '}
				{name}!
			</h3>
			<GreetingSelector value={greeting} onChange={setGreeting} />
		</>
	);
});

モジュールsrc/GreetingSelector.tsxのコンポーネント(GreetingSelector)は、親から状態変数値valueとその設定関数onChangeをプロパティとして受け取ります。設定を変えるのはふたつのラジオボタン(<input type="radio")です。

src/GreetingSelector.tsx
import type { FC } from 'react';

type Props = {
	onChange: (greeting: string) => void;
	value: string;
};
type InputItemmProps = Props & {
	greeting: string;
	text: string;
};
const InputItem: FC<InputItemmProps> = ({
	onChange,
	greeting,
	text,
	value,
}) => {
	return (
		<label>
			<input
				type="radio"
				checked={value === greeting}
				onChange={() => onChange(greeting)}
			/>
			{text}
		</label>
	);
};
export const GreetingSelector: FC<Props> = ({ value, onChange }) => {
	return (
		<>
			<InputItem
				onChange={onChange}
				greeting="Hello"
				text="Regular greeting"
				value={value}
			/>
			<InputItem
				onChange={onChange}
				greeting="Hello and welcome"
				text="Enthusiastic greeting"
				value={value}
			/>
		</>
	);
};

コンポーネントGreetingSelectorのラジオボタンを切り替えると、親のGreetingの状態(greeting)が変わります。greetingmemoで包まれており、受け取るpropsは変わらなくとも、状態が更新されればコンポーネントは再レンダーされるのです(サンプル002)。

サンプル002■React + TypeScript: memo 02

ただし、状態設定関数に現行と同じ値を渡して呼び出した場合は、コンポーネントのメモ化にかかわらず、再レンダーされません。関数コンポーネントは呼び出されても、レンダリングが省かれるのです。

コンテクストが変わるとメモ化されたコンポーネントは更新される

メモ化されたコンポーネントが確かめるのは、親から渡されたpropsの直前との違いです。propsとは別に、使っているコンテクストが変われば再レンダーされます。

つぎのモジュールsrc/App.tsxは、createContextでコンテクスト(ThemeContext)をつくっています。プロバイダにvalueとして与えるのは状態変数値themeです(ボタンクリックで値は切り替わります)。子コンポーネントGreetingに渡すプロパティのnameは決め打ち("Taylor")なので、値が変わることはありません。

src/App.tsx
import { createContext, useCallback, useState } from 'react';
import { Greeting } from './Greeting';

const defaultTheme = 'dark';
export const ThemeContext = createContext(defaultTheme);
function App() {
	const [theme, setTheme] = useState(defaultTheme);
	const handleClick = useCallback(() => {
		setTheme(theme === 'dark' ? 'light' : 'dark');
	}, [theme]);
	return (
		<ThemeContext.Provider value={theme}>
			<button onClick={handleClick}>Switch theme</button>
			<Greeting name="Taylor" />
		</ThemeContext.Provider>
	);
}

export default App;

モジュールsrc/Greeting.tsxの子コンポーネント(Greeting)は、useContextで取り出したコンテクストの値(theme)に応じて、要素のクラス(className)を切り替えています(要素のカラー変更)。コンポーネントはmemoで包みました。

src/Greeting.tsx
import { memo, useContext } from 'react';
import type { FC } from 'react';
import { ThemeContext } from './App';

type Props = {
	name: string;
};
export const Greeting: FC<Props> = memo(({ name }) => {
	console.log('Greeting was rendered at', new Date().toLocaleTimeString());
	const theme = useContext(ThemeContext);
	return <h3 className={theme}>Hello, {name}!</h3>;
});

つぎのサンプル003で試すと、親コンポーネントの[Switch theme]ボタンをクリックするたびに、子のGreetingは再レンダーされます(コンソール出力をお確かめください)。前述のとおり、Greetingはメモ化し、渡されるプロパティ(name)も変わりません。けれど、コンテクストの値(theme)が切り替わっています。すると、それを用いるコンポーネントは再レンダーされるのです。

サンプル003■React + TypeScript: memo 03

開発が進むにつれ、コンテクストのもつ値は膨らんでくるかもしれません。すると、子コンポーネントの再レンダーは、コンテクストの特定の値が変わったときに留めたい場合もあるでしょう。そういうときは、子コンポーネントを、新たにつくる親でラップしてください。useContextは親コンポーネントから呼び出すのです。そのうえで、子コンポーネントに必要な値は、それぞれプロパティとして与えます。こうすれば、コンポーネントのメモ化も働くでしょう。具体的なコードとサンプルについては、「子コンポーネントを包んだ親からuseContextを呼び出す」をお読みください。

propsはできるだけ変えない

memoで包んだコンポーネントは、受け取ったpropsのいずれかが浅い(shallow)比較で直前と異なるとき再レンダーされます。Reactがコンポーネントのpropsすべてを前回の値と比べるとき用いるのはObject.isです。とくに、オブジェクトは、プリミティブ値と異なり、参照が比較されることにご注意ください。

  • Object.is(3, 3): true
  • Object.is({}, {}): false

useMemopropsの値を無駄につくり直さない

memoの有効性を高めるには、子が受け取るpropsの値はできるだけ変えないようにします。親コンポーネントが値をuseMemoに包んで渡せば、オブジェクトであっても無駄につくり直されません。

const Page: FC = () => {
	const [name, setName] = useState('Taylor');
	const [age, setAge] = useState(42);
	const person = useMemo(
		() => ({ name, age }),
		[name, age]
	);
	return <Profile person={person} />;
}
const Profile: FC<Props> = memo(({ person }) => {

});

必要な値だけをpropsとして渡す

可能であれば、必要な値だけを子にpropsとして与えることです。使う値がオブジェクトの中の一部なら、丸ごとpropsで渡すことはありません。

const Page: FC = () => {
	const [name, setName] = useState('Taylor');
	const [age, setAge] = useState(42);
	return <Profile name={name} age={age} />;
}
const Profile: FC<Props> = memo(({ name, age }) => {

});

propsを更新の少ない値に変換して渡す

親からpropsで受け取ったオブジェクトそのものを使うのでなく、処理や評価した結果がほしいという場合もあるでしょう。親コンポーネントの側でできるのなら、結果だけ渡せばpropsの更新は減らせるかもしれません。

つぎのコード例では、子コンポーネントが受け取るのは、頻繁に変わるかもしれない値ではなく、値があるかどうかを示すboolean値(hasGroups)です。

const GroupsLanding: FC<Props> ({ person }) => {
	const hasGroups = person.groups !== null;
	return <CallToAction hasGroups={hasGroups} />;
}

const CallToAction: FC<CallToActionProps> = memo(({ hasGroups }) => {

});

propsで渡す関数の更新を省く

コンポーネントの中で定義された関数は、デフォルトではオブジェクトと同じように、レンダリングのたびにつくり直されます。メモ化された子コンポーネントにpropsで関数を渡す場合には、無駄な更新は避けなければなりません。考えられるのは、つぎのふたつの対応です。

  • 関数をコンポーネントの外で定めてください。
    • コンポーネント外の関数は、再レンダリングでつくり直されません。
  • コンポーネント内の関数定義をuseCallbackで包みましょう。
    • 依存配列が変わらないかぎり、関数は再定義されません。

memoの第2引数にカスタム比較関数を定める

子コンポーネントが渡されたpropsを直前と比べるとき、デフォルトの浅い比較(Object.is)では足りないことがありえます。その場合、memoの第2引数として渡せるのがカスタム比較関数です。比較関数の受け取るふたつの引数がそれぞれ前回と今回のprops、ふたつに変わりがないかをboolean値で返します。値が同じと評価するなら戻り値はtrueで、再レンダーされません。

const Chart: FC<Props> = memo(({ dataPoints }) => {
	// ...
}, arePropsEqual);

const arePropsEqual = (oldProps: Props, newProps: Props) => {
	return (
		oldProps.dataPoints.length === newProps.dataPoints.length &&
		oldProps.dataPoints.every((oldPoint, index) => {
			const newPoint = newProps.dataPoints[index];
			return oldPoint.x === newPoint.x && oldPoint.y === newPoint.y;
		})
	);
}

memoに第2引数で比較関数を与えた場合、効果はブラウザのデベロッパーツールで[パフォーマンス]パネルからお確かめください。なお、React は本番モードで動作させましょう。

カスタム比較関数を使う場合の注意

memoの第2引数(arePropsEqual)にカスタム比較関数を与えた場合、propsの値はオブジェクトだけでなく、関数も含めてすべて前回と正しく比べなければなりません

関数で注意すべきことは、親コンポーネントのpropsと状態をクロージャに閉じ込めることです。クロージャ内が異なるのに比較結果の戻り値をtrueにすると、きわめて見つけにくいバグの原因となります。たとえば、実際にはoldProps.onClick !== newProps.onClickなのにtrueを返すと、コンポーネントはonClickハンドラ内で以前のレンダー時のpropsと状態を「見続ける」ことになるからです。

propsのデータ構造の深さが100%わかっている場合以外、カスタム比較関数(arePropsEqual)で等価性を調べるのはお勧めしません。等価性を深く確かめようとすることは過剰な負荷につながります。あとでデータ構造が変わったら、数秒間フリーズしてしまうかもしれません。

トラブルへの対応

propsに渡しているのは同じ内容のオブジェクト・配列・関数なのにメモ化したコンポーネントが再レンダーされる

Reactが前回と今回のpropsが等しいかを調べるのは浅い比較です。つまり、親コンポーネントが再レンダーのたびにオブジェクトや配列をつくり直せば、中身のプロパティや要素が同じであっても、Reactは参照にもとづいて異なると評価します。また、コンポーネント内に定められた関数も、デフォルトではレンダー時に再定義されるので同じとはみなされません。これを避けるためには、前述「propsはできるだけ変えない」をご参照ください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0