@forinda/video-sdk-react
v1.0.0
Published
React hooks and components for the Forinda video SDK
Maintainers
Readme
@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
pnpm add @forinda/video-sdk-react @forinda/video-sdk-core @forinda/video-sdk-signaling-ws react react-domPeer deps:
@forinda/video-sdk-core,react@>=18. The hooks rely onuseSyncExternalStore, so React 17 isn't supported.@forinda/video-sdk-signaling-wsis the WebSocket transport used in the quick-start — swap or omit if you bring your own.
Quick start
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.
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.
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 publisherThe "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.
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.
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.
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.
const stats = useConnectionStats(publisher); // ConnectionStats[]
const oneViewer = useConnectionStats(viewer); // ConnectionStats | nulluseRoom(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.
const { room, state, error } = useRoom({ room: "demo", peerId: "alice" });
const { publisher } = usePublisher({ attach: room, stream });
const { channel } = useRoomChannel({ attach: room });
const { messages } = useChat(channel);| Option | Default | Description |
| ----------- | --------------------- | ------------------------------- |
| room | — | Required. Room id. |
| peerId | crypto.randomUUID() | Self id. |
| signaling | from provider | Pre-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:
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.
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| Option | Default | Description |
| ------------------ | --------------------- | --------------------------------------------------------------------------------- |
| room | — | Required. |
| peerId | crypto.randomUUID() | Self id. |
| signaling | from provider | Pre-built SignalingTransport. |
| manageJoin | true | Issue join + leave. Set false when sharing a transport with a Publisher/Viewer. |
| chatHistoryLimit | 200 | Rolling chat-buffer cap. |
| autoStart | true | Call 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.
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.
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.
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.
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.
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.
<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.
<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
useUserMediaanduseDevicesearly-return inert state on the server (typeof window === "undefined").usePublisheranduseViewerskip the construction effect on the server.<VideoView>renders an empty<video>element.
No hydration mismatch warnings expected.
Cleanup ordering
When a hook unmounts:
- The
AbortControlleraborts. - All event listeners detach.
- The underlying
Publisher/Viewer.stop()is called (void-ed; the cleanup function is sync). - State setters reset to initial values.
For useUserMedia, MediaStreamTrack.stop() is called on every track in the active stream.
Pitfalls
useSyncExternalStorerequires React 18+. No fallback shim.<VideoView>autoplay requiresmutedin modern browsers (Chromium/Safari). If you want unmuted autoplay, ensure a user gesture has occurred first.VideoSdkProvider.signalingis a factory, not an instance. Passing an instance means every hook shares one transport — usually wrong. The signature enforces the factory shape.opts.stream === nullskipsusePublisher'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.
