React useCallbackの実務的な使い方|パフォーマンス最適化とメモ化戦略

React / Next.js

React useCallbackの実務的な使い方|パフォーマンス最適化とメモ化戦略

useCallbackとは|簡易的な解説

ReactのuseCallbackは、関数をメモ化(保存)するHooksです。毎回レンダリングのたびに新しい関数を生成するのではなく、依存配列が変わるまで同じ関数参照を保持します。

実務では「なぜ必要なのか」という視点が重要です。JavaScriptでは関数もオブジェクトなので、毎回新しく生成されると参照が異なります。これが子コンポーネントへの不要な再レンダリングを引き起こすため、パフォーマンス最適化が必要になるわけです。

// useCallbackなしの問題パターン
function Parent() {
  const [count, setCount] = useState(0);
  
  // このhandleClickは毎回新しく生成される
  const handleClick = () => {
    console.log('clicked');
  };
  
  return <Child onClickHandler={handleClick} />;
}

// useCallbackで解決
function ParentFixed() {
  const [count, setCount] = useState(0);
  
  // 依存配列が変わるまで同じ関数参照を保持
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []); // 空の依存配列 = マウント時のみ生成
  
  return <Child onClickHandler={handleClick} />;
}

業務でのユースケース|実務で本当に必要な場面

useCallbackが活躍するシーンは限定的です。むやみに使うとコード量が増えて可読性が低下するため、本当に必要な場面を見極めることが重要です。

ユースケース1:フォーム入力の検索API呼び出し

ユーザーの入力を監視して検索APIを呼び出すパターンは実務で頻出です。入力のたびに新しい関数が生成されると、useEffectが不要に発火して無駄なAPI呼び出しが増えます。

// 実務例:検索フォームのAPI呼び出し
interface SearchParams {
  keyword: string;
  category: string;
}

function SearchForm() {
  const [params, setParams] = useState<SearchParams>({
    keyword: '',
    category: 'all'
  });
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  // fetchSearchはAPIコールするための関数
  const fetchSearch = useCallback(async (searchParams: SearchParams) => {
    if (!searchParams.keyword.trim()) {
      setResults([]);
      return;
    }

    setLoading(true);
    try {
      const response = await fetch(
        `/api/search?keyword=${searchParams.keyword}&category=${searchParams.category}`
      );
      const data = await response.json();
      setResults(data);
    } catch (error) {
      console.error('Search failed:', error);
    } finally {
      setLoading(false);
    }
  }, []); // 依存配列は空 = 関数は常に同じ

  // useEffectはfetchSearchの参照が変わるまで再実行されない
  useEffect(() => {
    const timer = setTimeout(() => {
      fetchSearch(params);
    }, 300); // デバウンス処理

    return () => clearTimeout(timer);
  }, [params, fetchSearch]);

  return (
    <div>
      <input
        type=\"text\"
        value={params.keyword}
        onChange={(e) =>
          setParams({ ...params, keyword: e.target.value })
        }
        placeholder=\"検索キーワード\"
      />
      {loading && <p>検索中...</p>}
      <div>
        {results.map((result) => (
          <div key={result.id}>{result.title}</div>
        ))}
      </div>
    </div>
  );
}

ユースケース2:Redux dispatches の最適化

Redux を使う場合、dispatch 関数をコンポーネントプロップとして渡すシーンがあります。この場合 useCallback を使うと、不要な再レンダリングを防げます。

// Redux接続の実例
import { useDispatch, useSelector } from 'react-redux';
import { fetchUserData, updateUserProfile } from './userSlice';

function UserProfile() {
  const dispatch = useDispatch();
  const user = useSelector((state) => state.user);

  // dispatchをメモ化することで、子コンポーネントの不要な再レンダリング防止
  const handleFetchUser = useCallback(
    (userId: string) => {
      dispatch(fetchUserData(userId));
    },
    [dispatch]
  );

  const handleUpdateProfile = useCallback(
    (data: UserData) => {
      dispatch(updateUserProfile(data));
    },
    [dispatch]
  );

  return (
    <>
      <UserHeader user={user} onFetch={handleFetchUser} />
      <UserForm user={user} onUpdate={handleUpdateProfile} />
    </>
  );
}

ユースケース3:複雑なイベントハンドラの共有

複数の子コンポーネントで同じロジックが必要な場合、親で定義した関数をメモ化して渡します。

// 複数のダイアログが同じ削除ロジックを使うケース
interface DialogItem {
  id: string;
  name: string;
}

function ItemManager() {
  const [items, setItems] = useState<DialogItem[]>([]);
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());

  // 削除処理をメモ化
  const handleDelete = useCallback((id: string) => {
    setItems((prevItems) => prevItems.filter((item) => item.id !== id));
    setSelectedIds((prev) => {
      const newSet = new Set(prev);
      newSet.delete(id);
      return newSet;
    });
  }, []);

  // 一括削除もメモ化
  const handleBulkDelete = useCallback(() => {
    const idsToDelete = Array.from(selectedIds);
    setItems((prevItems) =>
      prevItems.filter((item) => !idsToDelete.includes(item.id))
    );
    setSelectedIds(new Set());
  }, [selectedIds]);

  return (
    <>
      <ItemList items={items} onDelete={handleDelete} />
      <BulkDeleteButton
        selectedCount={selectedIds.size}
        onBulkDelete={handleBulkDelete}
      />
    </>
  );
}

実装コード|実務で使える完全な例

ここでは、実際の業務でそのまま応用できる例を示します。

フルスタック実装:ユーザー管理画面

// types.ts
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: string;
}

interface UserFilters {
  role: string;
  searchKeyword: string;
  sortBy: 'name' | 'createdAt';
}

// UserManagement.tsx
import React, { useState, useCallback, useEffect } from 'react';

function UserManagement() {
  const [users, setUsers] = useState<User[]>([]);
  const [filters, setFilters] = useState<UserFilters>({
    role: 'all',
    searchKeyword: '',
    sortBy: 'name'
  });
  const [loading, setLoading] = useState(false);

  // APIからユーザー取得
  const loadUsers = useCallback(async (userFilters: UserFilters) => {
    setLoading(true);
    try {
      const queryParams = new URLSearchParams({
        role: userFilters.role !== 'all' ? userFilters.role : '',
        keyword: userFilters.searchKeyword,
        sort: userFilters.sortBy
      });

      const response = await fetch(`/api/users?${queryParams}`);
      const data = await response.json();
      setUsers(data);
    } catch (error) {
      console.error('Failed to load users:', error);
    } finally {
      setLoading(false);
    }
  }, []);

  // ユーザー削除
  const handleDeleteUser = useCallback((userId: string) => {
    if (!window.confirm('このユーザーを削除しますか?')) {
      return;
    }

    setUsers((prevUsers) =>
      prevUsers.filter((user) => user.id !== userId)
    );

    // サーバーに削除リクエスト(実務ではエラーハンドリング必須)
    fetch(`/api/users/${userId}`, { method: 'DELETE' }).catch(() => {
      // ロールバック処理
      loadUsers(filters);
    });
  }, [filters, loadUsers]);

  // ロール変更
  const handleChangeRole = useCallback(
    (userId: string, newRole: 'admin' | 'user') => {
      const originalUsers = users;
      setUsers((prevUsers) =>
        prevUsers.map((user) =>
          user.id === userId ? { ...user, role: newRole } : user
        )
      );

      fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ role: newRole })
      }).catch(() => {
        setUsers(originalUsers);
        alert('ロール変更に失敗しました');
      });
    },
    [users]
  );

  // フィルター変更時にデータを再取得
  useEffect(() => {
    loadUsers(filters);
  }, [filters, loadUsers]);

  return (
    <div className=\"user-management\">
      <h1>ユーザー管理</h1>

      <UserFilters
        filters={filters}
        onFilterChange={setFilters}
      />

      {loading ? (
        <p>読込中...</p>
      ) : (
        <UserTable
          users={users}
          onDelete={handleDeleteUser}
          onChangeRole={handleChangeRole}
        />
      )}
    </div>
  );
}

// UserTable.tsx - メモ化されたコンポーネント
interface UserTableProps {
  users: User[];
  onDelete: (userId: string) => void;
  onChangeRole: (userId: string, role: 'admin' | 'user') => void;
}

const UserTable = React.memo(function UserTable({
  users,
  onDelete,
  onChangeRole
}: UserTableProps) {
  return (
    <table>
      <thead>
        <tr>
          <th>名前</th>
          <th>メール</th>
          <th>ロール</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        {users.map((user) => (
          <UserRow
            key={user.id}
            user={user}
            onDelete={onDelete}
            onChangeRole={onChangeRole}
          />
        ))}
      </tbody>
    </table>
  );
});

// UserRow.tsx - さらに細かくメモ化
interface UserRowProps {
  user: User;
  onDelete: (userId: string) => void;
  onChangeRole: (userId: string, role: 'admin' | 'user') => void;
}

const UserRow = React.memo(function UserRow({
  user,
  onDelete,
  onChangeRole
}: UserRowProps) {
  const handleRoleToggle = useCallback(() => {
    const newRole = user.role === 'admin' ? 'user' : 'admin';
    onChangeRole(user.id, newRole);
  }, [user.id, user.role, onChangeRole]);

  const handleDelete = useCallback(() => {
    onDelete(user.id);
  }, [user.id, onDelete]);

  return (
    <tr>
      <td>{user.name}</td>
      <td>{user.email}</td>
      <td>
        <button onClick={handleRoleToggle}>
          {user.role === 'admin' ? 'Admin→User' : 'User→Admin'}
        </button>
      </td>
      <td>
        <button onClick={handleDelete}>削除</button>
      </td>
    </tr>
  );
});

よくある応用パターン

パターン1:useCallbackチェーン

複数の関数が依存し合う場合、依存関係を正しく設定することが重要です。

function DataProcessor() {
  const [data, setData] = useState([]);
  const [processed, setProcessed] = useState([]);

  // 基本的な処理
  const processData = useCallback((rawData) => {
    return rawData.map((item) => ({
      ...item,
      processed: true
    }));
  }, []);

  // processDataに依存する別の処理
  const applyFilters = useCallback(
    (rawData, filters) => {
      const processed = processData(rawData);
      return processed.filter((item) =>
        filters.every((filter) => filter(item))
      );
    },
    [processData] // processDataを依存配列に含める
  );

  // applyFiltersに依存する最終処理
  const generateReport = useCallback(
    (rawData, filters) => {
      const filtered = applyFilters(rawData, filters);
      return {
        total: filtered.length,
        items: filtered
      };
    },
    [applyFilters] // applyFiltersを依存配列に含める
  );

  return null;
}

パターン2:useCallbackと useRef の組み合わせ

前の値と比較する必要がある場合、useRef を組み合わせます。

function PaginatedList() {
  const [page, setPage] = useState(1);
  const previousPageRef = useRef(1);

  const handlePageChange = useCallback((newPage: number) => {
    const previousPage = previousPageRef.current;
    previousPageRef.current = newPage;

    // ページ遷移時に前のページへのスクロール位置を保存
    if (newPage > previousPage) {
      console.log('次へ移動');
    } else {
      console.log('前へ戻る');
    }

    setPage(newPage);
  }, []);

  return (
    <button onClick={() => handlePageChange(page + 1)}>
      次へ
    </button>
  );
}

パターン3:条件付きメモ化

開発環境でのデバッグのため、useCallback を条件付きで使う場合があります。

function ConditionalCallback() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  // または

  const IS_DEV = process.env.NODE_ENV === 'development';

  const handleClickConditional = IS_DEV
    ? () => {
        console.log('clicked (no memoization in dev)');
      }
    : useCallback(() => {
        console.log('clicked (memoized in prod)');
      }, []);

  return null;
}

注意点と落とし穴

注意点1:過度なメモ化

useCallback を使うこと自体にコストがかかります。シンプルな関数であれば、毎回生成する方が高速なこともあります。

// ❌ 悪い例:シンプルすぎる関数をメモ化
function BadExample() {
  const handleClick = useCallback(() => {
    console.log('click');
  }, []);

  return <button onClick={handleClick}>Click</button>;
}

// ✅ 良い例:複雑な処理や子コンポーネント最適化が必要な場合のみ
function GoodExample() {
  const [items, setItems] = useState([]);

  const handleAdd = useCallback((newItem) => {
    setItems((prevItems) => {
      // 複雑なロジック
      const processed = complexProcess(newItem);
      return [...prevItems, processed];
    });
  }, []);

  return <ExpensiveChildComponent onAdd={handleAdd} />;
}

注意点2:依存配列の誤設定

依存配列を空にしすぎると、古い値を参照し続けるバグが発生します。

// ❌ バグ:countの最新値が反映されない
function BuggyCounter() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log(count); // 常に0
    setCount(count + 1);
  }, []); // countを依存配列に含めていない

  return (
    <>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </>
  );
}

// ✅ 正しい例1:countを依存配列に含める
function FixedCounter1() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log(count);
    setCount(count + 1);
  }, [count]); // countを含める

  return (
    <>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </>
  );
}

// ✅ 正しい例2:状態更新関数形式を使う(推奨)
function FixedCounter2() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    setCount((prevCount) => prevCount + 1); // 前の値を参照
  }, []); // countを含める必要がない

  return (
    <>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </>
  );
}

注意点3:React.memo とのセット使い

useCallback は React.memo と組み合わせてはじめて効果を発揮します。単独では意味がありません。

// ❌ 非効率:useCallbackを使ってもchildが常に再レンダリング
function Inefficient() {
  const handleClick = useCallback(() => {
    console.log('click');
  }, []);

  return <Child onClick={handleClick} />;
}

function Child({ onClick }) {
  return <button onClick={onClick}>Click</button>;
}

// ✅ 効率的:React.memoでchildをラップ
function Efficient() {
  const handleClick = useCallback(() => {
    console.log('click');
  }, []);

  return <MemoChild onClick={handleClick} />;
}

const MemoChild = React.memo(({ onClick }) => {
  console.log('Child rendered');
  return <button onClick={onClick}>Click</button>;
});

注意点4:ESLint 警告への対処

react-hooks/exhaustive-deps ESLint ルールを無視してはいけません。警告が出る場合は、設計を見直すべきです。

// ❌ 危険:ESLint警告を無視している
const handleUpdate = useCallback(() => {
  updateData(userId); // userIdに依存しているのに含めていない
}, []); // eslint-disable-line react-hooks/exhaustive-deps

// ✅ 正しい:警告に従う
const handleUpdate = useCallback(() => {
  updateData(userId);
}, [userId]); // userIdを含める

// または

// ✅ 別の解決策:useRefで保持
const userIdRef = useRef(userId);

useEffect(() => {
  userIdRef.current = userId;
}, [userId]);

const handleUpdate = useCallback(() => {
  updateData(userIdRef.current);
}, []);

まとめ

React の useCallback は強力なツールですが、実務では慎重に使う必要があります。

実務での使い分け

  • 使うべき場面:API 呼び出し、Redux dispatch、複数の子コンポーネントへの props 渡し、複雑なイベントハンドラ
  • 使わなくてよい場面:シンプルな onClick ハンドラ、state の単純な更新、小規模コンポーネント

ベストプラクティス

  1. 必要性を問う:本当にパフォーマンス問題があるか確認してから使う
  2. React.memo と組み合わせる:useCallback 単独では効果がない
  3. 依存配列を正確に設定する:ESLint 警告に従う
  4. 状態更新関数を活用する:setState の更新関数形式で依存配列を減らす
  5. チームで一貫性を持たせる:どんな時に使うか、コード規約を決める

useCallback は「早すぎる最適化の悪い例」になりやすいため、パフォーマンスプロファイラで実際の問題を確認してから導入することが、実務での成功の鍵です。

タイトルとURLをコピーしました