# リアルタイムを hooks に：React、Vue、Nuxt のためのパッケージ

> pusher-js を React や Vue につなぐとき、どのチームも同じ useEffect を書き、同じ落とし穴にはまります。私たちはそれを 3 つのパッケージにまとめ、AI の回答をすべてのタブ、すべてのメンバーに届けられるようにしました。

Source: https://hibira.io/ja/blog/react-vue-nuxt-realtime
Updated: 2026-11-03

pusher-js を React につなぐとき、たいていこう書き始めます。

```jsx
useEffect(() => {
  const channel = pusher.subscribe('orders');
  channel.bind('order.created', (order) => setOrders((list) => [order, ...list]));
  return () => pusher.unsubscribe('orders');
}, []);
```

6 行、特に問題はなさそうです。ところがプロジェクトが育つと、注文一覧と右上の通知ベルの両方が `orders` を聞くようになります。ページを切り替えると一覧はアンマウントされ、ベルは残り、そしてベルにはもう通知が届きません。開発モードでは React の StrictMode がコンポーネントを 2 回マウントし、接続も 2 本開きます。Next.js に移ると、サーバーレンダリング中にも `new Pusher()` が走り、誰も使わない WebSocket をサーバー上で開いてしまいます。

こうしたコードを私たちは多くのプロジェクトで見てきました。書き方はほぼ同じで、ハマる落とし穴も同じです。そこで、これを 3 つのパッケージにまとめました。**@hibira-io/react**、**@hibira-io/vue**、**@hibira-io/nuxt** です。この記事では、何をどう解決したのか、そしてついでに作ったもうひとつの機能、AI の回答を書かれていくそばからすべてのタブ、すべてのチームメンバーに届ける仕組みについて紹介します。

## 落とし穴その 1：2 つのコンポーネント、1 つのチャンネル

pusher-js の `subscribe()` は同じチャンネル名を一度しか購読しないので、2 つのコンポーネントは同じチャンネルオブジェクトを受け取ります。問題は `unsubscribe()` です。ほかに誰が使っているかに関係なく、チャンネルごと購読を解除してしまいます。

```text
sequence: 2 つのコンポーネントが orders チャンネルを共有し、片方がアンマウントされると
注文一覧 | 通知ベル | pusher-js
1 > 3: subscribe('orders')
2 > 3: subscribe('orders') | 同じチャンネルを受け取る
1 > 3: unsubscribe('orders') | 一覧がアンマウント
note: チャンネルごと解除され、ベルにはもう通知が届かない
```

私たちの解決策は、チャンネルごとのカウントです。使うコンポーネントがマウントされるたびに 1 増やし、アンマウントで 1 減らし、0 になったときだけ本当に購読を解除します。`useChannel('orders')` や `useEvent('orders', 'order.created', handler)` と書くだけで、どれが先にマウントされ、どれが先に消えるかを気にする必要はありません。

```text
flow: カウントで本当に解除するタイミングを決める
== マウント
注文一覧|useEvent('orders') --カウント 1--> *チャンネル orders|購読 --カウント 2--> 通知ベル|useEvent('orders')
== アンマウント
注文一覧|アンマウント --カウント 1--> *チャンネル orders|購読を維持 --カウント 0--> 通知ベル|消えてから解除
```

`useEvent` は最新のハンドラーも覚えています。コンポーネントは再レンダリングのたびに新しい関数を作ります。毎回イベントを付け直すと、忙しいチャンネルでは一瞬の取りこぼしが生まれます。私たちはイベントを一度だけバインドし、イベントが届いたときに最新のハンドラーを呼びます。

## 落とし穴その 2：サーバーレンダリングと StrictMode

Next.js、Remix、Nuxt はまずサーバーでページをレンダリングします。コンポーネント本体や `useMemo` の中で接続を作ると、サーバーでも接続してしまいます。誰も使わない WebSocket が 1 本増え、接続数も 1 つ多く数えられます。

React 18 以降の StrictMode は、片付け忘れの副作用を見つけるために、開発モードでコンポーネントをマウント、アンマウント、再マウントします。手書きの接続コードは、ここで 2 本目の接続を残しがちです。

```text
flow: 接続はブラウザで、マウント後にだけ作る
サーバーレンダリング|接続しない、hooks は空の値を返す --HTML--> ブラウザの hydration|サーバーと同じ表示 --マウント後--> *接続|ws.hibira.io
```

`HibiraProvider` は `useEffect` の中でだけ接続し、アンマウントで切断します。StrictMode の 2 回目のマウントでは、きれいな新しい接続が使われます。接続が開くまで `usePresence` は空のメンバー一覧を、`useConnectionState` は `initialized` を返します。サーバーがレンダリングした結果と同じなので、hydration の不一致の警告は出ません。Vue 版も同じ考え方で、プラグインはブラウザで初めて使われたときに接続し、サーバーでは何もしません。

## もうひとつ：AI の回答を、質問したタブだけでなく

この 2 年で多くのプロダクトが AI アシスタントを追加しました。よくある実装は Server-Sent Events です。ブラウザがリクエストを送り、バックエンドがモデルの出力を同じレスポンスに少しずつ書き戻します。

一人がひとつのタブで使うなら、これで十分です。でもプロダクトが共同作業の場になると、問題が出てきます。Alice が共有の仕様書の中で AI に質問しても、隣に座る Bob には回答が書かれていく様子が見えません。Alice の側で書き終わって保存され、Bob がリロードして、ようやく見えます。Alice が自分で開いているもうひとつのタブにも届きません。スマートフォンの通信が一瞬途切れると SSE のレスポンスも切れ、回答は半分で止まります。

```text
flow: SSE は質問したリクエストにだけ返す。チャンネルは購読している全員に届ける
== Server-Sent Events
モデル --ストリーム--> バックエンド --同じ HTTP レスポンス--> 質問したタブ
== チャンネルに送る
モデル --ストリーム--> バックエンド|streamToChannel() --`trigger()`--> *Hibira|ファンアウト --WebSocket--> すべてのタブ、すべてのメンバー
```

そこでパッケージには、バックエンド用の `streamToChannel()` とブラウザ用の `useStream()` を入れました。バックエンドはモデルのストリームを `streamToChannel()` に渡すだけで、テキストがチャンネルに配信されます。そのチャンネルを購読しているコンポーネントは、どれも `useStream()` で伸びていくテキストを受け取れます。

![同じ AI の回答が書かれていく様子を 2 つのブラウザで同時に見ている：左が質問した Alice、右が Bob](https://hibira.io/portal/articles/demo-shared-answer-ja.webp)

ローカルでのテスト中に撮ったスクリーンショットです。Alice が「AI に聞く」を押すと、回答が Alice と Bob の画面に同時に現れます。Bob の側では何もする必要がありません。

### 1 トークンごとに 1 メッセージ、ではない

モデルは 1 秒に数十トークンを書きます。トークンごとにメッセージを配信すると、少し長い回答で 1,000 件になります。メッセージの枠はすぐに尽き、受け取る側のブラウザも 1,000 回再レンダリングすることになります。

`streamToChannel()` はトークンをいったん集め、100 ミリ秒ごとにまとめて配信します。モデルがどれだけ速くても、チャンネルに届くのは 1 秒に約 10 件で、画面上のテキストは途切れずに流れて見えます。

```text
bars: 1,000 トークンを毎秒 50 トークンで生成したときの配信メッセージ数（実測）
unit: 件
トークンごとに 1 件: 1000
*streamToChannel()、100 ミリ秒ごとに 1 件: 203
```

モデルが速いほど差は大きくなります。同じ 1,000 トークンでも、毎秒 100 トークンなら 103 件で済みます。各メッセージは 3,500 バイト以内に収まり、Free プランの 1 件 4 KB の上限を下回ります。長いテキストを分けるときも、漢字や絵文字を途中で切ることはありません。

### 順番、切断、途中から来た人

各メッセージには連番が付いていて、`useStream()` はその順にテキストをつなぎます。順番が入れ替わって届いても、足りない 1 件を待ってからつなぎます。

最後のメッセージには「完了」の印に加えて、1 件に収まる限り回答の全文を載せます。これは途中から来た人のためです。Bob が回答の途中でページを開くと、それより前のテキストは見られませんが、最後の 1 件が届いた瞬間に画面が完全な回答に置き換わります。通信が一瞬切れた人も同じです。

```text
sequence: 回答の途中で Bob が加わる
バックエンド | *Hibira | Alice | Bob
1 > 2: 0〜5 件目 | 前半のテキスト
2 > 3: 0〜5 件目
note: Bob がここでページを開いて購読
1 > 2: 6〜9 件目
2 > 3: 6〜9 件目
2 > 4: 6〜9 件目 | 後半だけ見える
1 > 2: 10 件目：完了 ＋ 全文
2 > 3: 完了
2 > 4: 全文 | Bob の画面がそろう
```

回答が長すぎて最後の 1 件に全文が入らない場合、途中から来た人には `incomplete: true` が見えます。そのときは API を一度呼んで、保存済みの回答を取得してください。モデルがエラーになったり中断されたりしたときは購読者に `error` が届くので、「生成中」のまま止まることはありません。

## コードではこうなる

### React

```jsx
import { HibiraProvider, useEvent, usePresence, useStream } from '@hibira-io/react';

export function App() {
  return (
    <HibiraProvider appKey="あなたの app key" options={{ channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } }}>
      <Room id="spec-42" />
    </HibiraProvider>
  );
}

function Room({ id }) {
  const { members } = usePresence(`presence-room-${id}`);
  const { text, done } = useStream(`private-room-${id}-ai`);
  useEvent(`private-room-${id}`, 'comment.created', (comment) => addComment(comment));
  return (
    <>
      <Avatars members={members} />
      <Answer text={text} loading={!done} />
    </>
  );
}
```

バックエンド（Node.js、フレームワークは問いません）は、モデルのストリームをそのまま `streamToChannel()` に渡します。Anthropic、OpenAI、Gemini、Vercel AI SDK のストリーム形式に対応しています。

```js
import Pusher from 'pusher';
import { streamToChannel } from '@hibira-io/react/server';

const pusher = new Pusher({ appId, key, secret, host: 'ws.hibira.io', useTLS: true });
const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages });
await streamToChannel(pusher, `private-room-${id}-ai`, stream, { meta: { asker: user.name } });
```

### Vue

Vue 3 と Vue 2.7 で動きます。チャンネル名には ref や getter も渡せるので、ルートのパラメーターが変われば購読も自動で切り替わります。

```js
// main.js
createApp(App)
  .use(createHibira({ appKey: import.meta.env.VITE_HIBIRA_KEY, options: { channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } } }))
  .mount('#app');
```

```vue
<script setup>
import { usePresence, useStream } from '@hibira-io/vue';

const props = defineProps({ id: String });
const { members, count } = usePresence(() => `presence-room-${props.id}`);
const { text, done } = useStream(() => `private-room-${props.id}-ai`);
</script>
```

### Nuxt

Nuxt 3 と Nuxt 4 ではモジュール設定を 1 行足すだけです。composables は自動インポートされ、サーバールートでも `useHibiraServer()` と `streamToChannel()` がそのまま使えます。

```ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@hibira-io/nuxt'],
});
```

```ts
// server/api/ask.post.ts
export default defineEventHandler(async (event) => {
  const { roomId, question } = await readBody(event);
  const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages: [{ role: 'user', content: question }] });
  event.waitUntil(streamToChannel(useHibiraServer(event), `private-room-${roomId}-ai`, stream));
  return { ok: true };
});
```

Nuxt 2 のプロジェクトも忘れていません。Nuxt 2.16 以降は Vue 2.7 で動くので、`@hibira-io/nuxt/nuxt2` モジュールを使ってください。

## どうテストしているか

3 つのパッケージはどれも小さく、pusher-js 以外の依存はありません。ただ、さまざまな環境で動くので、テストの範囲はコードそのものよりずっと広くなっています。

- React 17、18、19 と Vue 3.5、Vue 2.7 で、それぞれユニットテストを実行。
- エンドツーエンドテスト：2 つのクライアントを本物のサーバーにつなぎ、presence の参加と退出、クライアントイベント、200 トークンのストリームが両側で欠けずに組み立てられることを確認。
- Nuxt 4.6、3.21、2.18 でそれぞれ小さなアプリを作り、Chromium で 2 人のユーザーとして開いて、接続、presence、サーバーイベント、ストリームのテキストを確認。コンソールのエラーも hydration の警告もなし。

## はじめかた

```sh
npm install @hibira-io/react pusher-js   # React
npm install @hibira-io/vue pusher-js     # Vue 3 / Vue 2.7
npx nuxi module add @hibira-io/nuxt      # Nuxt 3 / 4
```

始める前に、Hibira のコンソールでいくつか設定しておきます。

- **アプリを作成し、認証情報を控える。** App ID と key はアプリの「概要」ページでいつでも確認できます。secret は作成時に一度だけ表示されるので、その場でバックエンドの環境変数に保存してください（Nuxt では `NUXT_PUBLIC_HIBIRA_KEY`、`NUXT_HIBIRA_APP_ID`、`NUXT_HIBIRA_SECRET`）。key は公開してよく、secret はサーバーにだけ置きます。
- **許可するオリジンを追加する。** Free プランでは `*` が使えません。サイトのアドレス（開発中なら例えば `http://localhost:3000`）を「許可するオリジン」に追加しないと、ブラウザは接続できません。
- **認可ルートを用意する。** プライベートチャンネルと presence チャンネルには、ログイン中のユーザーを確認して購読に署名するバックエンドの `/api/hibira/auth` が必要です。3 つの README にそのまま使える例があります。
- **1 メッセージ 4 KB。** Free プランではイベント 1 件あたり 4 KB までです。`streamToChannel()` は自動でこの範囲に収めますが、自分で配信するイベントのサイズには気をつけてください。

上の Alice と Bob の画面を自分で再現するなら、別々のブラウザ、または通常ウィンドウとシークレットウィンドウを使い、それぞれ別のユーザーでログインしてください。同じブラウザの 2 つのタブはたいてい同じユーザーなので、presence では 1 人として数えられます。

3 つとも MIT ライセンスで、ソースは GitHub にあります：[hibira-io/react](https://github.com/hibira-io/react)、[hibira-io/vue](https://github.com/hibira-io/vue)、[hibira-io/nuxt](https://github.com/hibira-io/nuxt)。Pusher プロトコルで話すので、互換サーバーならどれでも使えます。すでに Laravel Echo を使っているプロジェクトなら、`echo.connector.pusher` を渡せば Echo と同じ接続を共有できます。

Free プランはクレジットカード不要です。プロダクトに AI アシスタントを加えようとしているなら、あるいは手書きの `useEffect` がリアルタイムのメッセージをたくさん処理しているなら、まずひとつのページをこの hooks に置き換えてみてください。ご意見があれば、GitHub の issue でぜひ教えてください。
