Skip to content

React ​

@talkline/react binds @talkline/core to React: a provider and a set of hooks that re-render when the client's state changes. It is headless — no components, no styles, no text. All the logic is in @talkline/core, which is a peer dependency: install both, with @talkline/protocol (Installing the SDKs).

The provider ​

Create the client once, outside React's lifecycle — not in an effect, which StrictMode runs twice — and give it to the provider:

tsx
import { useMemo } from 'react';
import { TalklineClient } from '@talkline/core';
import { TalklineProvider } from '@talkline/react';

export function App() {
  const client = useMemo(
    () =>
      new TalklineClient({
        url: 'wss://signal.talkline.example',
        token: () => fetch('/api/talkline-token', { method: 'POST' }).then((r) => r.json()).then((b) => b.token),
      }),
    [],
  );

  return (
    <TalklineProvider client={client} connect>
      <Calling />
    </TalklineProvider>
  );
}

connect connects on mount. The provider never closes the client: call client.close() yourself when the user signs out.

If that connect fails — a token your endpoint would not give, a server it could not reach — the provider passes the rejection to its onError prop, and to nothing else. Failures no promise was waiting for are the client's error event, which useErrors() collects; the two never carry the same error.

Hooks ​

HookReturns
useTalkline()The client, for calling methods: useTalkline().call({ to: ['agent:7'] })
useConnection(){ state, attempt, cause, reconnecting } — Connection and recovery
useIncomingCall()The call ringing this client, or null
useCall()The current call — placed, ringing (the same call useIncomingCall() returns) or up — and, once it is over, the call that ended until the next one replaces it; null before the first. Re-renders on every change to it
useRoom()The room this client is seated in, or null — null again once it leaves, which the end of a call does on both sides
useParticipants()Everyone else in the room
useTracks(participant)A participant's tracks
useActiveSpeakers()Who is speaking, loudest first
useLocalMedia(){ tracks, micEnabled, cameraEnabled, screenShareEnabled, toggleMic, toggleCamera, toggleScreenShare }
useVisibleTile(track)A ref for a remote video element: it attaches the track, and tells the media server how big the element is and whether it is visible. Use it instead of track.ref, not as well
useAudioPlayback(){ blocked, resume } — blocked when the browser needs a click before it plays audio
useQuality(identity?){ level } — this client's connection quality, or a participant's
useRecording(){ recording, acknowledge } — the room's recording, or null, and the user's consent to it — Recording a room
useAudioOnlyFallback()boolean — whether the media server sends this client audio only, its connection too slow for video
useStats()The latest media statistics samples
useDataMessages(limit?)The latest data messages
useErrors(limit?)The latest errors no promise was waiting for
useDevices(){ audioInput, audioOutput, videoInput, labelsAvailable, refresh }

Each returns a value that changes only when what it describes changes, so a component re-renders for its own reasons and no others.

A call screen ​

tsx
import { useState } from 'react';
import { isTalklineError, type RemoteParticipant } from '@talkline/core';
import { useAudioPlayback, useCall, useIncomingCall, useLocalMedia, useParticipants, useRoom, useTracks } from '@talkline/react';

function Calling() {
  const incoming = useIncomingCall();
  const room = useRoom();
  if (incoming) return <Ringing />;
  if (room) return <InCall />;
  return <Dialer />;
}

function Ringing() {
  const call = useIncomingCall()!;
  const [failed, setFailed] = useState<string | null>(null);
  const answer = () => call.accept().catch((err) => setFailed(isTalklineError(err) ? err.code : 'unknown'));
  return (
    <div>
      <p>{call.from} is calling</p>
      <button onClick={answer}>Answer</button>
      <button onClick={() => void call.reject()}>Decline</button>
      {failed === 'already_answered' && <p>Answered on another device.</p>}
    </div>
  );
}

function InCall() {
  const call = useCall();
  const room = useRoom()!;
  const people = useParticipants();
  const { micEnabled, toggleMic } = useLocalMedia();
  const playback = useAudioPlayback();

  return (
    <div>
      {people.map((p) => <Tile key={p.identity} participant={p} />)}
      {playback.blocked && <button onClick={() => void playback.resume()}>Turn on sound</button>}
      <button onClick={() => void toggleMic()}>{micEnabled ? 'Mute' : 'Unmute'}</button>
      <button onClick={() => void (call?.room ? call.hangup() : room.leave())}>Leave</button>
    </div>
  );
}

function Tile({ participant }: { participant: RemoteParticipant }) {
  const tracks = useTracks(participant);
  const video = tracks.find((t) => t.kind === 'video' && t.source === 'camera');
  const audio = tracks.find((t) => t.kind === 'audio');
  return (
    <figure>
      {video && <video ref={video.ref} playsInline />}
      {audio && <audio ref={audio.ref} />}
      <figcaption>{participant.displayName || participant.identity}</figcaption>
    </figure>
  );
}

track.ref is a stable ref callback: React attaches the track once and detaches it once, however often the component renders. In a group room, use useVisibleTile(track) for remote video instead, so tiles off screen are paused and small ones get a small video (A group room).

source tells a camera from a screen in a group room. In a one-to-one call nothing says which is which, so there every remote video track is camera and every remote audio track microphone — a screen shared in the call included — and the Tile above shows whichever video arrived.

How a call ended ​

useCall() keeps returning a call after it ends, until the next call replaces it. Its status is final and its endReason says why, from this side:

tsx
function LastCall() {
  const call = useCall();
  if (!call || call.endReason === null) return null;
  if (call.endReason === 'remote_hangup') return <p>They hung up.</p>;
  if (call.endReason === 'no_answer') return <p>No answer.</p>;
  return <p>Call ended.</p>;
}

The reasons are in the one-to-one call guide. Every hook is in the @talkline/react reference.

Talkline documentation