Skip to content

React API

The full type surface of @playhtml/react. For a gentler introduction, see Using React. For concept-by-concept usage, each page under Data and Capabilities has a React tab alongside the vanilla form.

Initializes the playhtml client for a React subtree. There must be exactly one PlayProvider per React root.

interface PlayProviderProps {
  initOptions?: InitOptions;
  pathname?: string;
  children: React.ReactNode;
}

Everything in initOptions maps one-to-one onto the vanilla playhtml.init() argument. See the init options reference.

pathname is optional and only needed for client-side-navigation frameworks (React Router, Next.js, etc.) where the browser Navigation API isn’t available. Pass it from your router and playhtml will rebuild rooms + rescan the DOM on pathname changes. See navigation for details.

import { PlayProvider } from "@playhtml/react";

<PlayProvider initOptions={{ cursors: { enabled: true } }}>
  <App />
</PlayProvider>;

HOC that returns a component with live, shared data plus a setData callback. The config controls how the element is wired into playhtml; the render function is a regular functional component that receives playhtml’s state as its first argument and your own props as its second.

withSharedState<T, V, P>(
  config: WithSharedStateConfig<T, V> | ((props: P) => WithSharedStateConfig<T, V>),
  render: (
    playhtmlProps: ReactElementEventHandlerData<T, V>,
    componentProps: P,
  ) => React.ReactNode,
): React.ComponentType<P>;
interface WithSharedStateConfig<T, V> {
  defaultData: T;
  defaultPresence?: V;
  id?: string;
  tagInfo?: TagType[];
}
  • defaultData: required. The initial value of data. Survives reload.
  • defaultPresence: optional. Your starting presence on this element: per-person state that clears when you leave. It does not persist.
  • id: optional. Stable id for the element. If omitted, playhtml derives one from the rendered DOM; see Dynamic elements for why stable ids matter.
  • tagInfo: optional. Marks the element as one of the built-in capabilities (e.g. [TagType.CanToggle]). For a built-in capability, prefer its wrapper component (CanToggleElement, CanMoveElement, …). See Capabilities.

defaultData and defaultPresence accept values, not functions that receive DOM elements. They are available on the first render. Derive them from React props with a props-dependent config.

interface ReactElementEventHandlerData<T, V> {
  data: T;
  setData: (data: T | ((draft: T) => void)) => void;
  everyone: Person<V>[];  // { pid, name, color, isMe, presence }
  me: Person<V>;
  setMyPresence: (next: V | ((draft: V) => V | void)) => void;
  ref: React.RefObject<HTMLElement>;
}

setData accepts either a replacement value or a mutator function. See data best practices for the merge semantics.

everyone, me, and setMyPresence are this element’s presence. Use them for signals tied to this element that should disappear when the person leaves. everyone lists everyone with the element on screen (including you), each with their current presence on it; your own is me.presence. setMyPresence takes a new value or a mutator, like setData. The 2.15 names live, users, and setLive still work.

Pass a callback instead of a config object when defaultData needs to derive from props:

export const Reaction = withSharedState(
  ({ initialCount }: { initialCount: number }) => ({
    defaultData: { count: initialCount },
  }),
  ({ data, setData }) => (
    <button id="reaction" onClick={() => setData((draft) => { draft.count += 1; })}>
      {data.count} reactions
    </button>
  ),
);

<Reaction initialCount={0} /> starts at zero when there is no saved state. Defaults seed shared state; changing props does not overwrite saved state.

Component form of withSharedState. Useful when you want JSX children (render-prop style) instead of wrapping a component, or when you need ref access to a specific element.

Pass computed values directly, such as defaultData={{ count: initialCount }}. The same value-only rule applies to defaultPresence. DOM-dependent default functions belong to the vanilla element API.

interface CanPlayElementProps<T, V> {
  id?: string;
  defaultData: T;
  defaultPresence?: V;
  tagInfo?: TagType[];
  standalone?: boolean;
  loading?: LoadingOptions;
  dataSource?: string;
  shared?: boolean | string;
  dataSourceReadOnly?: boolean;
  children: (props: ReactElementEventHandlerData<T, V>) => React.ReactNode;
}
  • id: required if the top-level child is a React Fragment. Otherwise defaults to the child’s id, or a hash of the child’s content. A stable id matters for cross-browser sync; see Dynamic elements.
  • defaultPresence: optional. Your starting presence on this element. It clears when you leave.
  • standalone: when true, the element initializes playhtml itself if no PlayProvider is present. Use it for one-off components mounted outside your provider tree (e.g. an Astro island). A no-op when a provider already exists.
  • loading: controls the loading affordance shown before the element’s first sync. See Loading options.
  • dataSource, shared, dataSourceReadOnly: wire the element to a shared source across pages or sites. See Shared data props and the Shared elements guide.
<CanPlayElement
  id="my-switch"
  defaultData={{ on: false }}
>
  {({ data, setData }) => (
    <button onClick={() => setData({ on: !data.on })}>
      {data.on ? "on" : "off"}
    </button>
  )}
</CanPlayElement>

Typed wrapper around CanPlayElement for draggable elements. Accepts the same dataSource, shared, and standalone props, plus three bounds props for constraining the drag area.

interface CanMoveElementProps {
  bounds?: string;
  boundsMinVisible?: number;
  boundsMinVisiblePx?: number;
  dataSource?: string;
  shared?: boolean | string;
  standalone?: boolean;
  children: React.ReactElement | ((data: MoveEventData) => React.ReactElement);
}
  • bounds: id or CSS selector of the container that constrains dragging. "arena", "#arena", and ".grid" all work.
  • boundsMinVisible: fraction (0–1) of the element that must stay inside bounds on every edge. Default 1, which keeps the full element inside. Lower values allow part of the element to hang over the edge; 0 drops the fraction constraint entirely.
  • boundsMinVisiblePx: absolute pixel floor on the keep-visible slice. Default 60; it applies when boundsMinVisible allows partial overhang.

Bounds apply only during dragging. Setup does not rewrite the element’s initial CSS layout or persisted position.

The effective keep-visible slice on each axis is max(boundsMinVisible × size, boundsMinVisiblePx). Set both knobs to 0 to opt fully out of the keep-visible guarantee. See can-move in the capabilities reference for the interaction details.

import { CanMoveElement } from "@playhtml/react";

<div id="fridge" style={{ position: "relative", height: 400 }}>
  <CanMoveElement bounds="fridge">
    <div id="magnet-a">🍎</div>
  </CanMoveElement>
  <CanMoveElement bounds="fridge" boundsMinVisible={0.5} boundsMinVisiblePx={0}>
    <div id="magnet-b">🥐</div>
  </CanMoveElement>
</div>;

Each built-in capability has a typed wrapper. All of them accept the shared dataSource, shared, and standalone props (see Shared data props); the table lists what’s unique to each. For the full set of options including loading and dataSourceReadOnly, use <CanPlayElement> or withSharedState; the capability wrappers only forward the three props above.

ComponentCapabilityExtra props
<CanToggleElement>can-togglereadOnly?: boolean: render the toggle read-only (sets data-source-read-only).
<CanSpinElement>can-spinnone
<CanGrowElement>can-grownone
<CanHoverElement>can-hoversets [data-playhtml-hover] on its child while anyone hovers; style that attribute instead of :hover.
<CanDuplicateElement>can-duplicateelementToDuplicate: RefObject<HTMLElement> (required), canDuplicateTo?: RefObject<HTMLElement>.

CanDuplicateElement takes refs rather than selector strings, since React owns the DOM:

import { CanDuplicateElement } from "@playhtml/react";

const template = useRef<HTMLImageElement>(null);
const bin = useRef<HTMLDivElement>(null);

<>
  <img ref={template} id="bunny-template" src="/pixel-bunny.png" />
  <CanDuplicateElement elementToDuplicate={template} canDuplicateTo={bin}>
    <button>clone a bunny</button>
  </CanDuplicateElement>
  <div ref={bin} />
</>;

For the live demos of each capability, see the Capabilities page. Every section has a React tab.

These props let an element participate in cross-page / cross-site sharing. They mirror the vanilla HTML attributes.

PropHTML attributeWhere it works
sharedsharedCanPlayElement and every capability wrapper. Mark this element as a source others can subscribe to. true is read-write; pass "read-only" to publish read-only.
dataSourcedata-sourceCanPlayElement and every capability wrapper. Subscribe to a source as a consumer. Format: "domain[/path]#elementId".
dataSourceReadOnlydata-source-read-onlyCanPlayElement / withSharedState only. Force a consumer to read-only even if the source is read-write. (On CanToggleElement, use its readOnly prop, which sets the same attribute.)
// Source — publishes its toggle state for any page to consume
<CanToggleElement id="light" shared>
  <div className="light" />
</CanToggleElement>

// Consumer — mirrors the light from another page, read-only via the toggle's readOnly prop
<CanToggleElement dataSource="playhtml.fun/#light" readOnly>
  <div className="light" />
</CanToggleElement>

Both divs get the toggled class when the light is on. Style both states so the change is visible:

.light {
  width: 80px; height: 80px; border-radius: 50%;
  background: gold; filter: brightness(0.4);
}
.light.toggled {
  filter: brightness(1.2) drop-shadow(0 0 24px gold);
}

CanPlayElement and withSharedState accept a loading prop controlling the affordance shown before the element’s first sync from the server (it’s hidden or animated so readers don’t see a flash of default state). The vanilla equivalents are the loading-behavior / loading-class / loading-style HTML attributes, which work on any playhtml element.

interface LoadingOptions {
  behavior?: "auto" | "hidden" | "animate" | "none";
  customClass?: string;
  style?: "breathing" | "pulse" | "fade" | "none";
}
  • behavior: "auto" (default) picks a reasonable affordance, "hidden" keeps the element invisible until synced, "animate" shows the loading animation, "none" disables the affordance entirely (element renders its default state immediately).
  • customClass: a CSS class applied while loading, so you can style the placeholder yourself.
  • style: the built-in loading animation: "breathing", "pulse", "fade", or "none".
<CanPlayElement
  id="my-switch"
  defaultData={{ on: false }}
  loading={{ behavior: "animate", style: "pulse" }}
>
  {({ data, setData }) => (
    <button onClick={() => setData({ on: !data.on })}>
      {data.on ? "on" : "off"}
    </button>
  )}
</CanPlayElement>

Access the playhtml context from any descendant of PlayProvider.

interface PlayContextInfo {
  isLoading: boolean;
  people: {
    everyone: Person[];
    me: Person;
    setMyPresence: (key: string, value: unknown) => void;
  };
  /** @deprecated Use people (or usePeople()) */
  cursors: CursorEvents;
  /** @deprecated Use usePeople("cursor") */
  cursorPresences: Map<string, CursorPresenceView>;
  configureCursors: (opts: Partial<CursorOptions>) => void;
  /** @deprecated Use people.me */
  getMyPlayerIdentity: () => PlayerIdentity | null;
  registerPlayEventListener: (type: string, handler: PlayEvent) => string;
  removePlayEventListener: (type: string, id: string) => void;
  dispatchPlayEvent: (msg: { type: string; eventPayload?: unknown }) => void;
}

For data, prefer the dedicated hooks (usePeople, usePageData), which subscribe and re-render for you.

The people on this page: everyone, me, and setMyPresence(key, value), the same object usePeople() returns. A component that reads usePlayContext() re-renders when someone joins, leaves, or changes their name, color, or presence. Cursor moves don’t re-render it; use usePeople("cursor") for those.

const { people } = usePlayContext();
people.everyone.length;      // how many people are here
people.me.presence.status;   // your status
people.setMyPresence("status", "away");

Before sync, everyone is empty and me.pid is "".

Boolean that is true until the room’s state from the server has landed, then flips to false. It is true again while client-side navigation loads a different room. Gate effects that should run once per synced session on it, and guard with a ref if they must run only once:

const { isLoading } = usePlayContext();
const didCountSession = useRef(false);

useEffect(() => {
  if (isLoading || didCountSession.current) return;
  didCountSession.current = true;

  setData((draft) => {
    draft.count += 1;
  });
}, [isLoading, setData]);

Use a ref guard plus the mutator form for this kind of write, and don’t include the field you write in the dependency list.

The older view of names and colors. Use people instead: people.everyone.map((p) => p.color) for allColors, and people.me for color and name. Components using this re-render when colors or identities change.

const { cursors } = usePlayContext();
// cursors.allColors: string[]
// cursors.color: string
// cursors.name: string

See Cursors for the full cursor configuration surface.

const {
  registerPlayEventListener,
  removePlayEventListener,
  dispatchPlayEvent,
} = usePlayContext();

Usually you’ll wrap these in a hook to bind a listener to the component’s lifecycle. See Events for the useConfetti pattern.

All hooks must be used inside a PlayProvider. Each is safe to call before playhtml has finished initializing: it returns an empty/default value, and any setter warns and no-ops until sync completes, then wires up automatically. You never need to gate them on a loading flag yourself.

The people on this page, the React form of playhtml.people: who’s here and what each of them is doing right now.

function usePeople(keys?: string | string[]): {
  everyone: Person[];   // { pid, name, color, isMe, presence }, including you
  me: Person;           // you
  setMyPresence: (key: string, value: unknown) => void; // null clears a key
};
const { everyone, me, setMyPresence } = usePeople("status");
setMyPresence("status", { text: "focused" });
everyone.map((person) => `${person.name}: ${person.presence.status?.text}`);

Re-renders when someone joins, leaves, or changes their name, color, or presence. Pass a key or a list of keys to re-render only when those keys change. Cursor moves are skipped unless you pass "cursor". everyone is in join order. Before sync, everyone is empty and me.pid is "".

To set your name or color, assign to playhtml.people.me.name / .color.

The earlier channel-based hook, returning { presences, setMyPresence, myIdentity }. It keeps working; use usePeople(key) instead.

Subscribe to a page-level data channel, persistent state not tied to any element. The shape mirrors useState.

type PageDataSetter<T> = T extends object
  ? T | ((draft: T) => void)
  : T | ((value: T) => T);

function usePageData<T>(
  name: string,
  defaultValue: T,
): [T, (data: PageDataSetter<T>) => void];
const [counter, setCounter] = usePageData("visits", { count: 0 });
setCounter((draft) => { draft.count += 1; });

For primitive channels, a functional update returns the next value:

const [viewCount, setViewCount] = usePageData("viewCount", 0);
setViewCount((value) => value + 1);

defaultValue is read only on first mount and when name changes. setCounter accepts a replacement value or a mutator function. Object and array mutators edit their draft in place; primitive updaters return the next value.

Join an isolated presence room separate from the page’s main presence. Returns null until synced (and briefly during a room change).

function usePresenceRoom(name: string): PresenceRoom | null;
const room = usePresenceRoom("lobby");
room?.people.setMyPresence("status", { text: "ready" });

The room and its connection are torn down automatically when the component unmounts or name changes.

Returns a pid-keyed Map of cursor views. Use usePeople("cursor") and read person.presence.cursor instead.

Register an element as a cursor zone. When your cursor is inside it, other clients render the cursor positioned relative to their own copy of the same element (matched by element id). This anchors cursors to a shared widget rather than absolute page coordinates.

function useCursorZone(
  ref: RefObject<HTMLElement | null>,
  options?: CursorZoneOptions,
): void;
const ref = useRef<HTMLDivElement>(null);
useCursorZone(ref);
return <div ref={ref} id="shared-canvas" />; // the element needs a stable id

Returns everyone as { pid, name, color, isMe }, without presence. Use usePeople().everyone.

Returns { color, pid, name }, with pid undefined until sync. Use usePeople().me.

Re-exported from playhtml. Use these as tagInfo entries when you want a built-in capability (can-move, can-toggle, etc.) wired into your component.

import { TagType } from "playhtml";

TagType.CanPlay;
TagType.CanMove;
TagType.CanToggle;
TagType.CanGrow;
TagType.CanSpin;
TagType.CanHover;
TagType.CanDuplicate;
TagType.CanMirror;

The repo has a collection of runnable React examples at packages/react/examples. Live versions are visible at playhtml.fun/experiments/one/ and playhtml.fun/experiments/two/.

A few things still in flux in the React package:

  • Hook ergonomics. A pure-hook interface (useSharedState({ id, defaultData })) is being evaluated as an alternative to the HOC form. The blocker is that hooks have no natural place to pin a stable id.