12k
All articles

不要なReactエフェクトを見つける

eslint-plugin-react-you-might-not-need-an-effectで不要なReactエフェクトを検出し、派生状態やイベント処理、props変更時のリセットをリファクタリングする方法を紹介します。

OpenReplay Team
OpenReplay Team
不要なReactエフェクトを見つける

eslint-plugin-react-you-might-not-need-an-effect は、Reactのドキュメントで不要とされている useEffect の呼び出しを検出するESLint/Oxlint用プラグインです。たとえば、state を導出するエフェクト、イベントを中継するエフェクト、親コンポーネントへデータを渡すエフェクトなどが対象です。

You Might Not Need an Effect(そのエフェクトは不要かも)を読んで納得したものの、いざ useEffect の呼び出しが400箇所もあるコードベースを開いてみると、どれが問題なのかを見分ける現実的な手段がない。そんな経験はないでしょうか。

本記事では、このプラグインのインストール方法、実際的なコンポーネントに対してどのような指摘が出るか、よくある4種類の指摘に対するリファクタリング方法、そして修正ではなく警告を抑制すべきケースについて解説します。フック自体のおさらいには、こちらのReactのuseEffectフックのガイドを参照してください。

重要なポイント

  • eslint-plugin-react-you-might-not-need-an-effect には9つのルールと2つのFlat Config用プリセットが含まれています。recommended はすべてのルールを警告として、strict はすべてのルールをエラーとして報告します。従来の .eslintrc を使うプロジェクト向けには、対応する legacy-recommended と legacy-strict プリセットが用意されています。
  • 現在の props と state だけに依存する値は、エフェクトで state にコピーするのではなく、レンダー中に計算しましょう。
  • 顧客IDのような prop が変わったときにコンポーネントの state をすべてリセットしたい場合は、その prop を key に指定してコンポーネントをレンダーします。
  • タイマー、DOM API、React以外のウィジェット、ネットワーク接続といった外部システムとReactを同期させるエフェクトこそ、useEffect 本来の役割です。これらは残しておきましょう。
  • 警告を抑制する場合は、ルール名を明記し、-- の後に理由を記述した eslint-disable-next-line コメントを使います。

不要なReactエフェクトとは何か

不要なエフェクトの大半は、2つのパターンのいずれかに当てはまります。値を再計算して state に格納するエフェクトか、ある state を監視して別の state をそれに合わせて更新するエフェクトです。どちらの場合も、Reactはまず古い値でレンダーをコミットし、エフェクトを実行してから、修正後の値で再度レンダーします。

不要なエフェクトが引き起こすこの余分なレンダーは、ユーザーの目にも見えてしまいます。エフェクトを多用したコンポーネントのセッションリプレイを見ると、2回目のレンダーで値が埋まるまでの1フレームの間、フィルタ済みリストが空になっていたり、合計欄にプレースホルダーが表示されていたりすることがあります。解決策は、ほぼ例外なく、エフェクトを調整することではなく state そのものを削除することです。

プラグインのインストールと実行

このプラグインは開発用依存関係としてインストールし、recommended プリセットを通じてESLintのFlat Configに組み込みます。すでに複数のESLintプラグインを管理している場合でも、競合せずに併用できます。

npm install --save-dev eslint-plugin-react-you-might-not-need-an-effect
// eslint.config.js
import reactYouMightNotNeedAnEffect from "eslint-plugin-react-you-might-not-need-an-effect";
import globals from "globals";

export default [
  reactYouMightNotNeedAnEffect.configs.recommended,
  {
    languageOptions: {
      globals: { ...globals.browser },
      parserOptions: { ecmaFeatures: { jsx: true } },
    },
  },
];

本記事の例はバージョン1.0.2を対象としています。プラグインのCHANGELOGによると、このバージョンは2026年8月20日にリリースされました。同じCHANGELOGには、no-pass-ref-to-parent と no-empty-effect が1.0より前に削除されたことも記載されているため、古い設定に含まれている場合は削除してください。Oxlintのユーザーは、Oxlintの jsPlugins フィールドを通じてプラグインを読み込みます。Oxlint、レガシー設定、そしてプリセットを使用する場合のFlat Configでは、ルールIDにケバブケースの react-you-might-not-need-an-effect/ プレフィックスを使用します。これは、プリセットがパッケージ名でプラグインを登録するためです。キャメルケースの reactYouMightNotNeedAnEffect/ プレフィックスが適用されるのは、READMEの単一ルールの例のように、そのキーで自分でプラグインを登録した場合のみです。

以下は4つのエフェクトを持つコンポーネントです。それぞれのエフェクトは、各ルールのドキュメントに記載されたパターンに一致するように書かれています。

import { useEffect, useState } from "react";
import { postOrder, useCustomerHistory } from "./api";

export function OrderPanel({ customerId, items, onLoaded }) {
  const [total, setTotal] = useState(0);
  const [submitted, setSubmitted] = useState(false);
  const [note, setNote] = useState("");
  const history = useCustomerHistory(customerId);

  // Effect 1
  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
  }, [items]);

  // Effect 2
  useEffect(() => {
    if (submitted) postOrder(customerId, items, note);
  }, [submitted, customerId, items, note]);

  // Effect 3
  useEffect(() => {
    onLoaded(history);
  }, [history, onLoaded]);

  // Effect 4
  useEffect(() => {
    setNote("");
    setSubmitted(false);
  }, [customerId]);

  return (
    <form onSubmit={(e) => { e.preventDefault(); setSubmitted(true); }}>
      <textarea value={note} onChange={(e) => setNote(e.target.value)} />
      <p>Total: {total}</p>
      <button type="submit">Place order</button>
    </form>
  );
}

npx eslint src/OrderPanel.jsx を実行してください。recommended では、指摘は警告として表示され、各行の末尾にルールIDが付きます。各エフェクトと、それが該当するルールの対応は次のとおりです。

エフェクトパターンルール
1props から計算される stateno-derived-state
2イベントのトリガーとして使われる state フラグno-event-handler
3取得したデータを親のコールバックに渡すno-pass-data-to-parent
4prop の変更時に state をクリアするno-adjust-state-on-prop-change(setter ごとに1件の警告)

エフェクト4は、key で解決できるケースです。より単純なコンポーネントであれば、同じパターンは no-reset-all-state-on-prop-change で報告されます。しかし、このコンポーネントのようにレンダー中に state を読み取っている場合、バージョン1.0.2では各リセットが no-adjust-state-on-prop-change として報告されます。

全ルールと、それぞれの置き換え方法は次のとおりです。

ルールエフェクトの置き換え先
no-derived-stateレンダー中に計算する値
no-chain-state-updates1つにまとめた state 更新
no-event-handlerイベントハンドラ内のコード
no-adjust-state-on-prop-changestate の再構成、またはレンダー時の計算
no-reset-all-state-on-prop-changekey として渡す prop
no-pass-live-state-to-parentstate のリフトアップ、またはフックからの戻り値
no-pass-data-to-parent親でのデータ取得と、その結果の props としての受け渡し
no-external-store-subscriptionuseSyncExternalStore
no-initialize-stateuseState() の初期値

このプラグインは呼び出しチェーンと依存配列を追跡するため、Lintの処理量が増えます。そのコストが自分のリポジトリでどの程度になるかは、ESLintのルールプロファイリング(TIMING=1)で確認できます。また、READMEでは、プラグインが依存配列の正しさを前提としていることから react-hooks/exhaustive-deps を、非同期関数の呼び出しを認識しやすくすることから typescript-eslint/no-floating-promises を、それぞれ有効にすることを推奨しています。

導出 state:レンダー中に計算する

既存の props と state から算出できる値は、レンダー中に計算しましょう。そのコピーを state に保持してエフェクトで同期させてはいけません。Reactのドキュメントでは、props や state に基づいて state を更新するの項でこの点を解説しています。

// Before
const [total, setTotal] = useState(0);
useEffect(() => {
  setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);

// After
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);

これで、合計値は最初のレンダーから正しく表示されます。計算コストが高いと判明した場合は useMemo でラップしてください。state に戻してはいけません。

イベントハンドラの処理:ハンドラ内に移動する

送信やクリックのハンドラでセットされた state フラグを待ち受けるエフェクトは、実質的にはイベントハンドラです。そのロジックはハンドラ自体の中で実行しましょう。その根拠はエフェクトからイベントを分離するで説明されています。

// Before
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
  if (submitted) postOrder(customerId, items, note);
}, [submitted, customerId, items, note]);

// After
function handleSubmit(e) {
  e.preventDefault();
  postOrder(customerId, items, note);
}

修正前のバージョンには実際のバグがあります。送信後にメモを編集すると、注文が再送信されてしまうのです。0.10.1以降、no-event-handler はこのように使われている props も検出するようになり、0.10.2以降は宣言箇所ではなく使用箇所で報告するようになりました。

データ取得と親へのデータ受け渡し

データを取得してエフェクト経由で親に渡している子コンポーネントは、その役割を親、フレームワークのデータローディング機能、またはデータ取得ライブラリに委ね、結果を props として受け取るべきです。Reactのドキュメントでは、親にデータを渡すの項で同様の制御の反転について説明しています。

// Before: child fetches, then pushes up
const history = useCustomerHistory(customerId);
useEffect(() => {
  onLoaded(history);
}, [history, onLoaded]);

// After: parent owns the data
function OrderPage({ customerId, items }) {
  const history = useCustomerHistory(customerId); // loader, library, or parent effect
  return <OrderPanel customerId={customerId} items={items} history={history} />;
}

データが一方向に流れることで、どのコンポーネントがデータを所有しているかが明確になります。また、OrderPanel は読み込んだ内容を報告するためだけの onLoaded コールバック prop を必要としなくなります。

prop 変更時の state リセット:key を使う

顧客IDのような prop が変わったときにコンポーネントの state をすべてリセットするには、その prop を key に指定してコンポーネントをレンダーします。これにより、Reactは新しいインスタンスをマウントします。このパターンはprop が変更されたときにすべての state をリセットするで解説されています。

// Before: Effect 4 inside OrderPanel
useEffect(() => {
  setNote("");
  setSubmitted(false);
}, [customerId]);

// After: delete the effect; in the parent
<OrderPanel key={customerId} customerId={customerId} items={items} history={history} />

no-adjust-state-on-prop-change は、prop に応じて一部の state だけが変わる部分的なケースを対象としたルールですが、前述のLint実行結果のとおり、エフェクト4も報告します。state の一部だけをリセットすべき場合は、通常、state を再構成するか、レンダー中に値を計算するのが解決策となります。

誤検知と残すべきエフェクト

コンポーネントをReactの管理外にあるもの(タイマー、DOM API、React以外のウィジェット、ネットワーク接続)と接続するエフェクトこそ、useEffect が作られた本来の目的です。これらはそのまま残しましょう。エフェクトを使って同期を行うで説明されているように、そのほとんどにはクリーンアップ関数が必要です。DOMの計測によって目に見えるちらつきが発生する場合は、エフェクトを残すかどうかよりも、useEffect と useLayoutEffect のどちらを選ぶかのほうが重要です。

ただし、外部ストアへのサブスクリプションは、同期用エフェクトを残すという原則の例外です。これらはuseSyncExternalStoreで扱うべきもので、エフェクトから state へコピーするのではなく、レンダー中にスナップショットを読み取ります。エフェクトベースの実装は no-external-store-subscription によって検出されます。

エフェクトの静的解析にはエッジケースが存在します。React公式のset-state-in-effectルールは、エフェクト内で state の setter が同期的に呼び出されると警告を出しますが、set-state-in-effect の厳格さに関するReactのissueでは、マウントフラグやクライアント判定のパターンをどこまで厳格に扱うべきかについて議論されています。こうしたパターンに対する警告は、どちらかに断定する判定結果ではなく、コードを確認するきっかけとして扱いましょう。両方のプラグインを併用するのも妥当な選択です。Reactのルールはエフェクト内の setState をカバーし、本プラグインは親へのコールバック、連鎖的な state 更新、ストアのサブスクリプションの検出を追加します。

警告を抑制する前に、次の点を確認してください。

  1. そのエフェクトはReact外部のシステムとやり取りしているか?
  2. 依存関係が変わったとき、またはコンポーネントがアンマウントされたときにクリーンアップが必要か?
  3. レンダー処理やハンドラに移動すると、ユーザーから見える動作が変わるか?

これらの答えがエフェクトを残す根拠になるのであれば、その行でルールを無効化し、ESLintの -- による説明記法を使って理由を記録しましょう。

// eslint-disable-next-line react-you-might-not-need-an-effect/no-initialize-state -- client-only value, avoids hydration mismatch

このプレフィックスは、プリセット、Oxlint、レガシー設定のいずれにも対応しています。reactYouMightNotNeedAnEffect/ を使うのは、そのキーで自分でプラグインを登録した場合のみです。

段階的な導入

eslint-plugin-react-you-might-not-need-an-effect は、まず recommended で導入しましょう。こうすることで、指摘は警告として表示され、CIは引き続きパスします。コンポーネントを1つずつ確認しながら、それぞれリファクタリングするか注釈を付けていき、警告数がゼロになった時点で strict に切り替えます。これにより、新たに追加された不要なエフェクトはビルドを失敗させるようになります。

よくある質問

eslint-plugin-react-you-might-not-need-an-effect は従来の .eslintrc 設定でも使えますか?

はい。従来の .eslintrc を使うプロジェクトでは、すべてのルールを警告として報告する場合は plugin:react-you-might-not-need-an-effect/legacy-recommended を、エラーとして報告する場合は plugin:react-you-might-not-need-an-effect/legacy-strict を extends に指定します。レガシー設定では、ルールIDと無効化コメントにケバブケースの react-you-might-not-need-an-effect/ プレフィックスを使用します。これはFlat Configのプリセットと同じプレフィックスです。

導出値を毎回のレンダーで計算する代わりに useMemo でラップすべきなのはどのような場合ですか?

useMemo は、計算が計測可能なほど遅い場合にのみ使用してください。Reactのドキュメントでは、console.time で計測し、合計がおおよそ1ms以上かかる場合にメモ化することを推奨しています。useMemo は結果を state に入れずにレンダー間でキャッシュするため、最初のレンダーから正しい値になります。React Compiler は多くの計算を自動的にメモ化できるため、導出値に対して手動で useMemo を呼び出す必要性は少なくなっています。

コンポーネントの key を変更すると、子コンポーネントの state もリセットされますか?

はい。key が変わると、Reactはそのコンポーネントとサブツリー全体をアンマウントし、新しいインスタンスをマウントします。すべての子コンポーネントはローカルの state を失い、DOMノードは再作成され、すべてのエフェクトはクリーンアップを実行した後に再実行されます。子の state を維持したまま一部の state だけをリセットしたい場合は、key を使うのではなく、その state を直接調整するか再構成してください。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.