React SDK
@applooma/rtc-react wraps the Web SDK in hooks and components. A hook owns the engine and keeps the participant list in React state, and two components render remote and local video. Everything from @applooma/rtc-web is re-exported, so you can drop down to the engine API at any point.
Install#
npm install @applooma/rtc-react
React 18 or newer is a peer dependency. The Web SDK is installed automatically as a dependency — you do not need to add it yourself.
These are client components
The hooks and views use browser APIs. In Next.js App Router, put 'use client' at the top of the file that uses them.
A complete example#
Your server issues the token. The browser never sees your API secret. See Quickstart for the server side.
'use client';import { useState } from 'react';import {useAppEngine,useRemoteTracks,AppVideoView,AppLocalView,} from '@applooma/rtc-react';export function CallScreen({ channel }: { channel: string }) {const { engine, join, leave, connectionState, remoteUsers } =useAppEngine({ appId: 'YOUR_APP_ID' });const tracks = useRemoteTracks(engine);const [joining, setJoining] = useState(false);async function start() {setJoining(true);try {// Token comes from YOUR server.const res = await fetch('/api/rtc-token', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ channel }),});const { token, wsUrl } = await res.json();await join(token, wsUrl, { role: 'host', camera: true });} finally {setJoining(false);}}return (<div><p>{connectionState} — {remoteUsers.length} other participants</p><AppLocalView engine={engine} style={{ width: 240, height: 180 }} />{tracks.map((track, i) => (<AppVideoView key={i} track={track} style={{ width: 240 }} />))}<button onClick={start} disabled={joining}>Join</button><button onClick={leave}>Leave</button></div>);}
useAppEngine#
useAppEngine(config: AppEngineConfig): UseAppEngineResult
Creates one engine for the lifetime of the component and subscribes to the events needed to keep its return value current. The engine is created on the first render and never recreated, so changing appId afterwards has no effect — remount the component instead.
| Returns | Type | Description |
|---|---|---|
engine | AppEngine | The underlying engine. Use it for anything the hook does not expose: enableCamera, enableMicrophone, enableScreenShare, switchCamera, sendData, and the full event API. |
connectionState | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | Re-renders on every change. Drive your reconnecting banner from this. |
remoteUsers | AppRemoteUser[] | Everyone else in the channel. Updated when someone joins or leaves. |
activeSpeakers | string[] | Identities currently speaking. Updated on every change. |
join | (token, wsUrl, options?) => Promise<void> | Calls engine.joinChannel. Stable across renders. |
leave | () => Promise<void> | Calls engine.leaveChannel. Stable across renders. |
AppEngineConfig
| Field | Type | Default | Description |
|---|---|---|---|
appId | string | required | Your project App ID from the console. Safe to ship in your bundle. |
adaptiveStream | boolean | true | Adjusts received video quality to the network and to the on-screen size of the element the track renders in. |
AppJoinOptions
| Option | Type | Default | Description |
|---|---|---|---|
role | 'host' | 'cohost' | 'audience' | undefined | Tells the SDK whether to publish. With audience, nothing is published even if camera is true. It must match the role in the token — the token is what the server enforces. |
camera | boolean | false | Publish the camera on join. |
microphone | boolean | true | Publish the microphone on join. Pass false to join muted. |
join rejects if the token is invalid or expired, if the connection fails, or if the user denies device access. It also throws Already in a channel — call leaveChannel() first when called twice without leaving.
useRemoteTracks#
useRemoteTracks(engine: AppEngine, uid?: string): AppTrack[]
Returns the remote tracks currently subscribed, as React state. With a uid, only that user's tracks are returned. Both audio and video tracks appear in the list.
It only sees tracks that arrive after it mounts
The hook builds its list from track-subscribed events. Call it in the same component that owns the engine, so it is listening before join runs. If you mount it later, tracks already subscribed will be missing until they are republished.
function UserTile({ engine, user }) {const tracks = useRemoteTracks(engine, user.uid);const video = tracks.find((t) => t.kind === 'video');const audio = tracks.find((t) => t.kind === 'audio');return (<div><span>{user.displayName ?? user.uid}</span>{video && <AppVideoView track={video} />}{audio && <AppVideoView track={audio} />}</div>);}
Components#
AppVideoView
Renders one remote track. It attaches the track on mount and detaches it on unmount, so you never call attach yourself.
| Prop | Type | Description |
|---|---|---|
track | AppTrack | Required. The track to render, from useRemoteTracks or a track-subscribed event. |
style | React.CSSProperties | Merged over the defaults: 100% width and height with objectFit cover. |
className | string | Applied to the element. |
muted | boolean | Mutes playback of this element. |
The component renders a <video> element for a video track and an <audio> element for an audio track, both with autoPlay. Render your audio tracks too — if you only render video, the call is silent.
AppLocalView
Renders the local camera preview, mirrored. It takes the engine rather than a track, and re-attaches whenever the connection state changes, so the preview appears once the camera is published after joining.
| Prop | Type | Description |
|---|---|---|
engine | AppEngine | Required. The engine from useAppEngine or useApp. |
style | React.CSSProperties | Merged over the defaults, which include a horizontal flip for the mirror effect. Override transform to remove it. |
className | string | Applied to the element. |
The element is always muted, so you never hear yourself. It stays blank until the camera is published — join with camera: true or call engine.enableCamera(true).
AppProvider and useApp#
Use these when several components need the same engine and you do not want to pass it down by prop.
<AppProvider config={{ appId }}>— creates one engine and puts it in context. It leaves the channel when it unmounts.useApp(): AppEngine— reads that engine. Throws if called outside a provider.
import { AppProvider, useApp, useRemoteTracks, AppVideoView } from '@applooma/rtc-react';function Stage() {const engine = useApp();const tracks = useRemoteTracks(engine);return <>{tracks.map((t, i) => <AppVideoView key={i} track={t} />)}</>;}export function App() {return (<AppProvider config={{ appId: 'YOUR_APP_ID' }}><Stage /></AppProvider>);}
Choose one or the other
useAppEngine creates its own engine. Calling it inside a provider gives you a second, unrelated engine. Inside a provider, use useApp and call engine.joinChannel directly.
Engine methods and events#
The hook covers joining, leaving and the participant list. Everything else is on the engine. All of it is documented in the Web SDK reference; the summary is below.
| Method | Returns | Description |
|---|---|---|
enableCamera(on = true) | Promise<void> | Publishes or unpublishes the camera. |
enableMicrophone(on = true) | Promise<void> | Publishes or unpublishes the microphone. |
enableScreenShare(on = true) | Promise<void> | Starts or stops a screen share. |
switchCamera() | Promise<void> | Flips between front and back camera. |
attachLocalVideo(el) | void | Manual alternative to AppLocalView. |
sendData(data, reliable = true) | Promise<void> | Broadcasts a string or Uint8Array to every participant. |
destroy() | Promise<void> | Disconnects and releases the engine permanently. |
Read-only state on the engine: connectionState, localUid, channelName and remoteUsers.
| Event | Payload | When it fires |
|---|---|---|
user-joined | (user: AppRemoteUser) | Someone joined, or was already present when you joined. |
user-left | (user: AppRemoteUser) | Someone left. |
track-subscribed | (track: AppTrack, user: AppRemoteUser) | A remote track is ready to render. |
track-unsubscribed | (track: AppTrack, user: AppRemoteUser) | A remote track stopped. |
connection-state-changed | (state) | The connection state changed. |
disconnected | (reason?: string) | The connection ended. Reason is a short code such as DUPLICATE_IDENTITY. |
data-received | (data: Uint8Array, from?: AppRemoteUser) | Someone called sendData. |
gift-received | (gift: AppGiftEvent) | A virtual gift was sent through the REST API. |
active-speakers-changed | (uids: string[]) | The speaking set changed. Also exposed as activeSpeakers by the hook. |
error | (err: Error) | A device error, such as permission denied. |
useEffect(() => {const onData = (bytes: Uint8Array) => {setMessages((m) => [...m, new TextDecoder().decode(bytes)]);};engine.on('data-received', onData);return () => { engine.off('data-received', onData); };}, [engine]);
AppRemoteUser and AppTrack#
| AppRemoteUser | Type | Description |
|---|---|---|
uid | string | The identity your server assigned in the token. Unique in the channel. |
displayName | string | undefined | The name from the token, if your server set one. |
metadata | string | undefined | The metadata from the token, as a string. Parse it yourself if you stored JSON. |
isSpeaking | boolean | True while this user is talking. |
audioEnabled | boolean | The user is publishing an unmuted microphone. |
videoEnabled | boolean | The user is publishing a camera. |
These properties read live state rather than a snapshot. They do not trigger a re-render on their own — read them in a render driven by remoteUsers or activeSpeakers.
| AppTrack | Signature | Description |
|---|---|---|
kind | 'audio' | 'video' | What this track carries. |
attach | attach(el?: HTMLMediaElement): HTMLMediaElement | Attaches to an element, or creates one. AppVideoView does this for you. |
detach | detach(): void | Detaches from every element. |
Lifecycle and cleanup#
useAppEngineleaves the channel when the component unmounts, so navigating away ends the call.AppProviderdoes the same.leave()ends the call but keeps the engine. You can calljoin()again with a new token from the same component.engine.destroy()releases the engine permanently and clears every listener. The hook does not call it, because the engine may be reused for another call. Call it yourself if the component owns the engine for the lifetime of the page.- Listeners you register with
engine.onin auseEffectmust be removed withengine.offin that effect's cleanup, passing the same function reference. Otherwise a remount stacks listeners and each callback runs once per mount. AppVideoViewdetaches its track on unmount andAppLocalViewremoves its listener, so removing a tile needs no extra cleanup.
Troubleshooting#
Permission denied
join rejects with a permission error when the user dismisses the browser prompt, or when the page is not on a secure origin. Serve over HTTPS, and re-prompt with engine.enableCamera(true) from a button click — browsers ignore requests that are not tied to a user gesture after a refusal.
Video plays but there is no sound
Browsers block audio until the user interacts with the page. If you join without a click — for example from an effect on mount — the audio elements stay paused. Trigger join from a button, and keep a visible unmute control as a fallback.
Nothing renders for a participant
First check the role in their token. A participant whose token was issued with role audience cannot publish, and passing camera: true to join does not change that. Second, check that useRemoteTracks is mounted in the component that joins, so it is listening before the tracks arrive.
A tab gets disconnected on its own
Two clients joined with the same identity. The newer connection wins and the older one is dropped with reason DUPLICATE_IDENTITY. In development this often means React Strict Mode mounted the component twice — the hook handles that correctly, but two open tabs sharing one token do not. Give each participant a unique identity in the token.
AppLocalView is blank
The camera is not published. Join with camera: true or call engine.enableCamera(true). Check also that you passed the engine from the same hook that joined.
The channel looks empty
Both sides must use the same channel name. Channel names must match /^[a-zA-Z0-9_-]{1,64}$/ — no spaces, dots or slashes — and are compared exactly, including case.