Realtime
import { createSnapshot } from "@lastshotlabs/snapshot";
const snap = createSnapshot({ apiUrl: "/api", ws: { url: "wss://api.example.com/ws" }, sse: { endpoints: { notifications: { url: "/api/sse/notifications" }, }, },});
function LiveDashboard() { snap.useRoom("dashboard"); snap.useRoomEvent("dashboard", "stats:update", (data) => { console.log("New stats:", data); });
return <div>Live dashboard</div>;}WebSocket
Section titled “WebSocket”Connect and manage rooms
Section titled “Connect and manage rooms”// useSocket returns the WebSocket managerconst { isConnected, send, reconnect } = snap.useSocket();
// useRoom auto-subscribes to a room on mount and unsubscribes on unmountsnap.useRoom("chat:general");
// useRoomEvent listens for specific events within a roomsnap.useRoomEvent("chat:general", "message:new", (data) => { appendMessage(data);});Send messages
Section titled “Send messages”const { send } = snap.useSocket();
send({ type: "chat:message", room: "chat:general", body: "Hello!" });Connection lifecycle
Section titled “Connection lifecycle”const { isConnected, reconnect } = snap.useSocket();
if (!isConnected) { return <ButtonBase label="Reconnect" onClick={reconnect} />;}Keepalive and detecting a dead connection
Section titled “Keepalive and detecting a dead connection”heartbeat writes message (default: the raw string "ping", which is not JSON) to
the socket every interval ms. On its own that is keepalive traffic — it stops idle
connections being reaped by NAT devices, and nothing reads the result.
timeoutMs turns it into a liveness check. If no frame of any kind arrives within that
window of a beat going out, the socket is treated as dead and reconnected:
const snap = createSnapshot({ apiUrl: "/api", ws: { url: "wss://api.example.com/ws", heartbeat: { enabled: true, interval: 25_000, timeoutMs: 10_000, message: "ping", }, },});Why this exists: when a network drops a connection silently, the close frame may never
reach the browser. readyState stays OPEN, isConnected stays true, and
reconnectOnFocus — which only fires when disconnected — never runs. The socket satisfies
every liveness check available while carrying nothing, and an unanswered beat is the only
observable symptom.
Only set timeoutMs when the server sends something in response to the heartbeat, or sends
traffic regularly on its own. Against a server that stays silent on an idle connection, a
healthy socket would be reconnected on every beat. If your server ignores unrecognised
frames, prefer an application message it does answer, and set message to that.
Live-updating table
Section titled “Live-updating table”function LiveUserTable() { const [users, setUsers] = useState([]); snap.useRoom("admin:users");
snap.useRoomEvent("admin:users", "user:created", (user) => { setUsers((prev) => [...prev, user]); });
snap.useRoomEvent("admin:users", "user:updated", (user) => { setUsers((prev) => prev.map((u) => (u.id === user.id ? user : u))); });
snap.useRoomEvent("admin:users", "user:deleted", ({ id }) => { setUsers((prev) => prev.filter((u) => u.id !== id)); });
return <DataTableBase columns={columns} rows={users} />;}Server-Sent Events (SSE)
Section titled “Server-Sent Events (SSE)”Connect to an SSE endpoint
Section titled “Connect to an SSE endpoint”const { status, data, error } = snap.useSSE("notifications");// status: "connecting" | "open" | "closed"
if (status === "connecting") return <p>Connecting...</p>;if (error) return <p>SSE error: {error.message}</p>;Listen for specific events
Section titled “Listen for specific events”snap.useSseEvent("notifications", "new-message", (data) => { showToast(`New message from ${data.sender}`);});
snap.useSseEvent("notifications", "system-alert", (data) => { showAlert(data.message);});Non-hook event listener
Section titled “Non-hook event listener”For use outside React components:
const unsubscribe = snap.onSseEvent("notifications", "heartbeat", (data) => { console.log("Server heartbeat:", data);});
// Later: unsubscribe();Community notifications via SSE
Section titled “Community notifications via SSE”const { data: notifications } = snap.useCommunityNotifications({ // Automatically connects to the configured SSE endpoint for community events});Push notifications
Section titled “Push notifications”import { usePushNotifications } from "@lastshotlabs/snapshot";
function NotificationSettings() { const { state, subscribe, unsubscribe } = usePushNotifications({ vapidPublicKey: "YOUR_VAPID_KEY", });
// state: "unsupported" | "denied" | "pending" | "subscribed" | "unsubscribed"
if (state === "unsupported") return <p>Push notifications not supported</p>; if (state === "denied") return <p>Notifications blocked by browser</p>;
return ( <SwitchField label="Push notifications" checked={state === "subscribed"} onChange={(checked) => (checked ? subscribe() : unsubscribe())} /> );}Realtime config
Section titled “Realtime config”Configure WebSocket and SSE endpoints directly on the snapshot runtime:
const snap = createSnapshot({ apiUrl: "/api", ws: { url: "wss://api.example.com/ws", }, sse: { endpoints: { notifications: { url: "/api/sse/notifications", }, activity: { url: "/api/sse/activity", }, }, },});Composition: live chat room with typing expiry
Section titled “Composition: live chat room with typing expiry”Typing indicators must auto-expire — if a user closes their browser mid-type, you’ll never get an isTyping: false event. Use a timeout to clear stale indicators:
import { useState, useRef, useEffect, useCallback } from "react";
function ChatRoom({ roomId }: { roomId: string }) { const [messages, setMessages] = useState<any[]>([]); const [typingUsers, setTypingUsers] = useState< { name: string; avatar?: string }[] >([]); const { send, isConnected } = snap.useSocket(); const typingTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>( new Map(), );
snap.useRoom(`chat:${roomId}`);
snap.useRoomEvent( `chat:${roomId}`, "message", useCallback((msg: any) => { setMessages((prev) => [...prev, msg]); }, []), );
snap.useRoomEvent( `chat:${roomId}`, "typing", useCallback(({ user, isTyping }: any) => { // Clear any existing expiry timer for this user const existing = typingTimers.current.get(user.name); if (existing) clearTimeout(existing);
if (isTyping) { setTypingUsers((prev) => prev.some((u) => u.name === user.name) ? prev : [...prev, user], ); // Auto-expire after 3 seconds typingTimers.current.set( user.name, setTimeout(() => { setTypingUsers((prev) => prev.filter((u) => u.name !== user.name)); typingTimers.current.delete(user.name); }, 3000), ); } else { setTypingUsers((prev) => prev.filter((u) => u.name !== user.name)); typingTimers.current.delete(user.name); } }, []), );
// Cleanup all timers on unmount useEffect(() => { return () => typingTimers.current.forEach((t) => clearTimeout(t)); }, []);
const sendMessage = (body: string) => { send({ type: "chat:message", room: `chat:${roomId}`, body }); };
return ( <ColumnBase> {!isConnected && ( <AlertBase severity="warning">Reconnecting...</AlertBase> )} <ChatWindowBase title={`Room: ${roomId}`} threadSlot={ <MessageThreadBase messages={messages} contentField="body" authorNameField="name" showTimestamps /> } typingSlot={ typingUsers.length > 0 ? ( <TypingIndicatorBase users={typingUsers} maxDisplay={3} /> ) : null } inputSlot={<ChatInput onSend={sendMessage} disabled={!isConnected} />} /> </ColumnBase> );}Optimistic updates with WebSocket confirmation
Section titled “Optimistic updates with WebSocket confirmation”For the best UX, show the user’s action immediately and reconcile when the server confirms:
function LiveTodoList() { const [todos, setTodos] = useState<any[]>([]); const { send } = snap.useSocket();
snap.useRoom("todos");
// Server confirms the create snap.useRoomEvent( "todos", "todo:created", useCallback((todo: any) => { setTodos((prev) => { // Replace optimistic entry (matched by tempId) with server version const without = prev.filter((t) => t.id !== todo.tempId); return [...without, todo]; }); }, []), );
snap.useRoomEvent( "todos", "todo:toggled", useCallback((update: any) => { setTodos((prev) => prev.map((t) => (t.id === update.id ? { ...t, done: update.done } : t)), ); }, []), );
const addTodo = (text: string) => { const tempId = crypto.randomUUID(); // Optimistic: show immediately setTodos((prev) => [ ...prev, { id: tempId, text, done: false, optimistic: true }, ]); // Send to server send({ type: "todo:create", text, tempId }); };
return ( <ListBase items={todos.map((t) => ({ id: t.id, title: t.text, description: t.optimistic ? "Saving..." : undefined, icon: t.done ? "check-circle" : "circle", onClick: () => send({ type: "todo:toggle", id: t.id }), }))} /> );}Next steps
Section titled “Next steps”- Community and Chat — full community CRUD hooks
- Chat Application recipe — complete chat application
- Data Tables and Lists — live-updating data displays