Following a stream
useSubscription opens a stream endpoint and follows it for as long as the component is mounted:
"use client"
import { q } from "@/lib/query"
export function Ticker({ symbol }: { symbol: string }) {
const { data, status, error } = q.prices.useSubscription("ticks", { variables: { symbol } })
if (error) return <p>Price feed unavailable: {error.message}</p>
if (status === "connecting") return <p>Connecting…</p>
return <p>{symbol}: {data?.price}</p>
}data— the latest event's payload, oroptimisticDatauntil the first one arrives.events— the stream's latest events, oldest first: 100 unlessmaxEventssays otherwise.maxEvents: Infinitykeeps every event, and the list then grows for as long as the stream runs. A newmaxEventsapplies without opening the stream again.status—connectingfrom the first render until the stream reports otherwise, thenconnected,reconnectingordisconnected. It isdisconnectedwhileenabledisfalse, and once the stream could not be opened.error— why the stream failed or could not be opened; the stream's next event clears it.close()— closes the stream before the component unmounts.
A change of method or variables opens another stream. data, events, error and status start over with it in the same render, so nothing of the previous stream is ever shown as the new one's. onData, onError and onStatusChange are called as the stream delivers an event, fails and changes status — the ones the component last rendered with, so a new handler, or a new maxEvents, applies without reopening the stream. The hooks write nothing as a component renders, which keeps them right under the React Compiler: from React 19.2 they hand their handlers to React's useEffectEvent.
Keeping a query up to date
useComputedQuery fetches a query and merges a stream of the same group into its cached data:
"use client"
import { q } from "@/lib/query"
export function Orders() {
const { data: orders } = q.orders.useComputedQuery("list", {
subscriptionMethod: "changes",
mapper: (orders, event) => [...(orders ?? []), event.data],
onStreamError: (error) => console.warn("Live updates stopped:", error.message),
})
return <ul>{orders?.map((order) => <li key={order.id}>{order.total}</li>)}</ul>
}The stream opens together with the query. Events that arrive before the query's first answer are held and merged into it, in order, before it is cached. subscriptionVariables opens the stream with other variables than the query's.
A stream that fails, is refused or cannot be opened does not fail the query: the query keeps its data and its error, and goes on without live updates. onStreamError says why, and onStreamStatusChange follows the stream's status.
Sending on a channel
useChannel joins a channel. It returns the last message the server sent on it, and send to send one back:
"use client"
import { q } from "@/lib/query"
export function Room({ room }: { room: string }) {
const { lastMessage, status, send } = q.chat.useChannel("room", { variables: { room } })
return (
<div>
<p>{lastMessage?.text}</p>
<button disabled={status !== "connected"} onClick={() => send({ text: "hello" })}>
Say hello
</button>
</div>
)
}send answers whether the message went out or waits to go out. A message sent before the channel has opened — from a mount effect, say — waits for it, and one sent while the connection comes back waits in the socket's queue. send answers false, with a warning, for a message it dropped: the channel is disabled or closed, the server ended it, the queue has no room left, or the message is larger than the socket accepts, which onError also reports with code 4413.
send in an effect
send returns a value, and an effect may only return its cleanup, so call it in a block: useEffect(() => { send(message) }, [send, message]).
error holds the last error on the channel: a RealtimeError whose code tells a refused message, a failed handler and a channel ended with a reason apart, or the ConfigurationError that kept the channel from opening. It is cleared when the channel connects again. Like a stream, a channel starts over — lastMessage, error, and status as connecting — when its method or variables change.
A proxy caller opens its channels at proxyPath.ws, which has no default: see createProxyQueryCaller.
A plain caller's status
The hooks above report their own status. For a stream or channel opened with a plain caller, useConnectionStatus follows it:
import { useConnectionStatus } from "@noverax/courier-react"
const { status, isConnected, isReconnecting } = useConnectionStatus(feed)It takes an SSESubscription or a WSChannel a caller returned, or a WSConnection; null reads as disconnected.
Next steps
- Streams & Channels — serve streams and channels, share a topic, and broadcast to it
- useQuery — fetch data with typed queries