Hooks
Every hook requires a RealtimeProvider above it and throws an error if one is missing.
| Hook | Returns | Opens a subscription |
|---|---|---|
useChannel | nothing | yes |
useChannelDemand | nothing | yes |
useChannelStatus | ChannelStatus | no |
useConnectionState | ConnectionState | no |
useNativeChannel | TNativeSubscription | null | no |
useNativeConnection | TNative | null | no |
useRealtimeClient | RegisteredClient | no |
Callbacks always see current render values without recreating the subscription, so you never need to memoise them. They may be async, but publications are not queued behind their completion.
useChannel
useChannel<TData = unknown>(
channel: string,
onEvent: PublicationHandler<TData>,
options?: { enabled?: boolean; parse?: (data: unknown) => TData },
): voidSubscribes to a channel and receives its publications.
useChannel("rooms:demo", (message, publication) => {
console.log(message, publication.event);
});The handler's second argument is the RealtimePublication: data, the optional provider event name, and native, the provider's own context.
parse runs on every publication and its return type drives inference. Pass it by reference (schema.parse, Number) or annotate a parameter on either side: TypeScript cannot infer the payload from an inline (raw) => ... arrow when the handler's parameter is also unannotated, and falls back to unknown. The explicit generic declares your wire contract instead, and nothing validates it at runtime. Supplying both requires them to agree.
useChannelStatus
useChannelStatus(
channel: string,
onChange?: (status: ChannelStatus) => void | Promise<unknown>,
): ChannelStatustype ChannelStatus = {
state: "detached" | "unsubscribed" | "subscribing" | "subscribed";
error: { error: unknown } | null;
recovered: boolean;
};Observing a channel's status does not subscribe to it. detached means there is no active native subscription. This happens when no enabled publication consumer demands the channel, or when the client has no active session, even if consumers are registered. unsubscribed instead describes a native subscription that reports it has stopped. If a channel stays detached, check both its enabled useChannel consumers and the provider's session.
error.error is the provider's own error value. The optional callback fires on changes after registration; it never replays the current value.
recovered answers whether the provider replayed the publications missed since the last subscription, and only subscribed can report true. Read it as "assume a gap unless told otherwise": false covers a provider that recovered nothing, one whose recovery failed, and one with no recovery to offer. See Refetch after a gap.
useConnectionState
useConnectionState(
onChange?: (state: ConnectionState) => void | Promise<unknown>,
): "disconnected" | "connecting" | "connected"The connection state, or disconnected when no session is active. connecting includes reconnect attempts the provider makes on its own.
useNativeConnection
useNativeConnection(): TNative | nullThe provider's native adapter connection, or null during server rendering and while no session is active. The component rerenders when a session replaces or releases it. The type is unknown until you register the client; then it follows the adapter:
const centrifuge = useNativeConnection(); // Centrifuge | null once registereduseChannelDemand
useChannelDemand(
channel: string,
options?: Pick<UseChannelOptions, "enabled">,
): voidKeeps a channel's native subscription open without consuming publications. A subscription exists only while something demands it, and only publication consumers demand. Use this when a component watches the provider's own subscription through useNativeChannel but reads nothing through Simulcast:
useChannelDemand("rooms:demo");
const subscription = useNativeChannel("rooms:demo");It is an ordinary consumer, so devtools counts it as one, and the subscription is released when the last consumer goes, this one included. A component that already calls useChannel needs nothing extra.
useNativeChannel
useNativeChannel(channel: string): TNativeSubscription | nullThe adapter's native subscription for a channel, or null while none exists. Reach for it when a provider exposes something on a subscription that Simulcast does not normalize, such as Centrifugo's join and leave events.
Observing it never opens a subscription. It follows the one a consumer demanded and returns to null when the last consumer leaves, so a component that only observes pairs it with useChannelDemand to keep the channel open:
const subscription = useNativeChannel("rooms:demo");
useChannelDemand("rooms:demo");
useEffect(() => {
if (subscription === null) {
return;
}
subscription.on("join", handleJoin);
return () => {
subscription.off("join", handleJoin);
};
}, [subscription]);The value changes identity when a session replaces the subscription, so an effect depending on it rebinds at the right moment. Its type is unknown until you register the client. Adapters without a per-channel native object report whatever their adapter returns.
Type the client once
Every hook reads the client from the provider, so TypeScript cannot know which adapter it carries. Augment Register next to the client to tell it:
// src/realtime.ts
export const realtime = new RealtimeClient({
adapter: centrifugo({ transport }),
});
declare module "@priemskiyyy/simulcast-react" {
interface Register {
client: typeof realtime;
}
}From then on useNativeConnection() returns Centrifuge | null, useRealtimeClient() is typeof realtime, useNativeChannel() returns the adapter's subscription type, and every useChannel handler receives the adapter's publication type in native. Nothing changes at runtime, and an application that skips the augmentation keeps unknown. TanStack Router types its hooks the same way.
A registration that does not name a RealtimeClient, such as the factory function instead of its return type, falls back to the unregistered types instead of failing. The hooks then return unknown with nothing pointing at the cause. Each example guards against that with a one-line compile-time check in src/realtime/register.ts.
The augmentation covers the whole TypeScript program, so it assumes one client per application, as the rest of Simulcast does. An application migrating between providers can register a union of both. A shared component library should not register at all, and keeps unknown.
useRealtimeClient
useRealtimeClient(): RealtimeClientThe provider's client. Use channel and native for imperative access, and diagnostics for custom inspection.
createChannelEventHooks
const { useChannelEvent } = createChannelEventHooks<Events>({ decode });Builds a hook typed by your event map. See Typed events.