Space persistence
Real Spaces stay open all day. Connections drop on commute, on train Wi-Fi, when laptops sleep. The user expects every message they typed to land — eventually. This demo shows the full round-trip: optimistic insert on send, mark in-flight, ack, drop on disconnect, persist to localStorage, retry on reconnect.
Live Demo
Hit Disconnect, send a few
messages (they'll be saved immediately), then hit
Reconnect. Failed messages
show a retry button. Click it to resend:
failed →
retrying →
delivered.
#space:resilient
idleSend a message into the space, disconnect, then reconnect. Anything in flight that didn't get acked is held in localStorage and offered back to you.
Persistence setup
Create an undelivered store backed by localStorage. Messages are keyed by channel and deduplicated by id.
import {
createUndeliveredSync,
createLocalStorage,
} from "@luciodale/react-socket";
type TStoredMessage = { id: string; channel: string; text: string };
const undelivered = createUndeliveredSync<TStoredMessage>({
storage: createLocalStorage(),
storageKey: "react_socket_demo_undelivered",
});
// Load any previously saved messages before connecting
await undelivered.init();Manager setup
One extractor for ack resolution. All other lifecycle logic lives in hooks.
const manager = new WebSocketManager<TClientMsg, TServerMsg>({
url: getWsUrl(),
serialize: (msg) => JSON.stringify(msg),
deserialize: (raw) => JSON.parse(raw),
ping: () => ({ type: "ping" }),
isPong: (msg) => msg.type === "pong",
getAckId: (msg) => (msg.type === "delivered" ? msg.ackId : undefined),
});Optimistic bridge
useSocketSendIntent fires on every send,
even when offline — insert the optimistic record here.
useSocketInFlightDrop fires when the
connection drops before ack — persist to localStorage and mark
failed. One bridge component owns both.
function OptimisticBridge() {
useSocketSendIntent(manager, ({ data, ackId }) => {
if (data.type !== "message" || !ackId) return;
useStore.setState((s) => {
const exists = s.messages.some((m) => m.id === ackId);
if (exists) {
return {
messages: s.messages.map((m) =>
m.id === ackId ? { ...m, status: "retrying" } : m,
),
};
}
return {
messages: [
...s.messages,
{ id: ackId, sender: "you", text: data.text, status: "sending" },
],
};
});
});
useSocketInFlightDrop(manager, (messages) => {
for (const { id, data } of messages) {
if (data.type === "message") {
undelivered.addMessage(data.channel, {
id,
channel: data.channel,
text: data.text,
});
}
}
const droppedIds = new Set(messages.map((m) => m.id));
useStore.setState((s) => ({
messages: s.messages.map((m) =>
droppedIds.has(m.id) ? { ...m, status: "failed" } : m,
),
}));
});
return null;
}Delivered bridge
When the server confirms, remove from localStorage and flip the UI status to delivered.
function DeliveredBridge() {
useSocketEvent(manager, "delivered", (msg) => {
undelivered.removeMessage(CHANNEL, msg.ackId);
useStore.setState((s) => ({
messages: s.messages.map((m) =>
m.id === msg.ackId ? { ...m, status: "delivered" } : m,
),
}));
});
return null;
}Manual retry
Failed messages stay visible with a retry button. Retry through
useSocketSend. If the socket is back up,
the message transitions retrying →
delivered via the bridges above.
function handleRetry(id: string, text: string) {
const sent = send({ type: "message", id, channel: CHANNEL, text }, id);
if (!sent) {
// still disconnected — stays as failed
}
}
// Offline send path: library returns false, store immediately
function handleSend(text: string) {
const id = crypto.randomUUID();
const sent = send({ type: "message", id, channel: CHANNEL, text }, id);
if (!sent) {
undelivered.addMessage(CHANNEL, { id, channel: CHANNEL, text });
}
}