Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
41
Help us understand the problem. What are the problem?

posted at

updated at

React HooksでWebSocket通信を行うサンプル

はじめに

WebSocketを使ってリアルタイム通信アプリを作ろうとした場合、React Hooksなら「はいはい、副作用はuseEffect使って書けばいいんでしょ」というのはわかるものの、いざ書くとなるとこに何を書けばいいやら案外迷うものです。
本記事では、簡単なチャットアプリを題材にその方法をまとめました。useEffectフックや、カスタムフックの使いどころについて少しでも参考になれば幸いです。

なお、WebSocketのライブラリはsocket.ioを用いています。

動作検証環境:

  • Mac OS Catalina 10.15.7
  • react 17.0.1
  • socket.io 3.0.3
  • express 4.17.1

サンプルアプリケーション概要

名前を入れて入室後、メッセージを送信あるいは他のユーザーが送信したメッセージを受信するという簡単なチャットアプリです。

chatapp_001.png

サーバー側の実装

socket.ioライブラリを使ってWebSocket通信を行う簡単なプログラムを作成し、Expressにホストします。

app.js
const app = require('express')();
const server = require('http').createServer(app);
const io = require('socket.io')(server);

io.on('connection', (socket) => {
    console.log('A client connected.');
    socket.on('send', (payload) => {
        console.log(payload);
        socket.broadcast.emit('broadcast', payload);
    });
    socket.on('disconnect', () => {
        console.log('Conenction closed.');
    });
});

server.listen(3001, () => {
    console.log('Listening..');
});

簡単な解説:

  • send イベントを受信すると、他のクライアントに対してブロードキャストを行います。
  • 3001番ポートで待ち受けます。

React Hooksアプリの実装

まず、3000番ポートで立ち上げたdev-serverで動くReactアプリから、3001番ポートで待ち受けるサーバーアプリとそのまま通信しようとするとクロスオリジンのエラーとなるため、クライアント側の package.json を編集してプロキシ設定を行っています。

"proxy": "http://localhost:3001",

チャットコンポーネントの実装の概略です(イベントハンドリングや、WebSocket通信を除く部分)。

Chat.js
const Chat = ({name}) => {
    const [messages, setMessages] = useState([{
        name: '管理人', text: `ようこそ、${name}さん`
    }]);

    const [text, setText] = useState('');

    //(途中略)

    return (
        <div>
            <div className="input">
                <input type="text" placeholder="メッセージ" value={text} onChange={handleInputChange} />
                <button disabled={!text} onClick={handleButtonClick}>送信</button>
            </div>
            <ul>
                {
                    messages.map((msg, idx) => {
                        return (
                            <Message key={idx} name={msg.name} text={msg.text} />
                        );
                    })
                }
            </ul>
        </div>
    );
}

useState フックを使って定義したステート変数 messages には、入室時の初期メッセージ、自分が送信したメッセージ、他のユーザーから受信したメッセージが時系列に格納された配列が入ります。
JSXではその配列を map 関数で変換して箇条書きリストに表示しています。

さて、ここからが本題のWebSocket通信です。

Chat.js
    const socketRef = useRef();

    useEffect(() => {
        console.log('Connectinng..');
        socketRef.current = io();
        socketRef.current.on('broadcast', payload => {
            console.log('Recieved: ' + payload);
            setMessages(prevMessages => [...prevMessages, payload]);
        });
        return () => {
            console.log('Disconnecting..');
            socketRef.current.disconnect();
        };
    }, []);

まず、 socket.io-clientの io 関数により作られるソケットオブジェクトをどこに宣言するべきかという問題があります。
ソケット接続はコンポーネントのマウント時に一度だけ行いたいため、 useEffect の第1引数に渡す関数内で初期化を行いますが、このオブジェクトは他の場所からも参照したいため、 useEffect の外側のスコープで定義する必要があります。
初めの頃、私は以下のように関数の外側に定義をしていました。

let socket;

const Chat = ({name}) => {

しかし、このような参照の保持にはまさに useRef フックが使えるということに気づきました。

    const socketRef = useRef();

さきほど「ソケット接続はコンポーネントのマウント時に一度だけ行いたい」と書きましたが、これを実現するためには、 useEffect 関数の第2引数の依存配列に空配列を指定します。
この第2引数の配列に含まれるプロパティやステート変数の値が変更されると、Reactがコンポーネントの再レンダリングを行う際に useEffect の第1引数の関数を再実行します。そのため、空配列を指定すると(値変更の監視対象変数が存在しないため)最初の一度だけ実行されるという挙動になるのです。

    useEffect(() => {
    // 略
    }, []);

さて、ソケット接続を最初に一度だけ行いたいのと同じように、接続のクローズも最後に一度だけ行いたいです。(ここで「最後」とは、コンポーネントがアンマウントされる時点を指します)。
これは、 useEffect の第1引数の関数の戻り値として後始末用の関数を返すことが実現可能です。

        return () => {
            console.log('Disconnecting..');
            socketRef.current.disconnect();
        }

サンプルアプリでは、退室ボタンをクリックすると Chat コンポーネントがレンダリング対象から外れアンマウントされるので、そのタイミングでコンソールのログに Disconnecting.. というメッセージが出るのを確認できます。

App.js
  return (
    <div>
      <Operation onEnter={handleEnter} onLeave={handleLeave} entered={entered} />
      { entered && <Chat name={name} />}
    </div>
  )

(上記コードで、 entered の値が偽のとき、 Chat コンポーネントはレンダリング対象から外れる)

useEffect の第1引数の関数では、ソケットオブジェクトを生成後、サーバーからの broadcast イベントを受信した際のコールバック関数を登録します。コールバック関数では、受信したメッセージをステート変数で管理されるメッセージ配列へ追加します(正確に言うと、新しいメッセージ配列をステート変数に格納)。

Chat.js
        socketRef.current = io();
        socketRef.current.on('broadcast', payload => {
            console.log('Recieved: ' + payload);
            setMessages(prevMessages => [...prevMessages, payload]);
        });

一方、送信ボタンクリックで呼び出されるイベントハンドラでは、新しいメッセージを作成して send イベントをサーバーに送出するとともに、ステート変数へも格納します。

Chat.js
    const handleButtonClick = (e) => {
        const aMessage = {
            name: name,
            text: text,
        };
        socketRef.current.emit('send', aMessage);
        setMessages(prevMessages => [...prevMessages, aMessage]);
        setText('');
    }

Chat コンポーネントのソース全量は以下のとおりです。

Chat.js
import {useState, useEffect, useRef} from 'react';
import Message from './Message';
import {io} from 'socket.io-client';

const Chat = ({name}) => {
    const [messages, setMessages] = useState([{
        name: '管理人', text: `ようこそ、${name}さん`
    }]);

    const [text, setText] = useState('');

    const socketRef = useRef();

    useEffect(() => {
        console.log('Connectinng..');
        socketRef.current = io();
        socketRef.current.on('broadcast', payload => {
            console.log('Recieved: ' + payload);
            setMessages(prevMessages => [...prevMessages, payload]);
        });
        return () => {
            console.log('Disconnecting..');
            socketRef.current.disconnect();
        };
    }, []);

    const handleInputChange = (e) => {
        setText(e.target.value);
    };

    const handleButtonClick = (e) => {
        const aMessage = {
            name: name,
            text: text,
        };
        socketRef.current.emit('send', aMessage);
        setMessages(prevMessages => [...prevMessages, aMessage]);
        setText('');
    }

    return (
        <div>
            <div className="input">
                <input type="text" placeholder="メッセージ" value={text} onChange={handleInputChange} />
                <button disabled={!text} onClick={handleButtonClick}>送信</button>
            </div>
            <ul>
                {
                    messages.map((msg, idx) => {
                        return (
                            <Message key={idx} name={msg.name} text={msg.text} />
                        );
                    })
                }
            </ul>
        </div>
    );
}

export default Chat;

カスタムフックを用いたリファクタリング

前述のChat コンポーネントには socket.io-client ライブラリを使った WebSocket 通信処理を記述していますが、これには以下の問題があります。

  • 特定の実装ライブラリへの依存が発生していること
  • 責務の分離ができていないこと

とくに、 useEffect 内ではなく、イベントハンドラ関数の内部にソケットを使った副作用コードが書かれているのは気持ち悪いです。

    const handleButtonClick = (e) => {
        const aMessage = {
            name: name,
            text: text,
        };
        socketRef.current.emit('send', aMessage);
        setMessages(prevMessages => [...prevMessages, aMessage]);
        setText('');

このような場合、カスタムフックを使えば簡単に処理を抽出してコードをリファクタリングすることが可能です。
関連するソースコードをごそっと別ファイルにコピーして手を加え、以下のようなカスタムフック関数を作成します。

useChatService.js
import {useState, useEffect, useRef} from 'react';
import {io} from 'socket.io-client';

const useChatService = (initialMessage) => {
    const [messages, setMessages] = useState([initialMessage]);

    const socketRef = useRef();

    useEffect(() => {
        console.log('Connectinng..');
        socketRef.current = io();
        socketRef.current.on('broadcast', payload => {
            console.log('Recieved: ' + payload);
            setMessages(prevMessages => [...prevMessages, payload]);
        });
        return () => {
            console.log('Disconnecting..');
            socketRef.current.disconnect();
        };
    }, []);

    const sendMessage = (name, text) => {
        const aMessage = {
            name: name,
            text: text,
        };
        socketRef.current.emit('send', aMessage);
        setMessages(prevMessages => [...prevMessages, aMessage]);
    }

    return [messages, sendMessage];
}

export default useChatService;

Chat コンポーネントから利用したい、メッセージの配列とメッセージ送信関数を戻り値として返却します。

Chat コンポーネントは以下のようにすっきりしました。

Chat2.js
import useChatService from './useChatService';

const Chat2 = ({name}) => {
    const [messages, sendMessage] = useChatService({
        name: '管理人', text: `ようこそ、${name}さん`
    });

    const [text, setText] = useState('');

    const handleInputChange = (e) => {
        setText(e.target.value);
    };

    const handleButtonClick = (e) => {
        sendMessage(name, text);
        setText('');
    }
    // 以下変更なし  

おわりに

サンプルソースコードは GitHub にアップしました。
サンプルの動作方法はそちらの README.md を参照ください。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
41
Help us understand the problem. What are the problem?