LoginSignup
1
3

React + TypeScript: useCallbackフックの使い方と使いどころ

Last updated at Posted at 2024-04-26

useCallbackは、再レンダリング間で関数定義をキャッシュするReactのフックです。

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

構文

const cachedFn = useCallback(fn, dependencies)

useCallbackはコンポーネントのトップレベルで呼び出して、再レンダリング間で関数の定義をキャッシュします。

import { useCallback } from 'react';
import type { FC } from 'react';

type Props = {

};
export const ProductPage: FC<Props> = ({ productId, referrer, theme }) => {
	const handleSubmit = useCallback((orderDetails) => {
		post('/product/' + productId + '/buy', {
			referrer,
			orderDetails,
		});
	}, [productId, referrer]);

};

引数

  • fn: キャッシュする関数型の値。渡す引数も戻り値も任意です。Reactは、まず最初のレンダー時には関数をそのまま返します(呼び出しはしません)。次回以降のレンダリングについてはつぎのとおりです。
    • 直前のレンダー時と依存値(第2引数の配列要素値)が変わっていないとき: Reactは前と同じ関数を返します。
    • 直前のレンダー時から依存値が変わった場合: 返されるのは今回のレンダー時に渡された関数です。そして、再利用に備えて保存されます。Reactは関数を返すだけで、呼び出しはしません。関数をいつ呼び出すかは、開発者が決めることです。
  • dependencies: 第1引数fnのコード内で参照されるすべてのリアクティブ値の(依存)配列。リアクティブな値に含まれるのは、プロパティと状態、およびコンポーネント本体に直接宣言された変数と関数です。React用に設定されたリンターであれば、リアクティブな値がすべて依存関係に正しく指定されているかを確かめます。依存配列には、特定の依存値が要素として含まれなければなりません。インラインで、[dep1, dep2, dep3]という記述です。Reactは、各依存値を直前の値とObject.isメソッドで比較します。

戻り値

最初のレンダー時は、useCallbackに渡された引数の関数fnです。

以降のレンダーでは、依存値が変わったかどうかにより、返す値は変わります。

  • 依存値に変更がない場合: 返されるのは前のレンダリング時に保存した関数です。
  • 依存値が変わったとき: このレンダー時に渡された関数fnが返されます。

注意

  • useCallbackはフックなので、呼び出せるのはコンポーネントまたはカスタムフックのトップレベルからのみです。ループ文や条件文の中からは呼び出しできません。それが必要なときは、コンポーネントを新たに切り出し、状態はその中に移してください。
  • Reactは、特別な理由がないかぎり、キャッシュされた値を破棄しません。開発環境ならReactは、たとえばコンポーネントのファイルが編集されたら、キャッシュを破棄します。開発と本番の両環境で、Reactがキャッシュを破棄するのは、コンポーネントの初期マウント完了に至らなかった場合です。
    Reactは、将来的にキャッシュの破棄が利用できる機能をさらに追加することもありえます。たとえば、仮想化リストの組み込みサポートを加えるようになった場合です。仮想化されたテーブルビューポートからスクロールアウトするアイテムのキャッシュを破棄することは意味があるでしょう。
    パフォーマンスの最適化が目的でuseCallbackを用いることは問題ありません。そうでない場合は、状態変数refを活用する方がより適切なこともありえます。

使い方

コンポーネントの再レンダーを省く

子コンポーネントに渡す関数をuseCallbackでキャッシュする

レンダーのパフォーマンス最適化のために、子コンポーネントに渡す関数をキャッシュすることが役立つかもしれません。コンポーネントの再レンダー間で関数をキャッシュするには、その定義をuseCallbackフックで包んでください。

export const ProductPage: FC<Props> = ({ productId, referrer, theme }) => {
	const handleSubmit = useCallback((orderDetails) => {
		post('/product/' + productId + '/buy', {
			referrer,
			orderDetails,
		});
	}, [productId, referrer]);

};

useCallbackに渡す引数はふたつです。

  1. 関数定義: 再レンダー間でキャッシュする関数。
  2. 依存関係のリスト: 関数が用いるコンポーネント内のすべての依存値を配列で渡します。

初期レンダー時: useCallbackから返される関数は、フックを呼び出すときに渡した第1引数の定義そのものです。

次回以降のレンダー時: Reactは各レンダーごとに、依存関係を直近に渡された依存値と比較します。依存関係に(Object.is()による比較で)変わりなければ、useCallbackが返すのは前回と同じ関数です。変更があったら、フックは今回のレンダーで渡された新たな関数を返します。

つまり、useCallbackは、依存関係が変わらないかぎり、再レンダリングの間関数定義をキャッシュするのです。

useCallbackでキャッシュした関数をmemoに包んだコンポーネントへ渡す

useCallbackがどう役立つのか、コード例で見ていきましょう。つぎのモジュールsrc/ProductPage.tsxは、子コンポーネントShippingFormonSubmitプロパティの値として関数handleSubmitを渡しています。

src/ProductPage.tsx
export const ProductPage: FC<Props> = ({ productId, referrer, theme }) => {
	const handleSubmit: (orderDetails: OrderDetails) => void = (orderDetails) => {
		post('/product/' + productId + '/buy', {
			referrer,
			orderDetails,
		});
	};
	return (
		<div className={theme}>
			<ShippingForm onSubmit={handleSubmit} />
		</div>
	);
};

また、ProductPageコンポーネントのプロパティthemeは、親(App.tsx)から受け取った値です。

src/App.tsx
export default function App() {
	const [isDark, setIsDark] = useState(false);
	return (
		<>
			<label>
				<input
					type="checkbox"
					checked={isDark}
					onChange={({ target: { checked } }) => setIsDark(checked)}
				/>
				Dark mode
			</label>
			<hr />
			<ProductPage

				theme={isDark ? 'dark' : 'light'}
			/>
		</>
	);
}

つぎのサンプル001で親コンポーネントのチェックボックス(<input type="checkbox")を切り替えたとき、画面カラーの反応が遅く感じるかもしれません(あえて、ShippingFormコンポーネントのレンダーに遅延を加えました)。

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

デフォルトでは、コンポーネントがレンダーされると、Reactはその子要素すべてを再帰的に再レンダーします。そのため、ProductPageが異なるthemeでレンダリングし直されると、ShippingFormコンポーネントも再レンダーされるのです。

もっとも、レンダリング負荷の少ないコンポーネントであれば、とくに問題ありません。再レンダーはできるだけ省きたいというとき、ひとつ考えられるのはコンポーネントをmemoでラップすることです。渡されたプロパティ(props)が前回のレンダー時と同じ場合には、再レンダーを省けます。

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

export const ShippingForm: FC<Props> = memo(({ onSubmit }) => {

});

コンポーネントShippingFormの受け取るプロパティ(props)がすべて同じなら、再レンダーされなくなりました。ただし、ここで重要になってくるのが関数のキャッシュです。まだ、ProductPageコンポーネントが子へ渡す関数(handleSubmit)にuseCallbackは使っていません。

src/ProductPage.tsx
export const ProductPage: FC<Props> = ({ productId, referrer, theme }) => {

	const handleSubmit: (orderDetails: OrderDetails) => void = (orderDetails) => {

	};
	return (
		<div className={theme}>
			<ShippingForm onSubmit={handleSubmit} />
		</div>
	);
};

このままでは、ProductPageが再レンダーされるたびに、handleSubmit新たな関数定義として扱われてしまいます。オブジェクトリテラルで、中身はまったく同じでも、参照が異なればJavaScriptからは別物とみなされるのと同じです。コンポーネントのプロパティ(props)として渡される関数も、中身が変わらなくても、同じとは評価されません。

そこで、必要なのがuseCallbackです。フックで包めば、依存配列が変わらないかぎり、つねに同じ関数が返されます

src/ProductPage.tsx
export const ProductPage: FC<Props> = ({ productId, referrer, theme }) => {
	// const handleSubmit: (orderDetails: OrderDetails) => void = (orderDetails) => {
	const handleSubmit: (orderDetails: OrderDetails) => void = useCallback(
		(orderDetails) => {

		},
		[productId, referrer]
	);

};

この変更を加えたサンプル002は、チェックボックス(theme)の反応が速くなったでしょう。この例は、useCallbackでラップした関数を、memoに包んだコンポーネントへ渡す手法のご紹介でした。ただ、とくに理由がなければuseCallbackは使わなくて構いません。

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

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

useCallbackuseMemo

useCallbackと似た機能を果たすフックにuseMemoがあります。どちらも、子コンポーネントを最適化するために有用です。両フックともに、プロパティとして渡す値をメモ化できます。

ProductPage.tsx
import { useMemo, useCallback } from 'react';

export const ProductPage: FC<Props> = ({ productId, referrer, theme }) => {
	const product = useData('/product/' + productId);

	const requirements = useMemo(() => { // 関数を呼び出して結果はキャッシュする
		return computeRequirements(product);
	}, [product]);

	const handleSubmit: (orderDetails: OrderDetails) => void = useCallback(
		(orderDetails) => {

		},
		[productId, referrer]
	);

	return (
		<div className={theme}>
			<ShippingForm requirements={requirements} onSubmit={handleSubmit} />
		</div>
	);
};

ふたつのフックの違いは、キャッシュする中身です。

  • useMemo: 引数の関数を呼び出した戻り値がキャッシュされます
    • 上記コード例では、依存配列の値productが変わらないかぎり、computeRequirements(product)は、再呼び出しされません。前にキャッシュした戻り値が用いられるのです。
    • requirementsプロパティを渡された子コンポーネントShippingFormは、無駄に再レンダーされません。
    • 依存値productが変わったとき、Reactはレンダー中に引数の関数を再呼び出して返します。
  • useCallback: キャッシュするのは引数の関数そのものです。関数は呼び出しません。
    • 上記コード例では、依存配列の値productIdまたはreferrerが変わらないかぎり、関数handleSubmitは、キャッシュした定義が用いられます。
    • onSubmitのコールバックとしてhandleSubmitを渡された子コンポーネントShippingFormは、無駄に再レンダーされません。
    • 関数handleSubmitが呼び出されるのは、子コンポーネントShippingFormの[Submit]ボタンを押したとき(onSubmitイベント)です。

すでにuseMemoフックをご存じであれば、useCallbackはつぎのように捉えればよいでしょう(「関数をメモ化する」参照)。なお、TypeScriptによる型づけは省きました(興味のある方は「useCallback」をお読みください)。

// 簡略化したReact内部の実装
function useCallback(fn, dependencies) {
	return useMemo(() => fn, dependencies);
}

useCallbackはどこに使うべきか

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

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

useCallbackで関数をキャッシュすることが役立つ場合

useCallbackで関数をキャッシュすることが役立つのは、具体的にはつぎのふたつの例でしょう。

  • memoで包まれた子コンポーネントに関数をプロパティ(props)として渡す場合。
    • useCallbackの依存値が変わらないかぎり、子コンポーネントは再レンダーされない。
    • 依存値が変わったときだけ、レンダリングされる。
  • 関数が他のフックの依存値に用いられている場合。
    • 他のuseCallbackでラップされた関数が依存している。
    • useEffectの依存配列に関数が含まれる。

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

useCallbackは、関数をつくらないわけではありません。関数はつねに定義されます(それは問題とはなりません)。ただし、前回のレンダーと変わりなければ、Reactにより新たな定義は無視され、キャッシュされた関数が返されるのです。

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

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

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

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

関数にuseCallbackを用いるかどうかによる違い

前掲サンプル001はuseCallbackなし、サンプル002にはuseCallbackを用いました。どちらも、コンポーネントShippingFormは、memoで包んでいます。また、ShippingFormの描画には、あえて負荷をかけました。ふたつのサンプルの違いを改めて確かめましょう。

useCallbackで子コンポーネントの無駄な再レンダーを省いた場合

サンプル002では、親コンポーネント(App.tsx)のチェックボックス([Dark mode])で切り替えるプロパティthemeの画面への反映は速やかです。プロパティを渡されたコンポーネントProductPagehandleSubmitには、useCallbackが用いられており、themeの値には依存しません。したがって、子コンポーネントShippingFormに与えるプロパティhandleSubmitは変わらず、再レンダーされないからです。memoによるShippingFormのラップが有効に働きました

他方で、カウンターの増減は反応が遅いです。カウンターの値(count)はShippingFormコンポーネントの状態なので、再レンダーせざるを得ません。このコード例では予期された動きです。

関数にuseCallbackを使わない場合

サンプル001でも、カウンターの増減は反応が遅いです(理由はサンプル002と同じ)。さらに、チェックボックス([Dark mode])で切り替えるプロパティthemeの画面への反映まで遅延します。useCallbackを使わない関数handleSubmitは、処理がまったく変わらなくても、つねに新たな関数として定義されるからです。ShippingFormコンポーネントは、そのたびに再レンダーされます。

ここで、ShippingFormコンポーネントの意図的な遅延を除いてみましょう。反応の遅れは気にするほどのものですか。インタラクションの速さが問題にならないかぎり、あえてメモ化するには及びません。

src/ShippingForm.tsx
export const ShippingForm: FC<Props> = ({ onSubmit }) => {

	/* let startTime = performance.now();
	while (performance.now() - startTime < 500) {
		// 意図的にコードを遅くするため500ミリ秒何もしない
	} */

};

なお、アプリケーションの遅れを根本的に解決するには、Reactは本番モードで実行すべきです。React Developer Toolsも無効にし、想定するユーザーの実機で確かめなければなりません。

メモ化されたコールバックから状態を更新する

メモ化されたコールバックから、直前の状態を更新したい場合があり得ます。たとえば、つぎのコード例のhandleAddTodo関数です。新たなTodoリストをつくるため、状態変数todosに依存します。

const TodoList = () => {
	const [todos, setTodos] = useState<Todo[]>([]);

	const handleAddTodo = useCallback((text: string) => {
		const newTodo = { id: nextId++, text };
		setTodos([...todos, newTodo]);
	}, [todos]);

};

メモ化した関数の依存値は、できるかぎり減らしたいところです。直近の状態から新たな状態を単純に定める場合、状態設定関数(setTodos)には更新用関数が渡せます。関数が引数に受け取るのは、現在の状態値です。これで、useCallbackから状態変数todosへの依存が除けます。

const TodoList = () => {
	const [todos, setTodos] = useState<Todo[]>([]);

	const handleAddTodo = useCallback((text: string) => {
		const newTodo = { id: nextId++, text };
		// setTodos([...todos, newTodo]);
		setTodos((todos) => [...todos, newTodo]);
	// }, [todos]);
	}, []); // ✅ todosへの依存は不要

};

エフェクトが無駄に実行されるのを防ぐ

エフェクトの中から関数を呼び出したいこともあるでしょう。けれど、つぎのコードは問題です。すべてのリアクティブな値は、依存配列に含めなければなりませんcreateOptionsを依存値として宣言すると、エフェクトがつねにチャットルームに再接続してしまうからです。

const ChatRoom: FC<Props> = ({ roomId }) => {

	const createOptions = () => {
		return {
			serverUrl: 'https://localhost:1234',
			roomId
		};
	}

	useEffect(() => {
		const options = createOptions();
		const connection = createConnection();
		connection.connect();
		return () => connection.disconnect();
	}, [createOptions]); // 🔴 NG: 依存がレンダーのたびに変わる

};

これを解決するには、エフェクトから呼び出さなければならない関数はuseCallbackで包んでしまえばよいでしょう。useCallbackの依存値roomIdが変わらないかぎり、再レンダー間で関数createOptionsは同じだと保証されるからです。

const ChatRoom: FC<Props> = ({ roomId }) => {

	// const createOptions = () => {
	const createOptions = useCallback(() => {
		return {
			serverUrl: 'https://localhost:1234',
			roomId: roomId
		};
	// }
	}, [roomId]); // ✅ OK: 依存値roomIdが変わると更新される

	useEffect(() => {
		const options = createOptions();

	}, [createOptions]); // ✅ OK: メモ化されたcreateOptionsが変わったときのみ更新される

};

もっとも、関数型の依存値を除ければさらに望ましいでしょう。このコード例の場合、関数createOptionsはエフェクトの中に移せます。useCallbackは使わずに済み、コードもシンプルになりました(「リアクティブなオブジェクトや関数をエフェクトの中に含める」参照)。

const ChatRoom: FC<Props> = ({ roomId }) => {

	useEffect(() => {
 		const createOptions = () => { // ✅ OK: useCallbackは使わない
			return {
				serverUrl: 'https://localhost:1234',
				roomId: roomId
		};

		const options = createOptions();

	}, [roomId]); // ✅ OK: roomIdが変わったときのみ更新される

};

カスタムフックを最適化する

カスタムフックを書く場合、返す関数はすべてuseCallbackでラップしてください。利用する開発者が、必要に応じてコードを最適化できるからです。

const useRouter = () => {
	const { dispatch } = useContext(RouterStateContext);
	const navigate = useCallback((url: string) => {
		dispatch({ type: 'navigate', url });
	}, [dispatch]);
	const goBack = useCallback(() => {
		dispatch({ type: 'back' });
	}, [dispatch]);
	return {
		navigate,
		goBack,
	};
};

トラブルへの対応

コンポーネントがレンダーされるたびにuseCallbackから返される関数が異なる

コンポーネントがレンダーされるたびにuseCallbackから返される関数が異なるという場合は、まず第2引数の依存配列を忘れていないか確かめましょう。第2引数がなければ、useCallbackの戻り値となる関数はレンダーのたびに新たになります。

const ProductPage: FC<Props> = ({ productId, referrer }) => {

	const handleSubmit = useCallback((orderDetails) => {
		post('/product/' + productId + '/buy', {
			referrer,
			orderDetails,
		});
	}); // 🔴 NG: 依存配列が与えられないとつねに新たな関数が返される

};

useCallbackに、依存配列は正しく与えなければなりません。

const ProductPage: FC<Props> = ({ productId, referrer }) => {

	const handleSubmit = useCallback((orderDetails) => {
		post('/product/' + productId + '/buy', {
			referrer,
			orderDetails,
		});
	// });
	}, [productId, referrer]); // ✅ OK: 依存が変わらないかぎり戻り値の関数は同じ

};

それでもレンダーのたびにuseCallbackから異なる関数が返されるなら、依存値の少なくともひとつは毎回変わっているということです。その値をつきとめなければなりません。コンポーネントから依存値をconsole.logでコンソールに出力しましょう。

const ProductPage: FC<Props> = ({ productId, referrer }) => {

	const handleSubmit = useCallback((orderDetails) => {

	}, [productId, referrer]);
	console.log([productId, referrer]);

};

異なる再レンダーからコンソールにログ出力された値を右クリックすると、[グローバル変数として保存]が選べます。最初の値がtemp1、2番目はtemp2といった具合です。すると、ブラウザコンソールでそれらの値をObject.isで同じかどうか確かめられます。

// 2回のレンダー間で配列要素が一致しているかを確かめる
Object.is(temp1[0], temp2[0]);
Object.is(temp1[1], temp2[1]);
Object.is(temp1[2], temp2[2]);

メモ化を損なう依存値があったら、除いてください。あるいは、その値をメモ化しましょう

ループ内のリスト項目からuseCallbackは呼び出せない

リスト項目からループ処理で、複数の子コンポーネントをつくる場合の問題です。以下のReportListは、配列(items)からmapで子コンポーネントChartを生成しています。Chartは、memoで包まれている想定です。子にプロパティ(onClick)として渡している関数handleClickはメモ化して、すべての子コンポーネントが無駄に再レンダーされるのを避けたい場合もあるでしょう。

けれど、ループ処理の中でuseCallbackは呼び出せません。フックが使えるのはReact関数のトップレベルのみだからです。

const ReportList: FC<Props> = ({ items }) => {
	return (
		<article>
			{items.map((item) => {
				// 🔴 NG: ループの中からuseCallbackは呼び出せない
				const handleClick = useCallback(() => {
					sendReport(item)
				}, [item]);
				return (
					<figure key={item.id}>
						<Chart onClick={handleClick} />
					</figure>
				);
			})}
		</article>
	);
};

この場合、useCallbackをトップレベルで呼び出せるよう、JSXとともに別コンポーネント(Report)に切り出せばよいでしょう。

const ReportList: FC<Props> = ({ items }) => {
	return (
		<article>
			{items.map((item) =>
				<Report key={item.id} item={item} />
			)}
		</article>
	);
};

const Report: FC<Props> = ({ item }) => {
	// ✅ OK: useCallbackはトップレベルで呼び出す
	const handleClick = useCallback(() => {
		sendReport(item)
	}, [item]);
	return (
		<figure>
			<Chart onClick={handleClick} />
		</figure>
	);
};

あるいは、今回のコード例でしたら、切り分けた子コンポーネント(Report)をmemoでラップしても構いません。渡されたプロパティitemが変わらなければ、子のChartコンポーネントも含めて再レンダーが省かれます。関数handleClickも、itemに依存させるuseCallbackで包む必要がありません。

const Report: FC<Props> = memo(({ item }) => {
	const handleClick = () => {
		sendReport(item);
	}
	return (
		<figure>
			<Chart onClick={handleClick} />
		</figure>
	);
});
1
3
1

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
1
3