← Blog

Realtime as hooks: our packages for React, Vue and Nuxt

· Hibira

Every team writes the same useEffect to wire pusher-js into React or Vue, and hits the same bugs. We turned it into three packages, and made them stream AI answers to every tab and every teammate.

Wiring pusher-js into React usually starts like this:

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

Six lines, nothing wrong with them. Then the project grows. The order list and the notification bell in the corner both listen to orders; you switch pages, the list unmounts, the bell stays, and the bell never hears about another order. In development, React's StrictMode mounts every component twice, and you get two connections. You move to Next.js, and new Pusher() also runs during server rendering, opening a WebSocket on the server that nobody uses.

We have seen this code in many projects, written almost the same way each time, with the same bugs each time. So we turned it into three packages: @hibira-io/react, @hibira-io/vue and @hibira-io/nuxt. This post is about what they fix, how, and one more thing we added along the way: sending an AI answer to every tab and every teammate as it is written.

Bug one: two components, one channel

pusher-js subscribes to a channel name only once, so two components get the same channel object. The trouble is unsubscribe(): it doesn't care who else is still listening. It unsubscribes the whole channel.

Order listNotification bellpusher-jssubscribe('orders')subscribe('orders')gets the same channelunsubscribe('orders')the list unmountsThe whole channel is gone; the bell never hears another order
Two components share the orders channel, and one unmounts

Our fix is a count per channel: each component that uses it adds one, each unmount takes one away, and the channel is only unsubscribed when the count reaches zero. You write useChannel('orders') or useEvent('orders', 'order.created', handler), and it no longer matters who mounted first or who leaves first.

Mount

Order listuseEvent('orders')
count 1
Channel orderssubscribed
count 2
Notification belluseEvent('orders')

Unmount

Order listunmounts
count 1
Channel ordersstill subscribed
count 0
Notification bellunsubscribed after it leaves
A count decides when to really unsubscribe

useEvent also keeps your latest handler. A component makes a new function on every render; rebinding the event every time leaves brief gaps on a busy channel. We bind once and call the newest handler when an event arrives.

Bug two: server rendering and StrictMode

Next.js, Remix and Nuxt render the page on the server first. If you open the connection in a component's body or in useMemo, the server connects too: one more WebSocket nobody uses, and one more connection counted against your plan.

Since React 18, StrictMode mounts, unmounts and mounts components again in development, to find side effects that aren't cleaned up. Hand-written connection code often leaves a second connection behind right here.

Server renderno connection, hooks return empty values
HTML
Browser hydrationsame markup as the server
after mount
Connectws.hibira.io
Connect only in the browser, only after mount

HibiraProvider connects only inside useEffect and disconnects on unmount, so StrictMode's second mount gets a clean new connection. Until the connection opens, usePresence returns an empty member list and useConnectionState returns initialized, exactly what the server rendered, so hydration never warns about a mismatch. The Vue version works the same way: the plugin connects the first time it is used in the browser, and does nothing on the server.

One more thing: an AI answer, not only for the tab that asked

Many products have added an AI assistant in the last two years. The usual way to stream the answer is Server-Sent Events: the browser sends a request, and the backend writes the model's output back into that same response, piece by piece.

That works well for one person in one tab. Once the product is collaborative, it breaks down. Alice asks the AI a question in a shared spec document; Bob, sitting next to her, can't see the answer being written, and only sees it after Alice's side finishes, saves, and Bob reloads. Alice's own second tab can't see it either. Her phone drops its connection for a second, the SSE response breaks, and she is left with half an answer.

Server-Sent Events

Model
stream
Your backend
the same HTTP response
The tab that asked

Sent to a channel

Model
stream
Your backendstreamToChannel()
trigger()
Hibirafan-out
WebSocket
Every tab, every teammate
SSE answers only the request that asked; a channel reaches everyone subscribed

So the packages come with a pair of tools: streamToChannel() for the backend and useStream() for the browser. The backend hands the model's stream to streamToChannel(), which publishes the text to a channel; any component subscribed to that channel gets the growing text from useStream().

Two browsers watching the same AI answer being written: Alice, who asked, on the left, and Bob on the right
Two browsers watching the same AI answer being written: Alice, who asked, on the left, and Bob on the right

We took this screenshot while testing locally: Alice clicks "Ask the AI", and the answer appears on Alice's and Bob's screens at the same time. Bob doesn't have to do anything.

Not one message per token

A model writes a few dozen tokens a second. Publishing one message per token turns a longer answer into a thousand messages: the message quota runs out fast, and the browsers on the other end re-render a thousand times.

streamToChannel() gathers tokens and publishes them every 100 milliseconds. However fast the model is, a channel gets about ten messages a second, and the text still looks like it is flowing.

One per token1,000 messagesstreamToChannel(), one per 100 ms203 messages
Messages published for 1,000 tokens at 50 tokens a second (measured)

The faster the model, the bigger the saving: the same 1,000 tokens at 100 tokens a second take only 103 messages. Each message also stays under 3,500 bytes, below the Free plan's 4 KB limit, and long text is never split in the middle of a Chinese character or an emoji.

Order, dropped connections and late joiners

Every message carries a sequence number. useStream() joins the text in that order, and if messages arrive out of order it waits for the missing one.

The last message carries the "done" mark and, when it fits in one message, the whole answer. That is for people who join late: Bob opens the page halfway through the answer and misses the beginning, but as soon as the last message arrives, his screen switches to the complete answer. The same goes for anyone whose connection dropped for a moment.

Your backendHibiraAliceBobmessages 0–5the first partmessages 0–5Bob opens the page and subscribes nowmessages 6–9messages 6–9messages 6–9only the second halfmessage 10: done + full answerdonefull answerBob's screen catches up
Bob joins halfway through the answer

If the answer is too long for the last message to carry it, late joiners see incomplete: true, and you can fetch the stored answer from your API once. When the model fails or is stopped, subscribers get an error too, rather than a spinner that never ends.

What it looks like in code

React

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

export function App() {
  return (
    <HibiraProvider appKey="your 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} />
    </>
  );
}

The backend (Node.js, any framework) passes the model's stream to streamToChannel() as it is. It understands the streams of Anthropic, OpenAI, Gemini and the Vercel AI SDK:

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

Works on Vue 3 and Vue 2.7. Channel names can be refs or getters, so when a route parameter changes, the subscription follows:

// main.js
createApp(App)
  .use(createHibira({ appKey: import.meta.env.VITE_HIBIRA_KEY, options: { channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } } }))
  .mount('#app');
<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

On Nuxt 3 and Nuxt 4 it is one line of module config. The composables are auto-imported, and server routes get useHibiraServer() and streamToChannel() too:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@hibira-io/nuxt'],
});
// 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 };
});

Projects still on Nuxt 2 aren't left out: Nuxt 2.16 and later run on Vue 2.7, so use the @hibira-io/nuxt/nuxt2 module.

How we test them

All three packages are small and have no dependencies besides pusher-js. But they run in many environments, so the tests cover much more than the code itself:

  • Unit tests on React 17, 18 and 19, and on Vue 3.5 and Vue 2.7.
  • End-to-end tests: two clients connect to a real server, and we check presence joins and leaves, client events, and that a 200-token stream comes back whole on both sides.
  • A small app each for Nuxt 4.6, 3.21 and 2.18, opened in Chromium as two users, checking the connection, presence, server events and streamed text, with no console errors and no hydration warnings.

Get started

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

Before you start, set up a few things in the Hibira console:

  • Create an app and keep its credentials. The app ID and key are always on the app's Overview page. The secret is shown only once, when you create the app, so put it in your backend's environment right away (for Nuxt: NUXT_PUBLIC_HIBIRA_KEY, NUXT_HIBIRA_APP_ID, NUXT_HIBIRA_SECRET). The key is public; the secret stays on the server.
  • Add allowed origins. The Free plan can't use *: add your site's address (in development, for example http://localhost:3000) under Allowed origins, or the browser won't connect.
  • Add an auth route. Private and presence channels need /api/hibira/auth on your backend to check who is signed in and sign the subscription. Each README has an example you can copy.
  • 4 KB per message. On the Free plan each event can be up to 4 KB. streamToChannel() already keeps its messages under that; watch the size of events you publish yourself.

To try the Alice and Bob screen yourself, use two different browsers, or a normal window and a private window, each signed in as a different user. Two tabs in the same browser are usually the same user, and presence counts them as one person.

All three are MIT licensed, with the source on GitHub: hibira-io/react, hibira-io/vue, hibira-io/nuxt. They speak the Pusher protocol, so they work with any compatible server. If your project already uses Laravel Echo, pass in echo.connector.pusher and they share Echo's connection.

The Free plan needs no credit card. If you are about to add an AI assistant to your product, or you have a pile of hand-written useEffects handling realtime messages, try switching one page to these hooks. If you have ideas, open an issue on GitHub and tell us.