ReactでWebSocketの接続をカスタムフックにまとめる方法
React で WebSocket を使うときは、接続から後片付けまでをカスタムフック1つにまとめておくと、コンポーネントは useWebSocket(url, { onMessage }) を呼ぶだけで済みます。フックの中では、useEffect でつないで後片付けで閉じ、受け取ったときの処理は useEffectEvent で包み、こちらが閉じたのではなく切れたときは待ち時間を伸ばしながらつなぎ直します。
筆者は検証環境で、Node.js の WebSocket サーバー(ws パッケージ)と小さなチャット画面を作り、Chromium を Playwright で操作して、サーバーが受けた接続の数とブラウザの動きを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、ws 8.22.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 141 を Playwright 1.56.1 で操作しました。サーバーは同じマシンの中で動かし、ws://(暗号化なし)でつないでいます。wss://、実際のネットワークの切断、ほかのブラウザでは試していません。
接続は useEffect の中で作り、後片付けで必ず閉じます。受け取ったときの処理は useEffectEvent で包んで、依存配列に入れません。後片付けで閉じたときはつなぎ直さず、送るのはつながっているときだけにします。
WebSocket のフックの全体
検証に使ったフックです。接続の状態(status)と、送るための関数(send)を返します。
import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react';
export type WebSocketStatus = 'connecting' | 'open' | 'closed';
export type WebSocketOptions = {
onMessage?: (event: MessageEvent) => void; // メッセージが届いたときの処理
reconnect?: boolean; // 思いがけず切れたとき、つなぎ直すか
retryDelay?: number; // 1回目のつなぎ直しまでの待ち時間(ミリ秒)。失敗のたびに2倍にする
maxRetryDelay?: number; // 待ち時間の上限(ミリ秒)
};
export function useWebSocket(
url: string,
{ onMessage, reconnect = true, retryDelay = 500, maxRetryDelay = 8000 }: WebSocketOptions = {},
) {
const [status, setStatus] = useState<WebSocketStatus>('connecting');
const socketRef = useRef<WebSocket | null>(null);
// つなぎ直さずに、届いた時点の最新の onMessage を呼ぶ
const handleMessage = useEffectEvent((event: MessageEvent) => {
onMessage?.(event);
});
useEffect(() => {
let socket: WebSocket;
let timer: ReturnType<typeof setTimeout> | undefined;
let attempt = 0; // 続けて失敗した回数
let disposed = false; // 後片付けが済んだか
function connect() {
setStatus('connecting');
socket = new WebSocket(url);
socketRef.current = socket;
socket.onopen = () => {
attempt = 0;
setStatus('open');
};
socket.onmessage = (event) => handleMessage(event);
socket.onclose = () => {
if (disposed) return; // 自分で閉じたときは、つなぎ直さない
socketRef.current = null;
setStatus('closed');
if (!reconnect) return;
const delay = Math.min(retryDelay * 2 ** attempt, maxRetryDelay);
attempt++;
timer = setTimeout(connect, delay);
};
}
connect();
return () => {
disposed = true;
clearTimeout(timer);
socketRef.current = null;
socket.close();
};
}, [url, reconnect, retryDelay, maxRetryDelay]);
// つながっているときだけ送る。送れなかったら false を返す
const send = useCallback((data: string) => {
const socket = socketRef.current;
if (socket?.readyState !== WebSocket.OPEN) return false;
socket.send(data);
return true;
}, []);
return { status, send };
}
使う側は、URL と受け取ったときの処理を渡します。次は、検証に使ったチャット画面から抜き出したものです。部屋を選ぶと URL の room が変わります。
const { status, send } = useWebSocket(`${baseUrl}/?room=${room}`, {
onMessage: (event) => setMessages((m) => [...m, String(event.data)]),
});
function handleSend() {
if (send(text)) {
setText('');
setNotice('');
} else {
setNotice('つながっていないので、送れませんでした');
}
}
検証用のサーバーは、つないだときに「部屋 a に入りました」を送り、受け取ったメッセージを「echo: 」を付けて返します。「こんにちは」を送ってから部屋を b に切り替えた結果です。
送ったあとのメッセージ: ["部屋 a に入りました","echo: こんにちは"]
部屋を b にしたあとのメッセージ: ["部屋 a に入りました","echo: こんにちは","部屋 b に入りました"]
サーバーから見た部屋 a の接続: [{"id":1,"open":true,"closed":true,"code":1005}]
サーバーから見た部屋 b の接続: [{"id":2,"open":true,"closed":false,"code":null}]
URL が変わると、後片付けで部屋 a の接続が閉じ、部屋 b に新しくつながりました。ページを閉じたあとは、部屋 b の接続も残っていませんでした。code の 1005 は、MDN の CloseEvent.code のページにある「No Status Received」です。後片付けでは close() をコードなしで呼んでいるので、サーバーには閉じた理由のコードが届きません。

受け取る処理は useEffectEvent で包む
使う側が描画のたびに新しい関数を渡す場合、onMessage を useEffect の依存配列に入れると、描画のたびにつなぎ直しになります。比べるために、onMessage をそのまま依存配列に入れたフックを作りました。
useEffect(() => {
const socket = new WebSocket(url);
socket.onopen = () => setStatus('open');
socket.onmessage = (event) => onMessage(event);
socket.onclose = () => setStatus('closed');
return () => socket.close();
}, [url, onMessage]);
受け取ったメッセージを state に入れる画面を、この書き方と記事のフックの両方で作り、開いてから2秒のあいだの接続を数えました。どちらの画面も、同じ deps という部屋につなぎます。
deps: 開いてから2秒のあいだにサーバーが受けた接続 175、そのうち閉じた接続 175、表示「部屋 deps に入りました(入力欄: )」
deps: 同じ2秒のあいだにページで作った WebSocket 356、そのうち open まで進んだもの 177、open する前に閉じたもの 178、届いたメッセージ 147
effect-event: 開いてから2秒のあいだにサーバーが受けた接続 1、そのうち閉じた接続 0、表示「部屋 deps に入りました(入力欄: )」
依存配列に入れた書き方では、2秒のあいだにページが WebSocket を356個作り、サーバーは接続を175回受けました。サーバーが受けた175の接続は、数えた時点でどれも閉じていました。作った WebSocket の半分ほどは、つながる前に閉じられています。この書き方では、setStatus で接続の状態が変わるなどして描画し直すたびに、onMessage が新しい関数になり、エフェクトがやり直されて接続を作り直します。作り直すと、また状態が変わります。記事のフックは、2秒のあいだ接続が1つのままでした。
記事のフックで、入力欄に abc と打ってから、サーバーがメッセージを送った結果です。
effect-event: 入力欄に abc と打ってからサーバーが送ったあと、サーバーが受けた接続 1、表示「お知らせ(入力欄: abc)」
打っているあいだも接続は1つのままで、届いたときの処理は最新の入力欄の値(abc)を読みました。公式ドキュメントの、カスタムフックにイベントハンドラを渡す例でも、受け取ったハンドラを依存配列に入れると描画のたびにつなぎ直すとして、useEffectEvent で包んでいます。useEffectEvent の注意書きにあるとおり、包んだ関数はエフェクトの中から呼ぶもので、描画のたびに別の関数になります。このフックでも、エフェクトの中で付けた onmessage から呼んでいます。useEffectEvent については、以前の記事「ReactのuseEffectEventでエフェクトをやり直さずに最新の値を読む方法」でも扱っています。
後片付けで閉じたときは、つなぎ直さない
つなぎ直しは onclose で始めます。ただし、後片付けで close() を呼んだときにも onclose は届きます。そこで、記事のフックでは後片付けで disposed を true にして、そのときはつなぎ直さないようにしています。比べるために、この判定を入れずに、onclose で必ずつなぎ直すフックも作りました。
function connect() {
socket = new WebSocket(url);
socket.onopen = () => setStatus('open');
socket.onclose = () => {
setStatus('closed');
setTimeout(connect, 500);
};
}
connect();
return () => socket.close();
つないだあとにボタンでコンポーネントを外し、2秒待ってから、サーバーで開いている接続を数えた結果です。
guard: サーバーが受けた接続 1、いま開いている接続 0
no-guard: サーバーが受けた接続 2、いま開いている接続 1
判定のある記事のフック(1行目)は、外したあとに接続が残りませんでした。判定のない書き方(2行目)は、閉じたあとの onclose でつなぎ直し、外したはずのコンポーネントの接続が1つ開いたままになりました。後片付けでは、つなぎ直しの予約(setTimeout)も clearTimeout で取り消しておきます。
切れたら、待ち時間を伸ばしながらつなぎ直す
つないだあとにサーバーを止め、13秒あまりたってから起動し直しました。そのあいだにページで作られた WebSocket と、作られるまでの時間です。
1回目: ひとつ前が閉じてから 501ms 後に作成、結果 close code 1006
2回目: ひとつ前が閉じてから 1001ms 後に作成、結果 close code 1006
3回目: ひとつ前が閉じてから 2001ms 後に作成、結果 close code 1006
4回目: ひとつ前が閉じてから 4001ms 後に作成、結果 close code 1006
5回目: ひとつ前が閉じてから 8000ms 後に作成、結果 open
最初に切れた接続の close: {"code":1006,"wasClean":false}
つながったあとにもう一度切ったとき: 閉じてから 501ms 後に作成
待ち時間は 0.5 秒から2倍ずつ伸び、上限の8秒で、起動し直したサーバーにつながりました。つながった時点で attempt を0に戻しているので、そのあとにもう一度切ったときは、0.5 秒後につなぎ直しました。サーバーが止まっているあいだの失敗は、どれも close のコードが 1006 でした。先ほどの MDN の CloseEvent.code のページによると、1006 は閉じる手順(close フレーム)なしで切れたことを表すコードです。検証のサーバーは、止めるときにクライアントを閉じる手順なしで切っています。
待ち時間を伸ばしておくと、サーバーが長く止まっていても、0.5 秒ごとにつなぎに行き続けることはありません。このフックは、reconnect を false にしないかぎり、サーバーが閉じる手順を踏んで切った場合も含め、自分で閉じたとき以外は必ずつなぎ直します。サーバーが意図して切ったときにはつなぎ直さない、といった区別が必要なら、onclose に届く event.code で分けます。
つながっていないときは送らない
ブラウザの WebSocket は、つながっていないときに send を呼ぶと、状態によって動きが変わります。フックを通さずに、素の WebSocket で試した結果です。
素の WebSocket: {"readyStateAtStart":0,"sendWhileConnecting":"InvalidStateError: Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.","readyStateAfterClose":3,"sendAfterClose":"エラーなし","bufferedAmountAfterClose":3}
つなぎにいっている途中(readyState が 0)では、InvalidStateError が投げられました。閉じたあと(readyState が 3)では、エラーにならずに bufferedAmount だけが増えました。MDN の WebSocket.send() のページにも、CONNECTING のときは例外を投げ、CLOSING と CLOSED のときはデータを黙って捨てる、とあります。閉じたあとの send はエラーにならないので、送れていないことに気づけません。
そこでフックの send は、readyState が OPEN のときだけ送り、送れなかったら false を返すようにしました。サーバーを止めているあいだに送信を押した結果です。
止まっているあいだに送信を押した結果: {"status":"切断","notice":"つながっていないので、送れませんでした","text":"届く?"}
使う側は false を受けて「送れませんでした」と表示し、入力欄の文字を消さずに残しました。サーバーを起動し直してつながったあと、残っていた文字のまま送信を押した結果です。
つなぎ直したあとの入力欄: "届く?"
つなぎ直したあとのメッセージ: ["部屋 a に入りました","部屋 a に入りました","部屋 a に入りました","echo: 届く?"]
残っていた「届く?」が、そのまま届きました。この画面では、送れなかったという表示は次に送れたときに消すので、上の GIF のように、つなぎ直したあともしばらく表示が残ります。
開発中の StrictMode では、接続が2回作られる
開発用のビルドで、StrictMode のあり・なしを比べました。
StrictMode なし: ページで作った WebSocket [{"opened":true}]、サーバーが受けた接続 1、いま開いている接続 1、画面のメッセージ ["部屋 a に入りました"]
StrictMode あり: ページで作った WebSocket [{"opened":false,"code":1006},{"opened":true}]、サーバーが受けた接続 2、いま開いている接続 1、画面のメッセージ ["部屋 a に入りました"]
StrictMode ありでは、ページで WebSocket が2つ作られ、サーバーも接続を2回受けました。1つ目はつながる前に後片付けで閉じられ、コンソールには「WebSocket is closed before the connection is established.」という警告が出ました。それでも開いている接続は最後に1つで、画面のあいさつも1回だけでした。
公式ドキュメントの、開発中にエフェクトが2回動くときの説明によると、これは開発中だけの、意図した動きです。後片付けがきちんとできていれば、1回だけ動いたときと見分けがつかないはずで、ref で2回目をよけるのでは直らない、とも書かれています。開発中にサーバーのログで接続が2回見えても、開いている接続が1つなら、フックの後片付けは働いています。
フックにまとめると、どのコンポーネントでも同じ動きになる
useWebSocket を1つ作っておけば、URL が変わったら閉じてつなぎ直す、外したら閉じる、切れたら待ち時間を伸ばしてつなぎ直す、つながっていなければ送らない、という決まりを、どのコンポーネントでも同じにできます。使う側が気にするのは、届いたときの処理と、send が false を返したときの表示だけです。





