[React] useEffect内のコールバック関数でstateが更新されない問題

本記事はReactのuseEffect内で作成するコールバック関数にてstateを更新しようとしても更新されない問題を扱います。

説明はイベントハンドラとイベントリスナのコールバック関数を比較して行います。ここで言うイベントハンドラとはJSX要素にてonClickなどで呼び出すイベント処理のことで、イベントリスナはaddEventListenerメソッドを使ってのイベント処理のことを指します。

イベントハンドラによって呼び出すコールバック関数内ではstate更新が期待通りなのに、useEffect内で登録するイベントリスナだとstateが更新されないという経験はありませんでしょうか。この問題はuseEffectの挙動やJavaScriptのクロージャが絡んでいる場合があります(実は下記の例ではuseStateのsetState関数の使い方にも問題があり、この点は最後に説明します)。

下記では同様の処理をするコンポーネントをイベントハンドラとイベントリスナのそれぞれを使って例示し、その挙動の違いについて示した後にイベントリスナで発生する問題と原因、そしてその解消方法を解説します。

イベントハンドラまたはイベントリスナを使うコンポーネントの例

以下のコードはdiv要素をクリックするとstateをカウントアップして表示することを意図したイベンドハンドラまたはイベントリスナを使ったコンポーネントの例です。

イベントハンドラの方は期待通りdivをクリックするとstateがカウントアップして表示されます。これに対して、イベントリスナの方は1にカウントアップした後はいくらdivをクリックしても1のままです。

イベンドハンドラの例

import React, { useState } from 'react';

const App = () => {
  const [state, setState] = useState(0);

  // イベントハンドラのコールバック関数
  const onMouseDown = () => {
    setState(state + 1);
  };

  return (
    <div
      onClick={onMouseDown}
      style={{ width: 50, height: 50, background: 'red' }}
    >
      {state}
    </div>
  );
};

export default App;

 

イベントリスナ1

↓ (赤四角内のdivをクリック)

イベントリスナ2

↓ (divをクリック)

イベントハンドラ1

↓ (divをクリック)

(以降3、4、5とカウントアップ)

イベンドリスナの例

import React, { useState, useEffect, useRef } from 'react';

const App = () => {
  const [state, setState] = useState(0);
  const ref = useRef(null);

  useEffect(() => {
    // イベントリスナを登録
    ref.current.addEventListener('mousedown', onMouseDown);

    return () => {
      // イベントリスナを解除
      ref.current.removeEventListener('mousedown', onMouseDown);
    };
  }, []);

  // イベントリスナのコールバック関数
  const onMouseDown = () => {
    setState(state + 1);
  };

  return (
    <div ref={ref} style={{ width: 50, height: 50, background: 'red' }}>
      {state}
    </div>
  );
};

export default App;

 

イベントリスナ1

↓ (赤四角内のdivをクリック)

イベントリスナ2

↓ (divをクリック)

(以降1のまま)

イベントリスナの例でstateが更新されない理由

上記の例でイベントリスナの方のstateが更新されない理由はコード内で赤字にしたuseEffectが原因です。useEffectの第2引数を空配列にすると初回レンダー時にしか第1引数の関数が実行されないので、divのクリックでイベントリスナのコールバック関数が呼ばれsetStateにてstateが1に更新され再レンダー(コンポーネント関数Appの再呼び出し)しても、useEffect第1引数の関数は実行されず初回レンダー時のコールバック関数を参照し続けます(JavaScriptのクロージャによる挙動)。そして初回レンダー時のコールバック関数が参照するstateは0なので何回divをクリックしてもstateは1よりは大きくなりません。

それに対してイベントハンドラの方はレンダー(コンポーネント関数Appの再呼び出し)の度にイベントハンドラのコールバック関数も再作成されるのでコールバックが参照するstateも更新されていて想定通りstateがカウントアップされていきます。

イベントリスナの例で発生する問題の解消例

イベントリスナの例で期待通りstateをカウントアップさせるには、コールバック関数が参照するstateが更新される度にコールバック関数を再作成させてやればよいので、以下赤字のコードを追加するだけで解決します。これでuseEffectの第2引数に指定した配列の各要素が更新されれば第1引数の関数が実行されるのでイベントリスナのコールバック関数も更新され新しいstateを参照するようになります。

ちなみにイベントリスナの登録前にイベントリスナの解除コードを追加しないと重ね掛けにならないかと心配する必要はありません。イベントリスナのインスタンスは重複して作成されないことが保証されています。(参考: MDN Web Docs 複数の同一のイベントリスナー)

import React, { useState, useEffect, useRef } from 'react';

const App = () => {
  const [state, setState] = useState(0);
  const ref = useRef(null);

  useEffect(() => {
    // イベントリスナを登録
    ref.current.addEventListener('mousedown', onMouseDown);

    return () => {
      // イベントリスナを解除
      ref.current.removeEventListener('mousedown', onMouseDown);
    };
  }, [state]);

  // イベントリスナのコールバック関数
  const onMouseDown = () => {
    setState(state + 1);
  };

  return (
    <div ref={ref} style={{ width: 50, height: 50, background: 'red' }}>
      {state}
    </div>
  );
};

export default App;

 

冒頭でuseStateのsetState関数の書き方にも問題があると述べましたが、実はもっと筋の良い解消方法があります。それはイベントリスなの例で下記赤字のようにsetState関数を書くやり方です。setState関数はこのように前回のstateの値を引数にとる関数を渡すこともできます。この書き方はReactの公式ドキュメントにあるuseState APIリファレンスにも記載があります。

setState関数で自己のstateを参照する場合は、下記の赤字のような書き方をした方が問題を防げます。

import React, { useState, useEffect, useRef } from 'react';

const App = () => {
  const [state, setState] = useState(0);
  const ref = useRef(null);

  useEffect(() => {
    // イベントリスナを登録
    ref.current.addEventListener('mousedown', onMouseDown);

    return () => {
      // イベントリスナを解除
      ref.current.removeEventListener('mousedown', onMouseDown);
    };
  }, []);

  // イベントリスナのコールバック関数
  const onMouseDown = () => {
    setState(preState => preState + 1);
  };

  return (
    <div ref={ref} style={{ width: 50, height: 50, background: 'red' }}>
      {state}
    </div>
  );
};

export default App;
sponsor