はじめに
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
サンプルアプリケーション概要
名前を入れて入室後、メッセージを送信あるいは他のユーザーが送信したメッセージを受信するという簡単なチャットアプリです。
サーバー側の実装
socket.ioライブラリを使ってWebSocket通信を行う簡単なプログラムを作成し、Expressにホストします。
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通信を除く部分)。
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通信です。
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..
というメッセージが出るのを確認できます。
return (
<div>
<Operation onEnter={handleEnter} onLeave={handleLeave} entered={entered} />
{ entered && <Chat name={name} />}
</div>
)
(上記コードで、 entered
の値が偽のとき、 Chat
コンポーネントはレンダリング対象から外れる)
useEffect
の第1引数の関数では、ソケットオブジェクトを生成後、サーバーからの broadcast
イベントを受信した際のコールバック関数を登録します。コールバック関数では、受信したメッセージをステート変数で管理されるメッセージ配列へ追加します(正確に言うと、新しいメッセージ配列をステート変数に格納)。
socketRef.current = io();
socketRef.current.on('broadcast', payload => {
console.log('Recieved: ' + payload);
setMessages(prevMessages => [...prevMessages, payload]);
});
一方、送信ボタンクリックで呼び出されるイベントハンドラでは、新しいメッセージを作成して send
イベントをサーバーに送出するとともに、ステート変数へも格納します。
const handleButtonClick = (e) => {
const aMessage = {
name: name,
text: text,
};
socketRef.current.emit('send', aMessage);
setMessages(prevMessages => [...prevMessages, aMessage]);
setText('');
}
Chat
コンポーネントのソース全量は以下のとおりです。
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('');
このような場合、カスタムフックを使えば簡単に処理を抽出してコードをリファクタリングすることが可能です。
関連するソースコードをごそっと別ファイルにコピーして手を加え、以下のようなカスタムフック関数を作成します。
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
コンポーネントは以下のようにすっきりしました。
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
を参照ください。