3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

useState, useEffectだけから卒業したい

Posted at

Reactのドキュメントは旧バージョンと新バージョンがある

Reactを初めて触ったのが2019年あたりなのですが、最近また触れるようになってドキュメントが新しくなっていました。

日本語版もあります(主要な部分は翻訳されていますが、全部ではないですし、わからなくなったら原文も併用した方がいいかもしれません)

最近のフロントエンドの事情はあまり詳しく無いので、間違い等あれば教えていただければ幸いです。

Reactについて

Reactは、Meta社が開発しているフロントエンド用のライブラリ1で、「コンポーネント」と呼ばれる単位でUI、画面の表示、データ制御を管理できます。

またHooksと呼ばれるReact独自の機能も提供しており、これによりコンポーネントで様々な機能を用いることができます。

データを保持するuseState

ユーザーが起こした行動(フォームの入力など)や時間的な変化などで、動的なサイトの実現にはそうしたデータの保持と更新が必要になります。しかし、単にバニラなコードのような書き方ではこうしたデータの保持を実現することができません。

ここではボタンを押すとカウントが増えるものを考えてみましょう。

Count.jsx
export default function Count() {
  let count = 0;

  function handleClick() {
    count = count + 1;
  }

  return (
    <>
      <button onClick={handleClick}>
        {count} times clicked!
      </button>
    </>
  );
}

CodePenなどで試してみると分かるのですが、このコードではボタンに表示される回数が更新されることはありません!
これは、コンポーネント内のローカル変数はレンダー間(つまり、今の描画と次の描画の間)でデータが保持されることは無いのです。
今回ではボタンを押すたびにhandleClick()が実行されますが、ローカル変数の値がレンダー間で保持されないため、レンダー間における差分はありません。Reactの再描画はレンダー間の差分発生時に実行されるため、ここでは内部でのローカル変数の変更はレンダー間の差分にはならず描画が走らない、ということになります。

そこでReactはレンダー間でコンポーネントに「記憶」させる機能を提供しています。それがuseStateです。

Count.jsx(修正後)
import { useState } from 'react';

export default function Count() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <>
      <button onClick={handleClick}>
        {count} times clicked!
      </button>
    </>
  );
}

こちらは期待どおりに動作したと思います。この様な形で、値を保持することができます。
const [a, b] = useState(initialValue)の記法を守れば利用できますが、0番目の値が記憶するデータを利用する変数、1番目の値が0番目の値に書き込む関数のため、慣習的に[value, setValue]のような書き方が好まれます。

useStateまとめ

  • レンダー間で値を保持するのはuseState
  • 変数名は[value, setValue]がおすすめ

対象に同期するuseEffect

副作用について

useEffectの話をする前に、備忘録の意味も込めて副作用について記しておきます。
「副作用がない」関数とは、純粋な計算のみを行う関数のことを指します。これは、同じ値を何度入れたとしても、その結果が同じで有ることが保証されていることを示しています。
例としては、最大公約数を求める関数gcd(a, b)があったとします。gcd(19, 57)はどんなに実行されたとしても19が返却されるべきであり、いくら高名な数学者が主張しようとgcd(19, 57)1になるべきではありません。この様に入力と出力の組が常に同じであることが保証されているのが、「副作用のない」関数と呼ばれます。
これに対し、「副作用のある」関数は、実行したときの環境に依存して結果が変わる純粋でない計算を指します。以下のような関数を考えてみましょう。

let counter = 0;

function incrementCounter() {
    counter++;
}

incrementCounter(); //1
incrementCounter(); //2
incrementCounter(); //3
//...

このincrementCounter()は、実行されるたびにcounterの値を1増やしていく(インクリメントする)ため、実行するときのプログラムの状態に依存して計算結果が変わることが分かります。このように、その計算が実行されるたびに状態が変化してしまい、同じ結果が保証されない関数のことを、「副作用のある」関数と呼びます。

エフェクト(Effect)について

ここでReactの開発チームは「特定のイベントによってではなく、レンダー自体によって引き起こされる副作用を指定するためのもの」を「エフェクト(Effect)」と呼称しています。
正直何を言ってるかわかりません。
レンダー自体によって引き起こされる副作用、つまりReactのレンダーとは関係のない外部システムとのやり取りがエフェクトになります・・・・・・多分。
Reactのレンダー副作用のない計算のみで構成されるべきです。propsstateに対して常に純粋な計算であるべきで、そうでない計算、例としてはReactではないウィジェット(地図など)、チャットルームへの接続といったものです。この様な副作用があり、かつ特定のユーザーイベントによって引き起こされるわけでも無い様な副作用をエフェクトとよび、これらを画面の更新後、コミットの最後に実行します。そしてそれをHookとして提供するのがuseEffectです。

具体例を見ていきましょう。ここでは、公式のサンプルを使用します。

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

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

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

  return (
    <>
      <label>
        Server URL:{' '}
        <input
          value={serverUrl}
          onChange={e => setServerUrl(e.target.value)}
        />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}
コード全文
App.jsx
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

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

  return (
    <>
      <label>
        Server URL:{' '}
        <input
          value={serverUrl}
          onChange={e => setServerUrl(e.target.value)}
        />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}
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} />}
    </>
  );
}
chat.js
export function createConnection(serverUrl, roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
    }
  };
}

useEffectは、「どんなエフェクトを実行するのか」と「購読する変数の配列」を引数に取ります。ここでは、レンダーのシステムが関知しないチャットルームへの接続及び切断を行うエフェクトを、[roomId, serverUrl]の変化に応じて実行します。
もう少し掘り下げると、マウント時にセットアップ関数が実行され、アンマウント時にクリーンアップ関数が実行されます。

useEffectの構造
useEffect(() => {
  //ここがセットアップ関数
  setup();
  return cleanup();//returnで返却される関数がクリーンアップ関数
},[dependencies/*購読する変数の配列*/]);

ページにコンポーネントがマウント、追加されたときに、まずセットアップ関数、例ではchatRoomが追加されたタイミングで、接続が実行されます。
次にユーザーが接続する部屋を切り替えて、「Open Chat」が押下されると、レンダー間に差分が発生し、当然その差分はuseEffectで購読している値の変更なので、useEffectの中身が実行されます。
ここでは、まず変更前の古いstatepropsにおけるクリーンアップ関数、切断が実行され、そのあとに新しいstatepropsでのセットアップ関数が実行されます。

ここまで長い説明になりましたが、本旨であるところの「useStateuseEffectだけから」抜け出したいので、まず改めて、「どんな場面でそれらのHooksが必要なのか」を理解する必要がありました。useEffectをただ同期のためだけに使えばいいや、みたいな考えでいるとどうしても無駄が多かったり、動くけど動作の重いシステムが出来上がってしまいます。

同期だけの目的でuseEffectを使用しない

あくまでuseEffectは「外部システム」との同期を取るためのもの、エフェクトを扱うためのフックのため、コンポーネントのstate間での同期などでエフェクトがあるかのような書き方は好ましくありません。

なので、使う前に同期するシステムがなにか、というのを考えて使用するべきです。

とはいえ、純粋なReact内の計算だとしても時間のかかるものはいくつか存在するでしょう。そうした計算はキャッシュする、あるいはメモ化をすることで同じ入力であればすぐに反映できるようにしておくことなどができます。

これまでの話で今まで書けていたコードで、かつusestateuseEffectのみで書くとどうしてもバグが発生しやすくなったりパフォーマンスが悪くなる様なコードからの脱却を目指したくなってきます。

今記事ではReactの組み込みフックである、useRefuseCallbackを使いながら、SSEクライアントの実装を行います。

SSEクライアントの実装

SSEとは

SSE(Server-Sents Event)は、簡単に言うとサーバ側が接続したことのあるクライアントに対して任意に一方向のイベント通知を行うことができる機能で、サーバクライアント形式のシステムにおける双方向通信の手段の一つです。
利点として、同じく双方向通信のプロトコルであるWebSocketと異なり、HTTP通信のみで完結することや、WSが過剰な通信プロトコルな場面でも扱いやすいという利点があります。
JavaScriptでは、このSSEを一つのEventSourceとして扱います。

const eventSource = new EventSource("https://example.com/sse");

eventSource.onmessage = function(msg) {
  const data = JSON.parse(msg.data);
  console.log(data);
}

eventSource.onerror = function(msg) {
  console.log(msg);
}

これにより、注文する商品メニューの画面で商品の売り切れを即時に反映させる、といったようなサーバ側でしか知り得ない情報を通知させることができます。

ReactでSSEを用いるために

これをReactを用いたクライアントの実装する場合、反映させるためのコンポーネントは

  • サーバにアクセスする、またSSEの接続を保持する
  • SSEの通知を受け取りデータを更新(=再レンダリングさせる)
  • なんらかの原因で接続のエラーが出た場合、復帰処理をする

といった機能が必要になります。そう、これは立派なEffectになります。(そもそも外部システムとの同期でもありますが)

とはいえ、そのままuseEffectを使うのはいただけません。どこまでがEffectで有るべきなのか、や通知のたびに重くなるような計算になってしまい描画がもたつくような状況は好ましくありません。

また、SSEの接続は画面の変化には何ら関係が無いのです。この様な接続をたとえばstateとして保持すると、画面の表示と関係がないのに差分が発生するたびに再レンダリングが起きることになります。全くの無駄です。

なので、こうした問題点を解決しつつ、実装していきましょう。

実装

なので、実装の際のポイントは、以下のようになります。

  • 純粋な計算と副作用のある計算を分ける
  • 画面に反映するために必要な値はstate、関係の無い値はrefにする
  • 接続・切断と必ず対になるので、セットアップ関数とクリーンアップ関数を記述する

このような点に気をつけて、サーバからのSSEの通知に同期するデータを返すHookを作成できます。

コード全文
hooks/useEventSource.js
import { useCallback, useEffect, useRef, useState } from 'react';

import { fetchData } from '../api/data';

interface Data {
  id
  name
  status
}

export function useEventSource() {
  const [datas, setDatas] = useState([]);
  const eventSourceRef = useRef(null);

  const connectToEventSource = useCallback((eventSourceUrl) => {
    if (eventSourceRef.current === null) {
      const eventSource = new EventSource(eventSourceUrl);

      eventSource.onmessage = function (event) {
        console.log('event: ', event.data);
        fetchData().then((datas) => setDatas(datas));
      }

      eventSource.onerror = function (event) {
        console.log('error: ', event);
        //reconnect
      }

      eventSourceRef.current = eventSource;
      return;
    }
  }, []);

  const closeEventSource = useCallback(() => {
    if (eventSourceRef.current !== null) {
      eventSourceRef.current.close();
      eventSourceRef.current = null;
    }
  }, [eventSourceRef.current]);

  useEffect(() => {
    connectToEventSource('https://example.com/sse');
  }, []);

  useEffect(() => {
    console.log('fetching data');
    fetchDatas().then((datas) => setDatas(datas));
  }, []);

  return [datas, setDatas] as const;
}

useRef

useRefは、

  • 値の変更によってレンダーを起こす必要のない値
  • React外部のDOMの値

のようなものを扱う際に使用するといいでしょう。公式ではuseRefは避難ハッチであり、「外に踏み出す」、つまり今回のEventSourceを保持する仕組みとして最適です。
他にもビデオの再生・停止などの実装などにも扱うことができ、Reactがまさに外部システムの参照や操作を行うときに必要と言えます。

公式でも言及されていますが、レンダー中に読み書きの必要な値の保持には必ずuseStateを用いましょう。

useCallback

useCallbackは、

  • 依存する値の変更がない限り関数の計算をキャッシュしたものを用いて再レンダーをスキップ

機能があります。
今回の場合は画面とSSEの接続には依存されるべき値は存在しないため、空の配列を渡すことにより接続、切断処理をキャッシュし、パフォーマンスを向上するために関数をラップしています。

  1. 最新のReact公式では「ライブラリ」と呼称されています。ライブラリの指すところの「再利用可能な部品の集合」以上の機能を提供しているという点では「フレームワーク」だと思うのですが、最近はNext.jsなどのフルスタックフレームワークが出ているためその様な呼称に変わったのかもしれません(最初に触れたときはフレームワークだった記憶があるのですが・・・・・・)

3
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?