0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Qiita Advent Calender 2025企画】React Hooks を Hackしよう!【Part12: useCallbackをふかぼろう!】

Posted at

useCallback再レンダー間で関数定義をキャッシュするための React フックです。子コンポーネントへ渡すコールバック関数を最適化し、不要な再レンダーを防ぐために使用されます。

const cachedFn = useCallback(fn, dependencies)

💡 React Compiler について
React Compiler は値や関数の自動的なメモ化を行うことで、手作業による useCallback 呼び出しの必要性を軽減します。将来的には、コンパイラによる自動メモ化が主流になる可能性があります。

1. なぜ useCallback が必要か

1.1 再レンダーと関数の再生成問題

React では、再レンダーが発生するたびにコンポーネント関数全体が再実行されます。これは関数定義も例外ではありません。

function ProductPage({ productId, referrer, theme }) {
  // ❌ 毎回のレンダーで新しい関数が生成される
  function handleSubmit(orderDetails) {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }

  return (
    <div className={theme}>
      {/* ShippingForm には毎回違う関数が渡される */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

問題: JavaScript では function() {}() => {} は常に異なる関数オブジェクトを生成します。これは {} が常に新しいオブジェクトを生成するのと同じです。

// この2つは常に false
console.log(() => {} === () => {});  // false
console.log({} === {});               // false

1.2 memo との組み合わせでの問題

React.memo でラップしたコンポーネントに関数を渡す場合、この問題は致命的になります。

import { memo } from 'react';

// memo でラップして再レンダーを最適化
const ShippingForm = memo(function ShippingForm({ onSubmit }) {
  console.log('ShippingForm がレンダーされました');
  // ...
});

function ProductPage({ productId, referrer, theme }) {
  // ❌ theme が変わるたびに handleSubmit は新しい関数
  function handleSubmit(orderDetails) { /* ... */ }

  return (
    <div className={theme}>
      {/* memo の最適化が効かない! */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

theme が変わっただけなのに、ShippingForm は毎回再レンダーされてしまいます。

1.3 useCallback が解決すること

useCallback を使うと、依存値が変化しない限り、同じ関数参照を保持できます:

function ProductPage({ productId, referrer, theme }) {
  // ✅ productId か referrer が変わった時だけ新しい関数を生成
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

  return (
    <div className={theme}>
      {/* theme が変わっても handleSubmit は同じ → 再レンダーをスキップ */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

1.4 useCallback の API

const cachedFn = useCallback(fn, dependencies)
引数 説明
fn キャッシュしたい関数。任意の引数を取り、任意の値を返せる
dependencies fn 内で参照されるすべてのリアクティブ値の配列

返り値:

  • 初回レンダー: 渡された fn 関数そのもの
  • 次回以降: 依存値が変化していなければキャッシュされた関数、変化していれば新しい関数

💡 重要: React は関数を呼び出しません。関数を返すだけです。呼ぶか呼ばないか、いつ呼ぶのかはあなたが決定できます。

1.5 似ているフックとの比較

useCallback と混同しやすいフックがいくつかあります。それぞれの違いを理解しましょう。

useCallback vs useMemo

項目 useCallback useMemo
目的 関数をメモ化 をメモ化
返り値 関数 計算結果(任意の型)
使用場面 子コンポーネントに渡すコールバック 高コストな計算結果のキャッシュ
// useCallback: 関数自体をキャッシュ
const handleClick = useCallback(() => {
  console.log(items);
}, [items]);

// useMemo: 計算結果をキャッシュ
const sortedItems = useMemo(() => items.sort(), [items]);

// 💡 実は useCallback は useMemo の糖衣構文!
// useCallback(fn, deps) === useMemo(() => fn, deps)

内部的な等価性:

// この2つは完全に同じ
const handleClick = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

const handleClick = useMemo(() => {
  return () => {
    doSomething(a, b);
  };
}, [a, b]);

useCallback vs 通常の関数定義

項目 useCallback 通常の関数定義
参照の安定性 依存値が同じなら同じ参照 毎回新しい参照
メモリ キャッシュ用の追加メモリ 最小限
使うべき時 memo との組み合わせ、エフェクトの依存値 それ以外
// ❌ 毎回新しい関数(memo が効かない)
function Parent() {
  const handleClick = () => console.log('clicked');
  return <MemoizedChild onClick={handleClick} />;
}

// ✅ 同じ関数を再利用(memo が効く)
function Parent() {
  const handleClick = useCallback(() => console.log('clicked'), []);
  return <MemoizedChild onClick={handleClick} />;
}

useCallback vs useRef

項目 useCallback useRef
目的 関数のメモ化 値の永続化
更新トリガー 依存値の変化 なし(手動で更新)
再レンダー 発生しない 発生しない
// useCallback: 依存値が変わると新しい関数
const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

// useRef: 常に最新の値を参照可能(関数は再生成されない)
const countRef = useRef(count);
countRef.current = count;
const handleClick = useCallback(() => {
  console.log(countRef.current);
}, []); // 依存配列が空!

1.6 重要な注意点

ルール 説明
パフォーマンス最適化のみ useCallback を外してもコードが動作すべき
トップレベルでのみ呼び出し ループや条件分岐内では使用不可
⚠️ キャッシュは破棄されうる 特定の状況でReactがキャッシュを破棄することがある
⚠️ すべての関数に使う必要はない memo と組み合わせない場合は不要
function Component({ items }) {
  // ❌ ループ内で呼び出せない
  items.map(item => {
    const handleClick = useCallback(() => select(item), [item]); // エラー!
  });
  
  // ✅ コンポーネントを分離する
  return items.map(item => <Item key={item.id} item={item} />);
}

function Item({ item }) {
  const handleClick = useCallback(() => select(item), [item]);
  return <button onClick={handleClick}>{item.name}</button>;
}

2. useCallback の内部構造を徹底解剖

2.0 全体像: useCallback の処理フロー

🎣 useCallback(フック呼び出し)
   ↓
📝 初回: mountCallback / 更新: updateCallback
   ↓
📋 Hook オブジェクトに [関数, 依存配列] を保存
   ↓
🔍 更新時: areHookInputsEqual で依存配列を比較
   ↓
✅ 同じなら: キャッシュされた関数を返す
   ↓
🔄 違うなら: 新しい関数をキャッシュして返す

2.1 エントリポイント: packages/react/src/ReactHooks.js

// packages/react/src/ReactHooks.js

export function useCallback<T>(
  callback: T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useCallback(callback, deps);
}

useCallback が呼ばれると、現在のディスパッチャの useCallback メソッドが呼び出されます。ディスパッチャはレンダリングのフェーズ(マウント時 or 更新時)によって異なる実装を持ちます。

2.2 コア実装: mountCallbackupdateCallback

初回レンダー時の処理 (mountCallback)

// packages/react-reconciler/src/ReactFiberHooks.js

function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L2896-L2901

ポイント:

  1. mountWorkInProgressHook() で新しい Hook オブジェクトを作成
  2. 関数は呼び出されないuseMemo との大きな違い!)
  3. [関数, 依存配列] のタプルを memoizedState に保存
  4. 関数をそのまま返す

更新時の処理 (updateCallback)

// packages/react-reconciler/src/ReactFiberHooks.js

function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];  // ← キャッシュされた関数を返す
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;  // ← 新しい関数を返す
}

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L2903-L2914

ポイント:

  1. updateWorkInProgressHook() で既存の Hook オブジェクトを取得
  2. areHookInputsEqual で前回の依存配列と比較
  3. 依存配列が同じ場合: prevState[0](キャッシュされた関数)を返す
  4. 依存配列が異なる場合: 新しい関数をキャッシュして返す

2.3 useMemo との比較: 内部実装の違い

useCallbackuseMemo の内部実装を比較すると、重要な違いが見えてきます。

// mountCallback: 関数を呼び出さない
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];  // 関数をそのまま保存
  return callback;                             // 関数をそのまま返す
}

// mountMemo: 関数を呼び出す
function mountMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();  // ← 関数を呼び出す!
  hook.memoizedState = [nextValue, nextDeps];  // 結果を保存
  return nextValue;                             // 結果を返す
}
項目 mountCallback mountMemo
保存するもの 関数自体 関数の実行結果
関数の呼び出し なし あり
Strict Mode での二重呼び出し なし あり

2.4 依存配列の比較: areHookInputsEqual

// packages/react-reconciler/src/ReactFiberHooks.js

function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
): boolean {
  if (prevDeps === null) {
    return false;
  }

  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (is(nextDeps[i], prevDeps[i])) {  // Object.is で比較
      continue;
    }
    return false;
  }
  return true;
}

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L454-L500

ポイント:

  • 各依存値を Object.is で比較
  • 1つでも異なれば false を返し、新しい関数がキャッシュされる
  • 開発モードでは配列長の変化を警告

2.5 Hook オブジェクトの構造

// packages/react-reconciler/src/ReactFiberHooks.js

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,  // useCallback では [関数, 依存配列]
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,           // 次の Hook へのリンク
  };

  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

Hook のリンクリスト構造:

Fiber.memoizedState
       ↓
    Hook1 → Hook2 → Hook3 → null
    (useState) (useCallback) (useEffect)

2.6 内部構造のまとめ

3. ユースケース

3.1 memo でラップしたコンポーネントへの関数渡し

最も一般的なユースケースです。

import { memo, useCallback, useState } from 'react';

// 重いコンポーネントを memo でラップ
const ExpensiveList = memo(function ExpensiveList({ 
  items, 
  onItemClick 
}: {
  items: Item[];
  onItemClick: (id: string) => void;
}) {
  console.log('ExpensiveList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onItemClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});

function App() {
  const [items] = useState([/* ... */]);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [theme, setTheme] = useState('light');

  // ✅ useCallback で関数をメモ化
  const handleItemClick = useCallback((id: string) => {
    setSelectedId(id);
  }, []); // setSelectedId は安定しているので依存配列に不要

  return (
    <div className={theme}>
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        Toggle Theme
      </button>
      {/* theme が変わっても ExpensiveList は再レンダーされない */}
      <ExpensiveList items={items} onItemClick={handleItemClick} />
    </div>
  );
}

3.2 エフェクトの依存値として関数を使う

useEffect の依存配列に関数を含める場合、useCallback が必要になります。

function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);

  // ❌ useCallback なし → エフェクトが毎回実行される
  // function createConnection() {
  //   return new WebSocket(`wss://chat.example.com/${roomId}`);
  // }

  // ✅ useCallback で関数をメモ化
  const createConnection = useCallback(() => {
    return new WebSocket(`wss://chat.example.com/${roomId}`);
  }, [roomId]);

  useEffect(() => {
    const connection = createConnection();
    connection.onmessage = (event) => {
      setMessages(prev => [...prev, JSON.parse(event.data)]);
    };
    return () => connection.close();
  }, [createConnection]); // roomId が変わった時だけ再接続

  return <MessageList messages={messages} />;
}

より良い解決策: 関数をエフェクト内に移動することで useCallback を不要にできます。

useEffect(() => {
  // ✅ エフェクト内で関数を定義
  const connection = new WebSocket(`wss://chat.example.com/${roomId}`);
  // ...
}, [roomId]);

3.3 state 更新関数を使った依存値の削減

メモ化したコールバックから state を更新する際、更新関数を使うと依存値を減らせます。

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

  // ❌ todos が依存値に含まれる
  // const handleAddTodo = useCallback((text: string) => {
  //   setTodos([...todos, { id: Date.now(), text }]);
  // }, [todos]);

  // ✅ 更新関数を使って依存値を削減
  const handleAddTodo = useCallback((text: string) => {
    setTodos(prevTodos => [...prevTodos, { id: Date.now(), text }]);
  }, []); // 依存配列が空に!

  return (
    <>
      <AddTodoForm onAdd={handleAddTodo} />
      <ul>
        {todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
      </ul>
    </>
  );
}

3.4 カスタムフックでの関数メモ化

カスタムフックを作成する際は、返す関数を useCallback でラップすることが推奨されます。

function useRouter() {
  const { dispatch } = useContext(RouterContext);

  // ✅ カスタムフックから返す関数はメモ化
  const navigate = useCallback((url: string) => {
    dispatch({ type: 'navigate', url });
  }, [dispatch]);

  const goBack = useCallback(() => {
    dispatch({ type: 'back' });
  }, [dispatch]);

  return { navigate, goBack };
}

// 使用側
function NavButton() {
  const { navigate } = useRouter();
  
  // navigate は安定した参照を持つ
  return <button onClick={() => navigate('/home')}>Home</button>;
}

3.5 イベントハンドラの最適化

フォームの入力ハンドラなど、頻繁に呼び出される関数の最適化。

function SearchForm({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = useState('');

  // ✅ onSearch が変わらない限り、同じ関数を使用
  const handleSubmit = useCallback((e: React.FormEvent) => {
    e.preventDefault();
    onSearch(query);
  }, [query, onSearch]);

  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={query} 
        onChange={e => setQuery(e.target.value)} 
      />
      <button type="submit">Search</button>
    </form>
  );
}

4. アンチパターンと正しい使い方

4.1 不要な useCallback

memo と組み合わせない場合、useCallback は不要です。

// ❌ 意味がない(memo されていないコンポーネントに渡している)
function Parent() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);
  
  return <Child onClick={handleClick} />; // Child は memo されていない
}

// ✅ シンプルに関数を定義
function Parent() {
  const handleClick = () => {
    console.log('clicked');
  };
  
  return <Child onClick={handleClick} />;
}

4.2 依存配列の間違い

// ❌ 依存値が不足
const handleClick = useCallback(() => {
  console.log(count); // count が古い値のまま!
}, []);

// ✅ 依存値を正しく指定
const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

4.3 オブジェクトや配列を依存値に含める

// ❌ options は毎回新しいオブジェクト
function Component({ options }: { options: Options }) {
  const handleClick = useCallback(() => {
    doSomething(options);
  }, [options]); // 毎回新しい関数に!
}

// ✅ 必要なプロパティだけを依存値に
function Component({ options }: { options: Options }) {
  const { mode, timeout } = options;
  const handleClick = useCallback(() => {
    doSomething({ mode, timeout });
  }, [mode, timeout]);
}

5. トラブルシューティング

5.1 useCallback が毎回異なる関数を返す

原因: 依存配列が指定されていない、または依存値が毎回変化している

// ❌ 依存配列なし
const handleClick = useCallback(() => {
  doSomething();
}); // 毎回新しい関数!

// ✅ 依存配列を指定
const handleClick = useCallback(() => {
  doSomething();
}, []);

デバッグ方法:

const handleClick = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

console.log([a, b]); // 依存値をログ出力して変化を確認

5.2 ループ内で useCallback を使いたい

// ❌ ループ内で呼び出せない
function Parent({ items }) {
  return (
    <>
      {items.map(item => {
        const handleClick = useCallback(() => { /* ... */ }, [item]); // エラー!
        return <Child key={item.id} onClick={handleClick} />;
      })}
    </>
  );
}

// ✅ コンポーネントを分離
function Parent({ items }) {
  return (
    <>
      {items.map(item => (
        <ItemWrapper key={item.id} item={item} />
      ))}
    </>
  );
}

function ItemWrapper({ item }) {
  const handleClick = useCallback(() => { /* ... */ }, [item]);
  return <Child onClick={handleClick} />;
}

6. まとめ

useCallback を使うべき場面

場面 説明
memo との組み合わせ 子コンポーネントの再レンダーを防ぐ
useEffect の依存値 エフェクトの不要な再実行を防ぐ
カスタムフックの返り値 利用側での最適化を可能にする

useCallback を使わなくてよい場面

場面 説明
memo なしのコンポーネント 効果がない
単純なイベントハンドラ オーバーヘッドが見合わない
計算コストの低い処理 最適化の必要がない

ポイント

  1. useCallback(fn, deps)useMemo(() => fn, deps) と等価
  2. 関数は呼び出されない、返されるだけ
  3. パフォーマンス最適化のみに使用
  4. memo と組み合わせて初めて効果を発揮
// 💡 最終的な使い方のベストプラクティス
const MemoizedChild = memo(Child);

function Parent() {
  const [count, setCount] = useState(0);
  
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []);
  
  return <MemoizedChild onClick={handleClick} />;
}
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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?