useEffectをむやみに使うとメンテナンスがかなり苦しくなるので、やめましょうと思った話

2025-01-28

はじめに

こんにちは。Belong でフロントエンドエンジニアをしている ryo です。
プロダクトに携わる中で、useEffect を使いすぎているコンポーネントのメンテナンスに携わったのですが、コードに改修を加える際にとても苦労をしました。
後から振り返ると、プロダクト黎明期のフロントエンドのプラクティスが確立されてない中で、典型的な useEffect のアンチパターンに陥っていたんだろうと思っていまして、こういった技術的負債が溜まるのはよくある事だと思っています。
今後の自分のためにも、具体的に何に困ったのか、どうするべきなのかを整理して、ブログにまとめることにしました。

何に困ったのか

私が今回メンテナンスを行なったコードは、だいぶざっくりですが以下のような状態でした。

// ...
useEffect(() => {
  updateSubType(selectedSubType)
}, [selectedSubType])

useEffect(() => {
  updateGrownIn(selectedGrownIn)
}, [selectedGrownIn])

useEffect(() => {
  if (category === 'fruit') {
    setSelectedSubType(defaultSubType)
    setSelectedGrownIn(defaultGlownIn)
  }
}, [category])

useEffect(() => {
  setSelectedSubType(defaultSubType)
  setSelectableSubType(getSelectableSubType(category))
}, [defaultSubType])

useEffect(() => {
  setSelectedGrownIn(defaultGrownIn)
  setSelectableGrownInList(filterGrownInList(defaultSubType))
}, [defaultGrownIn])

useEffect(() => {
  const [grownIn, subtype] = getSetting()
  setDefaultGrownIn(grownIn)
  setDefaultSubType(subtype)
}, [])

useEffect(() => {
  //   ...省略...
}, [])

useEffect(() => {
  //   ...省略...
}, [])
// ...

こんな調子で、一つのコンポーネントに useEffect が 8 つ利用されていました。こちら皆さんは変更を加えられそうですか?私は見た時に、「ウッ...」となりました。
上記の例を作成するために、リファクタリング以前のコードを読んでみましたが、やはりかなり読むのが苦しかったです。

冒頭でも少し触れましたが、今回対応に当たったコンポーネントの useEffect の使い方は、React 公式ドキュメントで紹介されているアンチパターンにほとんど該当していました。
React の公式ドキュメントではこの内容についてとても手厚く解説されており、誰しもが陥る落とし穴なんだろうなぁと感じています。

本題に入る前に、理解をしやすくするため、一旦 React の基礎的な知識を整理します。

React の基礎知識

ロジックの分類

公式ドキュメントの内容を少し噛み砕いて以下に記載します。:

  • React のコンポーネントは 3 つのロジックに分けられる
    • レンダーコード
      • UI を表示するためのコードで、これは純粋関数でなければならない
      • 平易に意訳すると、コンポーネントそのものを指しているイメージ
    • イベントハンドラ
      • 何かを実行して、変化させる、コンポーネント内にネストされた関数
      • 純粋関数でなくて良いし、むしろ副作用のための最適な場所
        • 外部へ HTTP POST リクエストを送信したり、画面の表示を変えたり etc...
          • (画面の表示を変える(React の状態を変える)には、コンポーネントのメモリを更新する必要がある)
    • エフェクト(useEffect による副作用)
      • 特定のイベントに属せないような、React 内で管理できない副作用を管理するためのもの
      • エフェクトは、コミットの最後に、画面が更新された後に実行される

上記の通り、エフェクトは最終手段である事が伺えます。

useEffect は React の世界から飛び出す選択

こちらも公式のドキュメントを少し噛み砕いて以下に記載します。:

  • エフェクトは通常、React のコードから「踏み出して」、何らかの外部システムと同期するために使用されるもの
  • 例えば、PC の時刻を取得したり、定期的に外部のサーバーと通信するような処理はこれに該当する
  • 逆に言えば、一般的な UI 操作には useEffect は使わない方が良いし、使わなくても実装が出来る

この基礎知識の上で、今回私がメンテナンスを行なったコードはなぜメンテナンスがしづらく感じたのかを整理します。

なぜメンテナンスがしづらかったのか

イベントハンドラを使うべき箇所で、useEffect を使っている

前述の通り、useEffect はコミットの最後に実行されます。この時 state の更新をしていると、再度 React のプロセスが最初からやり直されてしまいます。
だから影響を追うのが大変なんだと感じました。イベント実行時に、関連する useEffect がなければ、その処理はイベントハンドラ内だけを見ればいいため、かなり追いやすいかと思います。
個人的には、分割されていない様な、長い関数を読まなくてはいけないイメージかな?と思いました。もちろん「ペイント」のプロセスも最初からやり直しですので、パフォーマンスとしても良くないです。

不要な state を管理をしている

具体的には、上記の例で登場した setSelectableSubType, setSelectableGrownInの様な、すでにコンポーネントのメモリに保存されている値を元に、再度計算して専用のメモリに保存している処理が該当するかと思います。
公式ドキュメントにある通りですが、これは useEffect , useState を利用せずに、単にコンポーネントのトップレベルに書く方が、副作用の考慮も減ってコードが読みやすくなり、パフォーマンスもその方が良いです。

どうやって useEffect を減らしたか

実際に該当のコンポーネントの useEffect を、リファクタリングすることで 8 -> 2 個まで減らしました。どの様に解決したのかを冒頭で示したコード例を使って整理しようと思います。

1. イベントハンドラを使うべき箇所で、useEffect を使っている箇所をイベントハンドラに変更

以下についてです:

const [category, setCategory] = useState('fruit')
const [selectedSubType, setSelectedSubType] = useState('')
const [selectedGrownIn, setselectedGrownIn] = useState('')

const vegetables = ['cucumber', 'corn', 'tomato']
const fruits = ['apple', 'grape', 'orange']
const grownInList = ['japan', 'america', 'china']

useEffect(() => {
  if (category === 'fruit') {
    setSelectedSubType(defaultSubType)
    setSelectedGrownIn(defaultGlownIn)
  }
}, [category])
// ...
useEffect(() => {
  // ... 省略...
  updateSubType()
}, [selectedSubType])

useEffect(() => {
  updateGrownIn()
}, [selectedGrownIn])

return (
  <div>
    <select value={category} onChange={(e) => setSelectedFruit(e.target.value)}>
      <option value="vegitable">Vegitable</option>
      <option value="fruit">Fruit</option>
    </select>
    {category === 'fruit' && (
      <select
        value={selectedSubType}
        onChange={(e) => setSelectedSubType(e.target.value)}
      >
        {fruits.map((fruit) => (
          <option key={fruit} value={fruit}>
            {fruit}
          </option>
        ))}
      </select>
    )}
    {category === 'vegitable' && (
      <select
        value={selectedSubType}
        onChange={(e) => setSelectedSubType(e.target.value)}
      >
        {vegetables.map((vegitable) => (
          <option key={vegitable} value={vegitable}>
            {vegitable}
          </option>
        ))}
      </select>
    )}
    <select
      value={selectedGrownIn}
      onChange={(e) => setSelectedGrownIn(e.target.value)}
    >
      {grownInList.map((grownIn) => (
        <option key={grownIn} value={grownIn}>
          {grownIn}
        </option>
      ))}
    </select>
  </div>
)

ちょっと例が長くなってしまいましたが、要は、selectedSubType, selectedGrownInonChangeのイベントで、何か更新の処理をしたい事が伺えます。
もしかすると処理の共通化をしたくて、useEffect を使ったのかもしれませんが、後から読み返すと意図が分かりにくいです。

実際にリファクタリングをした結果、以下の様な感じで十分で、useEffect を使わなくても同じ機能を実装する事ができました。:

const [category, setCategory] = useState('fruit')
const [selectedSubType, setSelectedSubType] = useState('')
const [selectedGrownIn, setselectedGrownIn] = useState('')

const vegetables = ['cucumber', 'corn', 'tomato']
const fruits = ['apple', 'grape', 'orange']
const grownInList = ['japan', 'america', 'china']

return (
  <div>
    <select
      value={category}
      onChange={(e) => {
        setSelectedFruit(e.target.value)
        if (e.target.value === 'fruit') {
          setSelectedSubType(defaultSubType)
          setSelectedGrownIn(defaultGlownIn)
        }
      }}
    >
      <option value="vegitable">Vegitable</option>
      <option value="fruit">Fruit</option>
    </select>
    {category === 'fruit' && (
      <select
        value={selectedSubType}
        onChange={(e) => {
          setSelectedSubType(e.target.value)
          updateSubType(e.target.value)
        }}
      >
        {fruits.map((fruit) => (
          <option key={fruit} value={fruit}>
            {fruit}
          </option>
        ))}
      </select>
    )}
    {category === 'vegitable' && (
      <select
        value={selectedSubType}
        onChange={(e) => {
          setSelectedSubType(e.target.value)
          updateSubType(e.target.value)
        }}
      >
        {vegetables.map((vegitable) => (
          <option key={vegitable} value={vegitable}>
            {vegitable}
          </option>
        ))}
      </select>
    )}
    <select
      value={selectedGrownIn}
      onChange={(e) => {
        setSelectedGrownIn(e.target.value)
        updateGrownIn(e.target.value)
      }}
    >
      {grownInList.map((grownIn) => (
        <option key={grownIn} value={grownIn}>
          {grownIn}
        </option>
      ))}
    </select>
  </div>
)

イベントハンドラに置くことができる処理を useEffect からそこに移しただけですが、大分見通しが良くなったと思います。

公式ドキュメントのこちらに記載のある言葉が本当にその通りというか、とても大切だと思いましたので、以下に引用させていただきます。

あるコードがエフェクトにあるべきか、イベントハンドラにあるべきかわからない場合は、そのコードが実行される理由を自問してください。コンポーネントがユーザに表示されたために実行されるべきコードにのみエフェクトを使用してください。この例では、通知はユーザがボタンを押したために表示されるのであって、ページが表示されたためではありません!

2. 不要な state を削除する

以下についてです:

const [category, setCategory] = useState('fruit')
const [selectedSubType, setSelectedSubType] = useState('')
const [selectedGrownIn, setselectedGrownIn] = useState('')

const [selectableGrownInList, setSelectableGrownInList] = useState([])
const [selectableSubTypes, setSelectableSubTypes] = useState([])

useEffect(() => {
  setSelectedSubType(defaultSubType)
  setSelectableSubType(getSelectableSubType(category))
}, [defaultSubType])

useEffect(() => {
  setSelectedGrownIn(defaultGrownIn)
  setSelectableGrownInList(filterGrownInList(defaultSubType))
}, [defaultGrownIn])

useEffect(() => {
  const [grownIn, subtype] = getSetting()
  setDefaultGrownIn(grownIn)
  setDefaultSubType(subtype)
}, [])

return (
  <div>
    <select
      value={selectedSubType}
      onChange={(e) => {
        setSelectedSubType(e.target.value)
        updateSubType(e.target.value)
      }}
    >
      {selectableGrownInList.map((vegitable) => (
        <option key={vegitable} value={vegitable}>
          {vegitable}
        </option>
      ))}
    </select>
    <select
      value={selectedGrownIn}
      onChange={(e) => {
        setSelectedGrownIn(e.target.value)
        updateGrownIn(e.target.value)
      }}
    >
      {selectableSubTypes.map((grownIn) => (
        <option key={grownIn} value={grownIn}>
          {grownIn}
        </option>
      ))}
    </select>
  </div>
)

やりたい事としては、初回レンダー時に、defaultSubType, defaultGrownInを取得して、selectedSubType, selectedGrownInの初期値を設定したい様に見えます。
しかし、これは useEffect を使わずに、コンポーネントのトップレベルに書くだけで実現できます。:

const [grownIn, subtype] = getSetting()

const [category, setCategory] = useState('fruit')
const [selectedSubType, setSelectedSubType] = useState(subtype)
const [selectedGrownIn, setselectedGrownIn] = useState(grownIn)

const selectableGrownInList = filterGrownInList(subtype)
const selectableSubTypes = getSelectableSubType(category)

return (
  <div>
    <select
      value={selectedSubType}
      onChange={(e) => {
        setSelectedSubType(e.target.value)
        updateSubType(e.target.value)
      }}
    >
      {selectableGrownInList.map((vegitable) => (
        <option key={vegitable} value={vegitable}>
          {vegitable}
        </option>
      ))}
    </select>
    <select
      value={selectedGrownIn}
      onChange={(e) => {
        setSelectedGrownIn(e.target.value)
        updateGrownIn(e.target.value)
      }}
    >
      {selectableSubTypes.map((grownIn) => (
        <option key={grownIn} value={grownIn}>
          {grownIn}
        </option>
      ))}
    </select>
  </div>
)

もしかしたら、getSetting, getSelectableSubType, filterGrownInListの処理が重い処理だったため、キャッシュをしたくてエフェクトを利用したのかもしれません。
しかし、その場合は、useMemoの方が適切です。このエフェクトの使い方についてはアンチパターンとしてドキュメントに記載があります

ここまで整理した手法を、冒頭のコード例に適用すると、useEffect がどうなるのか以下に整理します。 のコメントがある useEffect が、削除可能なコードです。:

// ...
useEffect(() => {
  // ✅1.
  updateSubType(selectedSubType)
}, [selectedSubType])

useEffect(() => {
  // ✅1.
  updateGrownIn(selectedGrownIn)
}, [selectedGrownIn])

useEffect(() => {
  // ✅2.
  if (category === 'fruit') {
    setSelectedSubType(defaultSubType)
    setSelectedGrownIn(defaultGlownIn)
  }
}, [category])

useEffect(() => {
  // ✅2.
  setSelectedSubType(defaultSubType)
  setSelectableSubType(getSelectableSubType(category))
}, [defaultSubType])

useEffect(() => {
  // ✅2.
  setSelectedGrownIn(defaultGrownIn)
  setSelectableGrownInList(filterGrownInList(defaultSubType))
}, [defaultGrownIn])

useEffect(() => {
  // ✅2.
  const [grownIn, subtype] = getSetting()
  setDefaultGrownIn(grownIn)
  setDefaultSubType(subtype)
}, [])

useEffect(() => {
  //   ...省略...
}, [])

useEffect(() => {
  //   ...省略...
}, [])
// ...

ということで、8 個の useEffect が 2 個にできました。コード量も減り、かなり読みやすくなりますし、「ペイント」までのプロセスも制御しやすく、パフォーマンス面にも好影響があるかと思います。

まとめ

今回の対応を通して、useEffect のメンテナンスの難しさ、また、useEffect は使わなくても React の実装は基本的に出来る事を実体験を通して学ぶ事が出来たため、とても貴重な経験になりました。
これを教訓に、保守性が高く、変更容易性に優れ、パフォーマンス面でも最適なコードを書いていけるように努めていきたいと思います。

こういった内容だったり、React を用いたフロントエンド開発に興味がある方は エンジニアリングチーム紹介ページ をご覧いただけると幸いです。
最後までお読みいただき、ありがとうございました。

参考資料