エフェクトからイベントを分離する

イベントハンドラは、ユーザが同じ操作を繰り返した場合にのみ再実行されます。エフェクトはイベントハンドラとは異なり、props や state 変数のようなそれが読み取る値が前回のレンダー時と異なる場合に「再同期」を行います。また両方の動作をミックスさせて、ある値には反応して再実行されるが他の値には反応しないというエフェクトが欲しくなる場合もあります。このページでは、その方法を説明します。

このページで学ぶこと

  • イベントハンドラとエフェクトのどちらを選ぶか
  • エフェクトがリアクティブで、イベントハンドラがリアクティブでない理由
  • エフェクトのコードの一部をリアクティブにしたくない場合の対処法
  • エフェクトイベントとは何か、そしてエフェクトからエフェクトイベントを分離する方法
  • エフェクトイベントを使用してエフェクトから最新の props と state を読み取る方法

イベントハンドラとエフェクトのどちらを選ぶか

まず、イベントハンドラとエフェクトの違いについておさらいしておきましょう。

チャットルームのコンポーネントを実装している場合を想像してください。要件は次のようなものです。

  1. コンポーネントは選択中のチャットルームに自動的に接続する。
  2. “Send” ボタンをクリックすると、チャットにメッセージが送信される。

このためのコードはすでに実装されているが、それをどこに置くか迷っているとしましょう。イベントハンドラを使うべきでしょうか、エフェクトを使うべきでしょうか。このような質問に答える必要がある場合は常に、なぜそのコードを実行する必要があるのかを考えるようにしてください。

イベントハンドラは具体的なユーザ操作に反応して実行される

ユーザの立場からすると、メッセージの送信とは、“Send” という特定のボタンがクリックされたから起こるべきものです。それ以外のタイミングや理由でメッセージが送信されてしまうとユーザは怒ることでしょう。これが、メッセージの送信はイベントハンドラで行うべき理由です。イベントハンドラを使うことで、特定のユーザ操作を処理できます。

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>Send</button>
</>
);
}

イベントハンドラを使うことにより、ユーザがボタンを押したときにだけ sendMessage(message) が実行される、ということが保証されるのです。

エフェクトは同期が必要なときに常に実行される

コンポーネントはチャットルームへの接続を維持する必要がある、という要件もあるのでした。そのためのコードはどこに記述すべきでしょうか?

そのコードを実行する理由は、何か特定のユーザ操作ではありません。ユーザがチャットルーム画面に移動した理由や方法は問題ではありません。ユーザがチャットルームの画面を見てそれを操作できるようになった以上、そのコンポーネントは選択されたチャットサーバへの接続を維持する必要があります。チャットルームコンポーネントがアプリの初期画面であって、ユーザは何の操作も行っていないという場合でも、やはり接続は必要です。したがってエフェクトを使用するべきです。

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

このコードにより、ユーザが行った特定の操作とは関係なく、現在選択されているチャットサーバへの接続が常にアクティブであると確信することができます。ユーザがアプリを開いただけの場合でも、別のルームを選んだ場合でも、他の画面に移動して戻ってきた場合でも、このエフェクトによりコンポーネントが現在選択されているルームと同期し、必要なとき常に再接続を行うことが保証されます。

import { useState, useEffect } from 'react';
import { createConnection, sendMessage } from './chat.js';

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

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

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

  function handleSendClick() {
    sendMessage(message);
  }

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [show, setShow] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <button onClick={() => setShow(!show)}>
        {show ? 'Close chat' : 'Open chat'}
      </button>
      {show && <hr />}
      {show && <ChatRoom roomId={roomId} />}
    </>
  );
}

リアクティブな値とリアクティブなロジック

直感的にはイベントハンドラとは、例えばボタンをクリックするなど、常に「手動」でトリガされるものだと言えるでしょう。一方、エフェクトは「自動」であり、同期を保つために必要なだけ実行・再実行されます。

しかし、もっと正確な考え方があります。

コンポーネントの本体部分で宣言された props、state および変数のことをリアクティブな値 (reactive value) と呼びます。この例では、serverUrl はリアクティブな値ではありませんが、roomIdmessage はリアクティブな値です。これらはレンダーのデータフローに関わる値です。

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

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');

// ...
}

このようなリアクティブな値は、再レンダー時に変化する可能性があります。例えばユーザは message を編集したり、ドロップダウンで別の roomId を選択したりするかもしれません。イベントハンドラとエフェクトは、それぞれ異なる方法で値の変化に対応します。

  • イベントハンドラ内のロジックはリアクティブではない。ユーザが同じ操作(クリックなど)を再度行わない限り、再度実行されることはない。イベントハンドラは値の変化に「反応」することなく、リアクティブな値を読み取ることができる。
  • エフェクト内のロジックはリアクティブである。エフェクトがリアクティブな値を読み取る場合、依存配列としてそれを指定する必要がある。その後再レンダーによって値が変化した場合、React は新しい値でエフェクトのロジックを再実行する。

この違いを理解するため、先ほどの例をもう一度見てみましょう。

イベントハンドラ内のロジックはリアクティブではない

このコードをご覧ください。このロジックはリアクティブであるべきでしょうか、そうではないでしょうか?

// ...
sendMessage(message);
// ...

ユーザの観点からは、message が変化することがメッセージを送りたいという意味になるわけではありません。あくまでユーザが入力の最中だということでしかありません。つまり、メッセージ送信のロジックは、リアクティブであってはならないということです。リアクティブな値が変わったからと言って、再び実行されるべきではありません。したがって、これはイベントハンドラの中にあるべきです。

function handleSendClick() {
sendMessage(message);
}

イベントハンドラはリアクティブではないので、sendMessage(message) はユーザが送信ボタンをクリックしたときのみ実行されます。

エフェクト内のロジックはリアクティブである

ではこちらの行に戻りましょう。

// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...

ユーザの観点からは、roomId が変化することは、別の部屋に接続したいことを意味します。つまり、ルームに接続するためのロジックもリアクティブであるべきだ、ということです。コードがリアクティブな値の変化に「キャッチアップ」するようにし、値が変われば再度実行されるようにしたいのです。したがってこれはエフェクトの中にあるべきです。

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

エフェクトはリアクティブなので、createConnection(serverUrl, roomId)connection.connect() は、roomId の値が変わるごとに実行されます。エフェクトにより、チャットの接続が選択中のルームに同期された状態が維持されます。

エフェクトから非リアクティブなロジックを分離する

リアクティブなロジックと非リアクティブなロジックを混在させたい場合、少し厄介なことになります。

例えば、ユーザがチャットに接続したときに通知を表示したいとします。正しい色で通知を表示することができるよう、props から現在のテーマ(ダークまたはライト)を読み取ることにしましょう。

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

しかし、theme は(再レンダーの結果として変化する可能性があるので)リアクティブな値であり、そしてエフェクトが読み取るすべてのリアクティブな値は依存値として宣言する必要があります。というわけで theme はエフェクトの依存配列として指定しないといけません。

function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);