Chatpack
Client

React integration

React hooks backed by Chatpack's small external store.

Install React with the client:

pnpm add @chatpack/client react
"use client";

import { createChatClient } from "@chatpack/client/react";

export const chatClient = createChatClient();

export function Messages({ conversationId }: { conversationId: string }) {
  const result = chatClient.useMessages({ conversationId, limit: 50 });
  if (result.isPending) return <p>Loading…</p>;
  if (result.error !== null) return <p>{result.error.message}</p>;
  return (
    <>
      <ul>
        {result.data?.messages.map((message) => (
          <li key={message.id}>{message.body}</li>
        ))}
      </ul>
      {result.data?.nextCursor !== null && (
        <button onClick={() => void result.loadMore()}>Older</button>
      )}
    </>
  );
}

Available hooks are useConversations, useConversation, useMessages, useMessageSearch, and useRealtimeStatus. useTyping, usePresence, and useReceipts read the matching client plugin state. Hooks use React's useSyncExternalStore, so React 18 and React 19 share the same cache and stream behavior.

useMessageSearch({ query, limit }) searches every conversation visible to the signed-in participant. Results are case-insensitive, whole-token, AND-matched, relevance-ranked, and cursor-paginated. An empty query stays idle without a request. Unsupported adapters return SEARCH_UNSUPPORTED; successful results are snapshots: edits and tombstones patch loaded hits in place, and losing conversation access removes its hits, but new messages are not inserted or re-ranked. Call refetch() to recompute matches and rank.

const search = chatClient.useMessageSearch({ query: "release ready", limit: 20 });
if (search.error?.code === "SEARCH_UNSUPPORTED") return null;

The hook requests on every query change. Debounce text input before passing it to the hook (about 250 ms is a useful starting point); the client also bounds its per-instance search cache to ten normalized queries.

useConversations opens the stream and re-renders on incoming messages: the list reorders most-recently-active-first and unread badges update on their own, so a conversation list needs no event subscription of its own.

export function ConversationList() {
  const { data } = chatClient.useConversations();
  return (
    <ul>
      {data?.conversations.map((conversation) => (
        <li key={conversation.id}>
          {conversation.id} {conversation.unreadCount > 0 && <b>{conversation.unreadCount}</b>}
        </li>
      ))}
    </ul>
  );
}

Pass userId to createChatClient so the viewer's own messages never count as unread. See Client realtime for the full cache rules.

useRealtimeStatus() returns { status, error } with no data wrapper. status is idle, connecting, open, closed, or polling — the last meaning the stream is unavailable and data is being kept live by interval refetch instead. Treat it as connected-but-degraded: the list and open thread update, but useTyping, usePresence and useReceipts stay empty.

SSR does not open an SSE connection. The connection starts after a browser subscriber or any data hook mounts.