@poolse/react

A provider and hooks over @poolse/sdk. No UI and no CSS. The same hooks work in React on the web and in React Native. For prebuilt components, see @poolse/react-ui (web) or @poolse/react-native.

npm install @poolse/react @poolse/sdk

react 18 or newer is a peer dependency. @poolse/sdk is a regular dependency, so it is installed either way; list it yourself if you import from it directly, and keep the same major version. The build starts with 'use client', so it works in Next.js App Router client components.

Quick start

import { useState } from 'react';
import { PoolseProvider, useMessages, useTyping } from '@poolse/react';
import type { PoolseConfig } from '@poolse/react';

const config: PoolseConfig = {
  getToken: async () => {
    const res = await fetch('/api/chat-token', { method: 'POST' });
    const { token } = await res.json();
    return token;
  },
  userResolver: async (externalId) => {
    const u = await fetch(`/api/users/${externalId}`).then((r) => r.json());
    return { displayName: u.name, avatarUrl: u.avatar_url };
  },
};

export function App() {
  return (
    <PoolseProvider config={config}>
      <Chat conversationId="…" />
    </PoolseProvider>
  );
}

function Chat({ conversationId }: { conversationId: string }) {
  const { messages, send, loadMore, hasMore, loading } = useMessages(conversationId);
  const { typing, signalTyping } = useTyping(conversationId);
  const [draft, setDraft] = useState('');

  if (loading) return <p>Loading…</p>;
  return (
    <div>
      <button onClick={() => loadMore()} disabled={!hasMore}>Load older</button>
      {messages.map((m) => <p key={m.id}>{m.body}</p>)}
      {typing.size > 0 ? <em>Someone is typing…</em> : null}
      <input
        value={draft}
        onChange={(e) => { setDraft(e.target.value); signalTyping(); }}
        onKeyDown={(e) => {
          if (e.key === 'Enter' && draft.trim()) { void send({ body: draft }); setDraft(''); }
        }}
      />
    </div>
  );
}

<PoolseProvider>

<PoolseProvider config={config}>{children}</PoolseProvider>
PropTypeMeaning
configPoolseConfigThe same options as new Poolse(config).
childrenReactNode
  • The provider creates one Poolse instance when it mounts and calls destroy() when it unmounts.
  • Function options (getToken, fetch, userResolver, generateIdempotencyKey, onSocketError) are read through a ref on every call, so passing new functions on each render is fine.
  • Every other option is read once, at mount. In development the provider logs a warning if apiUrl, wsUrl or socketPath change afterwards.
  • To switch user or environment, remount the provider with a new key.

usePoolse()

Returns the provider's Poolse instance. Throws if there is no provider above the component. Use it to reach anything the hooks don't cover.

const poolse = usePoolse();
await poolse.me.update({ read_receipts: false });

Conventions

  • Every hook needs a <PoolseProvider> above it.
  • Hooks that take a conversation id subscribe to that conversation's realtime channel while mounted. Unmounting a hook removes its listeners but keeps the channel open for other components; the provider closes everything on unmount.
  • Ids named externalId are your user ids. Ids named userId, and the fields sender_id, user_id and reactions, are poolse user ids. useMe().me.id is the poolse id of the signed-in user; me.external_id is yours.
  • If the signed-in user is removed from a conversation, the server closes its channel and the mounted hooks for that conversation stop updating. The SDK drops the closed channel, so hooks that mount afterwards (for example after the user is added back) join a fresh one. To react at the moment of removal, use onClosed on the ConversationChannel.
  • Errors from REST calls inside actions (send, edit, …) reject the returned promise with the SDK's error classes. Errors while loading go into the hook's error field.

Reconnects

When the socket drops and comes back (a network change, a laptop waking up, or a phone app returning from the background), events sent in between aren't replayed. From 3.1 the hooks catch up on their own. Only a reconnect counts: the socket's first connect doesn't trigger a second load on mount.

HookOn reconnect
useMessagesFetches the newest 50 messages and merges them in by id, keeping older pages you loaded and pending sends. If more than 50 messages arrived while offline, the ones in between are only loaded by refetch(), which resets the list.
useConversationsReloads the conversation list without setting loading.
useLiveCall (and useRoom)Re-reads whether a call is live.
usePresenceGets a fresh presence snapshot when the channel rejoins.
useThread, useMembers, useReactions, useConversation, useMeNo automatic reload. Call refetch() where the hook has one, or remount.

Hooks

useMe()

const { me, loading, error } = useMe();
// me: Me | null   loading: boolean   error: Error | null

Fetches GET /v1/me on mount and updates whenever poolse.me.show() or poolse.me.update() returns, so a change made anywhere shows up everywhere. If another component already fetched it, the first render has me and loading: false.

useConversation(id)

const { conversation, loading, error, refetch } = useConversation(conversationId);
// conversation: Conversation | null   refetch: () => Promise<void>

One conversation over REST. Pass null to skip. It doesn't follow realtime changes; call refetch() after you update the conversation.

useConversations()

const {
  conversations,        // Conversation[]
  loading,              // boolean
  error,                // Error | null
  refetch,              // () => Promise<void>
  create,               // (attrs: ConversationCreateRequest) => Promise<Conversation>
  unreadCounts,         // Record<conversationId, number>, only entries above 0
  markConversationRead, // (conversationId: string) => void
} = useConversations();
  • Loads GET /v1/conversations on mount.
  • Once useMe has the user, it listens on the user channel: conversation:created adds the conversation (or replaces it if it is already in the list); conversation:updated updates the preview, time and last_sequence and moves the row to the top.
  • Unread counts come from unread_count. On conversation:updated the count is taken from the event when the event has one; otherwise it goes up by one for someone else's message and is set to 0 for your own.
  • create(attrs) posts the conversation and puts it first in the list.
  • markConversationRead(id) sets that row's count to 0 locally. It doesn't call the server; wire it to <ConversationView onMarkedRead>, which already advanced the read cursor.
  • Each call of the hook has its own state. Two components calling it make two requests.
  • After a reconnect it reloads the list (3.1).

useMessages(conversationId)

const {
  messages,      // Message[], oldest first (newest last)
  loading,       // true until the first page arrives
  error,         // Error | null
  hasMore,       // false once an older page came back short
  loadMore,      // () => Promise<void>
  send,          // (attrs: MessageCreateRequest) => Promise<Message>
  edit,          // (messageId: string, body: string) => Promise<Message>
  delete: del,   // (messageId: string) => Promise<void>
  markReadUpTo,  // (messageId: string) => Promise<void>
  refetch,       // () => Promise<void>
} = useMessages(conversationId);
  • Loading. Fetches the newest 50 messages on mount. loadMore() fetches the 50 before the oldest one you have. It does nothing while loading or when hasMore is false.
  • Live updates. Applies message:new, message:updated and message:deleted from the channel. A deleted message stays in the list with body: null and deleted_at set.
  • Threads. Thread replies aren't added to messages. Instead their root message's reply_count goes up (or down when a reply is deleted). Use useThread for the replies.
  • send(attrs) adds a temporary row at once, under the same id the server will store, and replaces it with the server's row. If the request fails, the temporary row is removed and the promise rejects; there is no "failed" flag. Pass attrs.id to choose the id yourself.
  • edit and delete change the row at once and put it back if the request fails.
  • markReadUpTo(messageId) moves your read cursor on the server. It doesn't change local state; useMembers and the unread counts pick up the effect.
  • refetch() reloads the newest page and resets paging.
  • Reconnect. From 3.1, after a reconnect it fetches the newest page and merges it in. See Reconnects.

useThread(conversationId, rootMessageId)

const {
  replies,    // Message[], oldest first
  loading, error, hasMore,
  loadMore,   // () => Promise<void>, loads replies after the newest one you have
  sendReply,  // (attrs: Omit<MessageCreateRequest, 'reply_to_id'>) => Promise<Message>
  edit,       // (messageId, body) => Promise<Message>
  delete: del,// (messageId) => Promise<void>
} = useThread(conversationId, rootMessageId);

Fetches up to 500 replies at once. sendReply sets reply_to_id to the root and behaves like useMessages().send. Realtime replies are matched by thread_root_id. It doesn't reload after a reconnect.

useReactions(messageId, options)

const { reactions, addReaction, removeReaction } = useReactions(message.id, {
  conversationId: message.conversation_id, // required
  initialReactions: message.reactions,     // optional seed
  currentUserId: me?.id,                   // optional: poolse user id, enables instant updates
});
// reactions: ReactionMap = Record<emoji, userIds[]>
// addReaction(emoji): Promise<void>   removeReaction(emoji): Promise<void>

The options argument is required. The hook follows reaction:added and reaction:removed for this message. With currentUserId set, a change shows at once and is undone if the request fails; without it, the map changes when the server's event arrives.

useTyping(conversationId)

const { typing, signalTyping } = useTyping(conversationId);
// typing: Set<string> of external ids, without yourself
  • signalTyping() sends a typing event, at most once every 500 ms. Call it on every keystroke.
  • Someone leaves the set when typing:stop arrives, when they send a message, or 4 seconds after their last typing event.
  • Use typing.size to check whether anyone is typing.

usePresence(conversationId)

const { online } = usePresence(conversationId);
// online: Set<string> of external ids joined to the conversation channel, yourself included

Pass an empty string to skip. The set is briefly empty while the socket reconnects; pair it with useRealtimeStatus if that matters.

useMembers(conversationId)

const {
  members,      // Membership[]
  loading, error,
  refetch,      // () => Promise<void>
  addMembers,   // (externalIds: string[], opts?: { role?: MemberRole }) => Promise<Membership[]>
  removeMember, // (externalId: string) => Promise<void>
} = useMembers(conversationId);
  • Loads the roster on mount. Pass an empty string to skip (members stays [], loading is false).
  • Applies member:read events to the matching row's last_read_message_id and last_read_at. Read events that arrive before the first load are kept and applied afterwards.
  • removeMember takes the external id, finds the member in the loaded list and calls the API with their poolse user id. If the id isn't in the list it does nothing. The row disappears at once and comes back if the request fails.

useAttachmentUpload()

const {
  queue,      // UploadItem[] in the order added
  attachment, // Attachment | null: the most recent finished upload
  uploading,  // boolean: any item pending or uploading
  error,      // Error | null: the most recent item error
  upload,     // (input: AttachmentUploadInput) => Promise<Attachment>
  uploadAll,  // (inputs: AttachmentUploadInput[]) => Promise<Attachment[]>
  cancel,     // (localId: string) => void
  remove,     // (localId: string) => void
  reset,      // () => void
} = useAttachmentUpload();
interface UploadItem {
  localId: string;            // queue id
  filename: string;           // 'file' when you gave none
  contentType: string;
  byteSize: number;
  status: UploadItemStatus;   // 'pending' | 'uploading' | 'ready' | 'error' | 'cancelled'
  loaded: number;             // bytes sent (browsers only; stays 0 elsewhere until done)
  attachment: Attachment | null; // set when ready
  error: Error | null;
}
  • upload and uploadAll take AttachmentUploadInput objects ({ body, contentType, byteSize, filename? }), not bare Files. uploadAll runs them in parallel and resolves in input order; if one fails it rejects.
  • cancel(localId) aborts an upload in flight. The item becomes cancelled and its promise rejects with AbortError.
  • remove(localId) cancels if needed and drops the item from the queue. It doesn't delete anything on the server.
  • reset() cancels everything and empties the queue. Unmounting cancels uploads in flight.
  • To send them, pass item.attachment.id (not localId) in attachment_ids.
function Attach({ conversationId }: { conversationId: string }) {
  const { queue, uploadAll } = useAttachmentUpload();
  const { send } = useMessages(conversationId);
  const ready = queue.filter((item) => item.status === 'ready' && item.attachment);

  return (
    <>
      <input
        type="file"
        multiple
        onChange={(e) => {
          const files = Array.from(e.target.files ?? []);
          void uploadAll(files.map((f) => ({ body: f, contentType: f.type, byteSize: f.size, filename: f.name })));
        }}
      />
      <button
        disabled={ready.length === 0}
        onClick={() => send({ attachment_ids: ready.map((item) => item.attachment!.id) })}
      >
        Send {ready.length} file(s)
      </button>
    </>
  );
}

useAttachmentUrl(attachmentId)

const { url, loading, error } = useAttachmentUrl(attachment.id);
// url: string | null

Fetches a download URL once per id. Pass null to skip. The URL is valid for one hour and the hook doesn't renew it; remount it (for example by changing its key) on screens that stay open longer.

useUser(externalId)

const { profile, loading } = useUser(message.sender_external_id);
// profile: { displayName: string; avatarUrl?: string | null } | null

Resolves your user id through config.userResolver, with the shared cache described under poolse.users. Pass null, undefined or '' to skip. profile is null when there is no resolver, the resolver returned null, or it threw. UseUserState is the type of the result.

useRealtimeStatus()

const status = useRealtimeStatus();
// 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed'

The socket's current status, for a "Reconnecting…" banner. See RealtimeStatus.

useReadReceipts()

const { visible, mode, shared, canChange, setShared } = useReadReceipts();
FieldTypeMeaning
visiblebooleanThis user sees other people's read state. When false, show no read ticks.
mode'on' | 'off' | 'user'The workspace setting.
sharedbooleanOthers see when this user reads.
canChangebooleantrue only in user mode: you may offer a switch.
setShared(share)(boolean) => Promise<void>Calls poolse.me.update({ read_receipts }). Fails with 409 read_receipts_fixed unless canChange.

Until useMe has loaded, and on servers that don't send the setting, the defaults are visible: true, mode: 'on', shared: true, canChange: false. receiptsOf(me) returns the same fields (without setShared) for a Me you already have. UseReadReceipts is the type.

canEditOwnMessage(args) and EDIT_WINDOW_MS

canEditOwnMessage({
  isSelf: boolean,
  deleted: boolean,
  receiptsVisible: boolean,
  readState: 'sent' | 'read' | undefined,
  insertedAt: string,
  now?: number,
}): boolean

The rule the UI packages use to show "Edit": only your own, undeleted messages. With read receipts visible, until someone has read it. With them hidden, for EDIT_WINDOW_MS (15 minutes) after sending. This is a UI rule; the API doesn't apply it.

useLiveCall(conversationId)

const { call, participantIds, refresh } = useLiveCall(conversationId);
// call: LiveCall | null   participantIds: string[] (poolse user ids)   refresh: () => void

Whether a call or room is live on the conversation, for a "Join" banner. Reads liveCalls.get on mount and after each reconnect, then follows the call:started, call:participants and call:finished events. Pass null for nothing. UseLiveCall is the type.

useRoom(conversationId, options)

Rooms on the web: drop-in voice or video for a conversation. It combines useLiveCall, poolse.rooms and useCallRoom. You need livekit-client in your app.

import * as livekit from 'livekit-client';
import { useRoom } from '@poolse/react';

function RoomButton({ conversationId }: { conversationId: string }) {
  const room = useRoom(conversationId, { livekit });
  if (room.joined) return <button onClick={room.leave}>Leave</button>;
  return (
    <button disabled={room.joining} onClick={() => room.join('video')}>
      {room.live ? 'Join' : 'Start a video room'}
    </button>
  );
}

options is UseRoomOptions: { livekit: LiveKitModule }. Returns UseRoom:

FieldTypeMeaning
liveLiveCall | nullThe room or call running now, whether or not you are in it.
presentIdsstring[]Poolse user ids in it now.
joinedbooleanYou are in it or connecting.
joiningbooleanjoin is in progress.
join(media?, opts?)('audio' | 'video', { ring?: boolean }) => Promise<void>Starts the room or joins the live one (media defaults to audio), connects the media, and turns the camera on for video. { ring: true } uses rooms.ring, which also rings the other members' phones. Errors go into error rather than rejecting. Fails with an error when the deployment has no SFU.
leave()() => voidDisconnects and leaves. The room goes on for the others.
mediaUseCallRoomParticipants, mic, camera and devices (below).
errorError | nullThe last join or media error.

The room ends by itself once it has been empty for a short time and leaves one line in the conversation's history. useRoom turns off the alone timer (aloneGraceMs: 0), because leaving a room is always your choice. <RoomJoinBar> and <RoomStage> render this hook.

useCallRoom(conversationId, options)

The media side on its own (a CallRoom in React state). useRoom uses it; call it directly only when you manage room membership yourself — then start or join the call (poolse.rooms.start) before join(), because media tokens are only issued for a live call or room.

options is UseCallRoomOptions: livekit (required), video, muted, facing, aloneGraceMs, with the meanings and defaults of CallRoomOptions. Options are read when the room is created, which happens when conversationId changes.

FieldTypeMeaning
status'idle' | 'connecting' | 'connected' | 'error'
participantsCallParticipant[]You first.
cameraEnabled / micEnabledbooleanYour own state.
facingMode'user' | 'environment'Which camera publishes.
devicesRecord<'videoinput' | 'audioinput' | 'audiooutput', CallDevice[]>Refreshed on connect and when devices change.
activeDevicesPartial<Record<CallDeviceKind, string>>Device id in use per kind.
selectDevice(kind, deviceId)Promise<void>Switch device.
refreshDevices()voidRe-read devices, for example when opening a picker (browsers only reveal labels after permission is granted).
alonebooleanEveryone else has left after someone was there. Wire it to your hang-up.
errorError | null
sfuAvailableboolean | nullnull until a join; false when the deployment has no SFU.
join()Promise<boolean>Connect. false when there is no SFU.
leave()voidDisconnect.
setCameraEnabled(on) / toggleCamera()Promise<void>
setMicrophoneEnabled(on) / toggleMic()Promise<void>
switchCamera()Promise<void>Front / rear.

UseCallRoom and UseCallRoomOptions are the types.

On phones, ringing calls and rooms run in @poolse/native instead of these hooks.

Helpers

Message grouping

FunctionReturnsBehaviour
computeGroupPosition(msg, prev, next, windowMs)GroupPositionWhere msg sits in a run of messages from one sender: 'standalone', 'first', 'middle' or 'last'. prev and next are the neighbours in display order (or null).
sameGroup(a, b, windowMs)booleanSame sender_id, same local calendar day, at most windowMs apart, and neither is a call-log message.
sameDay(isoA, isoB)booleanSame calendar day in the device's time zone.
formatDayLabel(iso, now?)stringToday, Yesterday, the weekday name within a week, then a short date (with the year if it isn't this year), using the device locale.

The web and React Native ConversationView group with a 5-minute window.

Re-exports from @poolse/sdk

So you don't need to import from two packages:

  • Error classes: ApiError, AuthError, PoolseError, NetworkError, RateLimitedError (details).
  • Call-log helpers: parseCallLog, parseCallLogBody, isCallLogMessage, isMessageVisibleTo, describeCallLog, callLogDisplayFor, formatCallDuration, formatCallLogPreview (details).
  • Types: Attachment, AttachmentStatus, AttachmentUploadInput, AttachmentUploadRequest, AttachmentUploadResponse, Conversation, ConversationCreateRequest, Me, MemberRole, Membership, Message, MessageCreateRequest, MessageType, PoolseConfig, PoolseUserProfile, RealtimeStatus, Uuid, CallLog, CallLogEntry, CallMissedEntry, CallLogMedia, CallLogDisplay, CallParticipantStatus, CallEndReason, LiveCall, LiveCallMember, RoomSession, DeviceKind, ReadReceiptsState (wire types).

Other types this package exports: PoolseProviderProps, ReactionMap, UploadItem, UploadItemStatus, UseUserState, GroupPosition, UseReadReceipts, UseLiveCall, UseRoom, UseRoomOptions, UseCallRoom, UseCallRoomOptions.

@poolse/react · poolse docs