Skip to content

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>;
}
// useSocket returns the WebSocket manager
const { isConnected, send, reconnect } = snap.useSocket();
// useRoom auto-subscribes to a room on mount and unsubscribes on unmount
snap.useRoom("chat:general");
// useRoomEvent listens for specific events within a room
snap.useRoomEvent("chat:general", "message:new", (data) => {
appendMessage(data);
});
const { send } = snap.useSocket();
send({ type: "chat:message", room: "chat:general", body: "Hello!" });
const { isConnected, reconnect } = snap.useSocket();
if (!isConnected) {
return <ButtonBase label="Reconnect" onClick={reconnect} />;
}

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.

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} />;
}
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>;
snap.useSseEvent("notifications", "new-message", (data) => {
showToast(`New message from ${data.sender}`);
});
snap.useSseEvent("notifications", "system-alert", (data) => {
showAlert(data.message);
});

For use outside React components:

const unsubscribe = snap.onSseEvent("notifications", "heartbeat", (data) => {
console.log("Server heartbeat:", data);
});
// Later: unsubscribe();
const { data: notifications } = snap.useCommunityNotifications({
// Automatically connects to the configured SSE endpoint for community events
});
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())}
/>
);
}

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 }),
}))}
/>
);
}