Reactライフサイクルと紐づけて、useEffectのデメリットとその対応策を整理してみた
概要
みなさん、useEffect使っていますか?
私はロジックの実装(特に非同期処理)の際、困ったらとりあえずこのhookに飛びついている気がします。
「コンポーネントのrender後、手軽に副作用を実行できる!」という点で、このhookはとても優れているのですが、当然ながらuseEffectにはデメリットもいくつかあります。
職場やSNSでちらほら、そうしたデメリットには注意したい、という声を耳にするので、本記事ではそのあたりのことを整理しようと思います。
情報に誤りがある場合は、ご指摘いただけますと大変幸いです。
Reactライフサイクル
useEffectってそもそもどういうものなんだっけ?という点から整理するため、まずはReactのライフサイクルについて触れていきましょう。
ライフサイクルを理解するにあたっては、こちらの記事がとても参考になります。
こちらの記事でも紹介されていますが、「Reactライフサイクル、要はこれ」というのを示した関係図を公式が提供してくれています。こちらです。
この図と上記記事の内容を、文章で少し要約します。
ライフサイクルには、「マウント」「更新」「アンマウント」の3つの流れがあり、それぞれの流れの中では、固有のライフサイクルメソッドが実行される。
マウント: 一番最初にコンポーネントが初期化され、DOMが描画され、その後にcomponentDidMountというメソッドが実行される流れ
更新: propsやstateが変更された時に、DOMが再描画され、その後にcomponentDidUpdateというメソッドが実行される流れ
アンマウント: componentWillUnmountというメソッドが実行された後、マウントが解除されてDOMが画面から消えるまでの流れ
しばしば、componentDidMountとcomponentDidUpdateでは、外部APIとの通信やDOMの操作などが実行されます。
上記のことをなんとなく踏まえた上で、useEffectがどういうものなのかを整理していきましょう。
ReactライフサイクルとuseEffect
上述したライフサイクルは、いわばクラスコンポーネントにおける概念となります。
そしてuseEffectは、「componentDidMount」「componentDidUpdate」「componentWillUnmount」という3つのライフサイクルメソッドを、関数コンポーネントの中でまとめて表現するためのAPIです。
要は、関数コンポーネントでクラスコンポーネントと同様のことを実現させるためのもの、というわけです。
React公式も、useEffectを以下のように説明しています。
React のライフサイクルに馴染みがある場合は、useEffect フックを componentDidMount と componentDidUpdate と componentWillUnmount がまとまったものだと考えることができます。
つまりDOMのマウントと更新、いずれのタイミングでも処理が走ります。
また、繰り返しになりますが、上述した3つのメソッドは、それぞれコンポーネントに以下の動きがあったタイミングで実行されます。
- componentDidMount: マウント時
- componentDidUpdate: propsやstateの更新時
- componentWillUnmount: アンマウント時
つまりuseEffectも同様のタイミングで実行されるわけですが、とりわけ副作用の実行という点でいうと、「マウント時」「propsやstateの更新時」の2つのタイミングに絞られます。
そしてさらに、useEffectは第2引数を指定すると、「マウント時」「第2引数の値の更新時」に副作用を実行するようになります。
では、そのあたりを踏まえたり踏まえなかったりして、useEffectのデメリットとその対応策について考えていきましょう。
useEffectのデメリット
無限ループを起こしうる
上述したライフサイクルをうっかり踏まえ忘れると、useEffectにより無限ループが発生する場合があります。
パターン1
たとえば以下のケースが挙げられます。
export default Component
import { useEffect, useState } from "react"
const Component = () => {
const [count, setCount] = useState(0)
useEffect(() => {
setCount(count + 1)
}, [count])
return <p>現在のcount数は{count}です!</p>
}
export default Component
これでループが起こる原因としては、第2引数に設定している値を変更する関数を、副作用に含んでいることが挙げられます。
イベントの順序としては、
コンポーネントのマウント -> useEffect実行 -> 副作用の実行(setCount) -> 第2引数に指定しているcountが更新 -> useEffect実行 -> 副作用の実行 -> ...
という流れとなっています。
なので対応方法としては、ただの原因の裏返しですが、第2引数に設定している値を変更する関数を副作用に含まない、ということになります。
ちなみに、setStateをuseEffectに入れるのが悪いわけではありません。
たとえば以下の例だとループは発生しません。
type User = {
name: string
age: number
}
const useUser = (id: number) => {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
const effect = async () => {
const res = await fetch(`https://hogehoge.com/users/${id}`)
setUser(res.json())
}
effect()
}, [id])
return item
}
(厳密には、上記のままだとres.jsonが型で弾かれます。雑に書いていてごめんなさい😇)
このケースでは、副作用であるeffectは第2引数に設定しているidの値を変えていないので、セーフです。
パターン2
また、別のループするケースを見てみましょう。
import { useEffect, useState } from "react"
type Text = {
content: string
}
const Component = () => {
const [state, setState] = useState(true)
const [text, setText] = useState<Text>({ content: "" })
const getText = () =>
state ? { content: "trueです!" } : { content: "falseです!" }
useEffect(() => {
setText(getText())
console.log("useEffectが実行されたよ!")
}, [getText])
return (
<div>
<p>stateは今{text.content}</p>
<button onClick={() => setState(!state)}>state変更</button>
</div>
)
}
export default Component
consoleを見るとえらいことになっています。
これは、setTextでsetしているgetTextが、JavaScript的には「呼ばれるたびに値が異なっている」と認識されてしまい、それによりループが発生する、という状況です。
イベントの順序としては、「コンポーネントのマウント -> useEffect実行 -> setTextが呼ばれる -> getTextが呼ばれる -> getTextの返り値が変わったと判定される -> コンポーネントのマウント -> useEffect実行 ... 」という流れとなっています。
この問題の対応方法としては、getTextをuseEffect内で定義するか、getTextをuseCallbackでラップすることが挙げられます。
一応useCallbackの方のコードを載せるとこんな感じです。
useCallbackの方
import { useCallback, useEffect, useState } from "react"
type Text = {
content: string
}
const Component = () => {
const [state, setState] = useState(true)
const [text, setText] = useState<Text>({ content: "" })
const getText = useCallback(() => {
return state ? { content: "trueです!" } : { content: "falseです!" }
}, [state])
useEffect(() => {
setText(getText())
console.log("useEffectが実行された!")
}, [getText, state])
return (
<div>
<p>stateは今{text.content}</p>
<button onClick={() => setState(!state)}>state変更</button>
</div>
)
}
export default Component
ちなみにeslint-plugin-react-hooksを入れていれば、こうしたミスには都度warningを出してくれるので、基本的にはその指示にしたがっておけば、この手の無限ループが起こることはないと思います。
ただ、副作用のロジックが大変複雑な場合は、linterにしたがったのにループした!ということも起こりうるようなので、注意が必要です。
可読性が下がる
ここからはライフサイクル関係ありませんが、同じくデメリットとしてあげられるものを紹介します。
useEffectの副作用のロジックが複雑な場合は、ちょっと見ただけだと、「これ、要は何をしているんだろう?」ということになりがちです。
もちろん、そもそもロジックは簡潔に書かれた方が好ましいですが、一方で、すでに他の誰かが書いてしまったロジックががやがやしていると、何をしているコードなのかがまずよくわからないので、リファクタする難易度は高いです。
対応方法としては、カスタムフックとして切り出してあげるのがよいです。
関数として切り出すと、強制的に名前をつけざるを得ないので、ざっくりと何をしているロジックなのかがわかります。
しかし、公式は以下のようにも言っているので、やりすぎには注意が必要です。
あまり焦って抽象化を加えないようにしましょう。関数コンポーネントがやれることが増えたので、平均的な関数コンポーネントはこれまでより長いものになるでしょう。それは普通のことですので、いますぐカスタムフックに分割しないといけないとは考えないでください。
しかし次の一文でこうも言っているので、「複雑さが一線を超えている」「どう見ても何をしているかよくわからない」という場合には切り出すのがベターなのだと思われます。
一方で、カスタムフックをどこで使えば複雑なロジックをシンプルなインターフェースに置き換えたり、ごちゃっとしたコンポーネントを整理したりできるのか、考え始めることをお勧めします。
ロジックとUIが混ざる
Reactでよくある問題が、UI部分とロジック部分が混合することですよね。
あまりに混ざりあい、どんどん密結合になってしまうと、改修時の影響範囲も比例して大きくなったりと、そのコンポーネントが技術的負債となってしまいます。
対応方法としては、これについても、カスタムフックとして切り出すのがよいです。
ただし、上述した通りカスタムフックは銀の弾丸ではないので、ロジックが重かったり複雑すぎたりした場合に切り出すのがベターでしょう。
所感
個人開発でuseEffectにwrite処理を任せたら、1度でなく2度実施されてしまったところから思いついた記事でした😇
いつuseEffectは実行されるのか?を知れてよかったし、そもそもライフサイクルを踏まえたとしても、なんか怖いからuseEffectにwriteはあまり任せない方がよいのかもしれない。
なににせよ、useEffectちょっと理解できた気がしてよかった😊
参考記事
Discussion