# Pre-call Self-Test

## Introduction

Build a pre-call self-test screen where users record a short loopback of their own microphone, camera, and network path
before joining a real call. Once recorded, they can watch the result back - what they see and hear is exactly what the
round trip through Stream's SFU delivers to other participants.

This recipe uses the [loopback recording API](https://getstream.io/video/docs/react/advanced/loopback-recording/). The result is a single
screen with three states: device preview, recording, and playback.

## Implementation

<Admonition type="info">

Consider using a dedicated [call type](https://getstream.io/video/docs/react/guides/configuring-call-types/) (for example `pre-call-test`)
and a recognisable call ID (for example prefixed with `pre_call_test_`) for the loopback call. That keeps self-tests
cleanly separated from real call sessions in logs, analytics, and the Stream dashboard, and lets you scope permissions
and defaults independently.

</Admonition>

The screen renders inside `<StreamCall>` on a throwaway call and owns the join → record → leave sequence itself. Do not
join when the screen mounts: `allowOwnTracksLoopback` is fixed at join time, and joining a call has billing and
call-analytics consequences, so it should follow a deliberate button press rather than a page load. The
[loopback recording guide](https://getstream.io/video/docs/react/advanced/loopback-recording/) shows how to create that call.

`useLoopbackRecording` gives you `recordingState` to drive the button, and `loopbackAudioStream` /
`loopbackVideoStream` as live "the echo is flowing" indicators.

```tsx
import { useCallback, useEffect, useRef, useState } from "react";
import {
  CallingState,
  ToggleAudioPreviewButton,
  ToggleVideoPreviewButton,
  VideoPreview,
  useCall,
  useCallStateHooks,
  useLoopbackRecording,
} from "@stream-io/video-react-sdk";

export const PreCallTest = () => {
  const call = useCall();
  const { useCallCallingState, useCameraState, useMicrophoneState } =
    useCallStateHooks();
  const callingState = useCallCallingState();
  const { isEnabled: isCameraEnabled } = useCameraState();
  const { isEnabled: isMicrophoneEnabled } = useMicrophoneState();
  const {
    startRecording,
    stopRecording,
    recordingState,
    loopbackAudioStream,
    loopbackVideoStream,
  } = useLoopbackRecording();

  const [error, setError] = useState<Error>();
  const [recordingUrl, setRecordingUrl] = useState<string>();
  const recordingUrlRef = useRef<string>();

  // the hook revokes nothing on your behalf
  const showRecording = useCallback((recording?: Blob) => {
    if (recordingUrlRef.current) URL.revokeObjectURL(recordingUrlRef.current);
    const url = recording ? URL.createObjectURL(recording) : undefined;
    recordingUrlRef.current = url;
    setRecordingUrl(url);
  }, []);

  useEffect(
    () => () => {
      if (recordingUrlRef.current) URL.revokeObjectURL(recordingUrlRef.current);
    },
    [],
  );

  const runTest = useCallback(async () => {
    if (!call) return;
    setError(undefined);
    showRecording(undefined); // back to preview while the next test runs
    try {
      await call.join({ create: true, allowOwnTracksLoopback: true });
      const recording = await startRecording({ includeVideo: isCameraEnabled });
      if (recording) showRecording(recording.blob);
    } catch (e) {
      setError(e instanceof Error ? e : new Error(String(e)));
    } finally {
      call.leave().catch(console.error);
    }
  }, [call, startRecording, showRecording, isCameraEnabled]);

  const isRecording = recordingState === "recording";
  const isConnecting =
    (callingState === CallingState.JOINING ||
      callingState === CallingState.JOINED) &&
    !isRecording;

  const onClick = () => (isRecording ? stopRecording() : runTest());

  const label = isRecording
    ? "Stop recording"
    : isConnecting
      ? "Connecting…"
      : "Record loopback";

  return (
    <div className="pre-call-test">
      <h1>Test your camera and microphone</h1>
      {error && <p role="alert">{error.message}</p>}

      {recordingUrl ? (
        <video src={recordingUrl} controls playsInline />
      ) : (
        <>
          <VideoPreview />
          <div>
            <ToggleAudioPreviewButton Menu={null} />
            <ToggleVideoPreviewButton Menu={null} />
          </div>
          <div role="status">
            {loopbackAudioStream ? "Audio echo live" : "Waiting for audio echo"}
            {loopbackVideoStream ? "Video echo live" : "Waiting for video echo"}
          </div>
        </>
      )}

      {!isMicrophoneEnabled && !isRecording && (
        <p role="status">
          Enable your microphone to run the test — a loopback recording always
          includes audio. Your camera is optional.
        </p>
      )}

      <button
        type="button"
        onClick={onClick}
        disabled={isConnecting || !isMicrophoneEnabled}
      >
        {label}
      </button>
    </div>
  );
};
```

## Surfacing stats during the test

`useCallStatsReport()` works inside the loopback call too. Render latency, jitter, and bitrate - with the SDK's
`<StatCard />`, or your own markup - while the user is recording, so connectivity issues surface even when the playback
looks fine.

See the [Call Stats Report](https://getstream.io/video/docs/react/advanced/stats/) guide for the shape of the report.


---

This page was last updated at 2026-08-22T17:30:00.350Z.

For the most recent version of this documentation, visit [https://getstream.io/video/docs/react/ui-cookbook/pre-call-self-test/](https://getstream.io/video/docs/react/ui-cookbook/pre-call-self-test/).