react-effects について
Reactで外部システムとの同期に`useEffect`を使う場面と、代替手段を選ぶ場面を解説する開発スキルです。React開発者向けに、不要な状態更新やEffectの連鎖を避ける方法と、クリーンアップや競合状態への対処を説明します。
主な機能
- 01不要な`useEffect`の見分け
- 02レンダー時の計算とイベント処理の活用
- 03状態リセットや購読の代替手段
- 04クリーンアップと非同期競合への対処
- 05依存関係とEffect連鎖の確認
活用シーン
- 01コンポーネントの`useEffect`をレビュー
- 02派生状態をEffectから取り除く
- 03Effectによるデータ取得の競合を防ぐ
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ whinc/ super-skills --skill react-effectsインストール後、Claude Code で/react-effectsを入力して読み込まれたか確認します。
同梱ファイルevals/evals.json
詳細
React の Effect:必要な場合と不要な場合
Effect は、React コンポーネントを外部システム(ブラウザー API、ネットワーク、サードパーティライブラリ)と同期するための非常口です。ほとんどのコンポーネントロジックに Effect は必要ありません。useEffect を書いたり、そのまま残したりする前に、以下のシナリオを確認してください。よりシンプルでパフォーマンスに優れた方法があるはずです。
2 つの質問
useEffect を使う前に、次のことを確認してください。
- レンダリング用にデータを変換していますか? そうであれば、代わりにレンダリング中に計算してください。
- ユーザーイベントを処理していますか? そうであれば、代わりにイベントハンドラーに記述してください。
どちらにも当てはまらない場合は、Effect が必要かもしれません。
Effect を使うべきでないシナリオ
1. Props または state から導出される値
最もよくある間違いです。既存の props または state から計算できる値は、そもそも state ではなく、レンダリング時に計算する値です。
Effect が有害な理由: React は古い値で一度レンダリングして DOM に反映し、その後 Effect が実行されて 2 回目のレンダリングサイクルを引き起こす setState が呼び出されます。ユーザーには一時的に古い UI が表示されます。
// WRONG: Redundant state + unnecessary Effect
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// RIGHT: Compute during render — zero extra renders, zero extra state
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const fullName = firstName + ' ' + lastName;
検出パターン: setSomeState(f(props, state)) の呼び出しだけを目的とする useEffect。
2. コストの高い計算のキャッシュ
計算コストが本当に高い場合(本番環境でのプロファイリングで 1ms 超)、state を使う Effect ではなく useMemo を使ってください。
// WRONG: Effect + state for caching
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// RIGHT (simple case): Just compute it
const visibleTodos = getFilteredTodos(todos, filter);
// RIGHT (expensive): useMemo skips recomputation when deps haven't changed
const visibleTodos = useMemo(
() => getFilteredTodos(todos, filter),
[todos, filter]
);
コストが高いかどうかの判断方法: 本番モードで console.time/console.timeEnd を使用してください。ログに記録された時間が一貫して 1ms 以上であれば、メモ化してください。追加のチェックが行われるため、開発モードでの計測時間は信頼できません。
3. Prop が変わったときにすべての state をリセットする
userId のような prop が変わったとき、コンポーネントの state(フォームのフィールド、スクロール位置など)をすべてクリアしたい場合は、Effect で state を一つずつリセットするのではなく、React の key の仕組みを使ってください。
Effect が有害な理由: コンポーネントは古い state で一度レンダリングされます(新しいユーザーに古いコメントが表示されます)。その後 Effect が state をクリアするため、2 回目のレンダリングが発生します。state を持つネストされた各コンポーネントにも、それぞれリセット用の Effect が必要になり、壊れやすくエラーが起きやすくなります。
// WRONG: Effect to reset state on prop change
function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment('');
}, [userId]);
return /* ... */;
}
// RIGHT: key tells React "this is a different component instance"
function ProfilePage({ userId }) {
return <Profile userId={userId} key={userId} />;
}
function Profile({ userId }) {
const [comment, setComment] = useState(''); // Auto-resets when key changes
return /* ... */;
}
検出パターン: 複数の state をリセットする useEffect(() => { setX(initial); setY(initial); ... }, [someProp])。
4. Prop が変わったときに一部の state を調整する
すべての state をリセットするのではなく、一部だけを調整したい場合もあります。多くの場合、state 自体をなくし、値を導出するのが最善の方法です。
// WRONG: Effect to clear selection when items change
function List({ items }) {
const [selection, setSelection] = useState(null);
useEffect(() => {
setSelection(null);
}, [items]);
return /* ... */;
}
// BETTER: Store the ID, derive the selected object
function List({ items }) {
const [selectedId, setSelectedId] = useState(null);
// If the selected item is still in the list, keep it; otherwise null
const selection = items.find(item => item.id === selectedId) ?? null;
return /* ... */;
}
レンダリング中にどうしても state を調整する必要がある場合(まれです)、Effect を使わずに行うこともできますが、このパターンは最終手段にしてください。
function List({ items }) {
const [prevItems, setPrevItems] = useState(items);
const [selection, setSelection] = useState(null);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
}
5. イベント固有のロジックを Effect に記述する
コードをユーザーの操作がきっかけで実行すべき場合(ボタンのクリックやフォームの送信など)は、state の変更に反応する Effect ではなく、イベントハンドラーに記述してください。
Effect が有害な理由: 追跡対象の state が変わるたびにロジックが実行されます。ページの読み込み、ナビゲーション、その他の state の復元時にも実行され、ユーザー操作への応答時だけに限定されません。
// WRONG: Shows notification whenever product.isInCart becomes true
// (including page refresh, back navigation, etc.)
function ProductPage({ product, addToCart }) {
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
}
// RIGHT: Notification is a direct response to user action
function ProductPage({ product, addToCart }) {
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
}
検出パターン: イベントハンドラーで設定された [someFlag] をきっかけに、showNotification、navigate、alert などの副作用を実行する useEffect。
6. ユーザー操作をきっかけとする POST リクエスト
ユーザー操作(フォームの送信、ボタンのクリック)に応じてサーバーにデータを送信する処理は、イベントハンドラーに記述してください。Effect に記述するのは、表示をきっかけとして実行するリクエスト(アナリティクスのページビューなど)のみです。
// WRONG: Roundabout way to send form data
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// RIGHT: Submit directly in the event handler
function handleSubmit(e) {
e.preventDefault();
post('/api/register', { firstName, lastName });
}
// This analytics Effect IS correct — it runs because the component displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
7. Effect の連鎖
それぞれが state を設定し、次の Effect を引き起こす複数の Effect。これにより不要なレンダリングが連鎖し、ロジックが追いにくくなります。
Effect の連鎖が有害な理由: 連鎖内の setState ごとに、個別のレンダリングが発生します。連鎖に Effect が 4 つある場合、コンポーネントは 1 回ではなく 5 回レンダリングされます。ロジックが複数の Effect に分散するため、追跡が困難になります。
// WRONG: Chain of Effects triggering each other
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1);
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
// RIGHT: Derive what you can, compute the rest in the event handler
const isGameOver = round > 5; // Derived, not state
function handlePlaceCard(nextCard) {
if (isGameOver) throw Error('Game already ended.');
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount < 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
検出パターン: ある useEffect が設定する state が、別の useEffect の依存配列に含まれている複数の useEffect フック。
8. アプリケーションの初期化
認証トークンの確認や localStorage からの設定の読み込みなど、コンポーネントのマウントごとではなく、アプリの読み込みごとに一度だけ実行するコードです。
// WRONG: Runs twice in StrictMode development, may cause issues
function App() {
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
}
// RIGHT (option A): Module-level guard
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
}
// RIGHT (option B): Module-level execution (runs on import, once)
if (typeof window !== 'undefined') {
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
9. 状態の変更を親コンポーネントに通知する
ローカルの状態が変化した後に Effect を使って親のコールバックを呼び出すと、余分なレンダーサイクルが発生し、更新の順序が予測しにくくなります。
// WRONG: Effect to notify parent — renders twice
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
useEffect(() => {
onChange(isOn);
}, [isOn, onChange]);
function handleClick() {
setIsOn(!isOn);
}
}
// RIGHT: Update child + notify parent in the same event
// React batches both setState calls into a single render
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
setIsOn(nextIsOn);
onChange(nextIsOn); // Parent updates in the same batch
}
function handleClick() {
updateToggle(!isOn);
}
}
// BEST: Fully controlled — no local state at all
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
}
検出パターン: useEffect(() => { onSomething(localState); }, [localState, onSomething])。
10. 親へのデータの受け渡し
子がデータを取得した後、Effect を使って親に渡します。これは React のデータフローを逆転させ、バグの追跡を難しくします。
// WRONG: Child fetches, then pushes data to parent via Effect
function Parent() {
const [data, setData] = useState(null);
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
useEffect(() => {
if (data) onFetched(data);
}, [onFetched, data]);
}
// RIGHT: Parent owns the data fetching, passes data down
function Parent() {
const data = useSomeAPI();
return <Child data={data} />;
}
原則: React ではデータは下方向に流れます。子と親の両方でデータが必要な場合は、親がデータを取得して子に渡してください。
11. 外部ストアの購読
React の制御外で変化するブラウザー API や外部データソース(例:navigator.onLine、ブラウザーの履歴、サードパーティの状態管理ライブラリー)を購読します。
// SUBOPTIMAL: Manual subscription in Effect
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function update() { setIsOnline(navigator.onLine); }
update();
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);
return isOnline;
}
// RIGHT: useSyncExternalStore — purpose-built for this
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine, // Client snapshot
() => true // Server snapshot
);
}
12. データの取得
Effect が適切なケースの一つです。ネットワークとの同期が必要ですが、クリーンアップ用のフラグを使って競合状態に対処する必要があります。
// CORRECT: Effect with ignore flag for race condition handling
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => { ignore = true; };
}, [query, page]);
本番アプリでは、データ取得をカスタムフックに切り出すか、キャッシュ、重複排除、競合状態への対処を自動で行う TanStack Query や SWR などのライブラリーを使うことをおすすめします。
クイックリファレンス:この Effect は必要ですか?
| Effect が行うこと | 代替方法 |
|---|---|
| props/状態から値を計算する | レンダー中に計算する(または、処理が重い場合は useMemo を使う) |
| prop の変更時にすべての状態をリセットする | key prop を追加する |
| prop の変更時に一部の状態を調整する | 値を保存せずに導出する |
| ユーザーのクリック/送信時にコードを実行する | イベントハンドラーに移す |
| ユーザー操作による POST リクエストを送信する | イベントハンドラーに移す |
| 別の Effect をトリガーする状態を設定する | 一つのイベントハンドラーにまとめる |
| アプリを一度だけ初期化する | モジュールレベルのコード、または didInit ガードを使う |
ローカルの setState 後に親の onChange を呼び出す | 同じイベントハンドラー内で onChange を呼び出す |
| 子から親にデータを渡す | データ取得を親に移す |
| 外部データソースを購読する | useSyncExternalStore を使う |
| データを取得する | Effect は維持しつつクリーンアップを追加する。TanStack Query の使用をおすすめします |
レビューチェックリスト
useEffect をレビューまたは記述するときは、次の点を確認してください。
- 必要性: レンダー時の計算、
useMemo、またはイベントハンドラーで代替できますか? - クリーンアップ: Effect で購読、タイマー、接続をクリーンアップしていますか?
- 競合状態: 非同期処理で
ignoreフラグまたは abort controller を使っていますか? - 依存関係: すべてのリアクティブな値が列挙されていますか? exhaustive-deps に対する
eslint-disableはありませんか? - 連鎖なし: この Effect 内の状態更新が別の Effect をトリガーしていませんか? している場合は、一つにまとめてください。
- ライフサイクルではないこと: 本当に外部システムとの同期が目的ですか? それとも、
componentDidMount/componentDidUpdateの考え方を別の形で表しているだけではありませんか?