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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176589/d0be6420-24b5-0163-25e6-e93e9337dc59.png)

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

```javascript: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` を編集してプロキシ設定を行っています。

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

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

```javascript: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通信です。

```javascript: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` の外側のスコープで定義する必要があります。
初めの頃、私は以下のように関数の外側に定義をしていました。

```javascript
let socket;

const Chat = ({name}) => {
```

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

```javascript
    const socketRef = useRef();
```

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

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

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

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

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

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

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

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

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

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

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

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

```javascript: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` 内ではなく、イベントハンドラ関数の内部にソケットを使った副作用コードが書かれているのは気持ち悪いです。

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

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

```javascript: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` コンポーネントは以下のようにすっきりしました。

```javascript: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](https://github.com/yonetty/react-hooks-sample-chat) にアップしました。
サンプルの動作方法はそちらの `README.md` を参照ください。