esc

Type to search...

Undelivered Sync

When messages fail to deliver (e.g. the connection drops mid-send), you can persist them to storage and retry later. The createUndeliveredSync utility handles this with a channel-based store backed by any async storage.

When You Need It

Without undelivered sync, messages that fail to send are simply lost. This is fine for ephemeral data like cursor positions, but for important messages (chat, commands, form submissions) you'll want persistence. There are two scenarios where messages need saving:

Connection drops mid-send:

  1. User sends a message while online — it enters the in-flight map
  2. Connection drops before the server confirms — useSocketInFlightDrop fires
  3. Save the dropped messages to the undelivered store
  4. On reconnect, onReady retries them

User sends while already disconnected:

  1. send() returns false — the message never enters the in-flight map
  2. Save it to the undelivered store immediately
  3. On reconnect, onReady picks it up and retries

Setup

undelivered.ts
import {
  createUndeliveredSync,
  createLocalStorage,
} from "@luciodale/react-socket";

type TUndeliveredMsg = {
  id: string;       // required — used for deduplication
  channel: string;
  text: string;
};

const undelivered = createUndeliveredSync<TUndeliveredMsg>({
  storage: createLocalStorage(),
  storageKey: "ws_undelivered", // optional, defaults to "ws_undelivered_messages"
});

// Load any previously saved messages from storage
await undelivered.init();

Storage Interface

createLocalStorage() wraps window.localStorage with error handling. You can provide any storage that implements IStorage:

storage.ts
import type { IStorage } from "@luciodale/react-socket";

const customStorage: IStorage = {
  getItem(key: string): Promise<string | null> { /* ... */ },
  setItem(key: string, value: string): Promise<void> { /* ... */ },
  removeItem(key: string): Promise<void> { /* ... */ },
};

All methods are async, so you can use IndexedDB, a server-side API, or any other async store.

API

api.ts
// Initialize — must be called before using the store
await undelivered.init();

// Check if initialized
undelivered.isInitialized(); // boolean

// Get messages for a channel
undelivered.getChannelMessages("general"); // TUndeliveredMsg[]

// Add a message (no-op if the ID already exists)
undelivered.addMessage("general", { id: "abc", channel: "general", text: "hello" });

// Remove a single message by ID
undelivered.removeMessage("general", "abc");

// Replace all messages for a channel
undelivered.setChannelMessages("general", [msg1, msg2]);

// Clear a single channel
undelivered.clearChannel("general");

// Clear everything and remove from storage
undelivered.clearAll();

// Subscribe to changes (returns unsubscribe function)
const unsub = undelivered.subscribe(() => {
  console.log("Undelivered store changed");
});

Integration with the Manager

Wire the undelivered store into the manager. There are two save paths: the useSocketInFlightDrop hook for messages dropped on disconnect, and a check on send()'s return value for messages sent while already offline:

manager.ts
// Assumption: the server echoes both ackId and channel in the confirm
// so the bridge has enough info to remove the right entry.
type TServerMsg =
  | { type: "delivered"; ackId: string; channel: string }
  | { type: "chat"; /* ... */ };

const manager = new WebSocketManager<TClientMsg, TServerMsg>({
  // ...

  // Wire ack detection once — library clears in-flight automatically
  getAckId: (msg) => (msg.type === "delivered" ? msg.ackId : undefined),

  // Retry undelivered messages after every (re)connect
  onReady() {
    for (const channel of ["general", "random"]) {
      for (const msg of undelivered.getChannelMessages(channel)) {
        manager.send({
          data: { type: "message", id: msg.id, channel, text: msg.text },
          ackId: msg.id,
        });
      }
    }
  },
});

// Mount once in your app shell. The bridge owns the React-side wiring.
function UndeliveredBridge() {
  // Save messages that were in-flight when the connection dropped
  useSocketInFlightDrop(manager, (messages) => {
    for (const { id, data } of messages) {
      if (data.type === "message") {
        undelivered.addMessage(data.channel, {
          id,
          channel: data.channel,
          text: data.text,
        });
      }
    }
  });

  // Remove from storage once the server confirms
  useSocketEvent(manager, "delivered", (msg) => {
    undelivered.removeMessage(msg.channel, msg.ackId);
  });

  return null;
}

// When sending, check the return value — if false, save immediately
function sendMessage(channel: string, text: string) {
  const id = crypto.randomUUID();
  const sent = manager.send({
    data: { type: "message", id, channel, text },
    ackId: id,
  });
  if (!sent) {
    undelivered.addMessage(channel, { id, channel, text });
  }
}

Reactivity

Use the subscribe method to react to changes. With Zustand, you can expose undelivered messages as part of your store:

useUndeliveredMessages.ts
import { useSyncExternalStore } from "react";

function useUndeliveredMessages(channel: string) {
  return useSyncExternalStore(
    (cb) => undelivered.subscribe(cb),
    () => undelivered.getChannelMessages(channel),
  );
}

Next Steps