Recipes
Refresh a query when a publication arrives
Realtime events say that something changed; the query owns the data. Invalidate on publication and let TanStack Query refetch:
import { useQueryClient } from "@tanstack/react-query";
import { useChannel } from "@priemskiyyy/simulcast-react";
const useRoomInvalidation = (roomId: string) => {
const queryClient = useQueryClient();
useChannel(`rooms:${roomId}`, () => {
return queryClient.invalidateQueries({ queryKey: ["rooms", roomId] });
});
};Returning the promise lets simulcast report a failed invalidation the same way it reports any handler failure. Publications keep flowing while it runs.
Append to a cached list
When the publication carries the full record, update the cache directly and skip the round trip:
useChannel<Message>(
`rooms:${roomId}`,
(message) => {
queryClient.setQueryData<Message[]>(
["rooms", roomId, "messages"],
(current) => (current === undefined ? current : [...current, message]),
);
},
{ parse: MessageSchema.parse },
);Parse at the boundary so a malformed publication never reaches the cache.
Refetch after a gap
A subscription that returns to subscribed may have missed publications while it was away. recovered tells you whether the provider replayed them, so a refetch only runs when there is a gap left to fill:
useChannelStatus(`rooms:${roomId}`, (status) => {
if (status.state !== "subscribed" || status.recovered) {
return;
}
return queryClient.invalidateQueries({ queryKey: ["rooms", roomId] });
});The callback fires on changes only, so the first subscribed also triggers one refetch, which most rooms want anyway. Providers without recovery report recovered: false throughout, which refetches after every reconnect.
One decoder for several channels
createChannelEventHooks is usually created once per application. Put the event map next to your DTO types and export the hook from the same module that owns the decoder, so codegen and hand-written calls share one source of truth:
export const { useChannelEvent } = createChannelEventHooks<Events>();Migrate between providers
Two clients can run side by side. Each provider owns its own session, and hooks resolve the nearest one, so a component belongs to exactly one client:
<RealtimeProvider client={pusherRealtime}>
<LegacyFeature />
<RealtimeProvider client={centrifugoRealtime}>
<MigratedFeature />
</RealtimeProvider>
</RealtimeProvider>MigratedFeature and everything below it use Centrifugo; LegacyFeature keeps Pusher. Move features into the inner tree one at a time. A component cannot use both clients through hooks, so reach the other one imperatively with client.channel(name).subscribe(...) if one component needs both.
If the application registers its client, name both while the migration runs:
declare module "@priemskiyyy/simulcast-react" {
interface Register {
client: typeof pusherRealtime | typeof centrifugoRealtime;
}
}useNativeConnection() then returns Pusher | Centrifuge | null, so narrow it before a provider-specific call:
const native = useNativeConnection();
if (native instanceof Centrifuge) {
await native.publish("rooms:demo", { text });
}Drop the old client from the registration once the migration finishes. Every provider still holding it fails to compile, which turns the last step into a compile-time checklist.
Provider-specific calls
For shared-connection adapters, provider-specific operations stay on the native client. Reach it through useNativeConnection(), typed once you register the client, and guard against null while no session is active:
const client = useNativeConnection(); // Centrifuge | null
const send = async (text: string) => {
if (client === null) {
return;
}
await client.publish(`rooms:${roomId}`, { text });
};