React useCallbackとは?関数の再生成を防ぐ基本テクニック
React useCallbackフックの基本的な使い方から実践的な活用方法まで解説。パフォーマンス最適化のための関数メモ化テクニックを詳しく紹介
みなさん、「Reactアプリが重い」「無駄な再レンダリングが多い」と感じたことはありませんか?
「コンポーネントが勝手に更新される」 「アプリの動作が遅くなってきた」 「パフォーマンス最適化って難しそう」
このような悩みを抱えたことはありませんか?
実は、これらの問題の多くは、関数の不要な再生成が原因なんです。 でも大丈夫です!useCallbackを使えば、簡単に解決できますよ。
この記事では、useCallbackの基本から実践的な使い方まで、初心者にも分かりやすく解説します。 一緒に、スムーズに動くReactアプリを作っていきましょう。
useCallbackって何?
useCallbackは、関数をメモ化するReactのフックです。
簡単に言うと、関数が毎回作り直されるのを防いでくれるんです。
問題:関数が毎回作られる
まず、問題のあるコードを見てみましょう。
// ❌ 問題のあるコード
function ParentComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// この関数は毎回新しく作られる
const handleClick = () => {
console.log('Button clicked');
};
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<ChildComponent onClick={handleClick} />
</div>
);
}
const ChildComponent = React.memo(({ onClick }) => {
console.log('ChildComponent rendered'); // 毎回実行される
return <button onClick={onClick}>子コンポーネントのボタン</button>;
});
このコードでは、nameが変わるたびに、handleClick
関数が新しく作られます。
そのため、ChildComponent
がReact.memo
で囲まれていても、毎回再レンダリングされてしまうんです。
これが無駄な再レンダリングの原因なんですね。
解決策:useCallbackで関数を固定
useCallbackを使って修正してみましょう。
// ✅ useCallbackで最適化したコード
function ParentComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// 関数を一度だけ作成
const handleClick = useCallback(() => {
console.log('Button clicked');
}, []); // 依存配列が空なので、一度だけ作成
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<ChildComponent onClick={handleClick} />
</div>
);
}
const ChildComponent = React.memo(({ onClick }) => {
console.log('ChildComponent rendered'); // 初回のみ実行される
return <button onClick={onClick}>子コンポーネントのボタン</button>;
});
これで、nameが変わってもChildComponent
は再レンダリングされません。
handleClick
関数が同じものを使い回されるからです。
useCallbackの基本的な書き方
useCallbackの構文を確認しましょう。
const memoizedCallback = useCallback(
() => {
// 実行したい処理
doSomething(a, b);
},
[a, b] // 依存配列
);
第一引数:メモ化したい関数 第二引数:依存配列(この値が変わったときだけ関数を作り直す)
依存配列が空の場合は、一度だけ関数が作られます。
実用的な例
検索機能での使用例を見てみましょう。
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
// 検索関数をメモ化
const handleSearch = useCallback(async (searchTerm) => {
setLoading(true);
try {
const response = await fetch(`/api/search?q=${searchTerm}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
} finally {
setLoading(false);
}
}, []); // 依存するstateがないので空配列
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="検索キーワードを入力"
/>
{loading && <p>検索中...</p>}
<SearchResults
results={results}
onSearch={handleSearch}
/>
</div>
);
}
この例では、handleSearch
関数が一度だけ作られます。
そのため、SearchResults
コンポーネントの無駄な再レンダリングを防げます。
依存配列の使い方
useCallbackで最も重要なのが、依存配列の管理です。
正しく設定しないと、思わぬバグの原因になってしまいます。
依存配列とは?
依存配列は、どの値が変わったときに関数を作り直すかを指定します。
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// ❌ 間違った依存配列
const fetchUserData = useCallback(async () => {
const userData = await fetch(`/api/users/${userId}`).then(r => r.json());
setUser(userData);
}, []); // userIdが依存配列にない
この例では、userId
が変わっても関数が更新されません。
そのため、古いuserIdでAPIを呼び出してしまいます。
正しい依存配列の書き方
外部の値を使う場合は、必ず依存配列に含めましょう。
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// ✅ 正しい依存配列
const fetchUserData = useCallback(async () => {
const userData = await fetch(`/api/users/${userId}`).then(r => r.json());
setUser(userData);
}, [userId]); // userIdを依存配列に含める
useEffect(() => {
fetchUserData();
}, [fetchUserData]);
return (
<div>
{user && (
<div>
<h1>{user.name}</h1>
</div>
)}
</div>
);
}
これで、userId
が変わったときに新しい関数が作られ、正しいAPIが呼び出されます。
複数の依存値がある場合
複数の値に依存する場合の例です。
function PostList({ userId, category }) {
const [posts, setPosts] = useState([]);
const fetchPosts = useCallback(async (page = 1, limit = 10) => {
const response = await fetch(
`/api/users/${userId}/posts?category=${category}&page=${page}&limit=${limit}`
);
const data = await response.json();
setPosts(data);
}, [userId, category]); // 複数の値を依存配列に含める
return (
<div>
<PostList
posts={posts}
onLoadMore={fetchPosts}
/>
</div>
);
}
userId
とcategory
のどちらが変わっても、関数が作り直されます。
state更新の最適化
state更新関数は、関数型更新を使うと依存配列を空にできます。
function TodoApp() {
const [todos, setTodos] = useState([]);
// 関数型更新を使う
const addTodo = useCallback((text) => {
const newTodo = {
id: Date.now(),
text: text.trim(),
completed: false
};
setTodos(prevTodos => [...prevTodos, newTodo]);
}, []); // 依存配列が空
// 普通の更新だと依存が必要
const addTodoNormal = useCallback((text) => {
const newTodo = {
id: Date.now(),
text: text.trim(),
completed: false
};
setTodos([...todos, newTodo]); // todosに依存
}, [todos]); // todosを依存配列に含める必要がある
return (
<div>
<TodoInput onAdd={addTodo} />
</div>
);
}
関数型更新(prevTodos => [...prevTodos, newTodo]
)を使うと、現在のstateに依存しません。
そのため、依存配列を空にできて、より効率的になります。
実践的な使用例
実際のアプリでよく使われるパターンを見てみましょう。
Todoアプリの例
本格的なTodoアプリでのuseCallback活用例です。
function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
// Todo追加
const addTodo = useCallback((text) => {
const newTodo = {
id: Date.now(),
text: text.trim(),
completed: false,
createdAt: new Date()
};
setTodos(prevTodos => [...prevTodos, newTodo]);
}, []);
// Todo完了切り替え
const toggleTodo = useCallback((id) => {
setTodos(prevTodos =>
prevTodos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []);
// Todo削除
const deleteTodo = useCallback((id) => {
setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
}, []);
// Todo編集
const editTodo = useCallback((id, newText) => {
setTodos(prevTodos =>
prevTodos.map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
)
);
}, []);
return (
<div className="todo-app">
<h1>Todo List</h1>
<TodoInput onAdd={addTodo} />
<div className="todo-controls">
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value="all">全て</option>
<option value="active">未完了</option>
<option value="completed">完了済み</option>
</select>
</div>
<TodoList
todos={todos}
filter={filter}
onToggle={toggleTodo}
onDelete={deleteTodo}
onEdit={editTodo}
/>
</div>
);
}
これらの関数は全てuseCallback
でメモ化されています。
そのため、フィルターが変わっても、各TodoアイテムのHandler関数は変わりません。
結果的に、不要な再レンダリングを防げます。
最適化された子コンポーネント
子コンポーネントもReact.memo
で最適化しましょう。
const TodoInput = React.memo(({ onAdd }) => {
const [text, setText] = useState('');
const handleSubmit = useCallback((e) => {
e.preventDefault();
if (text.trim()) {
onAdd(text);
setText('');
}
}, [text, onAdd]);
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="新しいタスクを入力"
/>
<button type="submit">追加</button>
</form>
);
});
const TodoItem = React.memo(({ todo, onToggle, onDelete, onEdit }) => {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);
const handleSave = useCallback(() => {
if (editText.trim()) {
onEdit(todo.id, editText);
setIsEditing(false);
}
}, [todo.id, editText, onEdit]);
const handleCancel = useCallback(() => {
setEditText(todo.text);
setIsEditing(false);
}, [todo.text]);
return (
<li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
{isEditing ? (
<div className="edit-mode">
<input
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') handleCancel();
}}
autoFocus
/>
<button onClick={handleSave}>保存</button>
<button onClick={handleCancel}>キャンセル</button>
</div>
) : (
<div className="view-mode">
<span
onDoubleClick={() => setIsEditing(true)}
className="todo-text"
>
{todo.text}
</span>
<button onClick={() => setIsEditing(true)}>編集</button>
<button onClick={() => onDelete(todo.id)}>削除</button>
</div>
)}
</li>
);
});
React.memo
とuseCallbackを組み合わせることで、最大限の最適化効果が得られます。
API呼び出しの最適化
データ取得機能での使用例です。
function UserManagement() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// ユーザー一覧取得
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
// ユーザー更新
const updateUser = useCallback(async (id, updates) => {
try {
const response = await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
});
if (!response.ok) {
throw new Error(`Update failed: ${response.status}`);
}
const updatedUser = await response.json();
// ローカルstateを更新
setUsers(prevUsers =>
prevUsers.map(user =>
user.id === id ? { ...user, ...updatedUser } : user
)
);
} catch (error) {
setError(error.message);
}
}, []);
// ユーザー削除
const deleteUser = useCallback(async (id) => {
try {
const response = await fetch(`/api/users/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Delete failed: ${response.status}`);
}
// ローカルstateから削除
setUsers(prevUsers => prevUsers.filter(user => user.id !== id));
} catch (error) {
setError(error.message);
}
}, []);
// 初回データ取得
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
return (
<div>
<h1>ユーザー管理</h1>
{loading && <p>読み込み中...</p>}
{error && <p>エラー: {error}</p>}
{users && (
<UserList
users={users}
onUpdate={updateUser}
onDelete={deleteUser}
/>
)}
</div>
);
}
API呼び出し関数をメモ化することで、useEffectの依存配列が安定します。
これにより、不要なAPI呼び出しを防げます。
カスタムフックでの活用
useCallbackは、カスタムフックでも威力を発揮します。
ローカルストレージとの同期
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// 値を設定する関数をメモ化
const setValue = useCallback((value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
// 値を削除する関数をメモ化
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.error(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
return [storedValue, setValue, removeValue];
}
このカスタムフックを使うと、ローカルストレージとの同期が簡単になります。
function UserSettings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [language, setLanguage] = useLocalStorage('language', 'ja');
return (
<div>
<h2>設定</h2>
<div>
<label>テーマ:</label>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">ライト</option>
<option value="dark">ダーク</option>
</select>
</div>
<div>
<label>言語:</label>
<select value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="ja">日本語</option>
<option value="en">English</option>
</select>
</div>
</div>
);
}
設定の変更が自動的にローカルストレージに保存されます。
デバウンス機能
入力値の変更を遅延させるデバウンス機能です。
function useDebounce(callback, delay) {
const callbackRef = useRef(callback);
const timeoutRef = useRef();
// 最新のコールバックを保持
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// デバウンスされた関数をメモ化
const debouncedCallback = useCallback(
(...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
},
[delay]
);
// クリーンアップ
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedCallback;
}
検索機能で使ってみましょう。
function SearchInput({ onSearch }) {
const [query, setQuery] = useState('');
// デバウンス付きの検索
const debouncedSearch = useDebounce((searchTerm) => {
if (searchTerm.length > 2) {
onSearch(searchTerm);
}
}, 300);
const handleInputChange = useCallback((e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
}, [debouncedSearch]);
return (
<input
type="text"
value={query}
onChange={handleInputChange}
placeholder="検索キーワードを入力"
/>
);
}
これで、入力停止から300ミリ秒後に検索が実行されます。
連続入力時の無駄なAPI呼び出しを防げます。
React.memoとの組み合わせ
useCallbackは、React.memo
と組み合わせることで真価を発揮します。
大量データを扱うリスト
function LargeList({ items, onItemClick, onItemDelete }) {
// フィルタリングとソートをメモ化
const processedItems = useMemo(() => {
return items.sort((a, b) => {
return a.name.localeCompare(b.name);
});
}, [items]);
// アイテムクリックハンドラをメモ化
const handleItemClick = useCallback((item) => {
onItemClick(item.id, item);
}, [onItemClick]);
// アイテム削除ハンドラをメモ化
const handleItemDelete = useCallback((itemId) => {
if (confirm('本当に削除しますか?')) {
onItemDelete(itemId);
}
}, [onItemDelete]);
return (
<div className="large-list">
<div className="list-stats">
表示中: {processedItems.length}件
</div>
<div className="list-container">
{processedItems.map(item => (
<ListItem
key={item.id}
item={item}
onClick={handleItemClick}
onDelete={handleItemDelete}
/>
))}
</div>
</div>
);
}
子コンポーネントもメモ化します。
const ListItem = React.memo(({ item, onClick, onDelete }) => {
console.log(`Rendering item: ${item.name}`); // デバッグ用
// アイテム固有のクリックハンドラ
const handleClick = useCallback(() => {
onClick(item);
}, [item, onClick]);
// アイテム固有の削除ハンドラ
const handleDelete = useCallback((e) => {
e.stopPropagation();
onDelete(item.id);
}, [item.id, onDelete]);
return (
<div className="list-item" onClick={handleClick}>
<div className="item-info">
<h3>{item.name}</h3>
<p>カテゴリ: {item.category}</p>
<p>作成日: {new Date(item.createdAt).toLocaleDateString()}</p>
</div>
<div className="item-actions">
<button onClick={handleDelete}>削除</button>
</div>
</div>
);
});
この組み合わせにより、アイテムの変更がない限り、各リストアイテムは再レンダリングされません。
大量のデータを扱う場合でも、スムーズに動作します。
使用例
function App() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
// データの読み込み
useEffect(() => {
const loadData = async () => {
setLoading(true);
try {
// 大量のデータを生成
const data = Array.from({ length: 1000 }, (_, index) => ({
id: index + 1,
name: `アイテム ${index + 1}`,
category: ['カテゴリA', 'カテゴリB', 'カテゴリC'][index % 3],
createdAt: new Date(Date.now() - Math.random() * 10000000000).toISOString(),
}));
setItems(data);
} catch (error) {
console.error('データの読み込みに失敗しました:', error);
} finally {
setLoading(false);
}
};
loadData();
}, []);
// アイテムクリックハンドラ
const handleItemClick = useCallback((itemId, item) => {
console.log('Item clicked:', item);
}, []);
// アイテム削除ハンドラ
const handleItemDelete = useCallback((itemId) => {
setItems(prevItems => prevItems.filter(item => item.id !== itemId));
}, []);
if (loading) {
return <div>データを読み込み中...</div>;
}
return (
<div className="app">
<h1>大規模リストアプリ</h1>
<LargeList
items={items}
onItemClick={handleItemClick}
onItemDelete={handleItemDelete}
/>
</div>
);
}
1000件のアイテムでも、スムーズに動作します。
useCallbackとReact.memoの威力を実感できるはずです。
よくある間違いと対策
useCallbackを使う際に、よくある間違いをご紹介します。
過度な最適化
// ❌ 効果が薄い最適化
function SimpleComponent() {
const [count, setCount] = useState(0);
// これは不要
const handleClick = useCallback(() => {
console.log('Clicked');
}, []);
// これも不要
const handleIncrement = useCallback(() => {
setCount(c => c + 1);
}, []);
return (
<div>
<p>{count}</p>
<button onClick={handleIncrement}>+1</button>
<button onClick={handleClick}>Log</button>
</div>
);
}
単純な関数や、子コンポーネントに渡さない関数は、メモ化不要です。
// ✅ 適切な最適化
function OptimizedComponent() {
const [count, setCount] = useState(0);
const [items, setItems] = useState([]);
// 子コンポーネントに渡すのでメモ化が有効
const handleItemAdd = useCallback((newItem) => {
setItems(prev => [...prev, newItem]);
}, []);
// 単純なstate更新は最適化不要
const handleIncrement = () => {
setCount(c => c + 1);
};
return (
<div>
<p>{count}</p>
<button onClick={handleIncrement}>+1</button>
<ItemList
items={items}
onAdd={handleItemAdd}
/>
</div>
);
}
メモ化すべき関数:子コンポーネントに渡される関数 メモ化不要の関数:コンポーネント内でのみ使用される単純な関数
依存配列の間違い
// ❌ 依存配列の間違い
function ProblematicComponent({ userId, apiEndpoint }) {
const [userData, setUserData] = useState(null);
// 問題1: 必要な値が依存配列にない
const fetchUser = useCallback(async () => {
const response = await fetch(`${apiEndpoint}/users/${userId}`);
const data = await response.json();
setUserData(data);
}, []); // userIdとapiEndpointが依存配列にない
// 問題2: 不安定な依存値
const processData = useCallback((data) => {
return data.map(item => ({
...item,
timestamp: new Date() // 毎回新しい日付
}));
}, [new Date()]); // 毎回新しいDateオブジェクト
return <div>{/* JSX */}</div>;
}
これらの問題を修正してみましょう。
// ✅ 正しい依存配列
function FixedComponent({ userId, apiEndpoint }) {
const [userData, setUserData] = useState(null);
// 修正1: 必要な依存値をすべて含める
const fetchUser = useCallback(async () => {
const response = await fetch(`${apiEndpoint}/users/${userId}`);
const data = await response.json();
setUserData(data);
}, [userId, apiEndpoint]);
// 修正2: 安定した値を使用
const processData = useCallback((data) => {
return data.map(item => ({
...item,
processedAt: Date.now() // 関数実行時の時刻
}));
}, []); // 外部依存なし
return <div>{/* JSX */}</div>;
}
ポイント:
- 関数内で使用する外部の値は、必ず依存配列に含める
- 毎回変わる値(
new Date()
など)は依存配列に入れない - ESLintのルール
exhaustive-deps
を有効にすると、間違いを防げる
メモリリーク対策
非同期処理では、コンポーネントのアンマウント時のクリーンアップが重要です。
// ❌ メモリリークの可能性
function LeakyComponent() {
const [data, setData] = useState(null);
const fetchData = useCallback(async () => {
const response = await fetch('/api/data');
const result = await response.json();
setData(result); // コンポーネントがアンマウントされても実行される
}, []);
return <div>{/* JSX */}</div>;
}
AbortControllerを使ってクリーンアップしましょう。
// ✅ メモリリーク対策済み
function SafeComponent() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async () => {
const controller = new AbortController();
setLoading(true);
try {
const response = await fetch('/api/data', {
signal: controller.signal
});
const result = await response.json();
// アンマウントされていないかチェック
if (!controller.signal.aborted) {
setData(result);
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Fetch error:', error);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
// クリーンアップ関数を返す
return () => controller.abort();
}, []);
useEffect(() => {
const cleanup = fetchData();
return cleanup;
}, [fetchData]);
return <div>{loading ? 'Loading...' : /* JSX */}</div>;
}
これで、コンポーネントがアンマウントされても安全です。
まとめ
useCallbackは、Reactアプリのパフォーマンス向上に欠かせないツールです。
useCallbackの効果的な使い方
- 子コンポーネントに渡す関数をメモ化する
- 依存配列を正しく管理する
- React.memoと組み合わせる
- 過度な最適化は避ける
基本パターン
// 基本形
const memoizedCallback = useCallback(() => {
doSomething();
}, []);
// 依存値あり
const memoizedCallback = useCallback((param) => {
doSomething(param, externalValue);
}, [externalValue]);
// 状態更新
const updateState = useCallback((newValue) => {
setState(prevState => ({ ...prevState, ...newValue }));
}, []);
注意すべきポイント
- すべての関数をメモ化する必要はない
- 依存配列には使用する外部の値を全て含める
- 非同期処理にはクリーンアップを実装する
パフォーマンス向上のコツ
- 測定してから最適化:まずは実際のボトルネックを特定
- 段階的な適用:効果的な部分から順番に最適化
- 継続的な見直し:アプリの成長に合わせて調整
useCallbackを正しく使うことで、ユーザーにとって快適なReactアプリを作ることができます。
ぜひ今回学んだテクニックを、あなたのプロジェクトで試してみてください。 きっと、アプリの動作が軽やかになることを実感できるはずです!