PortalProvider

@portalsdk/react is a thin hook layer over @portalsdk/core — the hooks are selectors over core's reactive stores (via useSyncExternalStore), and they never own connections: mounting a component that calls useChannel opens the socket, and unmounting releases it.

npm install @portalsdk/react @portalsdk/core

react (>=18 <20) is a peer dependency.

PortalProvider supplies a Portal client to every hook beneath it in the tree:

import { Portal } from "@portalsdk/core";
import { PortalProvider, useChannel } from "@portalsdk/react";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });

interface ChatMessage {
  text: string;
}

function Lobby() {
  const { messages } = useChannel<ChatMessage>({ channelId: "lobby" });
  return (
    <ul>
      {messages.map((m) => (
        <li key={m.id}>{m.content.text}</li>
      ))}
    </ul>
  );
}

export function App() {
  return (
    <PortalProvider client={portal}>
      <Lobby />
    </PortalProvider>
  );
}

Props

Prop Type Notes
client Portal Required. The provider is otherwise passive — it just publishes client on context. Its lifecycle stays owned by whoever constructed it.
token string | (() => string | Promise<string>) | undefined Optional. Forwarded to client.setToken.
children ReactNode

Connections are opened and closed by the hooks (via the handle refcount), not by the provider itself — the provider never touches the network on its own.

Driving login/logout through token

import { useState } from "react";
import { Portal } from "@portalsdk/core";
import { PortalProvider, useChannel } from "@portalsdk/react";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });

async function fetchPortalToken(): Promise<string> {
  const res = await fetch("/api/portal-token", { credentials: "include" });
  const { token } = (await res.json()) as { token: string };
  return token;
}

interface ChatMessage {
  text: string;
}

function Lobby() {
  const { messages } = useChannel<ChatMessage>({ channelId: "lobby" });
  return (
    <ul>
      {messages.map((m) => (
        <li key={m.id}>{m.content.text}</li>
      ))}
    </ul>
  );
}

export function App() {
  const [signedIn, setSignedIn] = useState(false);

  return (
    <PortalProvider client={portal} token={signedIn ? fetchPortalToken : undefined}>
      <Lobby />
      <button onClick={() => setSignedIn(true)}>Sign in</button>
      <button onClick={() => setSignedIn(false)}>Sign out</button>
    </PortalProvider>
  );
}

A fresh inline callback on every render does not reconnect — only an actual change of value or kind (string ↔ callback ↔ undefined) does, so token={fetchPortalToken} above is safe to pass as a new closure each render. Omitting token entirely (as opposed to passing undefined) leaves the client's own credential untouched — useful when something other than this provider owns login state.

See Tokens & auth for what changing the token does to already-mounted channels and the inbox.