Skip to content

Realtime in React

@vaifstudio/react wraps the realtime client in hooks. Every hook uses the client’s one shared connection, subscribes when the component mounts, and cleans up when it unmounts or its inputs change. React StrictMode’s double mount does not open a second socket.

Create the client once and pass it to VaifProvider:

import { createVaifClient } from "@vaifstudio/client";
import { VaifProvider } from "@vaifstudio/react";
import type { ReactNode } from "react";
const vaif = createVaifClient({
projectId: import.meta.env.VITE_VAIF_PROJECT_ID,
apiKey: import.meta.env.VITE_VAIF_PUBLISHABLE_KEY,
});
export function App({ children }: { children: ReactNode }) {
return <VaifProvider client={vaif}>{children}</VaifProvider>;
}
import { useState } from "react";
import { useRealtime } from "@vaifstudio/react";
type Todo = { id: string; list_id: string; title: string; done: boolean };
export function TodoList({ listId }: { listId: string }) {
const [todos, setTodos] = useState<Todo[]>([]);
const { status, error } = useRealtime<Todo>("todos", {
event: ["INSERT", "DELETE"],
filter: { field: "list_id", value: listId },
onInsert: (todo) => setTodos((all) => [...all, todo]),
onDelete: (todo) => setTodos((all) => all.filter((t) => t.id !== todo.id)),
});
if (error) return <p>Live updates are unavailable: {error.message}</p>;
return (
<ul data-live={status === "SUBSCRIBED"}>
{todos.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}
Option Meaning
event "*" (default), one operation, or an array such as ["INSERT", "UPDATE"]
filter { field, operator?, value }, an array of them (ANDed), or a string such as "list_id=42" or "total=gte.100"
columns Only receive these columns
onInsert, onUpdate, onDelete, onChange Callbacks. They can be inline functions: changing one does not resubscribe
enabled false pauses the subscription (status becomes "IDLE")

It returns { status, isSubscribed, error, lastEvent }. useSubscription(table, options) is the same subscription with the changes collected for you: { data, changes, isSubscribed, status, error, clearChanges }.

import { useBroadcast } from "@vaifstudio/react";
type Reaction = { emoji: string };
export function Reactions({ roomId }: { roomId: string }) {
const { send, messages } = useBroadcast<Reaction>(`room:${roomId}`);
return (
<div>
<button onClick={() => send("reaction", { emoji: "👍" })}>👍</button>
{messages.map((m, i) => (
<span key={i}>{m.payload.emoji}</span>
))}
</div>
);
}

useChannel(name, { type, maxMessages, enabled }) returns the lower-level { broadcast, messages, lastMessage, isJoined, error, leave }.

import { usePresence } from "@vaifstudio/react";
type Viewer = { name: string };
export function WhoIsHere({ name }: { name: string }) {
const { count, presence } = usePresence<Viewer>("document:123", { name });
return (
<p>
{count} viewing: {Object.values(presence).flat().map((p) => p.state.name).join(", ")}
</p>
);
}

Presence is per connection across the whole project; see Channels & Presence for what that means for rooms.

import { useRealtimeConnection } from "@vaifstudio/react";
export function ConnectionBadge() {
const { state, error, reconnect } = useRealtimeConnection();
if (error) return <button onClick={reconnect}>Offline: {error.message}. Retry</button>;
return <span>{state}</span>;
}

@vaifstudio/expo has the same hooks, and closes the shared connection while the app is in the background.