Skip to content

Documentation


Documentation / @forinda/video-sdk-react

@forinda/video-sdk-react ​

React 18+ hooks and components for the Forinda RTC SDK. Provides ergonomic wrappers around the framework-agnostic Publisher / Viewer from @forinda/video-sdk-core.

Install ​

bash
pnpm add @forinda/video-sdk-react @forinda/video-sdk-core @forinda/video-sdk-signaling-ws react react-dom

Peer deps: @forinda/video-sdk-core, react@>=18. The hooks rely on useSyncExternalStore, so React 17 isn't supported. @forinda/video-sdk-signaling-ws is the WebSocket transport used in the quick-start — swap or omit if you bring your own.

Quick start ​

tsx
import { VideoSdkProvider, usePublisher, useUserMedia, VideoView } from "@forinda/video-sdk-react";
import { defineWebSocketSignaling } from "@forinda/video-sdk-signaling-ws";

function App() {
  return (
    <VideoSdkProvider
      signaling={() => defineWebSocketSignaling({ url: "wss://signal.example.com" })}
      iceServers={[{ urls: "stun:stun.l.google.com:19302" }]}
    >
      <Broadcast />
    </VideoSdkProvider>
  );
}

function Broadcast() {
  const { stream } = useUserMedia({ audio: true, video: true });
  const { state, viewers } = usePublisher({ room: "demo", stream });

  return (
    <>
      <VideoView stream={stream} muted autoPlay playsInline mirror />
      <p>
        state: {state}, viewers: {viewers.length}
      </p>
    </>
  );
}

Hooks ​

useUserMedia(opts) ​

Request a MediaStream, manage tracks, expose state.

ts
const { stream, error, state, refresh, stop } = useUserMedia({ audio: true, video: true });
// state: "idle" | "requesting" | "granted" | "denied" | "error"

Cleans up tracks on unmount; uses AbortController so StrictMode double-mount in dev is idempotent. SSR-safe (returns { stream: null, state: "idle" } on the server).

useDisplayMedia(opts?) ​

Request a screen-share MediaStream via getDisplayMedia. Defaults to manual start (most apps want a button click), with { autoStart: true } to open the picker on mount.

ts
const { stream, state, start, stop } = useDisplayMedia();
// state: "idle" | "requesting" | "granted" | "denied" | "ended" | "error"

await start(); // opens browser picker
// later:
publisher?.replaceVideoTrack(stream!.getVideoTracks()[0]); // swap into existing publisher

The "ended" state fires when the user clicks the browser's own "Stop sharing" pill — wire it to swap back to the camera or unmount.

useDevices() ​

Subscribe to camera/mic/speaker enumeration with hotplug auto-refresh.

ts
const { cameras, microphones, speakers, refresh } = useDevices();

usePublisher(opts) ​

Construct + manage a Publisher. Subscribes to all the events you care about and exposes them as a snapshot.

ts
const {
  publisher,
  state,
  viewers,
  stats,
  error,
  start,
  stop,
  replaceVideoTrack,
  replaceAudioTrack,
} = usePublisher({
  room: "demo",
  stream, // from useUserMedia
  autoStart: true, // default
  stats: { interval: 1000 }, // optional
});

If signaling / iceServers / retry aren't supplied, the hook falls back to the nearest VideoSdkProvider.

useViewer(opts) ​

Symmetric to usePublisher for the viewer side.

ts
const { viewer, state, stream, stats, error, start, stop } = useViewer({
  room: "demo",
  publisherId: "alice",
  autoStart: true,
});

stream is null until the first track event fires; pass it to <VideoView>.

useConnectionStats(publisherOrViewer, { interval? }) ​

Standalone hook for consumers managing their own Publisher/Viewer who want stats.

ts
const stats = useConnectionStats(publisher); // ConnectionStats[]
const oneViewer = useConnectionStats(viewer); // ConnectionStats | null

useRoom(opts) ​

Construct a Room for the calling component's lifetime. The Room owns one signaling transport and one join; pair it with usePublisher({ attach: room }), useViewer({ attach: room }), useRoomChannel({ attach: room }) to share that transport without the duplicate-join footgun.

ts
const { room, state, error } = useRoom({ room: "demo", peerId: "alice" });
const { publisher } = usePublisher({ attach: room, stream });
const { channel } = useRoomChannel({ attach: room });
const { messages } = useChat(channel);
OptionDefaultDescription
room—Required. Room id.
peerIdcrypto.randomUUID()Self id.
signalingfrom providerPre-built SignalingTransport.

When attached, the child hooks ignore their own room / peerId / signaling options (taken from the Room). Standalone usage is unchanged.

Director / moderation surface (EPIC-12) ​

useRoom() also returns role, directors, and sendCommand for moderation UIs:

tsx
const { room, role, directors, sendCommand } = useRoom({
  room: "demo",
  peerId: "alice",
});

// Claim director after the Room is constructed:
useEffect(() => {
  if (!room) return;
  void room.ensureConnected().then(() => room.ensureJoined("director"));
}, [room]);

const isDirector = role === "director" || directors.includes("alice");

return (
  <>
    {isDirector && (
      <button onClick={() => sendCommand({ type: "mute", target: "bob", kind: "audio" })}>
        Mute Bob
      </button>
    )}
  </>
);

The available command shapes: mute / unmute ({ target, kind }), kick ({ target, reason? }), promote / demote ({ target }), set-bitrate ({ target, bitsPerSec }).

When the engine has enforceModerationCommands: true, non-director senders are rejected with SignalingPermissionError. Default honor mode just relays the command and lets the target's client decide whether to obey.

useRoomChannel(opts) ​

Construct a RoomChannel (presence + chat) for the lifetime of the calling component. Falls back to VideoSdkProvider's signaling factory when opts.signaling is omitted. Pass attach: room to share a Room's transport.

ts
const { channel, state, error } = useRoomChannel({ room: "demo", peerId: "alice" });
// state: "idle" | "connecting" | "connected" | "reconnecting" | "closed"
//   — passthrough of the underlying transport state, useful for "Connecting…" UI
OptionDefaultDescription
room—Required.
peerIdcrypto.randomUUID()Self id.
signalingfrom providerPre-built SignalingTransport.
manageJointrueIssue join + leave. Set false when sharing a transport with a Publisher/Viewer.
chatHistoryLimit200Rolling chat-buffer cap.
autoStarttrueCall channel.start() on mount.

usePresence(channel) ​

Live snapshot of every peer's attributes plus stable write callbacks. Re-renders on presence, presence-snapshot, and peer-left events.

ts
const { peers, setAttribute, removeAttribute, clearAttributes } = usePresence(channel);
// peers: Record<peerId, Record<string, JsonValue>>
await setAttribute("status", "🎬");

useChat(channel) ​

Live chat history plus a send callback. Omit to for a room-wide broadcast; pass a peerId for a DM. send() resolves with the entry's id so you can correlate with chat-status events on the underlying channel.

ts
const { messages, send } = useChat(channel);
const id = await send("hello room");
await send("psst", { to: "bob" });

// Subscribe to status changes via the underlying channel:
useEffect(() => {
  if (!channel) return;
  return channel.on("chat-status", ({ id, status }) => {
    // status: "pending" | "confirmed" | "failed"
  });
}, [channel]);

messages[i].status is "pending" until the server echoes the message back, then "confirmed". A chatAckTimeoutMs lapse (default 10s) flips it to "failed". Each entry's id matches the chat-status event payload.

useRaiseHand(channel) ​

Sugar over usePresence for the most common interaction pattern. Reads the channel peer's own "hand-raised" attribute.

ts
const { raised, raise, lower, toggle } = useRaiseHand(channel);

useRecorder(stream, opts?) ​

Record any MediaStream to a Blob. Wraps core's defineRecorder; constructs the MediaRecorder lazily on the first start() call and auto-stops on unmount.

ts
const { state, blob, downloadUrl, chunks, error, start, stop, pause, resume } = useRecorder(
  stream,
  {
    mimeType: "video/webm;codecs=vp9,opus",
    videoBitsPerSecond: 2_500_000,
    timesliceMs: 1_000,
    maxBufferedBytes: 200 * 1024 * 1024, // 200 MB cap
  },
);
// state: "idle" | "recording" | "paused" | "stopped" | "error"
//
// downloadUrl: string | null
//   Lazy URL.createObjectURL(blob), revoked automatically on next blob / unmount.
//   Bind directly to <a download href={downloadUrl}>...

When stream is null (e.g. before useUserMedia resolves) the hook returns inert state and start() is a no-op.

useUploader(recorder, uploader) ​

Wire a Recorder to an Uploader for the lifetime of the calling component. Returns { state, pendingBytes, error, retry } for "Uploading…" / "Failed (retry?)" UI.

tsx
import { defineRecorder, defineUploader } from "@forinda/video-sdk-core";
import { useMemo } from "react";

const { stream } = useUserMedia({ audio: true, video: true });
const recorder = useMemo(() => stream && defineRecorder(stream, { timesliceMs: 1000 }), [stream]);
const uploader = useMemo(() => defineUploader({ url: "/api/uploads" }), []);
const { state, pendingBytes, retry } = useUploader(recorder, uploader);

return (
  <>
    <p>
      Upload: {state} ({pendingBytes} bytes pending)
    </p>
    {state === "failed" && <button onClick={retry}>Retry</button>}
  </>
);

When the uploader transitions to "failed" the underlying recorder is paused automatically; retry() resumes it.

Components ​

<VideoView stream={...} /> ​

<video> wrapper that handles srcObject (which doesn't fit React's prop model) and forwards refs. Optional mirror prop applies transform: scaleX(-1) for selfie preview.

tsx
<VideoView stream={stream} muted autoPlay playsInline mirror />

Forwards every other <video> attribute (controls, poster, className, style, …).

Provider ​

<VideoSdkProvider signaling={...} iceServers={...} retry={...}> ​

Optional context — supplies default config to all hooks. Per-hook overrides win.

signaling is a factory (not an instance) so each usePublisher / useViewer gets its own transport. Multiple instances in one tree don't fight over one socket.

tsx
<VideoSdkProvider
  signaling={() => defineWebSocketSignaling({ url: "wss://..." })}
  iceServers={[{ urls: "stun:stun.l.google.com:19302" }]}
  retry={{ maxAttempts: 5 }}
>
  <App />
</VideoSdkProvider>

Behavior ​

StrictMode ​

Every effect uses AbortController cleanup. The dev-only mount→unmount→remount pattern is idempotent: torn-down media streams are stopped, and the remount fires a fresh request.

SSR ​

  • useUserMedia and useDevices early-return inert state on the server (typeof window === "undefined").
  • usePublisher and useViewer skip the construction effect on the server.
  • <VideoView> renders an empty <video> element.

No hydration mismatch warnings expected.

Cleanup ordering ​

When a hook unmounts:

  1. The AbortController aborts.
  2. All event listeners detach.
  3. The underlying Publisher / Viewer .stop() is called (void-ed; the cleanup function is sync).
  4. State setters reset to initial values.

For useUserMedia, MediaStreamTrack.stop() is called on every track in the active stream.

Pitfalls ​

  • useSyncExternalStore requires React 18+. No fallback shim.
  • <VideoView> autoplay requires muted in modern browsers (Chromium/Safari). If you want unmuted autoplay, ensure a user gesture has occurred first.
  • VideoSdkProvider.signaling is a factory, not an instance. Passing an instance means every hook shares one transport — usually wrong. The signature enforces the factory shape.
  • opts.stream === null skips usePublisher's construction. This is intentional — Publisher needs a stream. Wrap the publisher render in a guard or pass a stream.

License ​

MIT — © 2026 Felix Orinda.

Released under the MIT License.