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#

Terminal
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.

CallScreen.tsx
'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.

ReturnsTypeDescription
engineAppEngineThe 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.
remoteUsersAppRemoteUser[]Everyone else in the channel. Updated when someone joins or leaves.
activeSpeakersstring[]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

FieldTypeDefaultDescription
appIdstringrequiredYour project App ID from the console. Safe to ship in your bundle.
adaptiveStreambooleantrueAdjusts received video quality to the network and to the on-screen size of the element the track renders in.

AppJoinOptions

OptionTypeDefaultDescription
role'host' | 'cohost' | 'audience'undefinedTells 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.
camerabooleanfalsePublish the camera on join.
microphonebooleantruePublish 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.

Per-user tiles
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.

PropTypeDescription
trackAppTrackRequired. The track to render, from useRemoteTracks or a track-subscribed event.
styleReact.CSSPropertiesMerged over the defaults: 100% width and height with objectFit cover.
classNamestringApplied to the element.
mutedbooleanMutes 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.

PropTypeDescription
engineAppEngineRequired. The engine from useAppEngine or useApp.
styleReact.CSSPropertiesMerged over the defaults, which include a horizontal flip for the mirror effect. Override transform to remove it.
classNamestringApplied 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.
Shared engine
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.

MethodReturnsDescription
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)voidManual 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.

EventPayloadWhen 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.
Chat over the data channel
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#

AppRemoteUserTypeDescription
uidstringThe identity your server assigned in the token. Unique in the channel.
displayNamestring | undefinedThe name from the token, if your server set one.
metadatastring | undefinedThe metadata from the token, as a string. Parse it yourself if you stored JSON.
isSpeakingbooleanTrue while this user is talking.
audioEnabledbooleanThe user is publishing an unmuted microphone.
videoEnabledbooleanThe 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.

AppTrackSignatureDescription
kind'audio' | 'video'What this track carries.
attachattach(el?: HTMLMediaElement): HTMLMediaElementAttaches to an element, or creates one. AppVideoView does this for you.
detachdetach(): voidDetaches from every element.

Lifecycle and cleanup#

  • useAppEngine leaves the channel when the component unmounts, so navigating away ends the call. AppProvider does the same.
  • leave() ends the call but keeps the engine. You can call join() 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.on in a useEffect must be removed with engine.offin that effect's cleanup, passing the same function reference. Otherwise a remount stacks listeners and each callback runs once per mount.
  • AppVideoView detaches its track on unmount and AppLocalView removes 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.