今一度 useEffect を見つめ直してみる

2024/11/16に公開

はじめに

useEffect使ってますか?使ってますよね。
みなさま、なんとなくで使っていませんか?

  • stateが変わるから
  • fetchするから
  • 初期化処理をしたいから

などなど、さまざまな要件で使われていると思います。果たして、これらの使い方は本当に正しいのでしょうか?
そしてみなさまは正しく使えていますか?私は正しく使えていると自信を持って言えません。
そこで、今一度useEffectを見つめ直そうと思います。

純関数、副作用、そしてEffect

そもそも、Reactの主となる要素は関数です。コンポーネントの定義、カスタムフックの定義などを思い出してみると、あれもこれも関数だということがわかります。
そしてReactは関数型プログラミングと相性が良いです。そのため、まずは関数型プログラミングの要素である純関数と副作用、そして、React固有の定義であるEffectについて学んでいきます。

純関数

純関数とは、その名の通り純粋な関数のことです。身近な例では数学における関数が代表的でしょう。
y = 2x
この式を見てください。xが変わればyは変わります。そして、それ以外の要因でyが変わることはありません。
全く関係のないzが登場しても、株価が上がっても、明日になっても、来年になっても、過去でさえ、xが2であればyは4であり、xが3であればyは6です。
このように、純関数は同じ入力に対して、必ず同じ出力を返し、外部の変数に依存しません。
純関数は、変更の影響が閉じていることでテストを容易にし、安全で信頼性の高いコードを書くことができます。

副作用

副作用とは、引数に基づいて戻り値を計算する以外に関数が行うこと全てです。side effectとも呼ばれます。
例えば、「グローバル変数」や「関数内部で取得したDBの値」に基づいて戻り値が計算されている場合などです。
また、DBへの挿入、標準出力への書き出し、ログの出力などもこれまた副作用です。(全て戻り値の計算ではありません)
副作用のあるコードは変更の影響が非常に広く、テストがしづらかったり、結果を予測しづらくなったりします。

Effect

EffectとはReact固有の定義であり、レンダーによって引き起こされる副作用のことです。
例えば、チャットアプリケーションを考えてみてください。以下のような機能があると思います。

  • メッセージ送信
  • ファイルのアップロード
  • チャットルームの切り替え
  • 過去のチャットの取得
  • メッセージの購読

ここで、メッセージ送信、ファイルのアップロード、チャットルームの切り替えはユーザーの動作によって実行されることが多いでしょう。例えば、送信ボタンを押す、画像がドラッグ&ドロップされるなどです。
しかし、チャットの取得やメッセージの購読は、最初に、自動的に実行されているはずです。(LINEのトークルームを考えてみてください!あなたは、アプリケーションを開くたびに「チャットの取得」をクリックすることはありません!)
このような処理は、レンダー自体がトリガーとなり引き起こされる副作用と言えます。
そしてEffectは、コミットの最後、画面が更新された後に実行されます。

useEffectの定義

React リファレンスの説明は以下のようになっています。

useEffectは、コンポーネントを外部システムと同期させるための React フックです。

この説明では、「副作用を管理する」というニュアンスをあえて排除し、「外部システムと同期させる」というより絞ったユースケースで説明しています。
このことから、useEffectは、React内部の副作用は全て排除し、外部システムでどうしても必要な副作用のみを管理する手段として位置付けられていることが読み取れます。

useEffectの定義は以下のようになっています。

useEffect(setup, dependencies?)

setup:実行されるセットアップ関数です。セットアップ関数は、オプションでクリーンアップ関数を返すことができます。
dependencies(optional):セットアップ関数内で参照される全てのリアクティブな値のリストです。リアクティブな値には、props、state、コンポーネント本体に宣言された全ての変数および関数が含まれます。

ライフサイクル

useEffectのライフサイクルは以下のようになります。

  1. コンポーネントがDOMに追加
  2. setup関数の実行
  3. 依存配列(dependencies)の変更
  4. 古いpropsとstateでクリーンアップ関数を実行(オプショナル)
  5. 新しいpropsとstateでsetup関数の再実行
  6. クリーンアップ関数の実行(オプショナル)
  7. コンポーネントがDOMから削除

ただし、この順序はあまり意識しない方が良いのかもしれません。このライフサイクルはつまるところ、必要になったら実行し、不必要になったらお掃除するというだけです。
setup関数は、エフェクトの開始/終了という1サイクルのみにフォーカスする必要があります。
実行のタイミングは、"レンダー直後"、"アンマウント直前"ではありますが、これらにこだわりすぎると、useEffect外のものに関心が向いてしまい、コードが複雑になる恐れがあります。あなたたちは、どのように同期を開始し、どのように終了するのかに着目する必要があるのです。

依存配列

エフェクトの依存配列は、自分で「選ぶ」類のものではありません。依存配列は、周囲のコードによって導出されるものであり、自分で選んだり決定したりするものではないのです。
setup関数内で使用されるリアクティブな値は全て依存配列内に含まれる必要があります。

もし、依存配列から何かを削除するには、それが依存値である理由がないことを「証明」する必要があります。例えば、エフェクト内で使用している変数をコンポーネントの外に移動させるなどです。こうすることにより、再レンダー時に変更されないものであるということを証明できます。そしてもし、コンポーネントの外に移動するようなことがあれば、それは文字通りの定数[1]にするべきでしょう。

アンチパターン

ここからは具体例とともにコードの問題点と改善案を見ていきます。このコードとこれまでに書いたことを見比べながら見てみてください。

その1. 依存配列が足りない

チャットアプリケーションでは、チャットサーバーとそのルームで接続を確立するという要件があると思います。

export const ChatRoom = ({ roomId }) => {
    const [serverUrl, setServerUrl] = useState(`http://localhost:1234`)

    useEffect(() => {
        const connection = createConnection(serverUrl, roomId)
        connection.connect()
        return () => connection.disconnect()
    },[])
}

この時のuseEffectは初期化のためなので、依存配列には空の配列を渡すのみで良いのでしょうか?
答えはNoです。なぜなら、setup関数の中で、propsとstateであるroomIdserverUrlを使用しているからです。 このままでは、別のルームに行きたいのに一つのルームでしかチャットできないといったバグが発生する可能性があります。
この時の解決策として、以下の二つがあります。

  1. roomIdserverUrlを依存配列に含める
  2. リアクティブな値を見直す

まずは1から解説します。
こちらは単純な方法です。roomIdserverUrlを含めることでルールに従います。こうすることで、バグの発生リスクを減らし、エフェクトが依存する値について、Reactに「嘘」をつくことがないようにします。

サーバーのエンドポイントは変わらないから依存配列に含める必要がないという場合もあると思います。その場合は2の方法を実施します。
具体的には、serverUrlをコンポーネント外部の値とし、レンダーのデータフローに関わらないようにします。そうすることにより、useEffectはこの値を監視しなくても良くなるのです。もし、外部の値の変更を監視しなければならない場合は、そもそもの設計が間違っている可能性が大いにあります。例えば、Contextを使用するように変更する必要があるかもしれません。

上記をまとめるとこのようなコードに修正できます。

const SERVER_URL = `http://localhost:1234`

export const ChatRoom = ({ roomId }) => {
    useEffect(() => {
        const connection = createConnection(serverUrl, roomId)
        connection.connect()
        return () => connection.disconnect()
    },[roomId])
}

こうすることにより、リアクティブな値roomIdの変更を監視しつつ、不変な値SERVER_URLは定数としてコンポーネント外部に定義することができました。
これでユーザーは期待通りに別のルームへ遷移できることでしょう。

その2. クリーンアップしていない

その1と同じように、チャットアプリケーションを具体例に挙げます。この時点で何を書くのか、ある程度察しがつくかもしれないですね。

const SERVER_URL = `http://localhost:1234`

export const ChatRoom = ({ roomId }) => {
    useEffect(() => {
        const connection = createConnection(serverUrl, roomId)
        connection.connect().then(() => console.log(`✅connect to "${roomId}" succeeded!`))
    },[roomId])
}

このコードでは、useEffectが実行されるたびにチャットルームに接続されます。ここまでは問題ないです。では、いろんなルームに行くとどうなるでしょうか?
そうです。何回も同じルームへの接続が確立されてしまいます。同じ接続が積み重なっていくことで、メモリリークによるアプリケーション終了などもあるでしょう。そうならないにしてもパフォーマンスは必ず落ちます。
そこで、DOMのアンマウント時や依存配列の変更時には必ず接続をクローズする必要があります。

const SERVER_URL = `http://localhost:1234`

export const ChatRoom = ({ roomId }) => {
    useEffect(() => {
        const connection = createConnection(serverUrl, roomId)
        connection.connect().then(() => console.log(`✅connect to "${roomId}" succeeded!`))
        return () => connection.disconnect().then(() => console.log(`❌disconnect to "${roomId}" succeeded!`))
    },[roomId])
}

こうすることでユーザーは原因不明のバグに悩まされることなく、パフォーマンスを維持したままアプリケーションを利用することができるでしょう。

その3. propsの変更によってstateを変更している

コンポーネントはpropsとしてuserIdを受け取ります。ページにはコメント入力欄があり、その値を保持するためにcommentというstateを用意していました。

export const Profile = ({ userId }) => {
    const [comment, setComment] = useState('')
    // ...
}

ただ、propsのuserIdを変更しても、commentの値がリセットされないことに気づきました。そして、これを修正するためにuseEffectを追加しました。

export const Profile = ({ userId }) => {
    const [comment, setComment] = useState('')

    useEffect(() => {
        setComment('')
    },[userId])

    // ...
}

これで、userIdの変更を監視し、変更が加わるたびにcommentがリセットされるようになりました。めでたしめでたし……。とはいきませんでした。
要件は確かにこれでクリアできます。しかし、非常に非効率な解決法です。
もう一度useEffectのライフサイクルを思い出してみてください。値が変わった時にはまず古くなった値でレンダーされ、クリーンアップされた後に新しい値で再度レンダーされます。
そもそも、今回は外部システムとの同期が要件ではありません。つまり、useEffectは使うべきではないのです。ではどうするのか?元に戻してあげましょう。

export const Profile = ({ userId }) => {
    const [comment, setComment] = useState('')
    // ...
}

ただし、このままだと最初の問題であるuserIdを変更してもcommentの値がリセットされないという問題を解消できていません。そこで、Profileを呼び出す親コンポーネントでkeyを割り当てるのです。
keyにuserIdを割り当てることで、Profileコンポーネントを、stateを共有すべきでない2つの異なるコンポーネントとして扱わせることができます。
keyが変更されるたびに、ReactはDOMを再生成し、Profileコンポーネントとそのすべての子コンポーネントのstateをリセットします。

//変更なし
export const Profile = ({ userId }) => {
    const [comment, setComment] = useState('')
    // ...
}

export const Page = () => {
    //...
    return(
        <div>
            {/* keyとともにuserIdを割り当て */}
            <Profile userId={userId} key={userId}/>
        </div>
    )
}

おわりに

この記事では、useEffectの思想を理解することから、アンチパターンとその解消方法について改めて見つめ直しました。
useEffectは非常に強力かつ使い勝手の良いフックのため多用しがちですが、一歩間違えるとコードを複雑にし、予期せぬバグやパフォーマンスの低下につながります。そのため、要件を達成するためにuseEffectを使うべきなのか、それとも別のアプローチを取るべきなのか、今一度吟味してみましょう。その足掛かりとなれれば幸いです。
また、この記事はReactの公式ドキュメントを基に作成しています。より詳しい内容やそのほかのユースケースについてもわかりやすく書かれているため、Reactに慣れてきた方こそ一度目を通すことをお勧めします。

参考文献

  1. React
  2. コンポーネントを純粋に保つ
  3. そのエフェクトは不要かも
脚注
  1. 変数をイミュータブルにするために慣例的に使われている定数ではなく、システムのライフサイクルを通して全く変わることのない定数を指します。つまり、API_ENDPOINTのようなものです。 ↩︎

Discussion