useChannel

useChannel subscribes a component to one channel. It's a thin binding over the core ChannelHandle: it resolves the handle from the registry, drives the refcount from mount/unmount, and mirrors the handle's store through useSyncExternalStore. All state lives in core — the hook owns none of it.

import { useChannel } from "@portalsdk/react";

interface ChatMessage {
  text: string;
}

function ChatRoom({ channelId }: { channelId: string }) {
  const { messages, send, status } = useChannel<ChatMessage>({ channelId });

  return (
    <div>
      <p>status: {status}</p>
      <ul>
        {messages.map((m) => (
          <li key={m.id}>{m.content.text}</li>
        ))}
      </ul>
      <button onClick={() => send({ content: { text: "hello" } })}>Send</button>
    </div>
  );
}

Params

Param Type Notes
channelId string | undefined undefined renders inert and opens no connection — the two-pane "nothing selected" pattern. Changing the id releases the old handle and acquires the new one.
readOn "mount" | "visible" | "manual" When the channel watermark auto-advances. Default "mount". "visible" advances on mount if the document is visible, then again on each visibilitychange → visible while mounted. "manual" never auto-advances — call markAsRead() yourself.
history number | "none" Initial backfill on connect. Default 50.
metadata Record<string, unknown> Initial presence metadata for this session.
where MessageWhere<M> Reserved surface — typed, but rejected at runtime in v1 (NotYetSupportedError).
onMention (msg: Message<M>) => void Called when a message's mentions include you.
onMessage (msg: Message<M>) => void Called on every message delivered to this channel, persistent or ephemeral.
onError (err: PortalError) => void Called on channel errors.

The two-pane pattern

import { useChannel } from "@portalsdk/react";

interface ChatMessage {
  text: string;
}

function ChannelPane({ channelId }: { channelId: string | undefined }) {
  const { messages } = useChannel<ChatMessage>({ channelId });

  if (channelId === undefined) {
    return <p>Select a channel</p>;
  }

  return (
    <ul>
      {messages.map((m) => (
        <li key={m.id}>{m.content.text}</li>
      ))}
    </ul>
  );
}

Because useChannel itself tolerates channelId: undefined, you can call it unconditionally at the top of a component even before a selection exists — no need to branch above the hook call.

Result

useChannel returns UseChannelResult<M>: messages, send, loadPrevious, isLoadingPrevious, hasPrevious, channel, me, presence, activity, sendActivity, typing, sendTyping, unread, markAsRead, setMetadata, and status — mirroring the core channel surface field-for-field. Import the type directly if you want to name it, e.g. to type a prop:

import type { UseChannelResult } from "@portalsdk/react";

interface ChatMessage {
  text: string;
}

function MessageList({ messages }: { messages: UseChannelResult<ChatMessage>["messages"] }) {
  return (
    <ul>
      {messages.map((m) => (
        <li key={m.id}>{m.content.text}</li>
      ))}
    </ul>
  );
}

Typing indicators

import { useChannel } from "@portalsdk/react";

interface ChatMessage {
  text: string;
}

function Composer({ channelId }: { channelId: string }) {
  const { typing, sendTyping, send } = useChannel<ChatMessage>({ channelId });

  return (
    <div>
      {typing.length > 0 && <p>{typing.join(", ")} typing…</p>}
      <input onChange={() => sendTyping()} />
      <button onClick={() => send({ content: { text: "hi" } })}>Send</button>
    </div>
  );
}

Reacting to mentions and errors

import { useChannel } from "@portalsdk/react";
import type { PortalError } from "@portalsdk/core";

interface ChatMessage {
  text: string;
}

function ChatRoom({ channelId }: { channelId: string }) {
  const { messages } = useChannel<ChatMessage>({
    channelId,
    onMention: (msg) => console.log("mentioned in:", msg.content.text),
    onError: (err: PortalError) => console.error(err.code, err.message),
  });

  return (
    <ul>
      {messages.map((m) => (
        <li key={m.id}>{m.content.text}</li>
      ))}
    </ul>
  );
}

Reacting to every message, and updating presence metadata

onMessage fires on every message delivered to the channel — persistent or ephemeral — which is the right tool for high-frequency ephemeral traffic (live cursors, transient signals) that you want to react to as discrete events rather than read off the accumulated messages array. setMetadata replaces your own presence metadata mid-session — a direct pass-through to the channel handle's setMetadata:

import type { PointerEvent } from "react";
import { useChannel } from "@portalsdk/react";

interface CursorPosition {
  x: number;
  y: number;
}

function LiveCursor({ channelId }: { channelId: string }) {
  const { setMetadata, send } = useChannel<CursorPosition>({
    channelId,
    onMessage: (msg) => {
      if (msg.ephemeral) console.log("cursor from", msg.sender.id, msg.content);
    },
  });

  function onPointerMove(e: PointerEvent<HTMLDivElement>) {
    const position = { x: e.clientX, y: e.clientY };
    void send({ ephemeral: true, content: position });
    setMetadata({ cursor: position });
  }

  return <div onPointerMove={onPointerMove} style={{ width: "100%", height: "100%" }} />;
}

See Guides → Live cursors for the full pattern, including why you'd combine ephemeral sends with throttled presence metadata rather than using just one.