mcpField
R
react-effects

react-effects

github.com/whinc開発40123 回インストール4 日前に更新
リポジトリ

react-effects について

Reactで外部システムとの同期に`useEffect`を使う場面と、代替手段を選ぶ場面を解説する開発スキルです。React開発者向けに、不要な状態更新やEffectの連鎖を避ける方法と、クリーンアップや競合状態への対処を説明します。

主な機能

  1. 01不要な`useEffect`の見分け
  2. 02レンダー時の計算とイベント処理の活用
  3. 03状態リセットや購読の代替手段
  4. 04クリーンアップと非同期競合への対処
  5. 05依存関係とEffect連鎖の確認

活用シーン

  1. 01コンポーネントの`useEffect`をレビュー
  2. 02派生状態をEffectから取り除く
  3. 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 を使う前に、次のことを確認してください。

  1. レンダリング用にデータを変換していますか? そうであれば、代わりにレンダリング中に計算してください。
  2. ユーザーイベントを処理していますか? そうであれば、代わりにイベントハンドラーに記述してください。

どちらにも当てはまらない場合は、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 をレビューまたは記述するときは、次の点を確認してください。

  1. 必要性: レンダー時の計算、useMemo、またはイベントハンドラーで代替できますか?
  2. クリーンアップ: Effect で購読、タイマー、接続をクリーンアップしていますか?
  3. 競合状態: 非同期処理で ignore フラグまたは abort controller を使っていますか?
  4. 依存関係: すべてのリアクティブな値が列挙されていますか? exhaustive-deps に対する eslint-disable はありませんか?
  5. 連鎖なし: この Effect 内の状態更新が別の Effect をトリガーしていませんか? している場合は、一つにまとめてください。
  6. ライフサイクルではないこと: 本当に外部システムとの同期が目的ですか? それとも、componentDidMount/componentDidUpdate の考え方を別の形で表しているだけではありませんか?

コメント