npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@portalsdk/react

v0.1.4

Published

React bindings — hooks and providers wrapping the Portal core client.

Readme

@portalsdk/react

React bindings for the Portal client — a thin hook layer over @portalsdk/core. The hooks are selectors over core's reactive stores (via useSyncExternalStore); they never own connections, so mounting a component opens the socket and unmounting releases it.

Client-only: the hooks connect over WebSockets and read the DOM. They ship "use client". During server rendering — including a Next.js Client Component's server prerender pass, which runs despite "use client" — they render an inert idle snapshot instead of connecting: no acquire, no network, nothing thrown. See Using with Next.js App Router.

Install

npm install @portalsdk/react @portalsdk/core

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

Quickstart

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

// Construct once — synchronous and passive, no network until a hook mounts.
const portal = new Portal({
  apiKey: "pk_live_…",             // publishable; safe in the bundle
  token: async () => fetchJwt(),   // your signed user token (or a static string)
});

function App() {
  return (
    <PortalProvider client={portal}>
      <Badge />
      <Room channelId="room-7" />
    </PortalProvider>
  );
}

function Room({ channelId }: { channelId: string }) {
  const { messages, send, status, unread } = useChannel<{ text: string }>({ channelId });

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

function Badge() {
  const { counter } = useInbox();
  return <span>{counter}</span>;
}

PortalProvider

Supplies the client to the hooks. It also accepts an optional token prop, forwarded to client.setToken — pass a string or callback to log a user in, undefined to return to anonymous mode. A fresh inline callback on every render does not reconnect; only a real change of value or kind does.

// Anonymous until a token arrives; swapping it in/out logs the user in and out.
<PortalProvider client={portal} token={session?.jwt}>
  {children}
</PortalProvider>

Omit the token prop entirely to leave the client's own credential (from new Portal({ token }) or anonymous mode) untouched.

useChannel

const result = useChannel<M>({
  channelId, readOn, history, metadata, onMention, onMessage, onError,
});
  • channelId — the room to subscribe to. undefined renders inert (no connection) — the two-pane "nothing selected" pattern. Changing the id releases the old room and acquires the new one.
  • readOn — when the channel read position auto-advances: "mount" (default), "visible" (on a visible mount, then on each return to visibility), or "manual" (call markAsRead yourself).
  • history — initial backfill on connect (number, default 50, or "none").
  • metadata — initial presence metadata for this session.
  • onMention / onError — fire on a mention addressed to you and on a delivered error.
  • onMessage — fires on every message delivered to this channel, persistent or ephemeral. Useful for high-frequency ephemeral traffic (live cursors, presence-adjacent signals) that you want to react to as discrete events rather than read off the accumulated messages.

The result mirrors the channel: messages, send, loadPrevious / hasPrevious / isLoadingPrevious, channel (info), me, presence, activity / sendActivity / typing / sendTyping, unread / markAsRead, setMetadata, and status.

setMetadata(metadata) replaces your own presence metadata mid-session — a direct pass-through to the channel handle's setMetadata. Previously the only way to reach it was holding the core ChannelHandle yourself (portal.channel(id)) alongside the hook; that workaround still works (the handle is unchanged), but isn't necessary anymore.

Content types are per call site: useChannel<M>({ … }).

useInbox

const { channels, items, counter, unseen, markAllRead, status } = useInbox<D>({
  channelId, where, onItem,
});
  • channels / items — the (optionally filtered) conversation rows and item feed.
  • counter — the global badge (ignores this view's filter).
  • unseen — unseen items within this view's filter.
  • markAllRead — global, zero-arg.
  • channelId / where — scope the view.
  • onItem — fires once per item arriving after mount. Never fires for the items already present when the inbox becomes ready (that's what items is for), and never fires twice for the same item — a redelivered item updates its data in items but doesn't re-announce itself. A fresh inline callback on every render doesn't drop or duplicate events.

Anonymous users get a permanently-empty ready inbox, so calling code needs no special case.

Each item's id is the notification's idempotency key (see the sender's own docs), so it's the right thing to key a toast list on:

import { useState } from "react";
import { useInbox } from "@portalsdk/react";

function useAssignmentToasts() {
  const [toasts, setToasts] = useState<{ id: string; message: string }[]>([]);

  useInbox({
    onItem: (item) => {
      if (item.type !== "ticket.assigned") return;
      setToasts((current) => [...current, { id: item.id, message: item.title ?? "" }]);
    },
  });

  return toasts;
}

Using with Next.js App Router

useChannel/useInbox are Client Component hooks (they ship "use client"), same as any other stateful hook — no special wrapper is required. Put PortalProvider in a Client Component near the root of the subtree that needs it, and call the hooks from Client Components beneath it; everything above can stay a Server Component:

// app/providers.tsx
"use client";
import { Portal } from "@portalsdk/core";
import { PortalProvider } from "@portalsdk/react";

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

export function Providers({ children }: { children: React.ReactNode }) {
  return <PortalProvider client={portal}>{children}</PortalProvider>;
}
// app/layout.tsx (Server Component)
import { Providers } from "./providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

dynamic(() => import("./room"), { ssr: false }) is not required to use these hooks — that workaround was for the pre-0.1.2 throwing behavior. During the server's prerender pass a component calling useChannel/useInbox renders its inert idle state (no connection is opened there); once the same component re-renders in the browser, it connects normally. You only need ssr: false for reasons unrelated to Portal (e.g. another dependency that itself doesn't tolerate server rendering).

License

MIT