Listening on the Client
Experimental
Realtime and Broadcast are Experimental in 0.4.0. Production reliability work is still pending; review the production limits before relying on live updates.
Flux is the Holo browser client for broadcasting. Your framework's Flux package gives you hooks that subscribe when a component mounts and leave the channel when it unmounts.
There is no client setup to write. Flux fetches its connection settings from /broadcasting/config and authorizes private and presence channels through /broadcasting/auth. holo install broadcast sets up both.
Quick example
// app/orders/[id]/shipment-status.tsx
'use client'
import { useState } from 'react'
import { useFlux } from '@holo-js/flux-react'
export function ShipmentStatus({ orderId }: { orderId: string }) {
const [status, setStatus] = useState('pending')
useFlux(`orders.${orderId}`, 'orders.shipment-updated', (payload) => {
setStatus(payload.status)
})
return <p>Shipment: {status}</p>
}<!-- app/components/ShipmentStatus.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useFlux } from '@holo-js/flux-vue'
const props = defineProps<{ orderId: string }>()
const status = ref('pending')
useFlux(`orders.${props.orderId}`, 'orders.shipment-updated', (payload) => {
status.value = payload.status
})
</script>
<template>
<p>Shipment: {{ status }}</p>
</template><!-- src/lib/ShipmentStatus.svelte -->
<script lang="ts">
import { useFlux } from '@holo-js/flux-svelte'
export let orderId: string
let status = 'pending'
useFlux(`orders.${orderId}`, 'orders.shipment-updated', (payload) => {
status = payload.status
})
</script>
<p>Shipment: {status}</p>Subscribe with the real channel name, such as orders.42, not the pattern orders.{orderId} from your channel definition.
The payload type comes from the payload of the matching defineBroadcast in server/broadcast/. Holo regenerates these types when you run npx holo make:broadcast, npx holo prepare, or the dev server.
Choose the channel type
useFlux subscribes to a private channel. Use the explicit helpers when you want a different type:
| Helper | Channel type | Server helper |
|---|---|---|
useFlux(channel, events, callback) | private | privateChannel() |
useFluxPrivate(channel, events, callback) | private | privateChannel() |
useFluxPublic(channel, events, callback) | public | channel() |
useFluxPresence(channel, callbacks?) | presence | presenceChannel() |
The client type must match the server. A browser on the public channel feed won't receive events sent to privateChannel('feed').
If the server refuses a private or presence channel, the connection stays open and the browser's other channels keep working. Flux tries that channel again the next time you subscribe to it or connect. Each refusal goes to the channel's error callback. See Subscription errors and Refused subscriptions.
useFluxPublic('feed.global', 'feed.updated', (payload) => {
console.log(payload)
})Pass an array to handle several events with one callback:
useFlux(`orders.${orderId}`, ['orders.shipment-updated', 'orders.cancelled'], (payload) => {
console.log(payload)
})Pause and leave
Every event helper returns controls:
| Control | Effect |
|---|---|
stopListening() | Stop calling your callback. The channel stays joined. |
listen() | Start calling it again. |
leaveChannel() / leave() | Leave the channel. |
const shipment = useFlux(`orders.${orderId}`, 'orders.shipment-updated', onUpdate)
shipment.stopListening()
shipment.listen()You don't have to call leaveChannel() yourself. The helpers leave on unmount in React, on scope dispose in Vue, and on destroy in Svelte.
Hook options
Every hook accepts an optional options object as its last argument:
| Option | Effect |
|---|---|
client | Use this Flux client instead of the default flux client. Mainly useful in tests. |
onUnmount | Called with a function that leaves the channel. Store it and call it when you're done, instead of waiting for unmount. |
onError | Called with an Error when the server refuses the channel or its authorization fails. Channel hooks only. See Subscription errors. |
useFluxConnectionStatus also accepts onChange, and useFluxPresence takes onHere in its callbacks argument. Both are shown in their sections.
Vue and Svelte call onUnmount only when the hook runs outside a component. Inside a component they clean up automatically.
Presence channels
useFluxPresence joins a presence channel and keeps the current member list. Each member is the object your channel's authorize returned.
// app/chat/[roomId]/who-is-here.tsx
'use client'
import { useFluxPresence } from '@holo-js/flux-react'
type ChatMember = { id: string, name: string }
export function WhoIsHere({ roomId }: { roomId: string }) {
const { members } = useFluxPresence<ChatMember>(`chat.${roomId}`)
return (
<ul>
{members.map(member => <li key={member.id}>{member.name}</li>)}
</ul>
)
}<!-- app/components/WhoIsHere.vue -->
<script setup lang="ts">
import { useFluxPresence } from '@holo-js/flux-vue'
type ChatMember = { id: string, name: string }
const props = defineProps<{ roomId: string }>()
const presence = useFluxPresence<ChatMember>(`chat.${props.roomId}`)
</script>
<template>
<ul>
<li v-for="member in presence.members" :key="member.id">{{ member.name }}</li>
</ul>
</template><!-- src/lib/WhoIsHere.svelte -->
<script lang="ts">
import { useFluxPresence } from '@holo-js/flux-svelte'
type ChatMember = { id: string, name: string }
export let roomId: string
const { members } = useFluxPresence<ChatMember>(`chat.${roomId}`)
</script>
<ul>
{#each $members as member (member.id)}
<li>{member.name}</li>
{/each}
</ul>In React and Vue, members is a read-only array that re-renders the component when it changes. In Svelte, members is a store, so read it with $members.
Pass onHere to run code whenever the list changes. It receives the full list after every join and leave:
useFluxPresence<ChatMember>(`chat.${roomId}`, {
onHere(members) {
console.log(`${members.length} people in the room`)
},
})useFluxPresence also returns the same stopListening, listen, and leaveChannel controls as the event helpers.
Notifications
useFluxNotification receives broadcast notifications sent to a private channel:
useFluxNotification(`users.${userId}`, (notification) => {
console.log(notification)
})The callback receives { type, data }, where data is what your notification's broadcast() builder returned. It only fires for notifications that use the default event name. If the builder sets its own event, listen for that name with useFlux instead.
WARNING
Broadcast notifications send to the channel names returned by the notifiable's routeNotificationForBroadcast() exactly as written. To reach useFluxNotification('users.42', ...), return private-users.42, and add a defineChannel('users.{userId}', ...) rule that lets the user in.
Subscription errors
When the server refuses a channel, or Flux can't get the authorization for it, the subscription's error callback runs with an Error. The connection stays open, and the other channels keep working.
import { flux } from '@holo-js/flux'
flux.private(`orders.${orderId}`)
.listen('orders.shipment-updated', onUpdate)
.error((error) => {
console.warn(error.message)
})In a hook, pass onError in the options:
useFlux(`orders.${orderId}`, 'orders.shipment-updated', onUpdate, {
onError: (error) => {
console.warn(error.message)
},
})Flux drops a WebSocket frame that isn't valid JSON and keeps delivering the frames after it. Development builds log a warning for each dropped frame, and production builds don't log.
Connection status
useFluxConnectionStatus reports the WebSocket state: idle, connecting, connected, or disconnected.
Flux reconnects by itself after the WebSocket closes or fails. The status reads disconnected while it waits and connected once the socket opens again. It then rejoins your channels and authorizes private and presence channels again. Calling flux.disconnect() stops the reconnects.
// app/components/connection-badge.tsx
'use client'
import { useFluxConnectionStatus } from '@holo-js/flux-react'
export function ConnectionBadge() {
const status = useFluxConnectionStatus()
return <span>{status}</span>
}<!-- app/components/ConnectionBadge.vue -->
<script setup lang="ts">
import { useFluxConnectionStatus } from '@holo-js/flux-vue'
const status = useFluxConnectionStatus()
</script>
<template>
<span>{{ status }}</span>
</template><!-- src/lib/ConnectionBadge.svelte -->
<script lang="ts">
import { useFluxConnectionStatus } from '@holo-js/flux-svelte'
const status = useFluxConnectionStatus()
</script>
<span>{$status}</span>React returns the status string, Vue returns a read-only ref, and Svelte returns a store. Pass onChange to react to every change:
useFluxConnectionStatus({
onChange(status) {
console.log('Broadcast connection:', status)
},
})Use Flux directly
Import flux from @holo-js/flux when you're outside a component or need features the hooks don't cover, such as whispers. Direct subscriptions don't leave automatically, so call leaveChannel() when you're done.
import { flux } from '@holo-js/flux'
const feed = flux.channel('feed.global').listen('feed.updated', (payload) => {
console.log(payload)
})
const order = flux.private(`orders.${orderId}`).listen('orders.shipment-updated', (payload) => {
console.log(payload)
})
feed.leaveChannel()
order.leaveChannel()| Method | Returns |
|---|---|
flux.channel(name) | A public channel subscription. |
flux.private(name) | A private channel subscription. |
flux.presence(name) | A presence channel subscription with members, here(), joining(), and leaving(). |
flux.connect() / flux.disconnect() | Open or close the WebSocket. disconnect() also stops automatic reconnection. |
flux.getStatus() | The current connection status. |
flux.onStatusChange(callback) | Subscribe to status changes. Returns an unsubscribe function. |
A subscription has listen(event, callback), notification(callback), error(callback), listenForWhisper(name, callback), whisper(name, payload), stopListening(), and leaveChannel().
Presence callbacks
import { flux } from '@holo-js/flux'
const room = flux
.presence(`chat.${roomId}`)
.here((members) => {
console.log('In the room:', members)
})
.joining((member) => {
console.log('Joined:', member)
})
.leaving((member) => {
console.log('Left:', member)
})
.listen()
console.log(room.members)here receives the full member list. joining and leaving receive one member at a time.
Whispers
Whispers go from one browser to the others on a private or presence channel. The channel must allow the whisper name in its whispers option. See Client events (whispers).
import { flux } from '@holo-js/flux'
const room = flux.presence(`chat.${roomId}`)
room.listenForWhisper('typing', (payload) => {
console.log('Someone is typing:', payload)
})
await room.whisper('typing', { editing: true })Reference
All framework packages export the same helpers.
| Helper | Returns |
|---|---|
useFlux(channel, events, callback, options?) | Listener controls. Private channel. |
useFluxPrivate(channel, events, callback, options?) | Same as useFlux. |
useFluxPublic(channel, events, callback, options?) | Listener controls. Public channel. |
useFluxPresence(channel, callbacks?, options?) | Listener controls plus members. |
useFluxNotification(channel, callback, options?) | Listener controls. Private channel. |
useFluxModel(channel, events, callback, options?) | Same as useFluxPrivate. |
useFluxConnectionStatus(options?) | Status string (React), read-only ref (Vue), or store (Svelte). |
options.client lets you pass a specific Flux client instead of the default one, which is mainly useful in tests.
Available methods
Every Flux client method, subscription method, presence method, and hook. Hooks come from @holo-js/flux-react, @holo-js/flux-vue, or @holo-js/flux-svelte, and the rest from @holo-js/flux.
Flux client
- flux: the default client object
- createFluxClient: build a client with your own connector
- getFluxClient: read the default client
- status and options: read the client's state and settings
- connect, disconnect, getStatus, onStatusChange: manage the connection
- channel, private, presence: open a subscription
Subscriptions (returned by channel() and private())
- listen: receive events, or resume after stopping
- notification: receive broadcast notifications
- listenForWhisper and whisper: receive and send client events
- stopListening, leave, leaveChannel: stop or leave
- name and type: read the channel name and kind
Presence subscriptions (returned by presence())
Hooks
- useFlux, useFluxPublic, useFluxPrivate, useFluxModel: listen to events
- useFluxPresence: join a presence channel
- useFluxNotification: receive notifications
- useFluxConnectionStatus: read the connection state
Hooks return the same listener controls as subscriptions: stopListening(), listen(), leaveChannel(), and leave(). They're documented under listen and stopListening.
flux
The default Flux client. Import it to use Flux outside a component.
import { flux } from '@holo-js/flux'
flux.channel('feed.global').listen('feed.updated', (payload) => {
console.log(payload)
})createFluxClient
createFluxClient(options?) builds a new client. Pass connector to use your own transport, or connectorFactory to build one from the options. Without either, the client's connect() throws.
import { createFluxClient } from '@holo-js/flux'
const client = createFluxClient({ connector: myConnector })Use it in tests, or pass the result to a hook as client.
getFluxClient
getFluxClient() returns the default client that flux and the hooks use.
import { getFluxClient } from '@holo-js/flux'
const status = getFluxClient().getStatus()status
The current connection status: 'idle', 'connecting', 'connected', or 'disconnected'. It's a read-only property, so it updates each time you read it.
if (flux.status === 'connected') {
console.log('Live updates are on')
}options
The options the client was created with, frozen.
console.log(flux.options)connect
connect() opens the WebSocket and resolves once the attempt finishes. Subscribing connects automatically, so you rarely need to call it.
await flux.connect()disconnect
disconnect() closes the WebSocket, drops every channel it had joined, and sets the status to 'disconnected'.
await flux.disconnect()getStatus
getStatus() returns the connection status. It's the same value as the status property.
const status = flux.getStatus()onStatusChange
onStatusChange(callback) calls callback with the new status each time it changes. It returns a function that stops the callback.
const stop = flux.onStatusChange((status) => {
console.log('Broadcast connection:', status)
})
stop()channel
channel(name) subscribes to a public channel and returns a subscription.
const feed = flux.channel('feed.global')private
private(name) subscribes to a private channel. The browser asks /broadcasting/auth for permission first.
const order = flux.private(`orders.${orderId}`)presence
presence(name) subscribes to a presence channel and returns a presence subscription.
const room = flux.presence(`chat.${roomId}`)listen
listen(event, callback) calls callback when the event arrives. Pass an array of event names to handle several with one callback. Called with no arguments, listen() resumes callbacks you stopped with stopListening.
const shipment = flux.private(`orders.${orderId}`)
.listen(['orders.shipment-updated', 'orders.cancelled'], (payload) => {
console.log(payload)
})
shipment.listen()notification
notification(callback) calls callback for broadcast notifications sent to the channel. See useFluxNotification for the payload shape.
flux.private(`users.${userId}`).notification((notification) => {
console.log(notification)
})error
error(callback) calls callback with an Error when the server refuses this channel's subscription or the authorization request fails. See Subscription errors.
flux.private(`orders.${orderId}`).error((error) => {
console.warn(error.message)
})listenForWhisper
listenForWhisper(name, callback) calls callback when another browser sends a whisper with that name. The channel must allow the whisper in its whispers option.
flux.presence(`chat.${roomId}`).listenForWhisper('typing', (payload) => {
console.log(payload)
})whisper
whisper(name, payload) sends a whisper to the other browsers on the channel. It resolves once the message is sent.
await room.whisper('typing', { editing: true })stopListening
stopListening() stops calling your callbacks but keeps the channel joined. Call listen to start again.
shipment.stopListening()leave
leave() leaves the channel for every subscription of the same kind in this client, not just this one. Use leaveChannel to leave only this subscription.
order.leave()leaveChannel
leaveChannel() removes this subscription's callbacks. The WebSocket leaves the channel once no other subscription in the client is using it.
feed.leaveChannel()name
name is the channel name and type is 'public', 'private', or 'presence'. Both are read-only.
console.log(order.name, order.type)members
members is the current member list on a presence channel, an array of the objects your channel's authorize returned. It updates as people join and leave.
console.log(room.members.length)here
here(callback) calls callback with the full member list right away. Chain it with joining and leaving.
room.here((members) => {
console.log('In the room:', members)
})joining
joining(callback) calls callback with one member each time someone joins.
room.joining((member) => {
console.log('Joined:', member)
})leaving
leaving(callback) calls callback with one member each time someone leaves.
room.leaving((member) => {
console.log('Left:', member)
})useFlux
useFlux(channel, events, callback, options?) listens on a private channel and returns listener controls. The subscription starts when the component mounts and leaves on unmount. Changing channel or events re-subscribes.
useFlux(`orders.${orderId}`, 'orders.shipment-updated', (payload) => {
console.log(payload)
})useFluxPublic
useFluxPublic(channel, events, callback, options?) works like useFlux, on a public channel.
useFluxPublic('feed.global', 'feed.updated', (payload) => {
console.log(payload)
})useFluxPrivate
useFluxPrivate(channel, events, callback, options?) is the same as useFlux, and makes the private channel explicit.
useFluxPrivate(`orders.${orderId}`, 'orders.shipment-updated', onUpdate)useFluxModel
useFluxModel(channel, events, callback, options?) is the same as useFluxPrivate. Its name signals a model-change channel. The behavior is identical.
useFluxModel(`posts.${postId}`, 'posts.updated', onPostUpdated)useFluxPresence
useFluxPresence(channel, callbacks?, options?) joins a presence channel and returns listener controls with members. callbacks.onHere runs with the full member list after every join and leave.
const presence = useFluxPresence<ChatMember>(`chat.${roomId}`, {
onHere(members) {
console.log(members.length)
},
})
console.log(presence.members)React reads members as a getter that re-renders the component. Vue returns it as a read-only array. Svelte returns it as a store.
useFluxNotification
useFluxNotification(channel, callback, options?) receives broadcast notifications on a private channel. The callback gets { type, data }, where type is the notification's type name and data is what its broadcast() builder returned.
useFluxNotification(`users.${userId}`, (notification) => {
console.log(notification)
})useFluxConnectionStatus
useFluxConnectionStatus(options?) returns the connection status. React returns a string, Vue a read-only ref, and Svelte a store. options.onChange runs on every change.
const status = useFluxConnectionStatus({
onChange(next) {
console.log(next)
},
})Next steps
- Events and channels
- Realtime queries: keep query results live without writing events.
- Scaling in production