React:エフェクトから依存値を取り除く

イベントハンドラとエフェクトの選び方

まずはイベントハンドラとエフェクトの動きの概要について振り返り。

  • イベントハンドラ

    同じユーザ操作を再度実行した場合のみ再実行。副作用も含んで良い。

  • エフェクト

    props, state 変数のようなリアクティブな値が変化したときに再同期される。レンダーコードは純粋である必要がある。

ポイントとなるのは、『リアクティブな値』という点で、特定のコードがリアクティブな値に同期してついていけるようにするために、その値が異なったときに再度実行されるようにしたい。そういう場合に、エフェクトを用いる。

エフェクトからの切り離し

リンタが React 向けに設定されている場合、エフェクトで利用されている全てのリアクティブな値は、依存配列として指定されている必要がある。されていない場合、リンタエラーになる。

次の roomId, theme は依存配列に書く必要がある:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => { showNotification('Connected!', theme); });
    connection.connect();
    return () => { connection.disconnect() };
  }, [roomId, theme]);
  // ...

上記のコードは、依存配列内のコードが変化すると同期されるが、依存配列内の値の中で監視する必要のない変数がある場合には、エフェクトイベント が使える。

useEffectEvent フックを用いて、非リアクティブなロジックを抽出する。エフェクトイベントはリアクティブでないので、依存配列から削除する。エフェクトイベントでは、常に最新の state, props 取得できる。

function ChatRoom({ roomId, theme }) {
  // エフェクトイベント
  const onConnected = useEffectEvent(() => { showNotification('Connected!', theme); });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => { onConnected(); });  // <= theme が抽出された
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
  // ...

イベントハンドラとエフェクトイベントの違いは、イベントハンドラがユーザ操作に反応して実行されるのに対して、エフェクトイベントはエフェクトからトリガされる。

また、下記のコードは初回のみ同期され、handleMove 関数は canMovetrue の状態で記録される。つまり、エフェクトにより同期されることはないため、handleMove はずっと同じ処理となる。

// ...
const [canMove, setCanMove] = useState(true);

function handleMove(e) {
  if (canMove) {
    setPosition({ x: e.clientX, y: e.clientY });
  }
}

useEffect(() => {
  window.addEventListener("pointermove", handleMove);
  return () => window.removeEventListener("pointermove", handleMove);
}, []);

一方でエフェクトイベントを用いると値が再同期され、常に最新の state, props を参照できる

// ...
const [canMove, setCanMove] = useState(true);

const onMove = useEffectEvent((e) => {
  // <= イベントエフェクトでは常に最新の state, props が見える
  if (canMove) {
    setPosition({ x: e.clientX, y: e.clientY });
  }
});

useEffect(() => {
  window.addEventListener("pointermove", onMove);
  return () => window.removeEventListener("pointermove", onMove);
}, []);
// ...

エフェクトイベントの命名のコツ

経験則的にエフェクトイベントは、コードがいつ実行されるかではなく、ユーザ視点から何が起こったのかを基準にしてつけると良い。

  • 良い例:onMessage, onTick, onVisit, onConnected
  • 悪い例:onMount, onUpdate, onUnmount, onAfterRender

エフェクトで依存値を取り除く方法

エフェクトから依存値を取り除くにはいくつかやり方がある。

  • イベントハンドラへの移動を考える
  • エフェクト内の無関係なロジックは分ける
  • リンタに依存値でないことを示し、取り除く
  • 前回値を使う
  • リアクティブでない部分をエフェクトイベントへ抽出

イベントハンドラへの移動を考える

特定のユーザ操作に対応してコードを実行する場合は、当該ロジックをエフェクトではなくイベントハンドラに移動する。

下記コードはユーザ操作をエフェクトとしている例:

function Form() {
  const [submitted, setSubmitted] = useState(false);
  const theme = useContext(ThemeContext);

  useEffect(() => {
    if (submitted) {
      post("/api/register");
      showNotification("Successfully registered!", theme);
    }
  }, [submitted, theme]); // submitted はフォーム送信というユーザ操作に対応する

  function handleSubmit() {
    setSubmitted(true);
  }

  // ...
}

『ユーザ操作はイベントハンドラに移動する』に従い修正する:

function Form() {
  const theme = useContext(ThemeContext);

  function handleSubmit() {
    post("/api/register");
    showNotification("Successfully registered!", theme);
  }

  // ...
}

エフェクト内の無関係なロジックは分ける

依存値リスト内に複数のリアクティブな値があり、各依存値がロジックに関連がない場合、エフェクトは分割する。

下記のように各ロジックが互いに関係ないものを依存値リストに含めてしまうと、不必要にエフェクトを呼び出しロジックを実行することになる:

function ShippingForm({ country }) {
  const [cities, setCities] = useState(null);
  const [city, setCity] = useState(null);
  const [areas, setAreas] = useState(null);

  useEffect(() => {
    // country にのみ関係するロジック(cities と関係)
    // ...

    // city にのみ関連するロジック(city, areas と関係)
    // ...
  }, [country, city]);

関係がないならエフェクトを分け、互いを意図せずにトリガしないようにする:

function ShippingForm({ country }) {
  const [cities, setCities] = useState(null);
  useEffect(() => {
    // country にのみ関係するロジック(cities と関係)
    // ...
  }, [country]);

  const [city, setCity] = useState(null);
  const [areas, setAreas] = useState(null);
  useEffect(() => {
    // city にのみ関連するロジック(city, areas と関係)
    // ...
  }, [city]);

リンタに依存値でないことを示す

エフェクト内のリアクティブな値は、依存値リストに宣言しなければエラーとなってしまう:

const serverUrl = "https://localhost:1234";

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // <= roomId を依存値リストに入れない場合、リンタが起こってくる
  // ...
}

そこで、リンタに依存値が不要であることを示せれば依存値から取り除くことができる。

やることとしては、リアクティブな値をコンポーネントの外に出してあげることで、サイレンダー時に変更されないことを示すことができる:

const serverUrl = "https://localhost:1234";
const roomId = "music"; // <= コンポーネント外に出して、リアクティブでなくす

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

前回値を使ってエフェクトの依存値を切り離す

下記のコードでは、新規メッセージがあるたびにエフェクトを実行し、既存の messages 配列に新規メッセージを追加した新しい配列で messages 配列を更新する。

問題は メッセージに変化があるたびに再接続が発生してしまうところ:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  useEffect(() => {
    connection.connect();
    connection.on('message', (receivedMessage) => {
      setMessages([...messages, receivedMessage]); // メッセージ配列を更新
    });
    return () => connection.disconnect();
  }, [roomId, messages]);
  // ...

messages を更新したいが、エフェクト内に書いてしまうと再同期のトリガとなってしまう。

上記の例では、前回値に対して更新をかけており、そうした場合には、state 更新関数にコールバックを渡す方法が使える。コールバック関数を使うことで前回値を使えるようになり、そうすることで state 変数を明示的に使う必要がなくなる👀(state 更新関数にコールバックを渡す

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      setMessages(msgs => [...msgs, receivedMessage]); // <= コールバック関数を渡すと、コールバックの引数には前回値が入る
    });
    return () => connection.disconnect();
  }, [roomId]); // <= 依存値リストから messages を消せた
  // ...

リアクティブでない部分をエフェクトイベントへ抽出

isMutedtrue でない時に、ユーザが新しいメッセージを受信したら音を再生したいとすると、下記のようなコードになる:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const [isMuted, setIsMuted] = useState(false);

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      setMessages(msgs => [...msgs, receivedMessage]); // <= メッセージ配列更新
      if (!isMuted) {
        playSound(); // <= 受信音を再生
      }
    });
    return () => connection.disconnect();
  }, [roomId, isMuted]); // <= isMuted の ON/OFF 再生を制御したいが、、、
  // ...

課題は isMuted が更新されると不必要にネットワークの再接続が行われてしまうことにある。isMuted の最新値により処理を切り替えたいが isMuted には反応させたくない、そんな時に使えるのがエフェクトイベント。

エフェクトイベント内では、リアクティブな値(state, props)は最新値が使えるので下記のように抽出すれば良い:

import { useState, useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const [isMuted, setIsMuted] = useState(false);

  // リアクティブな値に反応させたくない部分を抽出
  const onMessage = useEffectEvent(receivedMessage => { 
    setMessages(msgs => [...msgs, receivedMessage]);
    if (!isMuted) {
      playSound();
    }
  });

  useEffect(() => {
    // ...
    connection.on('message', (receivedMessage) => {
      onMessage(receivedMessage);
    });
    return () => connection.disconnect();
  }, [roomId]); // isMuted を排除できた
  // ...

props をラップする

props が親コンポーネントから動的に変わるようなコードがある:

function ChatRoom({ roomId, onReceiveMessage }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    // ...
    connection.on('message', (receivedMessage) => { // connect.on のコールバックの戻り値が receivedMessage に返る
      onReceiveMessage(receivedMessage);
    });
    // ...
  }, [roomId, onReceiveMessage]); // 親コンポーネントから動的に渡される
  // ...

props に反応させたくない場合は、エフェクトイベントへ退避してあげれば良い:

function ChatRoom({ roomId, onReceiveMessage }) {
  const [messages, setMessages] = useState([]);

  // ラッパ関数オブジェクトを用意
  const onMessage = useEffectEvent(receivedMessage => {
    onReceiveMessage(receivedMessage);
  });

  useEffect(() => {
    // ...
    connection.on('message', (receivedMessage) => {
      onMessage(receivedMessage); // コールバックにはラッパ関数を渡す
    });
    // ...
  }, [roomId]); // 依存値リストから排除できた
  // ...

参考