EmpireUI
Get Pro
← Blog9 min read#webrtc#react#video call

WebRTC in React: Video Calls, Screen Share, Peer-to-Peer Data

Build real-time video calls, screen sharing, and P2P data channels in React with WebRTC — no third-party SDK required. Practical code, zero fluff.

Abstract neon network connections representing real-time peer communication

What WebRTC Actually Is (And What You're Responsible For)

WebRTC — Web Real-Time Communication — is a browser API that lets two clients talk directly to each other. Audio, video, arbitrary binary data. No server in the media path once the connection is established. That last part trips people up every time.

Here's what WebRTC handles for you: codec negotiation, jitter buffering, packet loss concealment, encryption (DTLS-SRTP, mandatory since 2014). Here's what it absolutely does not handle: finding the other peer. You need a signaling channel for that — a WebSocket, a REST endpoint, even a shared Google Doc in theory. The spec intentionally leaves signaling out of scope.

In practice, a minimal WebRTC setup has three moving parts: a signaling server you build, one or more STUN/TURN servers you either self-host or rent, and the browser RTCPeerConnection API you call from React. Most tutorials skip the TURN server and then wonder why video calls fail on corporate NATs. Don't be that tutorial.

Worth noting: the RTCPeerConnection API is stable across Chrome 90+, Firefox 78+, Safari 15+. You're not living on the bleeding edge here — this API shipped over a decade ago and the rough edges are well-documented.

Setting Up Signaling with a WebSocket Server

Before a single video frame moves peer-to-peer, both clients need to exchange SDP offers and ICE candidates. That exchange is signaling. You pick the transport. WebSockets are the standard choice because they're bidirectional and stay open.

A dead-simple Node signaling server looks like this:

// server.js — Node + ws package
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
const rooms = {};

wss.on('connection', (ws) => {
  ws.on('message', (raw) => {
    const msg = JSON.parse(raw);
    const { room, ...payload } = msg;

    if (!rooms[room]) rooms[room] = [];
    rooms[room].push(ws);

    // Relay to everyone else in the room
    rooms[room].forEach((client) => {
      if (client !== ws && client.readyState === 1) {
        client.send(JSON.stringify(payload));
      }
    });
  });
});

That's genuinely it for a two-person call. For production you'd add auth, room size limits, and reconnect logic. But don't over-engineer signaling — it carries kilobytes of JSON, not media. A single t3.micro can handle thousands of concurrent signaling sessions.

On the React side, open the socket once on mount and keep a stable ref to it. Don't recreate it on every render — that's the most common mistake I see in WebRTC React repos.

Building the RTCPeerConnection in a React Hook

Everything belongs in a custom hook. useWebRTC or useVideoCall — name doesn't matter, isolation does. You don't want RTCPeerConnection construction logic leaking into your UI components.

// hooks/useWebRTC.ts
import { useRef, useEffect, useCallback, useState } from 'react';

const ICE_SERVERS = [
  { urls: 'stun:stun.l.google.com:19302' },
  // Add your TURN server here for production
];

export function useWebRTC(signalingSocket: WebSocket | null) {
  const pcRef = useRef<RTCPeerConnection | null>(null);
  const localStreamRef = useRef<MediaStream | null>(null);
  const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);

  const createPeerConnection = useCallback(() => {
    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });

    pc.ontrack = (e) => {
      setRemoteStream(e.streams[0]);
    };

    pc.onicecandidate = (e) => {
      if (e.candidate && signalingSocket) {
        signalingSocket.send(JSON.stringify({
          type: 'ice-candidate',
          candidate: e.candidate,
        }));
      }
    };

    pcRef.current = pc;
    return pc;
  }, [signalingSocket]);

  const startCall = useCallback(async () => {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true,
    });
    localStreamRef.current = stream;

    const pc = createPeerConnection();
    stream.getTracks().forEach((track) => pc.addTrack(track, stream));

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

    signalingSocket?.send(JSON.stringify({ type: 'offer', sdp: offer }));
    return stream;
  }, [createPeerConnection, signalingSocket]);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      pcRef.current?.close();
      localStreamRef.current?.getTracks().forEach((t) => t.stop());
    };
  }, []);

  return { startCall, remoteStream, localStreamRef };
}

The ontrack handler fires when the remote peer's tracks arrive. You get the stream, shove it into state, and wire it to a <video> element. Simple. The ICE candidate handler fires repeatedly as the browser discovers network paths — NAT traversal in action, 19302 is Google's public STUN port.

Honestly, the trickiest part isn't the connection setup — it's the cleanup. Always call pc.close() and stop all tracks on unmount. Camera and mic LEDs staying on after a user leaves a call is a UX disaster and a trust destroyer.

One more thing — setLocalDescription triggers ICE gathering automatically. You don't need to call anything extra. The candidates just start flowing through onicecandidate.

Rendering Video and Handling the SDP Handshake

Video elements need ref attachment — you can't use the src prop for MediaStream objects in React. Use useEffect to assign srcObject when the stream is ready.

// components/VideoCall.tsx
import { useEffect, useRef } from 'react';

interface VideoProps {
  stream: MediaStream | null;
  muted?: boolean;
}

export function VideoPlayer({ stream, muted = false }: VideoProps) {
  const videoRef = useRef<HTMLVideoElement>(null);

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

  return (
    <video
      ref={videoRef}
      autoPlay
      playsInline
      muted={muted}
      style={{ width: '100%', borderRadius: '12px' }}
    />
  );
}

Always muted your local preview. Not muting yourself causes audio feedback that will make your users immediately close the tab. playsInline is required on iOS — without it Safari opens the video in fullscreen and your layout breaks.

For the SDP handshake, the answering peer needs to handle the incoming offer, create an answer, and relay it back. Wire this into your signaling message handler:

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

  if (msg.type === 'offer') {
    const pc = createPeerConnection();
    // Add local tracks before setting remote description
    const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
    stream.getTracks().forEach((t) => pc.addTrack(t, stream));

    await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    socket.send(JSON.stringify({ type: 'answer', sdp: answer }));
  }

  if (msg.type === 'answer') {
    await pcRef.current?.setRemoteDescription(new RTCSessionDescription(msg.sdp));
  }

  if (msg.type === 'ice-candidate') {
    await pcRef.current?.addIceCandidate(new RTCIceCandidate(msg.candidate));
  }
};

That's the full offer/answer cycle. Offer → Answer → ICE candidates. The connection transitions to connected state and media flows. You can observe this via pc.onconnectionstatechange — log it during development, it'll save you hours of debugging.

Screen Sharing with getDisplayMedia

Screen sharing is a separate API — getDisplayMedia instead of getUserMedia. The browser prompts the user to pick a screen, window, or tab. You get back a MediaStream with a video track of the selected surface.

async function startScreenShare() {
  const screenStream = await navigator.mediaDevices.getDisplayMedia({
    video: {
      frameRate: 30,
      width: { ideal: 1920 },
      height: { ideal: 1080 },
    },
    audio: true, // captures system audio on Chrome, not Firefox
  });

  const screenTrack = screenStream.getVideoTracks()[0];
  const senders = pcRef.current?.getSenders();
  const videoSender = senders?.find((s) => s.track?.kind === 'video');

  // Replace the camera track with the screen track
  await videoSender?.replaceTrack(screenTrack);

  // When user stops sharing via browser UI
  screenTrack.onended = async () => {
    const cameraStream = localStreamRef.current;
    const cameraTrack = cameraStream?.getVideoTracks()[0];
    if (cameraTrack) await videoSender?.replaceTrack(cameraTrack);
  };
}

replaceTrack is the key move here. You're swapping the video track on the existing sender without renegotiating the connection. No new offer/answer needed. This is what makes screen share feel instantaneous — the media path is already established, you're just changing what flows through it.

The onended handler fires when the user clicks "Stop sharing" in the browser's native UI bar — that little floating bar that appears at the top or bottom of the screen. You need this to swap back to camera automatically. If you skip it, your remote peer is left staring at a black frame.

Quick aside: system audio capture via getDisplayMedia works on Chrome and Edge but not Firefox as of mid-2026. Firefox returns the video track but silently drops the audio constraint. Test cross-browser before you promise audio capture in your feature spec.

If you want to pair screen share with a slick UI shell, check out some of the glassmorphism components on Empire UI — frosted panels work really well as video call overlays, especially for the control bar that sits over the video feed.

Peer-to-Peer Data Channels

Data channels let you send arbitrary data — text, binary, JSON, ArrayBuffers — directly between browsers with sub-100ms latency on good connections. No server sees the bytes. This is how you'd build a collaborative whiteboard, a file transfer, or a real-time game state sync.

// Create a data channel on the initiating peer
const dataChannel = pc.createDataChannel('chat', {
  ordered: true, // TCP-like, guaranteed order
});

dataChannel.onopen = () => console.log('Data channel open');
dataChannel.onmessage = (e) => console.log('Received:', e.data);

// On the receiving peer, listen for the channel
pc.ondatachannel = (e) => {
  const channel = e.channel;
  channel.onmessage = (evt) => {
    const msg = JSON.parse(evt.data);
    // Handle incoming message
  };
};

// Send a message
dataChannel.send(JSON.stringify({ type: 'chat', text: 'Hello!' }));

// Send binary (e.g., file chunk)
const chunk = fileBuffer.slice(offset, offset + 16384); // 16KB chunks
dataChannel.send(chunk);

The ordered: true option gives you TCP-like semantics — messages arrive in order, dropped packets are retransmitted. For real-time gaming you'd use ordered: false, maxRetransmits: 0 instead, which is more like UDP. Pick based on your tolerance for latency vs. completeness.

For file transfer, chunk your file into 16KB pieces (16384 bytes is a safe max before some browsers start complaining about buffer backpressure). Check dataChannel.bufferedAmount before sending each chunk — if it's above 16MB, pause and wait for bufferedamountlow to fire.

Look, data channels are genuinely underrated. Most devs reach for WebSockets even when the data is between two specific peers and doesn't need to touch a server. If you're already running WebRTC for video, adding a data channel costs you exactly one createDataChannel call.

STUN vs. TURN and Why You Can't Skip TURN

STUN (Session Traversal Utilities for NAT) discovers your public IP/port so the other peer can try to connect directly. This works maybe 70-80% of the time. The other 20-30% — symmetric NATs, strict corporate firewalls, some mobile carriers — you need TURN.

TURN (Traversal Using Relays around NAT) is a relay server. When direct P2P fails, both peers connect to TURN and their media bounces through it. It's the fallback that makes WebRTC actually work everywhere. It also costs money, because you're paying for bandwidth again.

const ICE_SERVERS = [
  { urls: 'stun:stun.l.google.com:19302' },
  {
    urls: 'turn:your-turn-server.com:3478',
    username: 'your-username',
    credential: 'your-credential',
  },
  {
    urls: 'turns:your-turn-server.com:443', // TLS, for firewalls that block 3478
    username: 'your-username',
    credential: 'your-credential',
  },
];

Self-host with coturn on a $6/month VPS if your traffic is light. Use Twilio's TURN, Cloudflare TURN, or Metered.ca for managed options. Generate time-limited credentials server-side — never hardcode long-lived TURN credentials in client-side code.

In practice, the ICE negotiation just works when you configure it right. The browser tries all candidates in priority order — host, server-reflexive (STUN), relay (TURN) — and picks the best path that actually connects. Monitor pc.getStats() to see which candidate pair won. If you're seeing relay candidates win consistently, your STUN-only deployment was failing silently for a fifth of your users.

Want to inspect the visual quality of your UI around the call? If you're building glassmorphism-style video interfaces — frosted overlays, blur panels — the glassmorphism generator is a fast way to dial in the exact backdrop-filter values before you write CSS. Saves the usual 40px vs. 60px trial and error.

FAQ

Do I need a backend server for WebRTC?

Yes — a signaling server to exchange SDP and ICE candidates, and a TURN server for NAT traversal. The media itself goes peer-to-peer, but setup requires a server.

Can I use WebRTC without a TURN server?

You can, but it'll silently fail for users behind symmetric NATs or strict firewalls — often 20-30% of connections. Ship without TURN only for internal tools on controlled networks.

How do I handle more than two participants?

Create one RTCPeerConnection per remote peer (mesh topology). Beyond 3-4 participants, mesh doesn't scale — switch to an SFU (Selective Forwarding Unit) like mediasoup or Pion.

Why is my video black on iOS Safari?

Two common causes: missing playsInline on the video element, or the stream hasn't been user-gesture-initiated. Attach the stream in a click handler, not on page load.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Parallax Scrolling in React: useScroll, GSAP and Pure CSSHTML Canvas Animations in React: Particles, Noise Fields, MoreWebSockets in React: Real-Time Chat, Notifications, Live DataServer-Sent Events in React: Real-Time Data Without WebSockets