@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/reactnpm install @livqeno/rtc @livqeno/reactyarn add @livqeno/rtc @livqeno/reactbun add @livqeno/rtc @livqeno/reactNeeds 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:
- 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. - 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
| Hook | Returns |
|---|---|
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.
| Hook | Returns |
|---|---|
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.
| Hook | Returns |
|---|---|
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.
| Hook | Returns |
|---|---|
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:
// Server Component
import { CallClient } from './call-client';
export default function Page() {
return <CallClient />;
}'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
| Mistake | What you'll see | Fix |
|---|---|---|
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 iceServers | Calls work on Wi-Fi but fail on mobile data. | Pass iceServers from the grant. |
Using useLivqeno() everywhere | Components re-render more than needed. | Use the smaller hooks, like useCamera() or useConnectionState(). |
Expecting leave() to end a live stream | The stream keeps going for everyone else. | End it from your server. See Streams & Lifecycle. |
Next steps
- Build a complete call UI: Build a video call.
- Add more people and a grid layout: Build a group call.
- Add a chat panel: Chat quickstart.
- Handle drops and reconnects gracefully: Reconnection.