@cariosan/react
Drop-in React components and hooks for Cariosan chat.
Pre-built React components + hooks layered on top of @cariosan/client. Use the components if you want chat UI in ten minutes; use the hooks if you want your own UI but the data layer done for you.
Provider
Wrap your app once:
The provider stores the Cariosan instance in context. Components and hooks read it via useCariosan().
Create the client once per signed-in user, outside render or in a useMemo/useRef, and do not rebuild it when the token changes. Token renewals happen inside the client (see Tokens), so hooks keep their state and pending messages are not lost. useConnectionStatus() stays "connected" while the SDK moves to a renewed token.
Drop-in components
Accessibility built in — every component is keyboard-navigable, includes aria-live regions for new messages, and renders placeholders during SSR so they hydrate live on the client without layout shift.
Hooks
Use these when you want custom UI but don't want to re-implement subscription plumbing:
Every hook handles subscribe / unsubscribe on the underlying Channel automatically — no cleanup code in your components.
useMessages keeps its list live for reactions, edits, and unsends too: each Message exposes reactions ([{ emoji, count, reacted_by_me }]), edited_at, and deleted_at, updated in place as events arrive. useMessageActions(channelId) returns { addReaction, removeReaction, editMessage, deleteMessage } bound to the channel, and the built-in MessageList already renders reaction pills, an “(edited)” marker, and deleted-message tombstones.
Staying in sync
useMessages repairs its own list; you do not write any reconnect code. On every (re)connect, and whenever the server sends a channel.resync signal (it detected a dropped event while your socket stayed up), the hook pages history from the newest message back until it covers everything it already holds, then reconciles:
- missed messages are inserted, with no duplicates and no gap, however many pages were missed;
- edits, unsends (tombstones) and reaction changes on messages it already had converge to server state;
- live messages that arrive while the fetch is in flight are kept, in order;
- if more pages were missed than
maxCatchUpPages(default 10, i.e. 500 messages), it resets to the newest window rather than leave a hole, andloadMorecontinues from there; - a failed catch-up is retried with backoff and never blanks the list or sets
error.
loadMore pages with the composite (created_at, id) cursor, so messages that share a timestamp are never skipped, and it discards a result that was overtaken by a catch-up. The same hook backs @cariosan/react-native, so mobile behaves identically. Building your own client? See Staying in sync.
Long conversations
MessageList renders every row for ordinary conversations. Once more than 200 messages are loaded it switches to a sliding window: only the rows near the viewport are mounted and two spacers stand in for the rest, so the DOM, memory and input latency stay bounded however far back the reader scrolls. The scrollbar still reflects the full list, the reader's position never jumps while estimated heights are replaced by real ones (it is anchored to the row they are reading), the list keeps following the bottom while you are there, and tapping a quote scrolls to a message even if it is currently unmounted.
Measured in Chromium with 10,000 loaded messages of varying height (React development build, in-memory client, so absolute times are indicative only): the page held ~135 DOM nodes instead of ~50,000, a live message appeared in ~33 ms instead of ~1.4 s, and the first rows rendered in ~0.7 s instead of ~12 s. Memory for the message objects themselves is not windowed (they stay in the hook's state). Rows are unmounted while off screen, so screen readers do not announce history as it scrolls (aria-live is switched off while you read back) and in-page find (Ctrl+F) only sees mounted rows; pass virtualizeAbove={Infinity} if you need either for very long histories. A custom renderMessage participates in windowing; its rows are measured, so keep their height stable.
Quoted replies
Send a reply by passing parentId to useMessageSender's sendMessage:
Each reply Message carries parent_id and a quoted_message preview ({ id, user_id, user_name, content, type, deleted }). The built-in MessageList renders that quote above the reply automatically — and shows "Original message was deleted" when the quoted parent has been unsent. See Quoted replies.
For the built-in compose flow (a "Reply" button on each bubble → a quote chip above the input → send), wrap the room in ReplyDraftProvider:
With the provider mounted, MessageList shows a Reply action per message, MessageInput shows a cancellable quote chip (Esc clears it), the next send is wired with parentId, and tapping a quote scrolls to the original. Without the provider these affordances stay hidden — the quote rendering on received replies always works regardless. Need finer control? Drive it yourself with useReplyDraft() ({ replyTo, setReplyTo, clearReply }).
Mentions
MessageInput ships an @ autocomplete over channel members. By default it lazily loads the roster via channel.getPresence() the first time you type @; pass mentionMembers to supply the candidate list yourself and skip the round-trip:
Picking a member inserts @Name and sends the resolved external_id in mentions — no fragile text parsing. MessageList highlights mention tokens automatically. For badges, useMentions() tracks live unread-mention counts per channel from the SDK's mention event, and the built-in ChannelList shows a mention dot:
useMentions accumulates from mount; seed accurate cold-load state from mention_unread_count on GET /v1/me/channels if you need it to survive reloads. See Mentions.
Sending states
MessageInput queues what you type, so your message appears in MessageList at once with a 🕒 while it is being sent. If the send fails it stays there as Not sent with Retry and Delete buttons, instead of the text disappearing. Once the server confirms it, it becomes a normal message with ✓ / ✓✓. Retry is always safe: every attempt carries the same idempotency key, so a message the server already stored is returned instead of being stored twice. A row is replaced by the real message as soon as that message arrives (live or from history), and a "Not sent" row disappears if the server turns out to have stored the message after all.
A failed row says why ("Not sent. you are muted in this channel", "Not sent. Try again in 12s.") and shows Retry only when it can help: a muted user, a frozen channel or a message rejected by moderation get just Delete. describeOutgoingFailure(entry) returns that sentence for your own rows.
For a custom list, useOutgoing(channelId) returns the pending and failed messages and the actions:
To restyle the built-in rows without writing a list, pass renderOutgoing={(outgoing, { retry, discard }) => …} to MessageList. useMessageSender still exists for a plain await-style send, with no pending or failed state.
Read receipts
The web MessageList now acknowledges delivered/read history when an open
channel finishes loading in a visible document, including messages sent while
the recipient was offline. It also acknowledges new messages and returning to
the visible tab. This marks the channel read, not individual bubble visibility.
Use markReadOnView={false} for custom unread/viewport semantics. The
acknowledgement is repeated once the WebSocket connects, because the delivery
ack travels over the socket and is dropped while it is closed. Receipt cursors
refresh on reconnect and channel resync. Read privacy settings still apply on
the server.
Pass currentUserId (the signed-in user's internal id or external_id) to MessageList and it lays out like a messenger: your own bubbles sit on the right, everyone else's on the left, and your bubbles get a ✓ / ✓✓ / ✓✓-blue indicator that updates live as members deliver/read. Without currentUserId the list is unchanged (no alignment, no ticks). Your own bubble colour is the CSS variable --cariosan-own-surface.
For custom bubbles, useReadReceipts(channelId) seeds the cursors and returns a receiptState(message) that re-renders on every cursor advance:
Delivery is acked automatically by the SDK. If a workspace disables read receipts, ticks never reach blue (delivery still shows). See Read & delivery receipts.
Search
useSearch(channelId?) powers a search box — pass a channelId to scope to one channel, omit it to search across all your channels. The box's styling is yours to provide.
loadMore appends the next page of the current search; every match appears once, best first, across pages. It does nothing when hasMore is false or a page is already loading. A newer search() (or clear()) supersedes anything still in flight, so a slow response for an old query never overwrites the current results. A failed loadMore sets error and keeps what you have; calling it again retries the same page.
See Search.
Theming
Note
All component styles are scoped — no global CSS conflicts.
No Tailwind or CSS-in-JS. Override CSS variables on any ancestor:
Full variable list:
Component class names use the same prefix (.cariosan-message, .cariosan-message__author, .cariosan-message-list, ...), so you can restyle a part without touching the variables.
Dark mode ships out of the box — set data-theme="dark" on any ancestor and every component picks it up. No extra config needed.
Bundle size
Measured against the current dist/ build (pnpm build && gzip -c dist/index.js | wc -c):
@cariosan/react: 10.0 KB gzipped (29.1 KB raw)@cariosan/react/styles.css: 1.8 KB gzipped (6.7 KB raw)@cariosan/client(peer): 8.2 KB gzipped (26.7 KB raw)
React + React DOM are peer dependencies, so they don't count toward the SDK weight. A complete React + Cariosan client + UI bundle lands around 65 KB gzipped including React 18.
Content Security Policy
The kit runs under a strict policy: no 'unsafe-eval', no 'unsafe-inline' for scripts, and style-src 'self'.
- Messages render as text. There is no
dangerouslySetInnerHTML. - No inline styles in server-rendered HTML. Server rendering (Next.js and the like) emits no
style="…"attributes. The only inline styles, the spacers of a long virtualised list, are set in the browser through the CSSOM, whichstyle-src 'self'allows. - Serve
styles.cssfrom your own origin. If your bundler inlines it into a<style>tag, give that tag your nonce.
See Security headers for the origins to allow and a Next.js nonce setup.
Missing a component?
The hooks cover every data surface the components use. If your design requires something we haven't built (a sidebar, a search bar, channel settings), build it with the hooks — no need to eject.
Private attachment links
The default MessageList displays an AttachmentLink for non-deleted messages with an attachment. It requests a member-authorized download grant on demand and discards it after expiry. Custom renderers can use the same component:
Render this inside ChatProvider. The channelId is the external channel identifier. Never use raw message.attachment_url as an anonymous fallback; authorization failure must remain a failure. Custom React Native clients use channel.getAttachmentDownload(message.id) from the shared TypeScript client before rendering media.
Was this page helpful?