livqenoDocs

SDKsReact

React SDK

React hooks and optional components for calls, chat, and live streaming.

@livqeno/react is for React apps (including Next.js) that use calls, chat, or live streaming. It wraps the browser packages (@livqeno/rtc, @livqeno/chat, and @livqeno/live) in React components and hooks.

Why use it instead of the plain packages? A call changes all the time: people join, cameras turn on, the network reconnects. With the plain packages you'd wire up event listeners and copy that state into React yourself. Here, a hook like useRemoteParticipants() just re-renders your component when something changes.

Like every client package, it runs in the browser and holds only a token made by your backend (TypeScript, Python, Go, Java or PHP). The hooks work with any UI you build. A few ready-made components are there if you want a fast start.

You need: a backend endpoint that returns a token, because the API key can't live in a browser. Build your token endpoint has it in TypeScript, Python, Go, Java, PHP and Dart, and the Quickstart walks through the whole flow.

Install

pnpm add @livqeno/rtc @livqeno/react

Needs react ^18 || ^19.

Quickstart

Say you're building a call page. Your page has already fetched the grant from your backend (for example, from POST /join-room). Then:

  1. Wrap your call screen in <LivqenoRoom>, passing values from the grant. It joins the room when it appears on screen and leaves when it's removed.
  2. Use hooks inside it to read state and control the camera.

In your React app:

'use client';
import {
  LivqenoRoom, useConnectionState, useLocalParticipant,
  useRemoteParticipants, useCamera, ParticipantView,
} from '@livqeno/react';
 
function CallPage({ token, endpoint, iceServers, telemetryUrl, roomName }) {
  return (
    <LivqenoRoom
      token={token}
      endpoint={endpoint}
      iceServers={iceServers}
      telemetryUrl={telemetryUrl}
      room={roomName}
      fallback={<p>Connecting…</p>}
    >
      <Call />
    </LivqenoRoom>
  );
}
 
function Call() {
  const state = useConnectionState();
  const local = useLocalParticipant();
  const remote = useRemoteParticipants();
  const camera = useCamera();
 
  return (
    <div>
      <p>Status: {state}</p>
      <button onClick={() => (camera.enabled ? camera.disable() : camera.enable())}>
        Camera: {camera.enabled ? 'on' : 'off'}
      </button>
      {local && <ParticipantView participant={local} />}
      {remote.map((p) => <ParticipantView key={p.identity} participant={p} />)}
    </div>
  );
}

<LivqenoRoom>

Every RTC hook must be inside <LivqenoRoom>. It joins the room and owns the connection. The hooks read from it, which is why a hook outside it has nothing to read.

<LivqenoRoom
  token={token}
  endpoint={endpoint}
  room={roomName}
  iceServers={iceServers}       // optional, from the same token-mint response
  telemetryUrl={telemetryUrl}   // optional, enables telemetry
  autoConnect                    // default true — joins on mount, leaves on unmount
  fallback={<p>Connecting…</p>} // shown while connecting, or on failure
  onError={(error) => {}}       // RTCError
>
  {children}
</LivqenoRoom>

token and endpoint are read once, when it mounts. A token is good for one join only. To join again with a new token, change the key: <LivqenoRoom key={token} token={token} .../>.

Want to join and leave yourself, for example behind a "Join call" button on a lobby screen? Set autoConnect={false}:

function ManualJoin() {
  const { join, leave, connectionState } = useLivqeno();
  return (
    <>
      <button onClick={() => join('room-id')} disabled={connectionState !== 'idle'}>Join</button>
      <button onClick={() => leave()}>Leave</button>
    </>
  );
}

Call hooks

HookReturns
useLivqeno()Everything: connectionState, room, localParticipant, remoteParticipants, error, reconnectCount, plus join() / leave(). Prefer a smaller hook below so your component re-renders less.
useRoom()The current Room, or undefined before you join.
useConnectionState()Just the connection state string.
useLocalParticipant() / useRemoteParticipants()You / everyone else.
useCamera() / useMicrophone(){ enabled, track?, enable(), disable(), error? }.
useActiveSpeakers()Who is talking now, loudest first (you included). Empty in silence.
useIsSpeaking(participant)true while that participant is speaking.
useConnectionQuality(participant)The server's score for their connection: 'excellent', 'good', 'poor', 'lost', or 'unknown' before the first one. Pass useLocalParticipant() for your own.
useTrackSubscriptionState(track){ state, layer } for a video you receive: 'active', 'paused_bandwidth' (your connection can't carry it) or 'paused_client' (you paused it). See Pause a video.

The participant and track hooks re-render only for their own participant or track, so a large grid doesn't re-render every tile when someone starts talking. They also work outside <LivqenoRoom>.

<ParticipantView> uses them already. The tile carries data-livqeno-speaking="true|false" and data-livqeno-connection-quality attributes for your CSS, and takes speakingClassName (added while they talk) and showConnectionQuality (an unstyled indicator):

<ParticipantView participant={p} className="tile" speakingClassName="tile--speaking" showConnectionQuality />

<LocalParticipantView> is the same tile, already set up for useLocalParticipant() — there's no participant prop to pass. If <ParticipantView> doesn't fit your design, build your own tile with useParticipants() and the two elements <ParticipantView> renders under the hood: <LivqenoVideo track={videoTrack} /> and <LivqenoAudio track={audioTrack} />.

Lower-level hooks

Use these when the hooks above aren't enough, for example for a camera picker or an error banner:

const client = useLivqenoClient();   // the RTCClient — devices, createCameraTrack
const livqeno = useLivqeno();          // { client, room, state } — room.on() directly
const error = useLivqenoError();     // the last RTCError this room emitted
const chatError = useChatError();  // the last LivqenoChatError, inside <LivqenoChat>

Use useLivqenoClient() to list devices (cameras, mics):

const client = useLivqenoClient();
const [cameras, setCameras] = useState<DeviceInfo[]>([]);
 
useEffect(() => {
  if (!client) return;
  void client.getDevices('videoinput').then(setCameras);
  return client.onDeviceChange(() => void client.getDevices('videoinput').then(setCameras));
}, [client]);

useLivqenoError() and useChatError() give you the last error, which is handy for an error banner. To act on every error, use onError on the provider or room.on('error', …).

Chat

Chat works the same way. Your backend makes a chat token (a separate token from the call token) and your page passes it in. Wrap your chat UI in <LivqenoChat> and use the hooks. In your React app:

'use client';
import { LivqenoChat, useMessages, useTyping } from '@livqeno/react';
 
function ChatPanel({ chatToken, apiUrl, room }) {
  return (
    <LivqenoChat token={chatToken} apiUrl={apiUrl} room={room}>
      <Thread />
    </LivqenoChat>
  );
}
 
function Thread() {
  const { messages, send } = useMessages();
  const { onInput } = useTyping(); // call on every keystroke — throttled and auto-stopping internally
 
  return (
    <div>
      {messages.map((m) => (
        <p key={m.id}>{m.senderId}: {m.text}</p>
      ))}
      <input
        onChange={onInput}
        onKeyDown={(e) => {
          if (e.key === 'Enter') send(e.currentTarget.value);
        }}
      />
    </div>
  );
}

<LivqenoChat> has its own connection, separate from <LivqenoRoom>. Use both for a call with a chat panel, or either one alone.

HookReturns
useChat()Everything, plus send(), loadMore(), connect(), disconnect(). Prefer a smaller hook below. For example, a typing indicator using useTyping() won't re-render on every message.
useMessages(){ messages, send, loadMore, loading, hasMore }.
usePresence(){ [userId]: status }.
useTyping(){ typingUsers, onInput(), stop() }. Call onInput() on every keystroke. It limits itself and stops after a pause.
useReactions()Add or remove reactions on a message.
useReadReceipts()Who has read what, including you.
useChatClient()The ChatClient itself (undefined until it connects). Use this when none of the hooks above do what you need.
useChatConnectionState()<LivqenoChat>'s connection state, as a string.

The full chat API (history, threads, attachments) is in Chat Overview. The chat UI kit (<ChatProvider>, <ChannelList>, <MessageList>, <MessageComposer>, <Thread>) is in React components → Adding chat.

Inbox & conversations

These hooks sit a level above a single chat room. They give you this user's list of conversations with unread counts, one room's messages (including ones just sent but not yet confirmed by the server), and that user's moderation status in a room. A live-stream audience viewer has no inbox, so these are all empty for them.

HookReturns
useInbox(options?){ conversations, totalUnread, loading, hasMore, loadMore(), refresh() }, newest activity first. options.pageSize sets how many load at first.
useUnreadCount(room?)Adds up the unread count across every conversation that isn't muted. Pass a room id (like conv_123) to get just that one conversation's count instead. This is the number you'd show on an inbox badge.
useConversation(room, options?){ messages, loading, hasMore, error, pinned, loadOlder(), send(input), retry(clientMessageId), cancel(clientMessageId), markRead() }. Joins the room automatically if it isn't joined yet. If more than one component uses the same room, they share the same data, so a message list and its composer always agree about a message that was just sent. options.pageSize controls how much history loads at once.
usePresenceCount(room?)How many people are currently online. In a large room, this comes from the server's presence.count; in a smaller one, it's a count of members whose status is online. Leave out room to use the provider's default room.
useModeration(room?){ muted, mutedUntil, banned, bannedUntil, block(userId), unblock(userId), listBlocks(), flagMessage(messageId, reason, note?), ban(userId, options?), unban(userId), mute(userId, options?), unmute(userId) } — this user's moderation status in the room, plus the actions themselves. ban/unban/mute/unmute only work for moderators; the server rejects them from anyone else. Updates automatically whenever a moderation event happens in the room.
function Inbox() {
  const { conversations, totalUnread, loadMore } = useInbox();
  return (
    <ul>
      {conversations.map((c) => (
        <li key={c.id}>{c.id} ({c.unreadCount} unread)</li>
      ))}
      {totalUnread > 0 && <button onClick={loadMore}>Load more</button>}
    </ul>
  );
}

ConversationStore is the class these hooks are built on. It's also exported directly, in case you want to build a conversation UI without React.

Live streaming

A live stream is one host broadcasting to many viewers, with chat, like a webinar. Your backend decides each user's role before they join: it calls addHost() for a host or createViewerToken() for a viewer, and sends the result (the credentials) to the page.

<LivqenoLiveStream> sets up both the room and the chat for a stream. Inside it, the normal hooks (useParticipants, useCamera, useMessages, useReactions, …) just work. There are no special "live stream" versions of them.

'use client';
import {
  LivqenoLiveStream, useLiveStream, useLiveStreamHost, useParticipants, useMessages,
} from '@livqeno/react';
 
function StreamPage({ credentials }) {
  return (
    <LivqenoLiveStream credentials={credentials} fallback={<p>Connecting…</p>}>
      {credentials.role === 'VIEWER' ? <Viewer /> : <Host />}
    </LivqenoLiveStream>
  );
}
 
function Host() {
  const { camera, microphone, leave } = useLiveStreamHost();
  const participants = useParticipants();
  const { messages, send } = useMessages();
 
  return (
    <div>
      <button onClick={() => camera.enable()}>Go live</button>
      <p>{participants.length} in the room</p>
      {messages.map((m) => <p key={m.id}>{m.senderId}: {m.text}</p>)}
      <button onClick={leave}>End for me</button>
    </div>
  );
}
 
function Viewer() {
  const { react } = useLiveStream();
  return <button onClick={() => react('❤️')}>❤️</button>;
}

credentials is exactly what your backend gets from addHost() / createViewerToken() (server SDK) or POST /v1/live-streams/:id/hosts / viewer-tokens (REST). Don't build it by hand.

HookReturns
useLiveStream(){ status, role, isHost, streamId, stream, error, leave(), react(emoji) }.
useLiveStreamRole(){ role, isHost }.
useLiveStreamHost()Same as useLiveStream(), plus camera / microphone. Throws for a VIEWER.
useLiveStreamViewer()Same as useLiveStream(). Throws for a HOST / CO_HOST.
useLiveStreamClient()The underlying LiveStream (from @livqeno/live), for anything else.

<LivqenoLiveStream> needs @livqeno/live installed. You only need it if you use live streaming.

Next.js

<LivqenoRoom> and every hook must run in a Client Component, because they need the browser's camera, microphone, and WebRTC support, which don't exist while Next.js renders on the server. Put the provider in a 'use client' file. The page around it can still be a Server Component:

app/call/page.tsx
// Server Component
import { CallClient } from './call-client';
export default function Page() {
  return <CallClient />;
}
app/call/call-client.tsx
'use client';
export function CallClient() {
  return <LivqenoRoom /* ... */>{/* ... */}</LivqenoRoom>;
}

Your token endpoint can live in the same Next.js app, as a Route Handler (for example app/api/join-room/route.ts). That's server code, so it's the right place for @livqeno/server and your API key.

Common mistakes

MistakeWhat you'll seeFix
Using a hook outside <LivqenoRoom> or <LivqenoChat>The hook has no room or chat to read.Move the component inside the provider.
Changing token on a mounted <LivqenoRoom>Nothing happens: the token is read once, at mount.Change the key too: <LivqenoRoom key={token} … />.
Leaving out iceServersCalls work on Wi-Fi but fail on mobile data.Pass iceServers from the grant.
Using useLivqeno() everywhereComponents re-render more than needed.Use the smaller hooks, like useCamera() or useConnectionState().
Expecting leave() to end a live streamThe stream keeps going for everyone else.End it from your server. See Streams & Lifecycle.

Next steps

  1. Build a complete call UI: Build a video call.
  2. Add more people and a grid layout: Build a group call.
  3. Add a chat panel: Chat quickstart.
  4. Handle drops and reconnects gracefully: Reconnection.