【Reactの難所】useEffectの "a missing dependency" エラーを解消する方法

  • 2024-04-07
  • 2022-03-16

【Reactの難所】useEffectの "a missing dependency" エラーを解消する方法

  • Keywords: 
  • React ] 

  • React Hooks ] 

  • pickup ] 

  • useEffect ] 

  • useRef ] 

  • useReducer ] 

  • Typescript ] 

はじめに

この記事では、useEffectの"a missing dependency"エラーという特定のエラーについて、その概要、背景、解決方法について紹介しています。このエラーについて取り上げる理由は、Reactアプリケーションを構築する人が何らかの形で必ず通る難所であると考えるからです。私自身、このエラーを解消するために、長い時間を費やして苦労した経験があります。しかし、エラーの解消方法について考える中で、useEffect Hookについての理解がだいぶ深まったのも事実です。その意味で学ぶことの多い示唆に富んだエラーとも言えるかもしれません。

このような背景もあり、この記事ではuseEffect Hookの説明から始めています。そのため、全体的にやや長い文章構成となっています。「useEffectについては十分理解しているのでエラーの解決方法だけを知りたい」という方は、前半部分を読み飛ばして、後半の解決方法だけを読むことができます。その場合は、目次の項目をクリックして、読みたい箇所に移動してください 🚀✨

useEffectとStale Stateについて

useEffectは、React Hooksの中でも利用頻度が高く基本的なHookの一つです。useEffectの役割は"side-effect"の発動ですが、それでは"side-effect"とは何でしょうか?

side-effectを和訳すると「副作用」ですが、useEffectにおけるside-effectは、Reactが行う演算処理のうち、rendering (DOMの変更)以外の処理のことです。ReactのFunctional Componentのうち、DOMの変更に関わる部分を本体(メインの効果)とすると、確かにそれ以外の効果を副作用と呼ぶのも理解できますね。

side-effectは「render以外の効果」というネガティブな定義付けですので、定義だけを聞いてもイメージが掴みづらくぼんやりしています。例えば、サーバーからデータを取得する際のネットワークリクエストなどがside-effectに該当します。

useEffectのside-effectとrenderは、概念的にも、動作面でも、互いに独立しています。時間順序的にはrenderが生じた後でside-effectが発動されます。

もう少し具体的に言うと、render が生じると(つまりDOMが変更されると)、useEffectの第1 argumentに設定した関数(= side-effect) が発動されます。

以下、useEffectのサンプルコードになります。

useEffect(() => effect(x), [x])

上記の例では、() => effect(x)の部分が第1 arugumentになります。第1 argumentとカンマで区切られている部分が第2 argumentです([x])。以下、第2 argumentを"deps array"と呼びます。

useEffectは、アプリケーションの様々な場面で活躍する頼りになるHookである一方で、乗りこなすのが難しいじゃじゃ馬のような存在でもあります。特にdeps arrayの取り扱いが要注意です。

deps arrayの役割は、side-effectの発動タイミングの指定です。deps arrayを設置しない場合は、renderが生じる度にside-effectが発動されます。

空のdeps array([])を追加すると、最初のrenderの時のみside-effectが発動されます。そして、deps arrayに変数を入れると(例えば[x])、最初のrender時に加えて、その変数が変化した時にside-effectが発動されます。特に最後の性質が重要なポイントです。

今後の議論のため、このdeps arrayについての性質を「仕様A」と呼ぶことにします。

<仕様A>

useEffectのdeps arrayに入れた変数が変化するとre-renderされ、side-effectが発動される

effectの関数内で変数(state / props)が使用されているにも関わらず、deps arrayに入っていない場合は、その変数はアップデートされません。例えば、effect関数内で用いられているstateの値が変化しているにも関わらず、deps array にそのstateが入っていなければ、古いsatateの値を参照したside-effectが実行され続けます。このようなstateを"stale state(古くなったstate)"と呼びます。stale stateはバグの温床です。useEffectを使った実行が思い通りに行かない原因にstale stateがあることも少なくありません。

stale stateを防ぐには、side-effect関数内で使用されているstate / propsについては全てdeps arrayに入れる必要があります。state / propsをdeps arrayに入れることにより、値が変化してre-renderが生じる時に新しい値に基づいて新しいside-effectを発動できるからです。この点は、React Hooksルールとして定められています。

<ルール A>

useEffectのside-effect関数で使用されているstate / props は全てdeps arrayに入れる必要がある

しかし、side-effect関数が複雑な構造で、複数のstate / propsを含む場合、その全てを把握して、deps arrayを管理することは大変です。

そのため、Reactではdeps arrayについてのlintルールの設定が推奨されています。

ESLintルールの"a missing dependency"エラーとは

lintルールとは、ソースコードを自動的にチェックして、特定のルールに違反した場合に、エラーメッセージを表示する仕組みのことです。例えば、ReactではHooksのlintルールが用意されており、Hooksルールに違反した場合にエラーメッセージが表示されます。

Reactでは、公式のESLintルールプラグインが用意されていますし、Next.js(Reactのフレームワーク )を使う場合は、デフォルトでESLintルールがインストールされます。

useEffectのdeps arrayについてのルールに違反した場合、次のようなエラーメッセージが表示される場合があります。

"React Hook useEffect has a missing dependency: "stateA". Either include it or remove the dependency array (useEffectのdepsに"stateA"が入っていません。追加するか、またはdeps arrayを削除してください)"

このメッセージは、side-effect関数の中で使用されているstateAがdeps arrayに入っていない場合に表示されます。すなわち、前述した"stale state"が発生する可能性がある場合です。

もちろん、本来的には、メッセージの指示に従ってside-effect内で使用されているstateをdeps array に入れるだけでよさそうですが。。

このエラーメッセージをめぐって生じうる問題について、具体例で考えてみましょう。

サンプルアプリを使った考察

例えば、次のようなアプリケーションを考えます。

◉ アプリケーションの概要

・二つのstate(valueA, valueB)を用意する

・ボタンをクリックする度に0〜1000の数字をランダムに生成して、valueAに割り当てる

・1秒おきに0〜1000の数字をランダムに生成して、その値とvalueAとの差の絶対値をvalueBに割り当てる

・valueBを3で割った余りの数(0, 1, or 2)に応じてテキストの色を変更する

<実装のポイント>

・ボタンをクリックしても、valueBの生成は中断されない。

まずは、実装上の問題点を明確にするために、useReducer / useRefの解決方法を適用しない場合を考えます。コードは次のようになります。

◉ index.tsx

(useRef / useReducerを使わないケース)


import React, { useState } from 'react'
import PageComponent from '../Components/PageComponent'
import useWithoutRef from '../utils/useWithoutRef'

const Without = () => {
    const [valueA, setValueA] = useState(337)
    const [valueB, setValueB] = useState(2022)
    const handleOnClick = () => {
        const value = Math.round(Math.random() * 1000)
        setValueA(value)
    }  
    useWithoutRef({valueA, setValueB})

  return (
    <PageComponent 
        valueA={valueA} 
        valueB={valueB} 
        handleOnClick={handleOnClick} 
        type="without"/>

  )
}

export default Without

◉ PageComponent.tsx


import Link from 'next/link'

interface ComponentProps{
    valueA: number
    valueB: number
    handleOnClick: () => void
    type: string
}
const PageComponent = ({valueA, valueB, handleOnClick, type} : ComponentProps) => {
    const style = (value: number) => {
        let color: string = ""
        if (value % 3 === 0) color = "text-pink-500"
        if (value % 3 === 1) color = "text-indigo-500"
        if (value % 3 === 2) color = "text-yellow-500"
        return color
    }
  return (
    <div className="text-center py-16">
        <div>
        </div>
        <h1>
            React APP 🚀✨
        </h1>
        <div>
        {type === "ref" && "useRefを使うケース"}
        {type === "reducer" && "useReducerを使うケース"}
        {type === "without" && "useRef / useReducerを使わないケース"}
        </div>
        <div>
            <p>Value A: {valueA}</p>
            <p>Value A % 3 = {valueA % 3}</p>
            <p className={`h3 ${style(valueB)}`}>{`Remainder A = ${valueA % 3} :  Remainder B = ${valueB % 3}`}</p>
            <p>Value B: {valueB}</p>
            <p>Value B % 3 = {valueB % 3}</p>
        </div>
        <button onClick={handleOnClick} className="buttonGreenBigger">
           新しい値Aを生成する
        </button>
        <div>
            <div className="h4>
                ◉<Link href="/" passHref>useRefを使うケース</Link>
            </div>
            <div>
                ◉<Link href="/reducer" passHref>useReducerを使うケース</Link>
            </div>
            <div>
                ◉<Link href="/without" passHref>useRef / useReducerを使わないケース</Link>
            </div>
        </div>
    </div>  )
}

export default PageComponent

◉ useWithoutRef.ts


import { Dispatch, SetStateAction, useCallback, useEffect } from 'react'

interface functionProps {
        valueA: number
        setValueB: Dispatch<SetStateAction>
    }

const useWithoutRef = ({valueA, setValueB} : functionProps) => {
    const handleValue = useCallback(() => {
        const value2 = Math.round(Math.random() * 1000)
        let calculated = value2 - valueA
        if(calculated < 0) calculated = calculated * -1
        setValueB(calculated)        
    }, [setValueB, valueA])

    useEffect(() => {
    const id = setInterval(handleValue, 1000);
    return () => clearInterval(id);
  }, [handleValue]) //valueAが変更される度に中断される
}

export default useWithoutRef

全てのコードはGithubに保存しています。

また、上記のコードを実装したアプリケーションを用意しました。この例では何の対策もしていませんので、実装が失敗しています。次のGIF画像絡も分かるように、ユーザーがボタンをクリックしてvalueAを生成すると、valueBの生成が中断されてしまいます。

useEffectの "a missing dependency" エラーについて説明するためのReactアプリケーション

useWithoutRefのコードで注目したいのは、useEffect の部分です。「ユーザーがボタンをクリックしてもvalueBが一定の間隔で生成され続ける」という点が実装のポイントですので、本来はdeps arrayに変数を入れたくありません。<仕様A>により、deps array内の変数が変化すると、新しいside-effectが発動されてしまう(=現在のside-effectが中断する)からです。

しかし、side-effectは関数"handleValue"に依存していますので、上述の<ルール A>に基づいて、handleValueをdeps arrayに入れる必要があります。すると、<仕様A>に基づいて、handleValueが変化するごとに、side-effectが発動されます。handleValueはvalueAが変更される度に変化するので、結果的に、ユーザーがボタンをクリックしてvalueAが変化するたびに、設計意図に反してside-effectが中断され、新しいside-effectが発動されます。side-effectの中断を防ぐために、deps arrayからhandleValueを外すと、前述したReact Hooksルール違反のエラーメッセージ "a missing dependency..." が表示されてしまいます。

このように、「valueAやhandleValue関数の変化をトリガーにしない」という設計意図を実現しようとすると、<仕様A>と<ルールA>の間で矛盾が生じてしまうわけです。

もちろん、ESLintルールの適用を解除すると、React Hooksのルールに違反してもエラーメッセージは表示されません。ESLintのエラーメッセージは、手動で無効にすることができます(deps arrayの直前の行に// eslint-disable-next-line react-hooks/exhaustive-depsと追加するとメッセージは表示されない)。しかし本当にこの方法で良いのでしょうか?

本来、ESlintルールは予期しないバグを未然に防ぐために設定するわけですから、エラーメッセージが表示される全ての箇所でeslint-disable-next-line を追加することは適切な対処方法ではありません。

それでは、どうすればReact Hooksのルールを無視せずにESlintエラーを解消することができるのでしょうか?以下、正攻法での解決方法をご紹介します。

React公式の解決方法

実は、Reactの公式ドキュメントに記載されているテクニックを使ってこの問題を解消することができます。私は長い時間この問題で悩んだ末に公式の方法にたどり着きました。まさに灯台下暗しですね。

厳密に言うと、公式ドキュメントで紹介されているのは、useEffectのdeps arrayに関する問題に対処するためのテクニックです。具体的には、「関数をdeps arrayに入れたくない場合の対処法」と「stateが頻繁に変化する場合の対処法」です。代表的なテクニックは次の通りです。

上記の4つの方法は、いずれもuseEffectを使う場合に欠かせないテクニックです。しかし、適用可能な場面がそれぞれ異なるため、問題の性質に応じて適切な方法を選択することになります。結論から言うと、今回のアプリケーションの場合では、方法1 / 方法2では問題を解消できません。しかし、方法3 & 方法4を使うと解消できます。

まずは、方法1 / 方法2について、今回のエラーを解消できない理由も含めて簡単に見ておきましょう。

関数をuseEffect内で定義する方法

はじめに、side-effectで使用されている"handleValue"関数をuseEffect内に移動するテクニックについて考えます。この方法のコンセプトは、「side-effectで使用する関数・変数を全てuseEffect内で定義することで、useEffect外の関数・変数に依存させない」という考え方です。

つまり、side-effectで必要な変数・関数を全てuseEffect内で定義可能な場合のみ有効な方法ということになります。handleValue関数はvalueAに依存していますので、この方法を達成するには、valueAをuseEffect内で定義する必要があります。ところが、valueAはstateです。「React Hooksの内部で別のReact Hooksを使用することができない」というルールに基づき、useEffect内でuseStateやuseReducerを使ってvalueAを定義することはできません。したがって、この方法は実行不可という結論です。

関数をコンポーネントの外で定義する方法

次に、handleValue関数をuseWithoutRefコンポーネントの外に移動する方法について考えます。この方法のコンセプトは「コンポーネント外の関数にすることで、いずれのprops / stateも参照しないことが保証される」という考え方です。

したがって外部関数(handleValue)がprops / stateに依存していないことが前提となりますので、今回のケースでは適切な方法ではありません。仮に、外部関数をカスタムHookにしたうえで、関数内で(例えばuseStateを使って)valueAを定義しても、上述の通りuseEffect内で別のHooksは使用不可ですので、いずれにせよ、この方法では詰みとなります。

useReducerを用いた解決方法

useReducerは、useStateと同じくstateの定義や変更など、state管理に用いられるHookです。

useStateとの相違点の1つは、useStateが1つのHookにつき1つのstateを定義するのに対して、useReducerでは複数のstateを同時に定義することです。このような仕様上の相違点に起因して、useStateではstate同士の独立性が保たれるのに対して、useReducerでは、1つのreducerでグルーピングされるstateは常に同時に変更されます。厳密に言うと、グルーピングされた複数の変数は、同一のstateの複数のプロパティーという位置付けであり、1つのプロパティーを変更する場合でも別の新しいstateが作られ、(たとえ他の変数が同一の値を保持しても)総入れ替えになります。この意味で、独立性が希薄です。このような特徴から、useReducerでは、グルーピングされたstate同士が相互依存関係にある場合や、同じ場面で同時に使用される場合を想定しています。

このような背景があるため、本来は、ESLintエラーの解消という点だけを考慮してuseStateをuseReducerに切り替えるのではなく、アプリケーション内におけるstate同士の関係性などを総合的に考慮したうえで、useState / useReducerを選択することが好ましいと考えます。

この点に十分に留意したうえで、以下、useReducerを用いたエラーの解消方法を具体的に見ていきたいと思います。

◉ index.tsx

(useReducerを使うケース)


import React, { useReducer } from 'react'
import PageComponent from '../Components/PageComponent'
import useWithReducer from '../utils/useWithReducer'

const Home = () => {
    const initialState = { 
            valueA: 337,
            valueB: 2022
          }
    const reducer = (
      state: {valueA: number, valueB: number}, 
      action: {type: string, payload: number}) => {
      const {type, payload} = action
      const {valueA, valueB} = state
      switch(type) {
        case "A_ONLY":
          const objectA = {...state, valueA: payload}
          return objectA
        case "B":
        const value2 = Math.round(Math.random() * 1000)
        let calculated = value2 - valueA
        if(calculated < 0) calculated = calculated * -1
        const objectB = {...state, valueB: calculated}
          return objectB
        default:
          return state
      }
    }
    const [{valueA, valueB}, dispatch] = useReducer(reducer, initialState)
    const handleOnClick = () => {
        const value = Math.round(Math.random() * 1000)
        dispatch({type:"A_ONLY", payload: value})
    }  
    useWithReducer({dispatch})

  return (
    <PageComponent 
        valueA={valueA} 
        valueB={valueB} 
        handleOnClick={handleOnClick} 
        type="reducer"/>
  )
}

export default Home

◉ useWithReducer.ts


import { Dispatch, useEffect } from 'react'

interface functionProps {
        dispatch: Dispatch<any>
    }

const useWithReducer = ({dispatch} : functionProps) => {
    useEffect(() => {
    const id = setInterval(() => dispatch({type: "B"}), 1000);
    return () => clearInterval(id);
  }, [dispatch]) //最初のrenderの時のみ実行される
}

export default useWithReducer

useReducerを用いた場合の動作はこちらからご覧いただけます。ボタンをクリックしてもvalueBが1秒おきに更新され続け、valueBの変化に対応したテキストの色の変化も中断しないことをご確認ください。当初に意図した通りの実装になっています。

useEffectの "a missing dependency" エラーについて説明するためのReactアプリケーション (useReducerによる解決方法の実装例)

それでは、なぜuseReducerを用いるとこのような実装ができるのでしょうか?

その理由は、useReducerを用いることで、side-effect関数を"dispatch"というコンポーネントの外部で定義した関数だけに依存させることができるからです。この点で、前述の「関数をコンポーネント外に移動させる」方法の考え方と類似していると言えるかもしれません。ただし、disptach関数はstate(valueA)に依存しません。この点が大きな違いです。

もう一つの特筆すべきポイントは、仕様上「dispatch関数は、re-renderが生じても変化しないことが仕様的に保証されている」という点です。dispatch関数は経時変化しないため、deps arrayの中に入れてもside-effectの発動のタイミングに影響しないというわけです (補足すると、useStateのsetState関数も経時変化しないため、deps arrayから省略できます)。

したがって、deps arrayにはdispatch関数だけが入り、dispatchは経時変化しないためside-effectのトリガーとならず、side-effectは最初のrender時に一度だけ実行されます。つまり、最初に実行されたside-effect(1秒間隔でのValueBの更新)が中断されることはありません。

今回のアプリケーションでは、このようにuseReducerを用いて、Reactルールに違反することなく実装できることが分かりました。しかしながら、 前述の通り、ESLintエラーの解消という観点だけを考慮してuseReducrを用いることは適切ではありません。状況によっては、useStateを用いてstateを定義することが好ましい場合もあります。そして、その場合に有効な解決方法についても知っておく必要があるのではないでしょうか。

次の節では、そのような場合に効果的な方法として、公式ドキュメントでは「最後の手段 (as a last resort)」とされている"useRef"を使用する方法をご紹介したいと思います。

useRefを用いた解決方法

useRefはReact Hooksの一つですが、useRefの使用頻度はuseStateやuseEffectなどと比べると少なめです。

しかし、useRefには他のHooksが持たない際立った特性があります。それは、renderの発生とは独立して値を設定できるという特徴です。

useRefが返すRef オブジェクトは、renderが生じても、意図的に値を変更しない限り、同一の値を保つという性質があります。Refオブジェクト自体は箱のようなもので、オブジェクトの.currentプロパティーを自由に変更可能です。そして.currentプロパティーの変更はre-renderを発生させません。つまり、useRefが返すRefオブジェクトはrenderの発生と独立して存在できるという特徴があります。

この特徴がなぜ重要かというと、side-effectの発動がrenderの発生と紐付いているからです。useEffectでは、原則として、全てのrender後にside-effectを発動します。deps arrayを追加するとside-effectは条件付きで発動しますが、所定の条件を満たしたrenderの後に発動されるという点で、やはりrenderと結びついています。ところが、Refオブジェクトの.currentプロパティーの変更はre-renderを発生させないことが仕様的に保証されています。したがって、.currentプロパティー自体がside-effect発動のトリガーになることはありません。useEffect内で.currentプロパティーを使用した場合にdeps arrayに入れなくても、Hooksルール違反にはならず、"a missing dependency"のエラーメッセージも表示されません。Refオブジェクトが持つこのような特徴をうまく利用して、deps arrayのエラーを解決することができます。

useRefを使う方法のコンセプトは、renderを生じさせずに値を変更できるというRefオブジェクトの特徴を使って、実行中のside-effectを中断することなく、side-effect関数の中身を変更する、という点です。

それでは、useRefを使った方法について、実際のコードを見てみましょう。

◉ index.tsx

(useRefを使うケース)


import React, { useState } from 'react'
import PageComponent from '../Components/PageComponent'
import useWithRef from '../utils/useWithRef'

const Home = () => {
    const [valueA, setValueA] = useState(337)
    const [valueB, setValueB] = useState(2022)
    const handleOnClick = () => {
        const value = Math.round(Math.random() * 1000)
        setValueA(value)
    }  
    useWithRef({valueA, setValueB})

  return (
    <PageComponent 
        valueA={valueA} 
        valueB={valueB} 
        handleOnClick={handleOnClick} 
        type="ref"/>
  )
}

export default Home

◉ useWithRef.ts


import { Dispatch, SetStateAction, useCallback, useEffect, useRef } from 'react'

interface functionProps {
        valueA: number
        setValueB: Dispatch<SetStateAction<number>>
    }

const useWithRef = ({valueA, setValueB} : functionProps) => {
    const handleValue = useCallback(() => {
        const value2 = Math.round(Math.random() * 1000)
        let calculated = value2 - valueA
        if(calculated < 0) calculated = calculated * -1
        setValueB(calculated)        
    }, [setValueB, valueA])

    const handleValueRef = useRef(handleValue)

    useEffect(() => {
        handleValueRef.current = handleValue
    }, [handleValue])//handleValueRef.currentを変更させる

    useEffect(() => {
    const handleValueCurrent = handleValueRef.current
    const id = setInterval(handleValueCurrent, 1000);
    return () => clearInterval(id);
  }, []) //最初のrenderの時のみ実行される
}

export default useWithRef

useRefを用いた場合の動作はこちらからご覧いただけます。useReducerを使う場合と同じく、ボタンをクリックしてvalueAを更新しても、valueBが1秒おきに更新され、テキストの色の変化も中断しないことをご確認ください。

useEffectの "a missing dependency" エラーについて説明するためのReactアプリケーション (useRefによる解決方法の実装例)

useWithRefの特徴は、useRefを利用して"handleValue"関数をRefオブジェクトに入れていることです(handleValueRef)。

Refオブジェクトは意図的に変更しない限りrenderとrenderの間で変化することはありませんので、Refオブジェクト(箱)の中身(.currentプロパティー)を変更させるロジックを別途用意する必要があります。

useWithRefの中では二つのuseEffectが設置されており、このうち、一つ目のuseEffectの役割がRefオブジェクトの更新です。handleValueが変更されるたびに(handleValue関数で使用するstate / propsの値が変化する度に)、新しいhandleValueを(.currentプロパティーとして)Refオブジェクトに入れています。こうすることで、Refオブジェクトの中身は常に最新の状態に保たれます。

二つ目のuseEffectでは、Refオブジェクトに入れたhandleValue関数のロジックを、.currentプロパティーとして取り出して使用しています(handleValueCurrent)。前述の通り、この方法はReact Hooksのルール上問題ありません。handleValueCurrentをdeps arrayに入れなくても仕様的に問題なく、deps arrayのエラーメッセージも表示されません。

したがって、このuseEffectでは空のdeps arrayを設置できるため、side-effectは、最初のrender時に一度だけ実行されます。すなわち、最初に実行されたside-effectは、その後ValueAの更新によって中断されることはありません。

最後に

今回の記事では、useEffectのESlintエラー"a missing dependency..."について、その概要、背景、解決方法をご紹介しました。

このエラーがReact Hooksルールを遵守するために用意されていること、そしてdeps arrayのルールが、Stale Stateを防ぎ、バグを予防するためのものであることをお伝えできたと思います。

また、このルールを遵守しようとすると、useEffectのside-effectの発動タイミングとの兼ね合いから実装が失敗する場合があること、そのような場合でもuseReducer / useRefを使ってエラーを解消できることを、実際のアプリケーションを使って示しました。本記事で使用したコード、実装例は以下の場所からご覧いただけます。

同様のエラーでお困りの方にとって、この記事でご紹介した解決方法がお役に立てれば幸いです😁✨