ReactとTypeScriptでコールバック関数を理解しよう
みなさんこんにちは。
最近はTypeScriptとReactを使ったフロントエンドのコードを書くことが増えてきました。まだまだ初学者なのですが、何かを調べていると「コールバック関数」という言葉をよく目にします。意味が曖昧なままだったので、この機会に整理して覚えようと思います。
最初は、async/await
やuseMemo
、useCallback
を使う場面でよく耳にする印象でした。
コールバック関数とは
コールバック関数は、引数として他の関数に渡され、その関数の中で必要に応じて呼び出される関数です。呼び出されるタイミングは関数の処理の途中や後、あるいは非同期イベントが発生した時など様々です。
MDN Web Docs 用語集より
MDN Web Docs用語集にはこのように書いてあります。正直イメージがわかず、自分がその書き方で何かコードを書いたかとなると怪しい、と思う人もいるでしょう。一度サンプルコードで考えてみます。
// callbackは string を受け取って、何も返さない関数であると宣言
function callName(callback: (name: string) => void): void {
callback("たろう");
}
// 名前を受け取って挨拶する関数
function sayHello(name: string): void {
console.log(`こんにちは、${name}さん!`);
}
// "こんにちは、たろうさん!"
callName(sayHello);
まずcallName
関数がコールバック関数を受け取る関数で、その後にsayHello
関数というコールバック関数の引数に当てはめる関数を作り、最後に渡してアクションを起こします。
実行されると、こんな感じになるとイメージできます。
// コールバック関数を使わず、処理を一つの関数にまとめた例
function callNameAndSayHello(): void {
const name = "たろう";
console.log(`こんにちは、${name}さん!`);
}
// "こんにちは、たろうさん!"
callNameAndSayHello();
このように書き換えると、より分かりやすいですね。コールバック関数とはこういうものだと理解できたと思います。
おそらく普段みなさんが書いている関数の使い方は関数呼び出しやインポートして使っているのではないでしょうか。
関数呼び出し
function sayHello(name: string): void {
console.log(`こんにちは、${name}さん!`);
}
sayHello("たろう");
インポート
// hello.ts
export function sayHello(name: string): void {
console.log(`こんにちは、${name}さん!`);
}
import { sayHello } from "./hello";
sayHello("たろう");
この使い方はよく見ると思いますが、コールバック関数も実はみなさんが思っているより多く使われています。
useStateのコールバック関数
import { useState } from "react";
const [count, setCount] = useState(0);
// 通常の値渡し
setCount(count + 1);
// コールバック関数(状態更新関数)
setCount(prevCount => prevCount + 1);
上は単純な値渡しですが、下のように書くと複数回呼び出したときでも正しく加算されます。
この下の例もコールバック関数の一種で、Reactでは「状態更新関数(updater function)」と呼ばれます。
通常の関数として定義すると、次のようになります。
function plusOne(prevCount: number): number {
return prevCount + 1;
}
setCount(plusOne);
このように書くと、Reactの状態更新がバッチ処理され、最新の状態を元に正しく更新できることが伝わりやすいと思います。
useCallback
import { useCallback } from "react";
const handleClick = useCallback((): void => {
// 何かの処理
}, [/* 依存配列 */]);
Reactの関数コンポーネントは再レンダリングされるたびに関数内の値や関数も新しく作り直されます。
function App() {
const handleClick = () => { /* ... */ };
return <button onClick={handleClick}>クリック</button>;
}
この時、propsとして渡された関数も再レンダリングごとに新しくなってしまいます。
useCallback
は「依存配列の値が変わらない限り、前回と同じ関数オブジェクトを返す」ことで、関数の再生成を防ぎます。これにより、子コンポーネントの無駄な再レンダリングを減らす効果があります。
ただし、useCallback
自体は関数のメモ化を行うだけで、子コンポーネントの再レンダリングを防ぐには、React.memo
などと組み合わせる必要があります。
また、過度に使うと逆にパフォーマンスが悪化する場合もあるため、必要な場面で使うことが推奨されます。
useMemo
useMemo
は「関数を使って値を計算し、その結果をメモ化する」ためのフックです。useCallback
との違いは、メモ化される対象が関数オブジェクトではなく“関数の返り値(値)”である点です。
useCallback
- 子コンポーネントに関数propsを渡す場合(
React.memo
と組み合わせて無駄な再レンダリングを防ぐため) - カスタムフックで関数を返したい場合
useMemo
- 計算コストの高い処理の結果を再利用したい場合
- オブジェクトや配列の参照を維持したい場合(propsで渡すときなど)
async/await
async/await
はPromiseのチェーン(.then()
など)のコールバック関数の使用を、より直線的で読みやすいコードに書き換えるための糖衣構文(シンタックスシュガー)です。
Promiseの場合
function fetchData(): Promise<string> {
// 1秒後にデータ取得が終わるイメージ
return new Promise((resolve) => {
setTimeout(() => {
resolve("データ取得完了!");
}, 1000);
});
}
// thenのコールバック関数で結果を受け取る
fetchData().then(function(result: string) {
console.log(result); // "データ取得完了!"
});
async/awaitの場合
async function main(): Promise<void> {
const result = await fetchData();
console.log(result); // "データ取得完了!"
}
main();
ちなみに、非同期処理が入れ子になってどんどん右にズレていくパターンは「コールバック地獄」と呼ばれ、コードの可読性が著しく下がります。こうならないように気をつけましょう。
// 1秒ごとに0→1→2→3と出力したい(コールバック地獄の例)
console.log(0);
setTimeout(function() {
console.log(1);
setTimeout(function() {
console.log(2);
setTimeout(function() {
console.log(3);
// さらに続くと...
}, 1000);
}, 1000);
}, 1000);
同じ処理をasync/await
で書くと、次のように直線的で読みやすくなります。
function wait(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function main(): Promise<void> {
console.log(0);
await wait(1000);
console.log(1);
await wait(1000);
console.log(2);
await wait(1000);
console.log(3);
}
main();
おわり
少し脱線しましたが、ここまで読めばコールバック関数がいかに身近なもので、多くの場面で使われているかがわかったのではないでしょうか。
ちなみに、配列のmapやreduceなどでもコールバック関数は頻繁に使われています。この記事をきっかけに、ぜひ身の回りのコードの中からも探してみてください。
Discussion