Palbase
Sign inGet started

Web SDK

React Hooks

@palbase/web/react exports exactly ten hooks and two types over the pb facades. The auth, flags and realtime family — useUser, useSession, useFlag, useFlags, useChannel — is joined by a messaging family (useChats, useChat, useMessages, useChatMembers, useTyping) bound to the MLS chat state. They are all thin useSyncExternalStore bindings: concurrent-rendering safe, re-rendering exactly when their slice of state changes, and carrying no behavior of their own. Everything they observe is reachable through pb directly — the same auth, flags, realtime and messaging state.

Quick example

The module starts with 'use client', so it slots straight into the Next.js App Router. react >= 18 is an optional peer dependency: importing @palbase/web never pulls React — only @palbase/web/react does.

'use client';
import '../palbase/client'; // configures pb in the client bundle — `palbase link` wires this
import { useUser, useFlag, useChannel } from '@palbase/web/react';

export function TodoHeader() {
  const user = useUser();
  const showStats = useFlag('todo_stats', false);
  const { status } = useChannel('todos', 'todo.created', (payload) => {
    console.log('new todo from another device:', payload);
  });

  if (!user) return <a href='/login'>Sign in</a>;
  return (
    <header>
      <span>{user.email}</span>
      {showStats && <TodoStats />}
      <small>live: {status}</small>
    </header>
  );
}

Subscriptions are managed for you — every hook cleans up its listeners on unmount, and there is nothing to unsubscribe manually.

There is no provider component

@palbase/web/react exports no provider and no React context. There is no <PalbeProvider>, no <PalbeClientProvider>, nothing to wrap your tree in. Every hook reads the process-global pb singleton directly, which is configured by importing the palbase/client barrel — which re-exports the environment's generated palbe.gen.ts — once per module graph.

The <Providers> component you may have seen in Next.js is generated app code written into your repo by palbase link — it imports the palbase/client barrel and calls setupPalbeNext(), then returns its children unchanged. It is not an SDK export, and nothing in @palbase/web requires it in a non-Next app.

The complete export list

Ten hooks and two types. Nothing else is exported from this entry point.

HookSignature
useUseruseUser(): AuthUser | null
useSessionuseSession(): SessionState
useFlaguseFlag<T extends FlagValue>(key: string, fallback: T): T
useFlagsuseFlags(): FlagsView
useChanneluseChannel(name: string, event: string, handler: (payload: RealtimePayload) => void): { status: ChannelStatus }
useChatsuseChats(): readonly Chat[]
useChatuseChat(chat: Chat): Chat
useMessagesuseMessages(chat: Chat): readonly ChatMessage[]
useChatMembersuseChatMembers(chat: Chat): readonly ChatMember[]
useTypinguseTyping(chat: Chat): readonly ChatMember[]
export interface SessionState { signedIn: boolean; user: AuthUser | null }
export type ChannelStatus = 'idle' | 'connected' | 'reconnecting' | 'error' | 'unavailable';

Hooks that do not exist

There is no hook for calls, presence, connection status, auth actions, analytics or performance. Specifically, none of these names is exported: useCall, useCalls, usePresence, useRealtimeStatus, useAuth, useSignIn, useAnalytics, usePerf, usePalbe, useUnread, useNotifyScope. Reach those surfaces through pb directly — pb.calls, pb.analytics, pb.realtime.status, pb.auth — and hold their state in your own component state.

useUser

function useUser(): AuthUser | null;

Returns the current authenticated user, or null when signed out. Re-renders on both auth-state transitions (a sign-in adopts a user, a sign-out clears it) and in-place profile changes — for example an emailVerified flip after pb.auth.refreshUser().

'use client';
import { useUser } from '@palbase/web/react';

export function Avatar() {
  const user = useUser();
  if (!user) return null;
  return <span title={user.id}>{user.email ?? 'phone user'}</span>;
}

AuthUser is { id: string; email: string | null; emailVerified: boolean; createdAt: string }email is null for phone-only users.

Note: after a full page reload the restored session has no cached user yet. The SDK fires a background hydrateUser() at boot, so useUser() returns null for a tick even though the user is signed in. Use useSession to tell "signed out" apart from "signed in, profile not loaded". See Auth for the session-restore lifecycle.

useSession

interface SessionState {
  signedIn: boolean;
  user: AuthUser | null;
}

function useSession(): SessionState;

The session slice. signedIn reflects refresh-token presence — the session truth, including immediately after a reload — while user is the cached profile, which may still be null until hydration lands. Snapshots are referentially stable: the hook returns the same object until signedIn or user actually changes, so it is safe in dependency arrays.

'use client';
import { useEffect } from 'react';
import { pb } from '@palbase/web';
import { useSession } from '@palbase/web/react';

export function SessionGate({ children }: { children: React.ReactNode }) {
  const { signedIn, user } = useSession();

  // Populate the profile if boot hydration has not landed yet.
  useEffect(() => {
    if (signedIn && !user) void pb.auth.refreshUser();
  }, [signedIn, user]);

  if (!signedIn) return <a href='/login'>Sign in</a>;
  if (!user) return <p>Loading…</p>;
  return <>{children}</>;
}

useFlag

function useFlag<T extends FlagValue>(key: string, fallback: T): T;

Subscribe to one feature flag. The component re-renders only when this key's value changes (primitives are compared with Object.is, object values structurally), and gets fallback until the key has a value. FlagValue is boolean | number | string | null | FlagValue[] | { [key: string]: FlagValue }; the concrete T is asserted by you, because flags are untyped on the wire and you own the fallback's type.

'use client';
import { useFlag } from '@palbase/web/react';

export function DueDates() {
  const enabled = useFlag('todo_due_dates', false);
  const maxTodos = useFlag('max_todos', 50);
  if (!enabled) return null;
  return <p>You can schedule up to {maxTodos} todos.</p>;
}

Note: an inline object fallback (useFlag('theme', { dark: false })) defeats snapshot caching while the key is absent — a fresh object reference every render keeps re-entering the cache. Hoist object fallbacks to a module-level const or wrap them in useMemo. Primitive fallbacks are unaffected.

Changing key re-subscribes to the new key automatically. Polling itself belongs to the flags facade — 30-second delta polling in the browser, with a visibility pause; nothing auto-starts off the browser. See Flags.

useFlags

function useFlags(): FlagsView; // Readonly<Record<string, FlagValue>>

Subscribe to the whole flag set; re-renders on any flag change. The returned view is frozen and identity-stable — a new object appears only when the set actually changes — so it is safe in dependency arrays.

'use client';
import { useFlags } from '@palbase/web/react';

export function FlagDebugPanel() {
  const flags = useFlags();
  return (
    <pre>
      {Object.entries(flags)
        .map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
        .join('\n')}
    </pre>
  );
}

Prefer useFlag in product code — useFlags re-renders on every flag change.

useChannel

type ChannelStatus = 'idle' | 'connected' | 'reconnecting' | 'error' | 'unavailable';

function useChannel(
  name: string,
  event: string,
  handler: (payload: RealtimePayload) => void,
): { status: ChannelStatus };

Subscribe handler to one realtime event on pb.realtime.channel(name) for the lifetime of the component, and get the live connection status back. RealtimePayload is Record<string, unknown> — the wire carries no schema, so narrow it yourself.

'use client';
import { useState } from 'react';
import { useChannel } from '@palbase/web/react';

export function LiveTodoFeed() {
  const [latest, setLatest] = useState<string | null>(null);
  const { status } = useChannel('todos', 'todo.created', (payload) => {
    setLatest(String(payload.title ?? 'untitled'));
  });

  return (
    <div>
      <span>connection: {status}</span>
      {latest && <p>Just created: {latest}</p>}
    </div>
  );
}
AspectBehavior
Handler identityHeld in a ref and refreshed every render — pass a fresh closure freely, no useCallback needed; it never tears down the subscription.
Re-subscriptionKeyed on [name, event] plus an internal configuration epoch — the subscription is also recreated when pb is configured or re-configured (codegen watch mode).
StrictModeSafe. Channel joins are reference-counted, so React's dev-mode mount → cleanup → remount nets to exactly one live subscription. There is deliberately no once-guard.
No WebSocket / not configuredRealtime is client-only. Where a channel cannot be opened in the browser — no WebSocket, pb not yet configured — the effect catches and reports status: 'unavailable' instead of throwing.
SSR'idle', not 'unavailable'. Status starts at 'idle' and only becomes 'unavailable' inside the effect, and effects do not run on the server. Do not gate server-vs-browser on status === 'unavailable'; it never fires during a server render.
Status valuesThe shared socket's connection state ('idle' | 'connected' | 'reconnecting' | 'error') plus 'unavailable'.

Note: channel names are your bare app-defined topics ('todos', 'room:42') — never add a realtime: prefix; the codec adds it on the wire. A sender does not receive its own broadcast. See Realtime for the channel API and for sending events.

Messaging hooks

Five hooks bind React components to the pb.messaging MLS chat state. They are the same kind of thin useSyncExternalStore bindings as the auth and flags hooks, with subscriptions cleaned up on unmount.

A Chat is the unit you pass to the per-chat hooks. You get one synchronously from pb.messaging.directChat(userId) (a DM) or pb.messaging.groupChat({ members }) (a group) — both return a local draft Chat instantly with no network, and the chat materializes on its first send. See Messaging for the Chat, ChatMessage and ChatMember value types; they are defined there and not redefined here.

Warning: the web SDK's chat surface is narrower than the iOS one, and these hooks cannot widen it. There is no media (an inbound media message decodes to a bubble with no content, and there is no API to send one), no group names (Chat.title falls back to Group <last4>), no display names (ChatMember.displayName is always null), no roles beyond reading one, and no safety numbers. Render user ids, not names.

useChats

function useChats(): readonly Chat[];

Mirrors pb.messaging.chats and re-renders whenever the chat list changes — a chat is added or removed, or the list hydrates from the durable catalog on launch.

'use client';
import { useChats } from '@palbase/web/react';

export function ChatList() {
  const chats = useChats();
  return (
    <ul>
      {chats.map((c) => (
        <li key={c.id}>
          {c.title} — {c.lastMessage?.text ?? 'No messages yet'}
        </li>
      ))}
    </ul>
  );
}

useChat

function useChat(chat: Chat): Chat;

Bind one Chat's changes: a new message, a members refresh, typing, or the draft → active materialization. It returns the same chat instance (pointer-stable) and forces a re-render on every chat.onChange, so you keep reading chat.messages, calling chat.send(...) and so on through it.

'use client';
import { useState } from 'react';
import { useChat } from '@palbase/web/react';
import type { Chat } from '@palbase/web';

export function Composer({ chat }: { chat: Chat }) {
  const live = useChat(chat);
  const [text, setText] = useState('');
  return (
    <form onSubmit={(e) => { e.preventDefault(); void live.send(text); setText(''); }}>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <small>{live.unreadCount} unread</small>
    </form>
  );
}

useMessages

function useMessages(chat: Chat): readonly ChatMessage[];

The ordered message transcript for a chat, newest last. Re-renders on a new message, a history page load (chat.loadEarlier(...)) or your own-send echo. A cached snapshot keeps the array reference stable until the list actually changes.

ChatMessage.text is string | null. On the web it is null for a system message or for an inbound media envelope, which the SDK cannot decode into content — there is no media support in @palbase/web, in either direction. A deleted message renders the tombstone text rather than null.

'use client';
import { useMessages } from '@palbase/web/react';
import type { Chat } from '@palbase/web';

export function Transcript({ chat }: { chat: Chat }) {
  const messages = useMessages(chat);
  return (
    <ol>
      {messages.map((m) => (
        <li key={m.id}>{m.text ?? <em>unsupported message</em>}</li>
      ))}
    </ol>
  );
}

useChatMembers

function useChatMembers(chat: Chat): readonly ChatMember[];

The chat roster — users, not devices. Re-renders when the roster changes, so when a member joins or leaves. ChatMember.role is a ChatRole, one of 'owner' | 'admin' | 'member', read off the members response; there is no API on the web SDK to set, promote, demote or ban a role.

'use client';
import { useChatMembers } from '@palbase/web/react';
import type { Chat } from '@palbase/web';

export function Roster({ chat }: { chat: Chat }) {
  const members = useChatMembers(chat);
  return (
    <ul>
      {members.map((m) => (
        <li key={m.id}>
          {m.userId} ({m.role}){m.isSelf && ' — you'}
        </li>
      ))}
    </ul>
  );
}

Warning: ChatMember.displayName is always null on the web SDK — the wire row carries no display name and every construction site hard-codes it. ResolvedMention.displayName is null for the same reason. Rendering m.displayName ?? m.userId will therefore always show a raw usr_… id; resolve names from your own backend if you need them.

useTyping

function useTyping(chat: Chat): readonly ChatMember[];

The users currently typing in a chat. Re-renders on typing start and stop. Same ChatMember values as useChatMembers, with the same displayName caveat.

'use client';
import { useTyping } from '@palbase/web/react';
import type { Chat } from '@palbase/web';

export function TypingIndicator({ chat }: { chat: Chat }) {
  const typing = useTyping(chat);
  if (typing.length === 0) return null;
  return <em>{typing.map((m) => m.userId).join(', ')} typing…</em>;
}

Note: simply observing an active chat subscribes its realtime topic and starts announcing your presence on a 25-second heartbeat. There is no opt-in step, and no opt-out surface on the web SDK.

Mounting before configuration

Every hook self-heals if a component mounts before the palbase/client barrel has configured pb — possible with unusual import orders or lazy bundles:

  • They never crash a render. Pre-configuration snapshots are safe defaults: useUsernull, useSession{ signedIn: false, user: null }, useFlag → your fallback, useFlags → an empty frozen set, useChannelstatus: 'idle' until its effect runs and downgrades to 'unavailable', the chat hooks → frozen empty arrays.
  • The moment configuration arrives — or changes, as when codegen watch mode regenerates the file — every mounted hook re-subscribes against the new runtime and re-reads its snapshot.

During a server render the same defaults apply: getServerSnapshot returns a null user, empty flags and empty chat arrays, so a client component that renders on the server does not crash and does not fabricate state.

In practice you avoid the pre-configuration window entirely by importing palbase/client (or a wrapper module that imports it) from every client module that uses pb — the pattern palbase link sets up.

Reference

HookReturnsRe-renders when
useUser()AuthUser | nullAuth state transitions, or the user profile changes
useSession(){ signedIn: boolean; user: AuthUser | null }Auth state transitions
useFlag(key, fallback)T extends FlagValueThat one flag's value changes
useFlags()FlagsViewAny flag changes
useChannel(name, event, handler){ status: ChannelStatus }Connection status changes — the handler runs outside the render cycle
useChats()readonly Chat[]The chat list changes or hydrates
useChat(chat)ChatThat chat changes — new message, members refresh, typing, draft → active
useMessages(chat)readonly ChatMessage[]A new message, a history page, or an own-send echo
useChatMembers(chat)readonly ChatMember[]The roster changes — a member joins or leaves
useTyping(chat)readonly ChatMember[]Typing starts or stops
  • Next.js — wiring pb into the App Router; these hooks are the client-component half
  • Auth — the flows behind useUser and useSession
  • Flags — polling, typed getters and subscriptions behind useFlag and useFlags
  • Realtime — channels, events and connection management behind useChannel
  • Messaging — the pb.messaging API and the Chat / ChatMessage / ChatMember value types
  • Overview — the pb surface these hooks observe