esc

Type to search...

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: failedretryingdelivered.

#space:resilient

idle

Send 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.

UndeliveredSync.tsx
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.

UndeliveredSync.tsx
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.

UndeliveredSync.tsx
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.

UndeliveredSync.tsx
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 retryingdelivered via the bridges above.

UndeliveredSync.tsx
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 });
  }
}