useThread
useThread subscribes a component to one thread of a channel. It's a selector over
core's ThreadHandle: it resolves the channel handle from the
registry, holds that channel's refcount while mounted — so a thread view shares the
channel's socket, and opens it if nothing else has — and mirrors the lens through
useSyncExternalStore.
import { useThread } from "@portalsdk/react";
interface ChatMessage {
text: string;
}
function ThreadPane({ channelId, threadId }: { channelId: string; threadId: string }) {
const { messages, send, status } = useThread<ChatMessage>({ channelId, threadId });
return (
<div>
<p>status: {status}</p>
<ul>
{messages.map((m) => (
<li key={m.id}>{m.content.text}</li>
))}
</ul>
<button onClick={() => send({ content: { text: "reply" } })}>Reply</button>
</div>
);
}
send needs no threadParentId — the hook supplies it.
The lens hands back the same snapshot while this thread is unchanged, so unrelated channel traffic — another thread's reply, presence, a typing indicator — doesn't re-render this component.
Params
UseThreadParams is UseChannelParams plus threadId:
| Param | Type | Notes |
|---|---|---|
channelId |
string | undefined |
As on useChannel — undefined renders inert and opens nothing. |
threadId |
string |
The thread: the id of the message it hangs off. |
history |
number | "none" |
Page size for the thread's own backfill. Default 50. |
readOn |
"mount" | "visible" | "manual" |
No effect here. A thread has no watermark of its own, and reading a thread never advances the channel's — per-thread read state lives on the inbox entry. |
where |
MessageWhere<M> |
Reserved, as on the channel: typed, rejected at runtime. |
onMessage / onMention |
(msg: Message<M>) => void |
Fire for this thread's replies only. |
onError |
(err: PortalError) => void |
Channel errors. |
Result
| Field | Type | Notes |
|---|---|---|
messages |
readonly Message<M>[] |
Replies in this thread only, oldest first. |
send |
(input) => Promise<SendAck> |
Replies into this thread. Rejects BlockedError with reason: "thread_depth_exceeded" past the 8-level cap. |
loadPrevious |
() => Promise<boolean> |
Older replies in this thread only. |
hasPrevious |
boolean |
false once the thread's first reply is reached. |
isLoadingPrevious |
boolean |
|
status |
ChannelStatus |
The channel's connection status — a thread rides the channel's socket. |
There's deliberately no presence, activity or unread here: those describe the room, not
the thread. Read them from useChannel alongside.
A channel and a thread, side by side
Mounting both is the normal case — they share one socket, and the channel keeps its own read state while the thread pane scrolls independently:
// file: ChannelWithThread.tsx
import { useState } from "react";
import { useChannel, useThread } from "@portalsdk/react";
interface ChatMessage {
text: string;
}
export function ChannelWithThread({ channelId }: { channelId: string }) {
const [openThread, setOpenThread] = useState<string | undefined>(undefined);
const { messages } = useChannel<ChatMessage>({ channelId });
return (
<div>
<ul>
{messages
.filter((m) => m.threadParentId === undefined)
.map((m) => (
<li key={m.id}>
{m.content.text}
<button onClick={() => setOpenThread(m.id)}>Open thread</button>
</li>
))}
</ul>
{openThread !== undefined ? <Replies channelId={channelId} threadId={openThread} /> : null}
</div>
);
}
function Replies({ channelId, threadId }: { channelId: string; threadId: string }) {
const { messages, loadPrevious, hasPrevious } = useThread<ChatMessage>({
channelId,
threadId,
});
return (
<aside>
{hasPrevious ? <button onClick={() => loadPrevious()}>Load older</button> : null}
<ul>
{messages.map((m) => (
<li key={m.id}>{m.content.text}</li>
))}
</ul>
</aside>
);
}
Nesting is the same component one level down: a reply's own id is a thread id, so render
Replies with threadId={reply.id}, up to the platform's 8-level cap.
Enumerating threads
There's no useThreads hook. The registry is a paged promise, not a reactive store, so
call it on the client you handed PortalProvider:
// file: ThreadList.tsx
import { useEffect, useState } from "react";
import { Portal } from "@portalsdk/core";
import type { ThreadNode } from "@portalsdk/core";
const portal = new Portal({ apiKey: "pk_your_publishable_key" });
export function ThreadList({ channelId }: { channelId: string }) {
const [threads, setThreads] = useState<readonly ThreadNode[]>([]);
useEffect(() => {
let live = true;
portal
.channel(channelId)
.threads()
.then((page) => {
if (live) setThreads(page.threads);
});
return () => {
live = false;
};
}, [channelId]);
return (
<ul>
{threads.map((t) => (
<li key={t.id}>
depth {t.depth} · {t.messageCount} replies
</li>
))}
</ul>
);
}
threads() doesn't need the channel acquired — it's an HTTP read, not a subscription.
For the sidebar of threads you are in, with unread counts, use
useInbox and read each entry's threadId instead; see
Threads.