とある案件でReactからWebSocketを扱う必要が出てきたので、その時調査したことをまとめます。
調査のつもりが、React 18で追加されたuseSyncExternalStoreを利用したオレオレ状態管理ライブラリ作りにいつのまにか脱線していたので、そのお話も添えて。
React、WebSocket共に目新しい技術ではないですが、誰かのためになれば幸いです。
1.はじめに
お品書きは次の通りです。
1.はじめに
2.WebSocket?
3.Hello World - React x WebSocket の基本形
4.自動で再接続できるようにする
5.カスタムフック化 - useSyncExternalStoreを利用したオレオレライブラリ
6.Notifications API と組み合わせる
7.データフェッチライブラリと組み合わせる
8.おわりに
実装例は、React 18 x TypeScriptで記載しています。
2.WebSocket?
サーバ、クライアント(ブラウザ)で双方向通信を可能にする技術です。
通信プロトコル(RFC6455)と、それを利用するWebSocket APIの組み合わせで双方向通信を実現します。
この記事では主に、ReactからWebSocket API を扱う方法について記載します。
WebSocket API (WebSockets) - Web API | MDN を引用します。
WebSocket API は、ユーザーのブラウザーとサーバー間で対話的な通信セッションを開くことができる先進技術です。
この API によって、サーバーにメッセージを送信したり、応答をサーバーにポーリングすることなく、イベント駆動型のレスポンスを受信したりすることができます。
3.Hello World - React x WebSocket の基本形
まずはシンプルに受信・送信を行うコンポーネントの実装例になります。
- サーバからメッセージを受信するとそのメッセージを画面に表示
- 送信ボタンを押すと、メッセージをサーバに送信
import React from 'react'
export const App = () => {
const [message, setMessage] = React.useState<string>()
const socketRef = React.useRef<WebSocket>()
// #0.WebSocket関連の処理は副作用なので、useEffect内で実装
React.useEffect(() => {
// #1.WebSocketオブジェクトを生成しサーバとの接続を開始
const websocket = new WebSocket('ws://localhost:5000')
socketRef.current = websocket
// #2.メッセージ受信時のイベントハンドラを設定
const onMessage = (event: MessageEvent<string>) => {
setMessage(event.data)
}
websocket.addEventListener('message', onMessage)
// #3.useEffectのクリーンアップの中で、WebSocketのクローズ処理を実行
return () => {
websocket.close()
websocket.removeEventListener('message', onMessage)
}
}, [])
return (
<>
<div>最後に受信したメッセージ: {message}</div>
<button
type="button"
onClick={() => {
// #4.WebSocketでメッセージを送信する場合は、イベントハンドラ内でsendメソッドを実行
socketRef.current?.send('送信メッセージ')
}}
>
送信
</button>
</>
)
}
ポイントとなる点はコード例中のコメントに記載していますが、ReactでWebSocketを扱う上で特に重要と思われる点は以下の2点です。
後は、画面の要件に応じて細部は変更していけば良いかと思います。例えば、
- 最後に受信したメッセージだけなく、受信した全てを表示したいのであれば、コメントの #2の部分で、
setMessage(prev => [...prev, event.data])
のようにして、受信したメッセージを配列で状態に保持1 - 送信が不要であれば、コメントの #1の部分でrefにWebSocketのオブジェクトを保持しないようにする2
- エラーやクローズなど他のイベントを拾いたければ、他のイベントハンドラを追加する
などなど。
WebSocket APIを利用方法の詳細は↓を参照ください。
4.自動で再接続できるようにする
ここからは実践編です。
実運用を考えると、WebSocketの接続断時に自動的に再接続したいところです。
が、残念ながら上の実装例では再接続しません。一度接続が切れると(このコンポーネントが再マウントされない限り)メッセージの受信・送信ができなくなってしまいます。
reconnecting-websocket
接続断時の再接続の実装ですが、例えばクローズ時のイベントハンドラで再接続するような実装例もみられますが、もっと楽したいですよね…ということで、こちらを使います。
「WebSocket APIとインタフェースの互換性があり」かつ、「接続断時に自動的に内部で再接続を行ってくれる」優れものです。
基本形に手を加える
基本形との差分を示すと次のようになります。
(import、TypeScriptの型定義の変更など本質的ではない変更を除けば)WebSocket
オブジェクト生成を、ReconnectingWebSocket
オブジェクトの生成に変更するだけです。
import React from 'react'
+ import ReconnectingWebSocket from 'reconnecting-websocket'
export const App = () => {
const [message, setMessage] = React.useState<string>()
- const socketRef = React.useRef<WebSocket>()
+ const socketRef = React.useRef<ReconnectingWebSocket>()
// #0.WebSocket関連の処理は副作用なので、useEffect内で実装
React.useEffect(() => {
// #1.WebSocketオブジェクトを生成しサーバとの接続を開始
- const websocket = new WebSocket('ws://localhost:5000')
+ const websocket = new ReconnectingWebSocket('ws://localhost:5000')
socketRef.current = websocket
// #2.メッセージ受信時のイベントハンドラを設定
const onMessage = (event: MessageEvent<string>) => {
setMessage(event.data)
}
websocket.addEventListener('message', onMessage)
// #3.useEffectのクリーンアップの中で、WebSocketのクローズ処理を実行
return () => {
websocket.close()
websocket.removeEventListener('message', onMessage)
}
}, [])
return (
<>
<div>最後に受信したメッセージ: {message}</div>
<button
type="button"
onClick={() => {
// #4.WebSocketでメッセージを送信する場合は、イベントハンドラ内でsendメソッドを実行
socketRef.current?.send('送信メッセージ')
}}
>
送信
</button>
</>
)
}
再接続時の間隔等はオプションで設定可能です。詳しくは、READMEを参照ください。
5.カスタムフック化 - useSyncExternalStoreを利用したオレオレライブラリ
実践ではこのあたり、カスタムフックに切り出したくなりますよね。ということで切り出します。
(ここからオレオレ状態管理ライブラリ作成に脱線していきました。。)
カスタムフックの利用側
カスタムフックの実装例に入る前に、カスタムフックのインタフェース、利用イメージを先に示します。
WebSocketClient
を生成し、WebSocketProvier
でアプリケーション全体を囲ってもらって…
const client = new WebSocketClient({
// 接続先のサーバのURL(複数可能)やオプションなど
urls: ['ws://localhost:5000', 'ws://localhost:5001'],
})
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<WebSocketProvider client={client}>
<App />
</WebSocketProvider>
</React.StrictMode>
)
useWebSocket
で受信した最新のデータと、送信のためのメソッドが取得できる、というようなイメージです。
export const App = () => {
// カスタムフック
const { data, send } = useWebSocket('ws://localhost:5000')
return (
<>
<div>最後に受信したメッセージ: {data}</div>
<button
type="button"
onClick={() => {
send('送信するメッセージ')
}}
>
送信
</button>
</>
)
}
このあたりは好みもあるでしょうが、以下を意識しました。
- 引数に接続先のURLを渡すと、戻り値に受信したメッセージが返ってくるシンプルなインタフェース
- 接続先のURL毎に受信したメッセージをキャッシュ
- 送受信するメッセージのJSON<=>オブジェクト変換、ジェネリクスによる型指定に対応
- 接続断時の再接続に対応
つまり、サーバのデータフェッチライブラリであるTanStackQuery(ReactQuery)やSWRと同じような使い勝手です。
次のようにも使うことができます。
//メッセージの型
type Message = {
id: number
from: string
text: string
}
export const App = () => {
//カスタムフック
//送受信するメッセージの型を指定
//第二引数(オプショナル)でメッセージ受信時のコールバックを指定
const { data, send } = useWebSocket<Message>('ws://localhost:5000', (m) => {
console.log(`${m.from}からのメッセージを受信しました`)
})
return (
<>
<div>最後に受信したメッセージ: {data?.text}</div>
<button
type="button"
onClick={() => {
send({
id: 1,
from: 'someone',
text: '送信するメッセージ',
})
}}
>
送信
</button>
</>
)
}
カスタムフックの実装例
Hello Worldで示した通りuseEffect
を利用しても実現できるかもしれませんが、せっかくなのでReact 18 から追加された useSyncExternalStore
を使ってみます。
Reactの公式リファレンスから引用します。
useSyncExternalStore は、選択的ハイドレーションやタイムスライスなどの並行レンダリング機能と互換性を持ちつつ外部データソースから読み出しやデータの購読を行うために推奨されるフックです。
今まで見てきた通り、WebSocketによる送受信はWebSocketオブジェクト自体が担いますので、このWebSocketオブジェクトを外部データソースと考えると良さそうですね。
実装例はメイン部分を抽出して記載しているので、細部を一部省略しています。
データストア(WebSocketClient
)の定義
まず、WebSocketで通信したデータを保持するデータストアを準備します。ここはReactとは切り離された世界です。TanStackQueryからインスパイアされて、WebSocketClient
と名付けます。
主に以下を担います。
- 接続のオープン/クローズ
- データの送受信
- 受信したデータ保持・管理
接続先のURL毎に状態を管理するので、URLをキーにデータやWebSocketのオブジェクトを保持します。
type Subscriber<R = unknown> = (message: R) => void | (() => void)
type WebSocketClientConfig = {
urls: string[]
}
export class WebSocketClient {
private readonly config: WebSocketClientConfig
private readonly subscribers = new Map<string, Set<Subscriber>>()
private readonly websockets = new Map<string, ReconnectingWebSocket>()
private data: { [url: string]: unknown } | undefined
constructor(config: WebSocketClientConfig) {
this.config = config
const { urls } = config
urls.forEach((url) => {
this.subscribers.set(url, new Set())
})
}
// #1.接続先のURLに対し接続を開始し、メッセージ受信時に実行されるイベントリスナを登録
// 受信したデータをストアに保持しつつ、登録されたサブスクライバを実行
open = () => {
const { urls } = this.config
urls.forEach((url) => {
const ws = new ReconnectingWebSocket(url)
console.log(`connectiong... ${url}`)
ws.addEventListener('message', (event: MessageEvent<string>) => {
const parsedData = jsonutil.parse(event.data)
this.data = { ...this.data, [url]: parsedData }
this.subscribers.get(url)?.forEach((s) => s(parsedData))
})
this.websockets.set(url, ws)
})
}
// #2. ストアが保持している現在の値を返す
get = <R,>(url: string) => this.data?.[url] as R
// #3. メッセージを送信
send = (url: string, message: unknown) => {
const target = this.websockets.get(url)
if (target?.readyState !== WebSocket.OPEN) {
throw new Error('websocket is not ready.')
}
target.send(jsonutil.stringfy(message))
}
// #4. 接続先のURL毎にサブスクライバの登録
subscribe = (url: string, subscriber: Subscriber | Subscriber[]) => {
const target = this.subscribers.get(url)
if (target) {
if (Array.isArray(subscriber)) {
subscriber.forEach((s) => target.add(s))
} else {
target.add(subscriber)
}
}
}
// #5. 接続先のURL毎にサブスクライバを解除
unsubscribe = (url: string, subscriber: Subscriber | Subscriber[]) => {
const target = this.subscribers.get(url)
if (target) {
if (Array.isArray(subscriber)) {
subscriber.forEach((s) => target.delete(s))
} else {
target.delete(subscriber)
}
}
}
// #6. 接続先のクローズ
close = () => {
this.websockets.forEach((w, u) => {
console.log(`closing... ${u}`)
w.close()
})
this.subscribers.forEach((s) => s.clear())
}
}
WebSocketClient
の初期化とProviderの準備
WebSocketClient
オブジェクトをReactのコンポーネント内で取得できるようにします。このあたりはシンプルに、Context
を使います。
WebSocketの接続のオープン/クローズをどこでやるか少し悩みましたが、このProviderが都合が良かったので合わせてここでやってしまいます。
type WebSocketProviderProps = {
client: WebSocketClient
children: React.ReactNode
}
const WebSocketContext = React.createContext<WebSocketClient | null>(null)
export const WebSocketProvider = ({ client, children }: WebSocketProviderProps) => {
React.useEffect(() => {
client.open()
return () => {
client.close()
}
}, [client])
return <WebSocketContext.Provider value={client}>{children}</WebSocketContext.Provider>
}
export const useWebSocketClient = () => {
const client = React.useContext(WebSocketContext)
if (!client) {
throw new Error('Context is null.')
}
return client
}
useSyncExternalStore
により状態を同期
カスタムフックのメインの部分になります。
useSyncExternalStore
により、WebSocketClient
が保持している状態とReactのコンポーネントが繋がります。具体的には、WebSocketClient
内で管理している受信データが変化すると、コンポーネントの再レンダリングが発生します。
useSyncExternalStore
の引数が若干わかりづらいので補足すると、
- 第一引数:
WebSocketClient
内の状態に変化が起きたら(=新たにサーバからメッセージを受信したら)呼び出されるコールバック(=onStoreChange
)を登録する関数 - 第二引数:
WebSocketClient
で保持している受信データの現在の値を返却する関数
を渡しています。
export const useWebSocket = <R = string, S = R>(
url: string,
onMessage: (message: R) => void = NoOpCallback
) => {
const client = useWebSocketClient()
const subscribe = React.useCallback(
(onStoreChange: () => void) => {
client.subscribe(url, [onMessage as Subscriber, onStoreChange])
return () => {
client.unsubscribe(url, [onMessage as Subscriber, onStoreChange])
}
},
[client, url, onMessage]
)
const getSnapshot = React.useCallback(() => client.get<R>(url), [client, url])
const data = React.useSyncExternalStore(subscribe, getSnapshot)
const send = React.useCallback(
(message: S) => {
client.send(url, message)
},
[client, url]
)
return {
data,
send,
}
}
これで一通り動作するWebSocketのデータ送受信 + オレオレ状態管理ライブラリの完成です。
接続先のURL毎に受信したメッセージをキャッシュし、新たなメッセージを受信すると戻り値のdata
が更新され、コンポーネントの再レンダリングが走ります。戻り値のsend
を利用すればメッセージを送信できます。
まさに、TanStackQuery(ReactQuery)やSWR的な動きになりました。
configでオプションを細かく指定できるようにしたり、エラーハンドリングや型の扱いを丁寧にしたりするなど改善を加えれば、かなりそれっぽいものになる気がしています。
尚、useSyncExternalStore
は現在(2022/12時点)再構築中のBeata版の公式リファレンスが詳しいので併せて参照ください。
6.Notifications API と組み合わせる
冒頭で述べたWebSocketを利用している案件では、サーバからメッセージ受信時に Notifications APIを利用してユーザに通知する、ということやっています。
このカスタムフックを利用する場合、次のようになり、非常にシンプルに実装できます。
export const App = () => {
const { data, send } = useWebSocket<Message>('ws://localhost:5000', (m) => {
// コールバックの中で、Notifications API を実行する
const n = new Notification('受信しました', {
body: m.text,
})
n.onclick = (e) => {
/* 通知クリック時の操作等 */
}
})
return /*(略)*/
}
(参考までに)NGな例
次のような実装もできなくはないですが、useEffect
の使い方としてはよろしくありません。
export const NGApp = () => {
const { data, send } = useWebSocket<Message>('ws://localhost:5000')
//❌ useEffectの使い方として非推奨
React.useEffect(() => {
if (data) {
const n = new Notification('受信しました', {
body: data.text,
})
n.onclick = (e) => {
/* 通知クリック時の操作等 */
}
}
}, [data])
return /*(略)*/
}
このあたりのuseEffect
の使い方、現在作成中のReactの公式ドキュメントの次の記事が詳しいです。私もついついやってしまう例がたくさんあり、とても勉強になりました。
7.データフェッチライブラリと組み合わせる
RESTやGraphQLでAPIを実行してサーバのデータを取得する際、TanStackQuery(ReactQuery)やSWR等のデータフェッチライブラリ利用しているケースが多いと思います。
この手のライブラリはポーリングの機能を有しているので3、WebSocketを利用せずともサーバ側からの通知「的な」処理を行うことも可能です。
WebSocketとデータフェッチライブラリを併用する場合、
- サーバのデータ取得・状態管理:データフェッチライブラリ
- サーバ側からイベント通知:WebSocket
という分担、より具体的に言うと、「サーバからのデータ取得の契機 = データフェッチライブラリのキャッシュ更新の契機」としてWebSocketを利用をするのが分かりやすいかなと、個人的には思っています。
TanStackQuery(ReactQuery)の場合
TanStackQueryと組み合わせる場合、このカスタムフックを利用すれば、次のように実装できます。
export const App = () => {
const queryClient = useQueryClient()
const { data, send } = useWebSocket<Message>('ws://localhost:5000', (m) => {
// 受信したイベントを元に QueryKeyを算出
const queryKey = toQueryKey(m)
// 受信したイベントに関連するデータのキャッシュを無効化し、関連するデータの再フェッチを実行
queryClient.invalidateQueries(queryKey)
})
return /*(略)*/
}
- WebSocketからイベント受信を契機にクライアント側のキャッシュを無効化
- サーバのデータの再フェッチを実行
- クライアント側のキャッシュを最新化
- サーバとクライアントのデータの同期
ということをやっています。TanStackQueryの機能を利用すればもっと高度なことができそうです。このあたり、TanStackQueryのメンテナである@TkDodoさんの以下のブログが大変詳しいので、是非ご一読いただくことをお勧めします。
(上のイベント受信時にキャッシュを最新化する実装についても言及されています。)
8.おわりに
この記事では、ReactでWebSocketを扱う方法をご紹介しました。
HelloWorldから再接続、useSyncExternalStore
、フェッチライブラリとの組み合わせなど多少実践的なところまで書いたつもりです。(ちなみに、個人的にはカスタムフックにどう切り出すか、というのを考えるのが結構好きだったりします。)
この記事が少しでも皆さんのお役に立ったならとても嬉しいです。最後まで読んでいただきありがとうございました。