Skip to main content
Back to Blog
Tutorials
4 min read
December 23, 2024

How to Implement WebRTC Video Calling in a React App

Build a peer-to-peer video calling feature in React using WebRTC with signaling, ICE candidates, screen sharing, and connection management.

Ryel Banfield

Founder & Lead Developer

WebRTC enables real-time peer-to-peer video and audio communication directly in the browser. Here is how to build it.

WebRTC Connection Manager

// lib/webrtc.ts
type SignalHandler = (data: { type: string; payload: unknown; to: string }) => void;

interface RTCConfig {
  iceServers: RTCIceServer[];
}

const defaultConfig: RTCConfig = {
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" },
    { urls: "stun:stun1.l.google.com:19302" },
  ],
};

export class WebRTCConnection {
  private pc: RTCPeerConnection;
  private localStream: MediaStream | null = null;
  private remoteStream = new MediaStream();
  private sendSignal: SignalHandler;
  private peerId: string;

  onRemoteStream: ((stream: MediaStream) => void) | null = null;
  onConnectionState: ((state: RTCPeerConnectionState) => void) | null = null;

  constructor(peerId: string, sendSignal: SignalHandler, config = defaultConfig) {
    this.peerId = peerId;
    this.sendSignal = sendSignal;
    this.pc = new RTCPeerConnection(config);

    this.pc.ontrack = (event) => {
      event.streams[0].getTracks().forEach((track) => {
        this.remoteStream.addTrack(track);
      });
      this.onRemoteStream?.(this.remoteStream);
    };

    this.pc.onicecandidate = (event) => {
      if (event.candidate) {
        this.sendSignal({
          type: "ice-candidate",
          payload: event.candidate.toJSON(),
          to: this.peerId,
        });
      }
    };

    this.pc.onconnectionstatechange = () => {
      this.onConnectionState?.(this.pc.connectionState);
    };
  }

  async startCall(stream: MediaStream) {
    this.localStream = stream;
    stream.getTracks().forEach((track) => {
      this.pc.addTrack(track, stream);
    });

    const offer = await this.pc.createOffer();
    await this.pc.setLocalDescription(offer);

    this.sendSignal({
      type: "offer",
      payload: offer,
      to: this.peerId,
    });
  }

  async handleOffer(offer: RTCSessionDescriptionInit, stream: MediaStream) {
    this.localStream = stream;
    stream.getTracks().forEach((track) => {
      this.pc.addTrack(track, stream);
    });

    await this.pc.setRemoteDescription(new RTCSessionDescription(offer));
    const answer = await this.pc.createAnswer();
    await this.pc.setLocalDescription(answer);

    this.sendSignal({
      type: "answer",
      payload: answer,
      to: this.peerId,
    });
  }

  async handleAnswer(answer: RTCSessionDescriptionInit) {
    await this.pc.setRemoteDescription(new RTCSessionDescription(answer));
  }

  async handleIceCandidate(candidate: RTCIceCandidateInit) {
    await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
  }

  toggleAudio(enabled: boolean) {
    this.localStream?.getAudioTracks().forEach((track) => {
      track.enabled = enabled;
    });
  }

  toggleVideo(enabled: boolean) {
    this.localStream?.getVideoTracks().forEach((track) => {
      track.enabled = enabled;
    });
  }

  async shareScreen() {
    const screenStream = await navigator.mediaDevices.getDisplayMedia({
      video: true,
    });
    const screenTrack = screenStream.getVideoTracks()[0];
    const sender = this.pc
      .getSenders()
      .find((s) => s.track?.kind === "video");

    if (sender) {
      await sender.replaceTrack(screenTrack);
    }

    // Restore camera when screen sharing stops
    screenTrack.onended = async () => {
      const cameraTrack = this.localStream?.getVideoTracks()[0];
      if (sender && cameraTrack) {
        await sender.replaceTrack(cameraTrack);
      }
    };

    return screenStream;
  }

  close() {
    this.localStream?.getTracks().forEach((t) => t.stop());
    this.pc.close();
  }
}

React Hook

// hooks/useVideoCall.ts
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { WebRTCConnection } from "@/lib/webrtc";

interface UseVideoCallOptions {
  roomId: string;
  userId: string;
  signalingUrl: string;
}

export function useVideoCall({ roomId, userId, signalingUrl }: UseVideoCallOptions) {
  const [localStream, setLocalStream] = useState<MediaStream | null>(null);
  const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
  const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
  const [audioEnabled, setAudioEnabled] = useState(true);
  const [videoEnabled, setVideoEnabled] = useState(true);

  const connectionRef = useRef<WebRTCConnection | null>(null);
  const wsRef = useRef<WebSocket | null>(null);

  const sendSignal = useCallback(
    (data: { type: string; payload: unknown; to: string }) => {
      wsRef.current?.send(JSON.stringify({ ...data, from: userId, room: roomId }));
    },
    [userId, roomId]
  );

  const startCall = useCallback(async () => {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: { width: 1280, height: 720 },
      audio: { echoCancellation: true, noiseSuppression: true },
    });
    setLocalStream(stream);
    return stream;
  }, []);

  useEffect(() => {
    const ws = new WebSocket(signalingUrl);
    wsRef.current = ws;

    ws.onopen = () => {
      ws.send(JSON.stringify({ type: "join", room: roomId, userId }));
    };

    ws.onmessage = async (event) => {
      const data = JSON.parse(event.data);

      switch (data.type) {
        case "user-joined": {
          const stream = await startCall();
          const conn = new WebRTCConnection(data.userId, sendSignal);
          conn.onRemoteStream = setRemoteStream;
          conn.onConnectionState = setConnectionState;
          connectionRef.current = conn;
          await conn.startCall(stream);
          break;
        }
        case "offer": {
          const stream = await startCall();
          const conn = new WebRTCConnection(data.from, sendSignal);
          conn.onRemoteStream = setRemoteStream;
          conn.onConnectionState = setConnectionState;
          connectionRef.current = conn;
          await conn.handleOffer(data.payload, stream);
          break;
        }
        case "answer": {
          await connectionRef.current?.handleAnswer(data.payload);
          break;
        }
        case "ice-candidate": {
          await connectionRef.current?.handleIceCandidate(data.payload);
          break;
        }
      }
    };

    return () => {
      connectionRef.current?.close();
      ws.close();
    };
  }, [roomId, userId, signalingUrl, sendSignal, startCall]);

  const toggleAudio = useCallback(() => {
    const next = !audioEnabled;
    connectionRef.current?.toggleAudio(next);
    setAudioEnabled(next);
  }, [audioEnabled]);

  const toggleVideo = useCallback(() => {
    const next = !videoEnabled;
    connectionRef.current?.toggleVideo(next);
    setVideoEnabled(next);
  }, [videoEnabled]);

  const shareScreen = useCallback(async () => {
    await connectionRef.current?.shareScreen();
  }, []);

  const endCall = useCallback(() => {
    connectionRef.current?.close();
    localStream?.getTracks().forEach((t) => t.stop());
    setLocalStream(null);
    setRemoteStream(null);
    setConnectionState("closed");
  }, [localStream]);

  return {
    localStream,
    remoteStream,
    connectionState,
    audioEnabled,
    videoEnabled,
    toggleAudio,
    toggleVideo,
    shareScreen,
    endCall,
  };
}

Video Call UI

// components/VideoCall.tsx
"use client";

import { useEffect, useRef } from "react";
import { useVideoCall } from "@/hooks/useVideoCall";

interface VideoCallProps {
  roomId: string;
  userId: string;
}

export function VideoCall({ roomId, userId }: VideoCallProps) {
  const {
    localStream,
    remoteStream,
    connectionState,
    audioEnabled,
    videoEnabled,
    toggleAudio,
    toggleVideo,
    shareScreen,
    endCall,
  } = useVideoCall({
    roomId,
    userId,
    signalingUrl: process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080",
  });

  const localRef = useRef<HTMLVideoElement>(null);
  const remoteRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    if (localRef.current && localStream) {
      localRef.current.srcObject = localStream;
    }
  }, [localStream]);

  useEffect(() => {
    if (remoteRef.current && remoteStream) {
      remoteRef.current.srcObject = remoteStream;
    }
  }, [remoteStream]);

  return (
    <div className="relative w-full max-w-4xl mx-auto">
      {/* Remote video (main) */}
      <div className="aspect-video bg-gray-900 rounded-xl overflow-hidden">
        {remoteStream ? (
          <video ref={remoteRef} autoPlay playsInline className="w-full h-full object-cover" />
        ) : (
          <div className="flex items-center justify-center h-full text-white/60">
            {connectionState === "connecting" ? "Connecting..." : "Waiting for participant..."}
          </div>
        )}
      </div>

      {/* Local video (picture-in-picture) */}
      <div className="absolute top-4 right-4 w-48 aspect-video bg-gray-800 rounded-lg overflow-hidden border-2 border-white/20">
        {localStream ? (
          <video ref={localRef} autoPlay muted playsInline className="w-full h-full object-cover" />
        ) : (
          <div className="flex items-center justify-center h-full text-white/40 text-xs">
            Camera off
          </div>
        )}
      </div>

      {/* Controls */}
      <div className="flex items-center justify-center gap-3 mt-4">
        <button
          onClick={toggleAudio}
          className={`p-3 rounded-full ${audioEnabled ? "bg-gray-700 text-white" : "bg-red-600 text-white"}`}
          aria-label={audioEnabled ? "Mute microphone" : "Unmute microphone"}
        >
          {audioEnabled ? "Mic On" : "Mic Off"}
        </button>
        <button
          onClick={toggleVideo}
          className={`p-3 rounded-full ${videoEnabled ? "bg-gray-700 text-white" : "bg-red-600 text-white"}`}
          aria-label={videoEnabled ? "Turn off camera" : "Turn on camera"}
        >
          {videoEnabled ? "Cam On" : "Cam Off"}
        </button>
        <button
          onClick={shareScreen}
          className="p-3 rounded-full bg-gray-700 text-white"
          aria-label="Share screen"
        >
          Screen
        </button>
        <button
          onClick={endCall}
          className="p-3 rounded-full bg-red-600 text-white"
          aria-label="End call"
        >
          End
        </button>
      </div>
    </div>
  );
}

Need Real-Time Communication Features?

We build video calling, live chat, and real-time collaboration tools. Reach out to discuss your project.

WebRTCvideo callingreal-timeReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles