はじめに
React Hooksの登場により、関数コンポーネントでも状態管理やライフサイクル処理が可能になりました。しかし、「useEffectの依存配列がよくわからない」「カスタムフックの作り方がわからない」という声をよく聞きます。
この記事では、基本的なHooksから実践的なカスタムフックの作成まで、具体的なコード例とともに詳しく解説します。
ただ 実務でハマるポイントは
- useEffectが無限ループする 処理が二重に走る
- 依存配列の意味が分からず eslintを黙らせるだけになる
- 状態が散らばって リファクタが効かない
のような 設計の問題 です。
HooksはAPIを覚えるだけではなく
状態と副作用をどう切るか を理解すると一気に安定します。
まず押さえる Hooksを事故らせない前提
この先の理解が早くなるように、最初に地図を置きます。
- 値はレンダーごとに再計算される。レンダー間で保持したいものだけが state / ref
- effectは「描画の結果」と「外部世界」を整合させるための同期
- 依存配列は最適化スイッチではなく、このeffectが参照する値の宣言
実務で詰まるのは、useEffectの使い方そのものよりも「どの問題を state で解き、どの問題を effect で解くか」の切り分けです。
いつstateを増やすべきか 減らすべきか
stateはUIの状態遷移を表す最小集合に寄せます。
- 入力中の値、選択中のタブ、モーダルの開閉など UIそのもの: state
- 既存stateから計算できる派生値(合計、表示用整形、フィルタ済み配列): 計算する
派生値をstateに入れると「二重の真実」になり、更新漏れが起きやすいです。
ありがちな落とし穴と対処
- effectでstateを更新して無限ループ
- まず「そのstateは本当に必要か」を疑う。派生なら計算に寄せる
- eslintの依存配列警告を無視する
- 入れたくない値を参照している設計が原因。関数の持ち上げ、useCallback、引数化で解く
- StrictModeでeffectが二重に走って壊れる
- 開発時は副作用が二度起きても壊れない実装にする(購読解除、idempotent)
- 子に渡す関数が毎回変わって再レンダー
- まずは計測してから。必要な場面だけuseCallbackとReact.memoを使う
最小アンチパターン集(NG/OKで覚える)
ここで紹介するのは「全Hooks共通で頻出」の最小セットです。
useEffect 特有の事故(fetchの競合、cleanup漏れ、StrictMode二重実行など)は、後半の「useEffectの最小アンチパターン集」も合わせて見ると一気に解像度が上がります。
1) 派生stateを持つ(同期漏れで壊れる)
// NG: 派生値をstateにコピー
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((s, x) => s + x.price, 0));
}, [items]);
// OK: その場で計算(必要ならuseMemo)
const total2 = useMemo(() => items.reduce((s, x) => s + x.price, 0), [items]);
2) 「一度だけ実行したい」から [](参照している値がある)
// NG: userIdを参照しているのにdepsが空
useEffect(() => {
fetch(`/api/users/${userId}`);
}, []);
// OK: 参照した値を宣言する
useEffect(() => {
fetch(`/api/users/${userId}`);
}, [userId]);
3) propsでオブジェクト/関数を毎回作って渡す(memoが効かない)
// NG
<Child onClick={() => doSomething(id)} config={{ id, mode }} />
// OK(必要な場合)
const onClick = useCallback(() => doSomething(id), [id]);
const config = useMemo(() => ({ id, mode }), [id, mode]);
<Child onClick={onClick} config={config} />
4) refでUIを更新しようとする
// NG: refの変更では再レンダーされない
const countRef = useRef(0);
countRef.current++;
return <div>{countRef.current}</div>; // 期待通りに更新されない
// OK: UIに出すならstate
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
レビュー用チェックリスト
- stateに「派生値」や「冗長なコピー」を持っていない
- effectは外部世界との同期だけに使っている
- 依存配列から値を抜くのではなく、参照しない設計に変えている
- 購読やタイマーは必ずクリーンアップしている
- カスタムフックの戻り値の形が使う側の意図を表している
この記事のゴール
- 依存配列は 参照している値の宣言 であることが分かる
- 副作用を useEffect に押し込めず UIの状態遷移として組み立てられる
- カスタムフックで 再利用と責務分離 を安全に進められる
先に結論 よくある判断基準
useEffectはデータ同期の最後の手段
useEffectは 外部世界と同期する ときに使います。
外部世界の例
- DOM API
- WebSocketやEventSource
- setIntervalなどのタイマー
- 手動で購読するストア
逆に
描画結果を別の状態にコピーする ために使うと
二重の真実 が生まれてバグりやすいです。
依存配列は 参照した値の一覧 である
依存配列は
- いつ再実行すべきか
を人間が推測する場所ではなく
- このeffectはどの値に依存しているか
を宣言する場所です。
だから
- 参照している値は入れる
- 入れたくないなら参照しない設計にする
が基本方針になります。
状態はUIを表す最小集合にする
状態が増えるほど組み合わせが爆発します。
まずは
- loading
- error
- data
のような 画面の状態遷移 を表す形に寄せ
派生値は計算する useMemoなど で表現すると安定します。
useState:状態管理の基本
基本的な使い方
import { useState } from 'react';
function Counter() {
// [現在の値, 更新関数] = useState(初期値)
const [count, setCount] = useState(0);
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>増やす</button>
<button onClick={() => setCount(count - 1)}>減らす</button>
<button onClick={() => setCount(0)}>リセット</button>
</div>
);
}
関数型更新
前の状態に基づいて更新する場合は、関数型更新を使用します。
function Counter() {
const [count, setCount] = useState(0);
// NG: 連続呼び出しで意図しない結果になる可能性
const incrementTwiceBad = () => {
setCount(count + 1);
setCount(count + 1); // countはまだ古い値
};
// OK: 前の状態を受け取って更新
const incrementTwice = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
};
return (
<div>
<p>カウント: {count}</p>
<button onClick={incrementTwice}>+2</button>
</div>
);
}
オブジェクトや配列の状態
interface User {
name: string;
email: string;
age: number;
}
function UserForm() {
const [user, setUser] = useState<User>({
name: '',
email: '',
age: 0
});
// オブジェクトの一部を更新(スプレッド構文)
const updateName = (name: string) => {
setUser(prev => ({ ...prev, name }));
};
// 汎用的な更新関数
const updateField = (field: keyof User, value: string | number) => {
setUser(prev => ({ ...prev, [field]: value }));
};
return (
<div>
<input
value={user.name}
onChange={e => updateField('name', e.target.value)}
placeholder="名前"
/>
<input
value={user.email}
onChange={e => updateField('email', e.target.value)}
placeholder="メール"
/>
</div>
);
}
function TodoList() {
const [todos, setTodos] = useState<string[]>([]);
// 配列に追加
const addTodo = (todo: string) => {
setTodos(prev => [...prev, todo]);
};
// 配列から削除
const removeTodo = (index: number) => {
setTodos(prev => prev.filter((_, i) => i !== index));
};
// 配列の要素を更新
const updateTodo = (index: number, newValue: string) => {
setTodos(prev => prev.map((todo, i) =>
i === index ? newValue : todo
));
};
return (
<ul>
{todos.map((todo, index) => (
<li key={index}>
{todo}
<button onClick={() => removeTodo(index)}>削除</button>
</li>
))}
</ul>
);
}
遅延初期化
初期値の計算がコストの高い場合は、関数を渡して遅延初期化します。
function ExpensiveComponent() {
// NG: 毎回レンダリング時に実行される
const [data, setData] = useState(expensiveComputation());
// OK: 初回のみ実行される
const [data, setData] = useState(() => expensiveComputation());
return <div>{data}</div>;
}
// ローカルストレージからの読み込み
function usePersistentState<T>(key: string, defaultValue: T) {
const [state, setState] = useState<T>(() => {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : defaultValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState] as const;
}
useEffect:副作用の処理
先に結論:useEffectで解くべき問題/解かない問題
useEffectは「レンダーの結果」と「外部世界」を同期するための仕組みです。
使う(外部世界との同期)
- データ取得(ただしキャッシュや状態管理レイヤがあるならそこで吸収)
- 購読(WebSocket, EventSource, ブラウザイベント、ストア)
- タイマー(setInterval / setTimeout)
- DOM API(スクロール位置、フォーカスなど)
使わない(UI内の派生を作るため)
-
Aから計算できるBを effect でsetBする(状態の二重管理) - 「一度だけ実行したいから
[]」の乱用(本当は参照している値がある)
依存配列で詰まったときの実務手順
- まず「effect内で参照している値」を列挙する(props/state/関数)
- 依存配列に入れる(宣言)
- 入れたくない値がある場合は、参照しない設計に変える
- 引数として渡す/責務を外へ出す(カスタムフック、呼び出し元へ持ち上げ)
-
useCallback/useMemoで安定化する(必要な場合のみ)
「依存配列から抜きたくなる」典型パターンの直し方
パターン1: “最新の値”だけ欲しい(stale closure回避)
イベントハンドラやタイマーで「最新のstateを読みたい」だけなら、stateをdepsに入れてeffectを作り直す必要がない場合があります。
function useLatest<T>(value: T) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
function Example() {
const [count, setCount] = useState(0);
const countRef = useLatest(count);
useEffect(() => {
const id = window.setInterval(() => {
// ここでは最新のcountを参照できる
console.log(countRef.current);
}, 1000);
return () => clearInterval(id);
}, [countRef]); // ref自体は安定なのでeffectは作り直されにくい
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
注意: これは「再購読が不要なケース」に限定して使います。外部世界(購読対象)を count に応じて変える必要があるなら、素直にdepsに入れて作り直します。
パターン2: effectの中で関数を参照してdepsが増える
effectの中で“処理”を組み立てているとdepsが雪だるま式に増えます。処理を外に出して引数化すると、依存関係が整理されます。
function fetchUserById(userId: string) {
return fetch(`/api/users/${userId}`).then(r => r.json());
}
useEffect(() => {
let cancelled = false;
fetchUserById(userId).then(data => {
if (cancelled) return;
setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);
パターン3: オブジェクト/関数propsが毎回変わってループ
- 必要なプリミティブだけ依存にする(例:
config.id) - どうしてもオブジェクトが必要なら、生成箇所を上に寄せる or
useMemoで安定化する(ただし計測して必要な場合だけ)
パターン4: イベント購読でdepsが増え、毎回再購読してしまう
addEventListener のような購読は、depsが増えるほど「解除→再登録」が頻発しがちです。
購読自体は固定し、最新のコールバックだけ差し替える形(useEvent風)にすると安定します。
function useEvent<T extends (...args: any[]) => any>(handler: T) {
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
return useCallback((...args: Parameters<T>) => {
return handlerRef.current(...args);
}, []);
}
function useWindowEvent(type: string, handler: (e: Event) => void) {
const stableHandler = useEvent(handler);
useEffect(() => {
window.addEventListener(type, stableHandler);
return () => window.removeEventListener(type, stableHandler);
}, [type, stableHandler]);
}
function Example() {
const [count, setCount] = useState(0);
useWindowEvent('click', () => {
// countが変わっても購読し直さず、常に最新のロジックが走る
setCount(c => c + 1);
});
return <div>clicked: {count}</div>;
}
注意: これは「購読先(typeや対象)が変わらない」ケースで特に有効です。
購読対象自体が変わるなら、depsに入れて再購読するのが正しい設計です。
基本的な使い方
import { useState, useEffect } from 'react';
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 副作用を実行
async function fetchUser() {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (error) {
console.error('Failed to fetch user:', error);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]); // userIdが変更されたときに再実行
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return <div>{user.name}</div>;
}
依存配列の理解
useEffect(() => {
// 毎回のレンダリング後に実行
});
useEffect(() => {
// マウント時のみ実行
}, []);
useEffect(() => {
// count または name が変更されたときに実行
}, [count, name]);
クリーンアップ関数
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// クリーンアップ関数
return () => {
clearInterval(intervalId);
};
}, []);
return <div>経過時間: {seconds}秒</div>;
}
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
### 事故りやすいポイント
- クリーンアップがない購読は、StrictModeや画面遷移で二重購読→二重送信→メモリリークになりがち
- `setInterval` / イベントリスナー / WebSocket は「必ず解除」が基本
- 非同期処理(fetch等)は「完了後にsetState」する前にコンポーネントがアンマウントされ得る
return () => {
connection.disconnect();
};
}, [roomId]);
return <div>チャットルーム: {roomId}</div>;
}
よくある間違いと対処法
// NG: 無限ループ
function BadComponent() {
const [data, setData] = useState([]);
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(setData);
}); // 依存配列がないため毎回実行
return <div>{data.length}</div>;
}
// NG: オブジェクトを依存配列に含める
function AlsoBadComponent({ config }: { config: { id: string } }) {
useEffect(() => {
// configは毎回新しいオブジェクトなので無限ループ
}, [config]);
}
// OK: 必要なプロパティだけを依存配列に含める
function GoodComponent({ config }: { config: { id: string } }) {
const { id } = config;
useEffect(() => {
fetchData(id);
}, [id]);
}
// OK: useMemoでオブジェクトをメモ化
function AlsoGoodComponent({ id, name }: { id: string; name: string }) {
const config = useMemo(() => ({ id, name }), [id, name]);
useEffect(() => {
processConfig(config);
}, [config]);
}
## useEffectの最小アンチパターン集(実務でよく事故る順)
### 1) effectに `async` を直接渡す
`useEffect` の戻り値は cleanup 関数(または `undefined`)である必要があります。`async` 関数は Promise を返すので、意図しない挙動になります。
```tsx
// NG
useEffect(async () => {
const res = await fetch('/api');
// ...
}, []);
// OK
useEffect(() => {
(async () => {
const res = await fetch('/api');
// ...
})();
}, []);
2) cleanup漏れ(購読・タイマー・イベントで増殖)
// NG: 解除がない
useEffect(() => {
const id = window.setInterval(tick, 1000);
}, []);
// OK
useEffect(() => {
const id = window.setInterval(tick, 1000);
return () => clearInterval(id);
}, [tick]);
3) 依存配列を“抜いて”eslintを黙らせる(依存隠し)
依存を抜くのは「再実行したくない」気持ちの表れですが、たいていは設計の匂いです。
抜くと、すぐに stale closure(古い値を参照)や更新漏れが起きます。
// NG
useEffect(() => {
doSomething(userId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// OK: 参照を宣言
useEffect(() => {
doSomething(userId);
}, [userId]);
4) 「派生state」をeffectで作る(二重の真実)
このパターンは記事冒頭の「最小アンチパターン集(派生state)」でも扱っています。
ポイントは 「状態を増やす前に、計算で済むか」を疑う ことです。
5) fetchの競合(古いレスポンスで上書きする)
特に「検索」「入力に応じた候補」系で起きがちです。
// OK例: requestIdで最後のリクエストだけ反映(またはAbortController)
useEffect(() => {
let active = true;
(async () => {
const res = await fetch(`/api?q=${q}`);
const data = await res.json();
if (!active) return;
setResult(data);
})();
return () => {
active = false;
};
}, [q]);
6) unmount後に setState して警告・リークに見える
画面遷移が多いUIや、モーダル/タブの表示切替で発生します。
中断(AbortController)か「無視するフラグ」で防止します。
useEffect(() => {
let cancelled = false;
(async () => {
const data = await load();
if (cancelled) return;
setState(data);
})();
return () => {
cancelled = true;
};
}, [load]);
7) StrictMode(開発時)の二重実行で壊れる副作用
- cleanupがない購読
- “一度だけ”前提の外部API呼び出し(トラッキング等)
対策は「二重でも壊れない」実装(idempotent)に寄せることです。
たとえば購読は必ず解除し、fetchは中断可能にしておきます。
8) 依存が増えて「解除→再登録」が頻発する(再購読嵐)
依存配列に状態やコールバックが増えるほど、購読が作り直されます。
購読自体は固定し、最新ロジックだけ差し替える(useEvent風)パターンが効くことがあります(※購読対象が固定のとき)。
function useEvent<T extends (...args: any[]) => any>(handler: T) {
const ref = useRef(handler);
useEffect(() => {
ref.current = handler;
}, [handler]);
return useCallback((...args: Parameters<T>) => ref.current(...args), []);
}
useEffect(() => {
const onMessage = useEvent((msg: MessageEvent) => {
// 最新stateを使った処理
handle(msg.data);
});
socket.addEventListener('message', onMessage);
return () => socket.removeEventListener('message', onMessage);
}, [socket]);
9) effectの中で“重い処理”をして描画がカクつく
effectはレンダー後に走るため「UIは描画されたけど、その直後の処理で固まる」形の体感劣化になります。
- 重い計算は
useMemoや前処理へ寄せられないか - 分割できるならIdle時間やWeb Workerへ(ケースによる)
- まずは計測(Performance/Profiler)して、本当にそこがボトルネックか確認
10) 1つのuseEffectに責務を詰め込みすぎる(巨大effect)
巨大effectは、依存配列が雪だるまになり、どの変更で何が起きるかが追いにくくなります。
分割の目安は次の通りです。
- 同期したい「外部世界」が別なら effect を分ける(例: fetch と windowイベント)
- 依存が違うなら effect を分ける(片方が変わると片方も再実行されるのを避ける)
結果として、cleanupの責務も分かれ、StrictMode耐性も上がります。
fetchの典型テンプレ(AbortControllerで中断できるようにする)
React 18のStrictMode(開発時)では、effectが マウント→アンマウント→再マウント のように二度走ることがあります。
副作用が二重でも壊れないように、fetchは中断可能にしておくと安全です。
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await res.json();
setUser(data);
} catch (e) {
// AbortErrorは「中断しただけ」なのでエラー扱いしない
if (e instanceof DOMException && e.name === 'AbortError') return;
console.error(e);
}
})();
return () => {
controller.abort();
};
}, [userId]);
実務テンプレ:データ取得をuseEffectで“事故らせない”
データ取得を自前で書くと、実務では次の4点で壊れがちです。
- レース: 古いリクエストが後から返り、最新データを上書きする
- 中断: 画面遷移やStrictModeの再マウントで不要なリクエストが残る
- エラー分類: Abortをエラーとして扱ってしまい、不要なエラー表示やリトライになる
- 再試行/キャッシュ: 要件が増えて、自作が雪だるま式に複雑化する
最小の“レース防止 + 中断 + エラー分類”テンプレ
type LoadState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function isAbortError(e: unknown) {
return e instanceof DOMException && e.name === 'AbortError';
}
function useUser(userId: string) {
const [state, setState] = useState<LoadState<User>>({ status: 'idle' });
useEffect(() => {
const controller = new AbortController();
let requestId = 0;
requestId++;
const current = requestId;
setState({ status: 'loading' });
(async () => {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as User;
// レース防止: “最後に開始したリクエスト”だけ反映
if (current !== requestId) return;
setState({ status: 'success', data });
} catch (e) {
if (isAbortError(e)) return;
setState({ status: 'error', error: e instanceof Error ? e : new Error('Unknown error') });
}
})();
return () => controller.abort();
}, [userId]);
return state;
}
手に負えなくなる前に:ライブラリ導入の目安
次が必要になったら、SWR / TanStack Query(React Query)などの採用を検討した方が総コストが下がりやすいです。
- キャッシュ(stale-while-revalidate、期限、キー設計)
- リトライ(指数バックオフ、エラー種別で分岐)
- 重複排除(同じリクエストの同時発行をまとめる)
- ページネーション/無限スクロール
- バックグラウンド更新
# useContext:コンテキストの活用
## 基本的な使い方
```tsx
import { createContext, useContext, useState, ReactNode } from 'react';
// コンテキストの型定義
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// コンテキストの作成
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// プロバイダーコンポーネント
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// カスタムフック(推奨)
function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// 使用例
function ThemeToggleButton() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
現在のテーマ: {theme}
</button>
);
}
function App() {
return (
<ThemeProvider>
<ThemeToggleButton />
</ThemeProvider>
);
}
複数のコンテキストを組み合わせる
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = async (email: string, password: string) => {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
const userData = await response.json();
setUser(userData);
};
const logout = () => {
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
// 複数のプロバイダーを組み合わせる
function AppProviders({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
);
}
useReducer:複雑な状態管理
いつuseReducerを選ぶか(判断基準)
useReducer は「状態遷移が増えて、setState が散らばり始めた」ときに効きます。
- 画面状態が
loading / error / dataのように遷移する - 複数の操作が同じstateの複数フィールドを更新する
- 更新ルールを1箇所(reducer)に集めたい
逆に、単純な入力フォーム程度なら useState の方が読みやすいことも多いです。ポイントは“複雑さが増えたときに、遷移の中心を作る”ことです。
実務テンプレ:ロード状態(loading/error/success/empty)を状態遷移で表す
「とりあえず loading: boolean と error: string | null」で始めると、画面が増えた瞬間に破綻しやすいです。
useReducer で状態遷移を固定すると、更新漏れが減り、UIも読みやすくなります。
type State<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'empty' }
| { status: 'error'; error: string };
type Action<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; data: T }
| { type: 'FETCH_EMPTY' }
| { type: 'FETCH_ERROR'; message: string };
function reducer<T>(state: State<T>, action: Action<T>): State<T> {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading' };
case 'FETCH_SUCCESS':
return { status: 'success', data: action.data };
case 'FETCH_EMPTY':
return { status: 'empty' };
case 'FETCH_ERROR':
return { status: 'error', error: action.message };
default:
return state;
}
}
function UserList() {
const [state, dispatch] = useReducer(reducer<User[]>, { status: 'idle' });
useEffect(() => {
const controller = new AbortController();
dispatch({ type: 'FETCH_START' });
(async () => {
try {
const res = await fetch('/api/users', { signal: controller.signal });
if (!res.ok) throw new Error(String(res.status));
const users = (await res.json()) as User[];
if (users.length === 0) {
dispatch({ type: 'FETCH_EMPTY' });
} else {
dispatch({ type: 'FETCH_SUCCESS', data: users });
}
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
dispatch({ type: 'FETCH_ERROR', message: e instanceof Error ? e.message : 'Unknown error' });
}
})();
return () => controller.abort();
}, []);
if (state.status === 'idle' || state.status === 'loading') return <div>Loading...</div>;
if (state.status === 'error') return <div>Error: {state.error}</div>;
if (state.status === 'empty') return <div>No users</div>;
return (
<ul>
{state.data.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
基本的な使い方
import { useReducer } from 'react';
interface State {
count: number;
step: number;
}
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'setStep'; payload: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step };
case 'decrement':
return { ...state, count: state.count - state.step };
case 'reset':
return { count: 0, step: 1 };
case 'setStep':
return { ...state, step: action.payload };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
return (
<div>
<p>カウント: {state.count}</p>
<p>ステップ: {state.step}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>リセット</button>
<input
type="number"
value={state.step}
onChange={e => dispatch({ type: 'setStep', payload: Number(e.target.value) })}
/>
</div>
);
}
フォーム管理での活用
interface FormState {
values: {
name: string;
email: string;
password: string;
};
errors: {
name?: string;
email?: string;
password?: string;
};
isSubmitting: boolean;
}
type FormAction =
| { type: 'SET_FIELD'; field: string; value: string }
| { type: 'SET_ERROR'; field: string; error: string }
| { type: 'CLEAR_ERRORS' }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_END' }
| { type: 'RESET' };
const initialState: FormState = {
values: { name: '', email: '', password: '' },
errors: {},
isSubmitting: false
};
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: undefined }
};
case 'SET_ERROR':
return {
...state,
errors: { ...state.errors, [action.field]: action.error }
};
case 'CLEAR_ERRORS':
return { ...state, errors: {} };
case 'SUBMIT_START':
return { ...state, isSubmitting: true };
case 'SUBMIT_END':
return { ...state, isSubmitting: false };
case 'RESET':
return initialState;
default:
return state;
}
}
function SignupForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
const handleChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ type: 'SET_FIELD', field, value: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
try {
await submitForm(state.values);
dispatch({ type: 'RESET' });
} catch (error) {
// エラー処理
} finally {
dispatch({ type: 'SUBMIT_END' });
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={state.values.name}
onChange={handleChange('name')}
placeholder="名前"
/>
{state.errors.name && <span>{state.errors.name}</span>}
<input
value={state.values.email}
onChange={handleChange('email')}
placeholder="メール"
/>
{state.errors.email && <span>{state.errors.email}</span>}
<button type="submit" disabled={state.isSubmitting}>
{state.isSubmitting ? '送信中...' : '登録'}
</button>
</form>
);
}
useMemo と useCallback:パフォーマンス最適化
先に結論:まず計測、次に構造、最後にuseMemo/useCallback
useMemo/useCallback は便利ですが、入れれば速いではありません。
まずは「何が再レンダーを引き起こしているか」を把握します。
- 親のstate更新で子が再レンダーしているだけなら正常(Reactはそれを前提に最適化されています)
- 高コストなのは「重い計算」「巨大リスト」「メモ化された子に毎回新しいprops(関数/オブジェクト)を渡す」など
実務では React DevTools Profiler で“どこが重いか”を見てから、必要最低限の箇所だけ最適化するのが安全です。
最適化の実務手順(迷ったらこれ)
- Profilerで計測: どのコンポーネントが何回レンダーされ、どれが重いかを確認
- 原因を分類: 親のstate変更/props同一性(関数・オブジェクト)/重い計算/巨大リスト
- 構造を直す: stateのスコープを狭める、propsを小さくする、リストを分割/仮想化
-
最後にメモ化: それでも必要な箇所だけ
React.memo+useMemo/useCallback
この順番にすると「メモ化の沼」にハマりにくいです。
useMemo/useCallbackの最小アンチパターン集(NG/OKで覚える)
1) “とりあえず”で入れる(効果がない、複雑さだけ増える)
// NG: 軽い計算にuseMemo
const doubled = useMemo(() => count * 2, [count]);
// OK
const doubled2 = count * 2;
目安として、次のようなときに初めて候補になります。
- 計算が重い(配列の集計/ソート/正規化など)
- その計算が「同じ入力で何度も走っている」
- もしくは memo化された子に渡すpropsの同一性がボトルネック
2) depsを抜いて“固定化”する(stale closureになる)
// NG: idを参照しているのにdepsが空
const onClick = useCallback(() => doSomething(id), []);
// OK
const onClick2 = useCallback(() => doSomething(id), [id]);
3) useCallbackしても子がmemo化されていない(無意味になりがち)
// NG: Childがmemo化されていないなら、親のuseCallbackは効きにくい
const onClick = useCallback(() => setCount(c => c + 1), []);
return <Child onClick={onClick} />;
// OK: 本当にprops同一性が必要な場所だけ、子側も含めて設計する
const ChildMemo = memo(Child);
return <ChildMemo onClick={onClick} />;
4) useMemoで「参照安定のためだけ」に巨大オブジェクトを作る
オブジェクトを安定化したい気持ちで useMemo(() => ({...巨大...}), deps) を書くと、
deps設計が難しく、更新漏れや“結局毎回作られる”が起きがちです。
まずは「子に渡すpropsの形」を小さくする(必要なプリミティブだけ渡す)方が安全です。
5) memo化の順序ミス(まず構造、最後にメモ化)
症状: 遅い → useMemo/useCallbackを追加 → まだ遅い → さらに追加
このループは大抵、原因が「巨大リスト」「無駄な再レンダー」「propsの形が悪い」「分割が足りない」などにあります。
メモ化の前に、次を優先すると改善が出やすいです。
- コンポーネント分割(状態のスコープを小さくする)
- propsを小さくする(大きいオブジェクトを渡さない)
- リストは仮想化(例: react-window)を検討
useMemo:計算結果のメモ化
import { useMemo, useState } from 'react';
function ExpensiveComponent({ items }: { items: number[] }) {
const [filter, setFilter] = useState('');
// フィルタリング結果をメモ化
const filteredItems = useMemo(() => {
console.log('Filtering items...');
return items.filter(item => item.toString().includes(filter));
}, [items, filter]);
// 合計値をメモ化
const total = useMemo(() => {
console.log('Calculating total...');
return filteredItems.reduce((sum, item) => sum + item, 0);
}, [filteredItems]);
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<p>合計: {total}</p>
<ul>
{filteredItems.map(item => <li key={item}>{item}</li>)}
</ul>
</div>
);
}
### 使いどころの目安
- 「計算が重い」かつ「入力が変わらないレンダーが多い」ときに効きます
- 逆に、依存が毎回変わるなら効果は薄く、メモ化のオーバーヘッドが勝つこともあります
**注意**: `useMemo` は「再計算しない保証」ではなく、あくまで最適化ヒントです(結果が捨てられる可能性があります)。ロジックの正しさを `useMemo` に依存させないようにします。
useCallback:関数のメモ化
import { useCallback, useState, memo } from 'react';
// メモ化されたコンポーネント
const ExpensiveButton = memo(({ onClick, label }: { onClick: () => void; label: string }) => {
console.log(`Rendering button: ${label}`);
return <button onClick={onClick}>{label}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// useCallbackでメモ化しないと、textが変わるたびにbuttonが再レンダリング
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []);
const decrement = useCallback(() => {
setCount(prev => prev - 1);
}, []);
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<p>Count: {count}</p>
<ExpensiveButton onClick={increment} label="増やす" />
<ExpensiveButton onClick={decrement} label="減らす" />
</div>
);
}
### 使いどころの目安
- `React.memo` された子に関数propsを渡していて、propsの同一性が重要なとき
- `useEffect` の依存として関数を渡すとき(ただし「そもそも関数を依存にする設計」を疑うのが先)
**注意**: `useCallback(fn, deps)` は「関数を固定する」のではなく「depsが変わるまで同じ関数参照を返す」です。depsを抜くのではなく、参照している値を正しくdepsに入れます。
使いすぎに注意
// NG: 単純な計算にuseMemoは不要
const doubled = useMemo(() => count * 2, [count]);
// OK: 単純に計算すればいい
const doubled = count * 2;
// NG: 単純なコールバックにuseCallbackは不要(子がmemo化されていない場合)
const handleClick = useCallback(() => setCount(count + 1), [count]);
// OK: そのまま書く
const handleClick = () => setCount(count + 1);
useRef:参照の保持
useRefは2種類ある(混同しない)
useRef は同じAPIですが、実務では用途が2つに分かれます。
-
DOM参照(
ref={...}): inputのfocus、サイズ計測など - 値の箱(レンダー間で保持したいが再レンダーは不要): intervalId、最新のcallback、前回値など
後者をstateで持つと「更新のたびに再レンダー」が走り、タイマーやイベント処理が不安定になりやすいです。
useRefの最小アンチパターン集(NG/OKで覚える)
1) refでUIを更新しようとする
refの更新は再レンダーを起こしません。UIに出す値はstateで管理します。
// NG
const countRef = useRef(0);
const inc = () => {
countRef.current += 1;
};
return (
<div>
<button onClick={inc}>+1</button>
<p>{countRef.current}</p>
</div>
);
// OK
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<p>{count}</p>
</div>
);
2) refを “mutable state” として濫用する(デバッグ不能になりがち)
refは便利ですが、何でもrefに入れると「いつ、どこで、何が変わったか」が追えなくなります。
基本は UIに影響するならstate、外部オブジェクトのハンドル(timerId等)ならref で割り切ると安定します。
3) 依存配列を減らすためにrefに逃がす(本当は再同期が必要)
「depsが増えるのが嫌だからrefに入れておく」は危険です。
外部世界(購読対象や接続先)が変わるなら、再購読/再接続が必要で、depsに入れるのが正しい設計です。
4) 最新値保持パターンの落とし穴
最新値をrefで保持する(useLatest / useEvent)は便利ですが、使いどころを間違えるとバグります。
- OK: タイマー/イベント内で“最新の値を読む”だけ(購読対象は固定)
- NG: 本来は「値が変わったら外部世界を切り替えるべき」なのに、refで誤魔化す
DOM要素への参照
import { useRef, useEffect } from 'react';
function TextInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// マウント時にフォーカス
inputRef.current?.focus();
}, []);
const handleClick = () => {
inputRef.current?.select();
};
return (
<div>
<input ref={inputRef} />
<button onClick={handleClick}>全選択</button>
</div>
);
}
値の保持(再レンダリングを引き起こさない)
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef<number | null>(null);
const start = () => {
if (intervalRef.current !== null) return;
intervalRef.current = window.setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
};
const stop = () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
useEffect(() => {
return () => stop(); // クリーンアップ
}, []);
return (
<div>
<p>{seconds}秒</p>
<button onClick={start}>開始</button>
<button onClick={stop}>停止</button>
</div>
);
}
// 前回の値を保持
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>現在: {count}, 前回: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
カスタムフックの作成
カスタムフック設計のミニテンプレ(実務)
カスタムフックは「再利用」のためだけではなく、責務分離のために使うと効果が出ます。
- 入力(引数): 外から注入できるものを増やすとテストしやすい(例:
fetcherを渡す) - 出力(戻り値): UIが欲しい形(状態遷移)で返す(
{data, loading, error, actions...}) - 失敗時: 例外を握りつぶさず、呼び出し側が表示できる形にする
- ブラウザAPI: SSR環境では存在しない(
window)ので分岐や遅延初期化を意識する
テスト容易性の観点(この4つで差がつく)
-
依存注入:
fetchやlocalStorageを直呼びしない。fetcher/storageを引数で受け取れるようにする - 副作用境界: effectは“外部世界との同期”だけにし、ドメインロジックは純粋関数へ寄せる
- 時間: debounce/intervalなど時間依存は、delayを引数にする・テストでは擬似時計を使える設計にする
- 中断: abort/cancelをサポートすると、StrictModeや画面遷移でも壊れにくく、テストもしやすい
小さなテンプレ例:fetcher注入 + Abort対応のuseAsync
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
type Fetcher<T> = (signal: AbortSignal) => Promise<T>;
function useAsync<T>(fetcher: Fetcher<T>, deps: unknown[]) {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading' });
fetcher(controller.signal)
.then((data) => setState({ status: 'success', data }))
.catch((e) => {
if (e instanceof DOMException && e.name === 'AbortError') return;
setState({ status: 'error', error: e instanceof Error ? e : new Error('Unknown error') });
});
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return state;
}
// usage: fetcherを注入できるのでテストが簡単
// const state = useAsync((signal) => api.getUser(userId, { signal }), [userId]);
注意: 上のテンプレは「depsの設計」が肝です。deps を外から渡す場合は、呼び出し側で依存が正しく宣言される前提になります。
もう一段安全にするなら、deps を引数で渡さず useAsync(fetcher, [/*参照した値*/]) の形に固定するなど、チームのルールを決めるのがおすすめです。
基本的なカスタムフック
// useToggle: トグル状態の管理
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue(prev => !prev);
}, []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
// 使用例
function Modal() {
const { value: isOpen, toggle, setFalse: close } = useToggle();
return (
<div>
<button onClick={toggle}>モーダルを開く</button>
{isOpen && (
<div className="modal">
<p>モーダルの内容</p>
<button onClick={close}>閉じる</button>
</div>
)}
</div>
);
}
データフェッチング用カスタムフック
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (e) {
setError(e instanceof Error ? e : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
### 事故りやすいポイント(fetch系)
- URLが変わるたびに「古いリクエストが後から返って上書き」するレースに注意(中断、もしくはリクエストIDで無視)
- キャッシュ/リトライ/重複排除が必要になったら、SWR/React Query等の採用も含めて検討する(自作し続けると運用負荷が増えがち)
// 使用例
function UserList() {
const { data: users, loading, error, refetch } = useFetch<User[]>('/api/users');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<button onClick={refetch}>更新</button>
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
フォーム管理用カスタムフック
interface UseFormOptions<T> {
initialValues: T;
validate?: (values: T) => Partial<Record<keyof T, string>>;
onSubmit: (values: T) => Promise<void>;
}
function useForm<T extends Record<string, any>>({
initialValues,
validate,
onSubmit
}: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (field: keyof T) => (
e: React.ChangeEvent<HTMLInputElement>
) => {
setValues(prev => ({ ...prev, [field]: e.target.value }));
};
const handleBlur = (field: keyof T) => () => {
setTouched(prev => ({ ...prev, [field]: true }));
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
return;
}
}
setIsSubmitting(true);
try {
await onSubmit(values);
setValues(initialValues);
setTouched({});
} finally {
setIsSubmitting(false);
}
};
const reset = () => {
setValues(initialValues);
setErrors({});
setTouched({});
};
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset,
setValues
};
}
// 使用例
function SignupForm() {
const form = useForm({
initialValues: { email: '', password: '' },
validate: (values) => {
const errors: Record<string, string> = {};
if (!values.email) errors.email = 'メールは必須です';
if (!values.password) errors.password = 'パスワードは必須です';
if (values.password.length < 8) {
errors.password = 'パスワードは8文字以上必要です';
}
return errors;
},
onSubmit: async (values) => {
await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(values)
});
}
});
return (
<form onSubmit={form.handleSubmit}>
<input
value={form.values.email}
onChange={form.handleChange('email')}
onBlur={form.handleBlur('email')}
placeholder="メール"
/>
{form.touched.email && form.errors.email && (
<span>{form.errors.email}</span>
)}
<input
type="password"
value={form.values.password}
onChange={form.handleChange('password')}
onBlur={form.handleBlur('password')}
placeholder="パスワード"
/>
{form.touched.password && form.errors.password && (
<span>{form.errors.password}</span>
)}
<button type="submit" disabled={form.isSubmitting}>
登録
</button>
</form>
);
}
ローカルストレージ連携
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue] as const;
}
// 使用例
function Settings() {
const [settings, setSettings] = useLocalStorage('settings', {
darkMode: false,
notifications: true
});
return (
<div>
<label>
<input
type="checkbox"
checked={settings.darkMode}
onChange={e => setSettings({
...settings,
darkMode: e.target.checked
})}
/>
ダークモード
</label>
</div>
);
}
デバウンス・スロットル
// useDebounce
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// 使用例:検索入力
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) {
// APIを呼び出す
searchAPI(debouncedQuery);
}
}, [debouncedQuery]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="検索..."
/>
);
}
// useDebouncedCallback
function useDebouncedCallback<T extends (...args: any[]) => any>(
callback: T,
delay: number
) {
const callbackRef = useRef(callback);
const timeoutRef = useRef<number>();
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
return useCallback((...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
callbackRef.current(...args);
}, delay);
}, [delay]);
}
まとめ
この記事では、React Hooksについて基礎から実践的なカスタムフックまで解説しました。
ここから先は「現場で壊れないための最小チェックリスト」です。レビューやデバッグでそのまま使えます。
| Hook | 用途 |
|---|---|
| useState | 状態管理の基本 |
| useEffect | 副作用の処理 |
| useContext | グローバルな状態共有 |
| useReducer | 複雑な状態管理 |
| useMemo | 計算結果のメモ化 |
| useCallback | 関数のメモ化 |
| useRef | DOM参照・値の保持 |
実務チェックリスト(レビュー観点)
state
- stateはUIの状態遷移の最小集合になっている(派生値のコピーを持っていない)
- 状態が増えてきたら
useReducerで遷移の中心を作れている(更新ロジックが散らばっていない)
useEffect
- effectは「外部世界との同期」だけに使っている(派生stateを作っていない)
- 依存配列から値を抜いてeslintを黙らせていない(抜きたくなる設計を直している)
- 購読/タイマー/イベントは必ずcleanupしている
- fetch等の非同期は「中断か無視」を実装している(StrictMode/画面遷移で壊れない)
追加で、次を満たしていると“事故率”が下がります。
- effectに
asyncを直接渡していない(Promiseを返さない) - fetchの競合(古いレスポンスの上書き)を防げている(中断/リクエストID/無視フラグ)
- StrictMode(開発時)で副作用が二重でも壊れない(idempotent)
参照: 本文の「useEffectの最小アンチパターン集」を見ながらレビューすると、見落としが減ります。
useMemo / useCallback
- まず計測(Profiler)して、必要な箇所だけに入れている(惰性で入れていない)
- メモ化が“正しさ”に影響していない(あくまで最適化)
- 子が
React.memoされていないのにuseCallbackを乱用していない
追加で、次を確認すると“最適化がバグの原因”になるのを避けやすいです。
- depsを抜いて固定化していない(stale closureにならない)
- 「オブジェクトを安定化するためだけのuseMemo」が巨大化していない(propsの形の見直しが先)
- メモ化の前に、状態スコープ/分割/リスト仮想化など“構造改善”を検討している
useRef
-
useRefを「DOM参照」と「値の箱」で混同していない - refの更新でUIを変えようとしていない(UIに影響するならstate)
追加で、次を満たしていると設計が壊れにくいです。
- depsを減らすためにrefへ逃がしていない(本来再同期が必要ならdepsに入れる)
- refを“なんでも入るmutable state”にしてデバッグ不能にしていない
カスタムフック
- 戻り値がUIの意図を表す形(
{data, loading, error, actions...})になっている - 依存性注入(fetcher等)やSSR配慮が必要な箇所を意識できている
追加で、次を確認するとテストと運用が楽になります。
- 外部依存(fetch/storage/time)を注入できる(直呼びしない)
- StrictMode/画面遷移を想定して中断(abort/cancel)できる
トラブルシュート最短ルート
「無限ループ」や「effectが止まらない」
- effectの中で
setStateしていないか(派生stateを作っていないか) - effectが参照している値を列挙し、依存配列に入っているか
- 依存に「毎回変わる object/function」が入っていないか
- 必要な値だけ依存にする/作成場所を変える/必要な場合だけ
useMemo/useCallback
- 必要な値だけ依存にする/作成場所を変える/必要な場合だけ
「StrictModeで二重に走って壊れる(開発だけ壊れる)」
- cleanupがあるか(購読解除、タイマー停止)
- fetchが中断可能か(AbortController)
- 副作用が二重でも壊れない(idempotent)設計か
「遅い/再レンダーが多い」
- React DevTools Profilerで“本当に重い場所”を特定する
- propsの形を見直す(巨大オブジェクト、毎回作る配列/関数)
- 最後に
React.memo+useCallback/useMemoを最小限入れる
カスタムフックのポイント
-
useから始める命名規則 - 再利用可能なロジックを抽出
- 複数のHooksを組み合わせて高度な機能を実現
- テストしやすい設計
Hooksを使いこなすことで、より読みやすく保守しやすいReactアプリケーションを構築できます。まずは基本的なHooksから始めて、徐々にカスタムフックの作成にも挑戦してみてください!