ESLint - React - You Might Not Need An Effectの使い方

You Might Not Need An Effectとは

You Might Not Need An Effectとは、Reactの公式ドキュメントで紹介されているuseEffectに関する考え方です。

ReactのuseEffectは副作用を扱うためのフックですが、実際には多くの場面で本来は不要なuseEffectが書かれていることがよくあります。

propsまたはstateに基づいて状態を更新する

firstNameとlastNameという2つの状態変数を持つコンポーネントがあるとします。

これらを連結してfullNameを生成したいとします。

さらに、firstNameまたはlastNameが変更されるたびにfullNameも更新したいとします。この場合、まず思いつくのはfullName状態変数を追加してuseEffect内で更新することです。

App.tsx
import { useState, useEffect } from 'react'
import './App.css'

function App() {
  const [firstName, setFirstName] = useState('Taro')
  const [lastName, setLastName] = useState('Yamada')
  const [fullName, setFullName] = useState('')

  useEffect(() => {
    setFullName(firstName + ' ' + lastName)
  }, [firstName, lastName])

  return (
    <div>
      <h1>Hello, {fullName}!</h1>
      <div>
        <div>
          <label htmlFor="firstName">First Name: </label>
          <input
            id="firstName"
            value={firstName}
            onChange={(e) => setFirstName(e.target.value)}
          />
        </div>
        <div>
          <label htmlFor="lastName">Last Name: </label>
          <input
            id="lastName"
            value={lastName}
            onChange={(e) => setLastName(e.target.value)}
          />
        </div>
      </div>
    </div>
  )
}

export default App

このコードは動作しますが、fullNameの値が古いままレンダリングパス全体を実行し、その後すぐに更新された値で再レンダリングするため、非効率的です。

この場合は「const fullName = firstName + ' ' + lastName」にしたほうが無駄な処理を削減できるので効率的です。

App.tsx
import { useState } from 'react'
import './App.css'

function App() {
  const [firstName, setFirstName] = useState('Taro')
  const [lastName, setLastName] = useState('Yamada')
  const fullName = firstName + ' ' + lastName

  return (
    <div>
      <h1>Hello, {fullName}!</h1>
      <div>
        <div>
          <label htmlFor="firstName">First Name: </label>
          <input
            id="firstName"
            value={firstName}
            onChange={(e) => setFirstName(e.target.value)}
          />
        </div>
        <div>
          <label htmlFor="lastName">Last Name: </label>
          <input
            id="lastName"
            value={lastName}
            onChange={(e) => setLastName(e.target.value)}
          />
        </div>
      </div>
    </div>
  )
}

export default App

このコードはシンプルな内容なので、Reactの中級者以上であればコードに問題があることはすぐにわかると思います。

でも、初心者だったり、コードが複雑だったりすると前述のような非効率なコードがあっても気づかない可能性があります。

ESLintのプラグインでReactの非効率なコードを自動検出

ReactのYou Might Not Need an Effectに該当する非効率なコードを目視だけですべて判別するのは難しいです。

しかし、以下のYou Might Not Need an Effectに該当するReactの不要なuseEffectsを検出するESLintプラグインをインストールすれば誰でも簡単に問題のある箇所がわかります。

使い方は、まず以下のコマンドでESLintのプラグインをインストールします。

npm i -D eslint-plugin-react-you-might-not-need-an-effect

次にeslint.config.jsを以下のようにして、You Might Not Need an EffectのESLintプラグインを有効化します。

eslint.config.js
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import reactYouMightNotNeedAnEffect from 'eslint-plugin-react-you-might-not-need-an-effect'

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      js.configs.recommended,
      tseslint.configs.recommended,
      reactHooks.configs['recommended-latest'],
      reactRefresh.configs.vite,
    ],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
  },
  reactYouMightNotNeedAnEffect.configs.recommended,
])

これであとは「npm run lint」を実行すればESLintが実行されるので、You Might Not Need an Effectのルールがあれば検出されます。

VS CodeならESLintの拡張機能をインストールして、問題のあるコードをリアルタイムで検出できるようにしておくと良いでしょう。

You Might Not Need an Effect no-derived-state Sample

ESLintのYou Might Not Need an Effectプラグインは以下の10種類のルールに違反していないかチェックします。

  1. no-derived-state
    useEffectに派生状態を保存できないようにする。
  2. no-chain-state-updates
    useEffect内での状態更新の連鎖を禁止する。
  3. no-event-handler
    状態と効果をイベントハンドラーとして使用することを禁止します。
  4. no-adjust-state-on-prop-change
    propが変更されたときにuseEffectの状態を調整することを禁止します。
  5. no-reset-all-state-on-prop-change
    propが変更されたときにuseEffect内のすべての状態をリセットすることを禁止します。
  6. no-pass-live-state-to-parent
    useEffect内でliveのstateを親に渡すことを禁止します。
  7. no-pass-data-to-parent
    useEffect内で親にデータを渡すことを禁止します。
  8. no-initialize-state
    useEffect内での状態の初期化を禁止します。
  9. no-manage-parent
    propsのみを使用するエフェクトを禁止する。
  10. no-empty-effect
    空のuseEffectを許可しない。

1から10のうち1については最初に解説しましたので、2から10についても解説します。

no-chain-state-updates

useEffect内での状態更新の連鎖を禁止します。

例えば、以下のようにuseStateのroundが更新された際に、useEffectでroundを検知して別のuseStateのsetIsGameOverの状態更新が検出された場合は警告を出します。

App.tsx
import { useState, useEffect } from 'react'
import './App.css'

function App() {
  const [round, setRound] = useState(1)
  const [randomNum, setRandomNum] = useState(
    Math.floor(Math.random() * 100) + 1
  )
  const [gameStatus, setGameStatus] = useState<'playing' | 'cleared'>('playing')
  const [isGameOver, setIsGameOver] = useState(false)

  useEffect(() => {
    if (round === 10) {
      setIsGameOver(true)
    } else {
      setIsGameOver(false)
    }
  }, [round])

  const isCleared = gameStatus === 'cleared'

  const handleDrawNumber = () => {
    const newRandomNum = Math.floor(Math.random() * 100) + 1
    setRandomNum(newRandomNum)

    if (newRandomNum === 7) {
      setGameStatus('cleared')
    } else {
      setRound(round + 1)
    }
  }

  const handleReset = () => {
    setRound(1)
    setRandomNum(Math.floor(Math.random() * 100) + 1)
    setGameStatus('playing')
  }

  return (
    <div style={{ padding: '20px', textAlign: 'center' }}>
      <h1>Game Round: {Math.min(round, 10)}</h1>
      <h2>{round < 11 && randomNum}</h2>

      {isCleared ? (
        <div>
          <h2>🎉 Clear 🎉</h2>
          <p>You got 7! Congratulations!</p>
          <button onClick={handleReset}>Play Again</button>
        </div>
      ) : isGameOver ? (
        <div>
          <h2>💀 Game Over 💀</h2>
          <button onClick={handleReset}>Try Again</button>
        </div>
      ) : (
        <div>
          <p>Get 7 to win!</p>
          <button onClick={handleDrawNumber}>Draw Number</button>
        </div>
      )}
    </div>
  )
}

export default App

You Might Not Need an Effect no-chain-state-updates Sample

この場合は以下のようにuseStateを適切に使用して状態を管理すれば、useEffectを使用せずに処理できます。

App.tsx
import { useState } from 'react'
import './App.css'

function App() {
  const [round, setRound] = useState(1)
  const [randomNum, setRandomNum] = useState(
    Math.floor(Math.random() * 100) + 1
  )
  const [gameStatus, setGameStatus] = useState<
    'playing' | 'cleared' | 'gameOver'
  >('playing')

  const isGameOver = gameStatus === 'gameOver'
  const isCleared = gameStatus === 'cleared'

  const handleDrawNumber = () => {
    const newRandomNum = Math.floor(Math.random() * 100) + 1
    setRandomNum(newRandomNum)

    if (newRandomNum === 7) {
      setGameStatus('cleared')
    } else {
      setRound(round + 1)
      if (round + 1 > 9) {
        setGameStatus('gameOver')
      }
    }
  }

  const handleReset = () => {
    setRound(1)
    setRandomNum(Math.floor(Math.random() * 100) + 1)
    setGameStatus('playing')
  }

  return (
    <div style={{ padding: '20px', textAlign: 'center' }}>
      <h1>Game Round: {Math.min(round, 10)}</h1>
      <h2>{round < 11 && randomNum}</h2>

      {isCleared ? (
        <div>
          <h2>🎉 Clear 🎉</h2>
          <p>You got 7! Congratulations!</p>
          <button onClick={handleReset}>Play Again</button>
        </div>
      ) : isGameOver ? (
        <div>
          <h2>💀 Game Over 💀</h2>
          <button onClick={handleReset}>Try Again</button>
        </div>
      ) : (
        <div>
          <p>Get 7 to win!</p>
          <button onClick={handleDrawNumber}>Draw Number</button>
        </div>
      )}
    </div>
  )
}

export default App

no-event-handler

useStateとuseEffectをイベントハンドラーとして使用することを禁止します。

例えば、以下のようにカートに商品を追加するコードで、useEffectを使用してuseStateのproductの変更を検知して処理するイベントハンドラーとしての使用は警告が表示されます。

App.tsx
import { useState, useEffect } from 'react'
import './App.css'

function App() {
  const [product, setProduct] = useState({
    id: 1,
    name: 'Sample Product',
    price: 1000,
    isInCart: false,
  })

  const [cart, setCart] = useState<
    { id: number; name: string; price: number; isInCart: boolean }[]
  >([])

  const addToCart = (product: {
    id: number
    name: string
    price: number
    isInCart: boolean
  }) => {
    setCart((prevCart) => [...prevCart, product])
    setProduct((prevProduct) => ({ ...prevProduct, isInCart: true }))
  }

  const showNotification = (message: string) => {
    alert(message)
  }

  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`)
    }
  }, [product])

  function handleBuyClick() {
    addToCart(product)
  }

  function handleResetClick() {
    setCart([])
    setProduct((prevProduct) => ({ ...prevProduct, isInCart: false }))
  }

  return (
    <div style={{ padding: '20px', textAlign: 'center' }}>
      <div>
        <h2>{product.name}</h2>
        <p>Price: ¥{product.price}</p>
        <p>Status: {product.isInCart ? 'In Cart' : 'Not in Cart'}</p>
        <p>Cart Items: {cart.length}</p>
        <div style={{ marginTop: '20px' }}>
          <button onClick={handleBuyClick}>Add to Cart</button>
          <button onClick={handleResetClick}>Reset Cart</button>
        </div>
      </div>
    </div>
  )
}

export default App

You Might Not Need an Effect no-event-handler Sample

この場合はuseEffectを使用してイベントハンドラーのような処理をする必要はないので、以下のようなコードにすると良いです。

App.tsx
import { useState } from 'react'
import './App.css'

function App() {
  const [product, setProduct] = useState({
    id: 1,
    name: 'Sample Product',
    price: 1000,
    isInCart: false,
  })

  const [cart, setCart] = useState<
    { id: number; name: string; price: number; isInCart: boolean }[]
  >([])

  const addToCart = (product: {
    id: number
    name: string
    price: number
    isInCart: boolean
  }) => {
    setCart((prevCart) => [...prevCart, product])
    setProduct((prevProduct) => ({ ...prevProduct, isInCart: true }))
    alert(`Added ${product.name} to the shopping cart!`)
  }

  function handleBuyClick() {
    addToCart(product)
  }

  function handleResetClick() {
    setCart([])
    setProduct((prevProduct) => ({ ...prevProduct, isInCart: false }))
  }

  return (
    <div style={{ padding: '20px', textAlign: 'center' }}>
      <div>
        <h2>{product.name}</h2>
        <p>Price: ¥{product.price}</p>
        <p>Status: {product.isInCart ? 'In Cart' : 'Not in Cart'}</p>
        <p>Cart Items: {cart.length}</p>
        <div style={{ marginTop: '20px' }}>
          <button onClick={handleBuyClick}>Add to Cart</button>
          <button onClick={handleResetClick}>Reset Cart</button>
        </div>
      </div>
    </div>
  )
}

export default App

no-adjust-state-on-prop-change

propが変更されたときにuseEffectの状態を調整することを禁止します。

以下のように、prop (messageColor) を受け取って、useEffectで検知してuseStateの変更を行うと警告が表示されます。

App.tsx
import { useState, useEffect } from 'react'
import './App.css'

type Props = { messageColor: string }

function Message({ messageColor }: Props) {
  const [isColorChange, setIsColorChange] = useState(false)

  useEffect(() => {
    if (messageColor) {
      setIsColorChange(true)
    }
  }, [messageColor])

  return (
    <>
      <h1 style={{ color: messageColor }}>Change Color!</h1>
      <h2>isColorChange: {isColorChange.toString()}</h2>
    </>
  )
}

function App() {
  const [color, setColor] = useState('')
  const colors = ['red', 'green', 'blue']

  return (
    <div>
      <div style={{ marginBottom: '20px' }}>
        <h2>Select Color</h2>
        {colors.map((colorOption) => (
          <button
            key={colorOption}
            onClick={() => setColor(colorOption)}
            style={{
              backgroundColor: colorOption,
              color: 'white',
            }}
          >
            {colorOption}
          </button>
        ))}
      </div>
      <Message messageColor={color} />
    </div>
  )
}

export default App

You Might Not Need an Effect no-adjust-state-on-prop-change Sample

この場合はuseEffectを使用せずにuseStateでprop (messageColor) が変更されているか検知して、変更されていたらuseStateで必要に応じて更新するほうが良いです。

以下の例ではprevMessageColorに前回のpropをuseState(messageColor)で保存しています。

よく誤解されるのですが、useStateの初期値はコンポーネントの初回マウント時のみ設定されるので、初期値はボタンを押すたびに変わりません。(つまり、初期値は空文字で固定)

App.tsx
import { useState } from 'react'
import './App.css'

type Props = { messageColor: string }

function Message({ messageColor }: Props) {
  const [isColorChange, setIsColorChange] = useState(false)
  const [prevMessageColor, setPrevMessageColor] = useState(messageColor)

  if (messageColor !== prevMessageColor) {
    setIsColorChange(true)
    setPrevMessageColor(messageColor)
  }

  return (
    <>
      <h1 style={{ color: messageColor }}>Change Color!</h1>
      <h2>isColorChange: {isColorChange.toString()}</h2>
    </>
  )
}

function App() {
  const [color, setColor] = useState('')
  const colors = ['red', 'green', 'blue']

  return (
    <div>
      <div style={{ marginBottom: '20px' }}>
        <h2>Select Color</h2>
        {colors.map((colorOption) => (
          <button
            key={colorOption}
            onClick={() => setColor(colorOption)}
            style={{
              backgroundColor: colorOption,
              color: 'white',
            }}
          >
            {colorOption}
          </button>
        ))}
      </div>
      <Message messageColor={color} />
    </div>
  )
}

export default App

no-reset-all-state-on-prop-change

propが変更されたときにuseEffect内のすべての状態をリセットすることを禁止します。

App.tsx
import { useState, useEffect } from 'react'
import './App.css'

function List({ item }: { item: string }) {
  const [selection, setSelection] = useState(null)

  useEffect(() => {
    setSelection(null)
  }, [item])

  return <div>{selection}</div>
}

function App() {
  return <List item="test" />
}

export default App

You Might Not Need an Effect no-reset-all-state-on-prop-change Sample

no-pass-live-state-to-parent

useEffect内で親にデータを渡すことを禁止します。

App.tsx
import { useState, useEffect } from 'react'
import './App.css'

function Toggle({ onChange }: { onChange: (isOn: boolean) => void }) {
  const [isOn, setIsOn] = useState(false)

  useEffect(() => {
    onChange(isOn)
  }, [isOn, onChange])

  function handleClick() {
    setIsOn(!isOn)
  }

  return (
    <button
      onClick={handleClick}
      style={{
        backgroundColor: isOn ? '#4CAF50' : '#f44336',
      }}
    >
      {isOn ? 'ON' : 'OFF'}
    </button>
  )
}

function App() {
  const [isOn, setIsOn] = useState(false)

  return (
    <div>
      <Toggle onChange={setIsOn} />
      <p>現在は{isOn ? 'ON' : 'OFF'}です。</p>
    </div>
  )
}

export default App

You Might Not Need an Effect no-pass-live-state-to-parent Sample

この場合はuseEffectを使用しなくても、直接useStateだけで変更できます。

App.tsx
import { useState } from 'react'
import './App.css'

function Toggle({ onChange }: { onChange: (isOn: boolean) => void }) {
  const [isOn, setIsOn] = useState(false)

  function handleClick() {
    setIsOn(!isOn)
    onChange(!isOn)
  }

  return (
    <button
      onClick={handleClick}
      style={{
        backgroundColor: isOn ? '#4CAF50' : '#f44336',
      }}
    >
      {isOn ? 'ON' : 'OFF'}
    </button>
  )
}

function App() {
  const [isOn, setIsOn] = useState(false)

  return (
    <div>
      <Toggle onChange={setIsOn} />
      <p>現在は{isOn ? 'ON' : 'OFF'}です。</p>
    </div>
  )
}

export default App

no-pass-data-to-parent

useEffect内で親にデータを渡すことを禁止します。

App.tsx
import { useState, useEffect } from 'react'
import './App.css'

function Child({
  onFetched,
}: {
  onFetched: (data: { status: string }) => void
}) {
  useEffect(() => {
    fetch('https://dummyjson.com/test')
      .then((res) => res.json())
      .then((fetchedData) => {
        onFetched(fetchedData)
      })
  }, [onFetched])

  return null
}

function App() {
  const [data, setData] = useState<{ status: string } | null>(null)

  return (
    <div>
      <Child onFetched={setData} />
      {data ? <p>Status: {data.status}</p> : <p>Loading...</p>}
    </div>
  )
}

export default App

fetchなどを使用したAPIの取得などは子ではなく親で処理して子に渡さないほうが良いです。

App.tsx
import { useState, useEffect } from 'react'
import './App.css'

function Child({ data }: { data: { status: string } | null }) {
  return data ? <p>Status: {data.status}</p> : <p>Loading...</p>
}

function App() {
  const [data, setData] = useState<{ status: string } | null>(null)

  useEffect(() => {
    fetch('https://dummyjson.com/test')
      .then((res) => res.json())
      .then(setData)
  }, [])

  return (
    <div>
      <Child data={data} />
    </div>
  )
}

export default App

no-initialize-state

useEffect内での状態の初期化を禁止します。

App.tsx
import { useEffect, useState } from 'react'
import './App.css'

function App() {
  const [userName, setUserName] = useState(null)

  useEffect(() => {
    setUserName('Anonymous')
  }, [])

  return <h1>{userName}</h1>
}

export default App

You Might Not Need an Effect no-initialize-state Sample

useStateの初期化はuseState('Anonymous')のようにuseStateで行ってください。

条件分岐によって初期値が異なる場合は、以下のようにuseState自体を使わなくても良いことがあります。

App.tsx
import './App.css'

function App() {
  const userName = new Date().getMonth() === 11 ? 'Santa Claus' : 'Anonymous'

  return (
    <h1>{userName}</h1>
  )
}

export default App

no-manage-parent

propsのみを使用するエフェクトを禁止する。

App.tsx
import { useState, useEffect } from 'react'
import './App.css'

function Child({ isOpen, onClose }) {
  useEffect(() => {
    if (!isOpen) {
      onClose()
    }
  }, [isOpen, onClose])

  return (
    <div>
      {isOpen ? <p>開いています</p> : <p>閉じています</p>}
    </div>
  )
}

function App() {
  const [isOpen, setIsOpen] = useState(true)

  const handleClose = () => {
    console.log('Childが閉じられました')
  }

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? '閉じる' : '開く'}
      </button>
      <Child isOpen={isOpen} onClose={handleClose} />
    </div>
  )
}

export default App

You Might Not Need an Effect no-manage-parent Sample

この場合はuseEffectを使わずに以下のように書けます。

App.tsx
import { useState } from 'react'
import './App.css'

function Child({ isOpen }: { isOpen: boolean }) {
  return <div>{isOpen ? <p>開いています</p> : <p>閉じています</p>}</div>
}

function App() {
  const [isOpen, setIsOpen] = useState(true)

  const handleToggle = () => {
    if (isOpen) {
      console.log('Childが閉じられました')
    }
    setIsOpen(!isOpen)
  }

  return (
    <div>
      <button onClick={handleToggle}>{isOpen ? '閉じる' : '開く'}</button>
      <Child isOpen={isOpen} />
    </div>
  )
}

export default App

no-empty-effect

空のuseEffectを許可しない。

空のuseEffectなんて書かれるケースは初心者でもないと思いますが、ルールとして存在しています。

App.tsx
useEffect(() => {}, [])

まとめ

ESLintのYou Might Not Need an Effectプラグインの10種類のルールについてサンプルコード付きで説明しましたが、要するにuseEffectを不必要に書いて非効率なコードを書かないよう気をつけましょうということです。

10種類もルールがありますが、プラグインとVS Codeの拡張機能を入れておけば、ルール違反があればリアルタイムで警告が表示されるので、自然とルールを覚えることができます。

useEffectは使いすぎるとパフォーマンスが悪くなったり、バグの温床になることがあるので、このプラグインを入れて無駄なuseEffectの使用を避けるようにすれば、必ずパフォーマンスが良く、バグの少ないコードになると思います。