Patterns
A few shapes that come up often when wiring @portalsdk/react into a real app.
One client, at the top of the tree
Construct the Portal client once, outside your component tree (module scope), and
provide it once at the root. PortalProvider is passive — it just publishes the client
on context — so nesting more than one is rarely useful; a single provider near your app
root is enough for every useChannel/useInbox call beneath it.
import type { ReactNode } from "react";
import { Portal } from "@portalsdk/core";
import { PortalProvider } from "@portalsdk/react";
const portal = new Portal({ apiKey: "pk_your_publishable_key" });
function AppRoot({ children }: { children: ReactNode }) {
return <PortalProvider client={portal}>{children}</PortalProvider>;
}
Switching channels without unmounting the shell
Since channelId: undefined renders useChannel inert, a channel switcher can hold the
selection in state and let the hook track it — no manual acquire/release, and no
remount of the surrounding layout:
import { useState } from "react";
import { useChannel } from "@portalsdk/react";
interface ChatMessage {
text: string;
}
function ChannelSwitcher() {
const [channelId, setChannelId] = useState<string | undefined>(undefined);
const { messages } = useChannel<ChatMessage>({ channelId });
return (
<div>
<nav>
{["general", "random", "help"].map((id) => (
<button key={id} onClick={() => setChannelId(id)}>
{id}
</button>
))}
</nav>
{channelId === undefined ? (
<p>Pick a channel</p>
) : (
<ul>
{messages.map((m) => (
<li key={m.id}>{m.content.text}</li>
))}
</ul>
)}
</div>
);
}
Changing channelId releases the old handle and acquires the new one automatically.
Watching several channels at once
Each useChannel call is independent, so a sidebar of live-updating room previews is
just one hook call per row:
import { useChannel } from "@portalsdk/react";
interface ChatMessage {
text: string;
}
function RoomPreview({ channelId }: { channelId: string }) {
const { messages, unread } = useChannel<ChatMessage>({ channelId, readOn: "manual" });
const last = messages[messages.length - 1];
return (
<div>
<strong>{channelId}</strong> {unread > 0 && <span>({unread})</span>}
<p>{last?.content.text ?? "No messages yet"}</p>
</div>
);
}
function Sidebar({ channelIds }: { channelIds: string[] }) {
return (
<>
{channelIds.map((id) => (
<RoomPreview key={id} channelId={id} />
))}
</>
);
}
Each preview opens its own connection for as long as it's mounted — since
portal.channel(id) is a lookup-or-create keyed by id, a preview and a full open room
for the same channel share the same underlying socket rather than opening a second one.
readOn: "manual" keeps a preview from silently clearing its own unread badge just by
being on screen.
Pairing a channel view with the inbox badge
useChannel's unread and useInbox's per-channel entry are two different read
positions — see Inbox for why. A typical room header shows
the channel's own unread count while active, and clears the inbox's notice for that
channel on open:
import { useEffect } from "react";
import { useChannel, useInbox } from "@portalsdk/react";
interface ChatMessage {
text: string;
}
function RoomHeader({ channelId }: { channelId: string }) {
const { unread } = useChannel<ChatMessage>({ channelId });
const { channels } = useInbox();
useEffect(() => {
channels.get(channelId)?.markAsRead();
}, [channelId, channels]);
return <h2>Room ({unread} unread)</h2>;
}
Guarding against double-invoked effects
useChannel/useInbox connections are refcounted in core, with a short grace period
after the last release() — so React StrictMode's development-only double
mount/unmount/mount doesn't cause a visible reconnect. You don't need to add your own
guards around the hooks for this.