@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>
| Prop | Type | Meaning |
|---|---|---|
config | PoolseConfig | The same options as new Poolse(config). |
children | ReactNode |
- The provider creates one
Poolseinstance when it mounts and callsdestroy()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,wsUrlorsocketPathchange 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
externalIdare your user ids. Ids nameduserId, and the fieldssender_id,user_idandreactions, are poolse user ids.useMe().me.idis the poolse id of the signed-in user;me.external_idis 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
onClosedon 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'serrorfield.
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.
| Hook | On reconnect |
|---|---|
useMessages | Fetches 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. |
useConversations | Reloads the conversation list without setting loading. |
useLiveCall (and useRoom) | Re-reads whether a call is live. |
usePresence | Gets a fresh presence snapshot when the channel rejoins. |
useThread, useMembers, useReactions, useConversation, useMe | No 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/conversationson mount. - Once
useMehas the user, it listens on the user channel:conversation:createdadds the conversation (or replaces it if it is already in the list);conversation:updatedupdates the preview, time andlast_sequenceand moves the row to the top. - Unread counts come from
unread_count. Onconversation:updatedthe 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 whenhasMoreisfalse. - Live updates. Applies
message:new,message:updatedandmessage:deletedfrom the channel. A deleted message stays in the list withbody: nullanddeleted_atset. - Threads. Thread replies aren't added to
messages. Instead their root message'sreply_countgoes up (or down when a reply is deleted). UseuseThreadfor 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. Passattrs.idto choose the id yourself.editanddeletechange 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;useMembersand 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:stoparrives, when they send a message, or 4 seconds after their last typing event. - Use
typing.sizeto 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 (
membersstays[],loadingisfalse). - Applies
member:readevents to the matching row'slast_read_message_idandlast_read_at. Read events that arrive before the first load are kept and applied afterwards. removeMembertakes 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;
}
uploadanduploadAlltakeAttachmentUploadInputobjects ({ body, contentType, byteSize, filename? }), not bareFiles.uploadAllruns them in parallel and resolves in input order; if one fails it rejects.cancel(localId)aborts an upload in flight. The item becomescancelledand its promise rejects withAbortError.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(notlocalId) inattachment_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();
| Field | Type | Meaning |
|---|---|---|
visible | boolean | This user sees other people's read state. When false, show no read ticks. |
mode | 'on' | 'off' | 'user' | The workspace setting. |
shared | boolean | Others see when this user reads. |
canChange | boolean | true 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:
| Field | Type | Meaning |
|---|---|---|
live | LiveCall | null | The room or call running now, whether or not you are in it. |
presentIds | string[] | Poolse user ids in it now. |
joined | boolean | You are in it or connecting. |
joining | boolean | join 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() | () => void | Disconnects and leaves. The room goes on for the others. |
media | UseCallRoom | Participants, mic, camera and devices (below). |
error | Error | null | The 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.
| Field | Type | Meaning |
|---|---|---|
status | 'idle' | 'connecting' | 'connected' | 'error' | |
participants | CallParticipant[] | You first. |
cameraEnabled / micEnabled | boolean | Your own state. |
facingMode | 'user' | 'environment' | Which camera publishes. |
devices | Record<'videoinput' | 'audioinput' | 'audiooutput', CallDevice[]> | Refreshed on connect and when devices change. |
activeDevices | Partial<Record<CallDeviceKind, string>> | Device id in use per kind. |
selectDevice(kind, deviceId) | Promise<void> | Switch device. |
refreshDevices() | void | Re-read devices, for example when opening a picker (browsers only reveal labels after permission is granted). |
alone | boolean | Everyone else has left after someone was there. Wire it to your hang-up. |
error | Error | null | |
sfuAvailable | boolean | null | null until a join; false when the deployment has no SFU. |
join() | Promise<boolean> | Connect. false when there is no SFU. |
leave() | void | Disconnect. |
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
| Function | Returns | Behaviour |
|---|---|---|
computeGroupPosition(msg, prev, next, windowMs) | GroupPosition | Where 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) | boolean | Same sender_id, same local calendar day, at most windowMs apart, and neither is a call-log message. |
sameDay(isoA, isoB) | boolean | Same calendar day in the device's time zone. |
formatDayLabel(iso, now?) | string | Today, 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.