usePeerConnection
A hook for managing WebRTC peer connections
A React hook for managing RTCPeerConnection lifecycle. Handles SDP negotiation, ICE candidates, and media tracks for peer-to-peer connections.
Source Code
View the full hook implementation in the Hook Source Code section below.
Signaling Required
This hook manages the connection, but you need a signaling server to exchange SDP offers/answers and ICE candidates between peers.
Features
- Connection Management - Full RTCPeerConnection lifecycle
- SDP Negotiation - Create offers/answers, set descriptions
- ICE Handling - Add ICE candidates, track gathering state
- Track Management - Add/remove media tracks
- Data Channels - Create data channels for messaging
- State Tracking - Monitor connection, ICE, and signaling states
Cross-Tab Video Call (Manual Signaling)
Simulate a real P2P video call between two browser tabs. Since there is no signaling server, you will manually copy/paste the SDP offers and answers.
Tab A: The Caller (Starts Call)
Open this example in Tab A. It will request camera access and generate an offer.
- Click Start Call and copy the offer JSON.
- Paste it into Tab B (Example 2) and create an answer.
- Paste the answer back here to connect.
This will turn on your camera and generate an offer.
"use client";
import { useState, useRef, useEffect } from "react";
import { useUserMedia } from "@repo/hooks/webrtc/use-user-media";
import { usePeerConnection } from "@repo/hooks/webrtc/use-peer-connection";
import { useTrackToggle } from "@repo/hooks/webrtc/use-track-toggle";
import { Button } from "@repo/ui/components/button";
import {
Copy,
Check,
ArrowRight,
Video,
VideoOff,
Mic,
MicOff,
RotateCcw,
} from "lucide-react";
/**
* REMOTE VIDEO CALL - Tab A (Caller)
*/
export const Example1 = () => {
const [key, setKey] = useState(0);
const handleReset = () => {
setKey((k) => k + 1);
};
return <CallerComponent key={key} onReset={handleReset} />;
};
const CallerComponent = ({ onReset }: { onReset: () => void }) => {
const [step, setStep] = useState<1 | 2 | 3>(1);
const [offerSdp, setOfferSdp] = useState("");
const [answerInput, setAnswerInput] = useState("");
const [copied, setCopied] = useState(false);
const [isStartingCall, setIsStartingCall] = useState(false);
const [iceCandidates, setIceCandidates] = useState<RTCIceCandidateInit[]>(
[],
);
// Video refs
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
// 1. Get User Media
const {
stream: localStream,
start: startCamera,
stop: stopCamera,
isActive,
} = useUserMedia();
// 2. Mute Controls
const { isAudioEnabled, isVideoEnabled, toggleAudio, toggleVideo } =
useTrackToggle(localStream);
// 3. Peer Connection (Caller)
const {
peerConnection,
connectionState,
createOffer,
setRemoteDescription,
addIceCandidate,
addTrack,
close: closeConnection,
} = usePeerConnection({
onIceCandidate: (candidate) => {
if (candidate) {
setIceCandidates((prev) => [...prev, candidate.toJSON()]);
}
},
onTrack: (event) => {
if (remoteVideoRef.current && event.streams[0]) {
remoteVideoRef.current.srcObject = event.streams[0];
}
},
});
// Attach local stream
useEffect(() => {
if (localVideoRef.current && localStream) {
localVideoRef.current.srcObject = localStream;
}
}, [localStream]);
// Create Offer
const handleCreateOffer = async () => {
setIsStartingCall(true);
if (!localStream) {
await startCamera();
}
};
// Effect to create offer once stream is ready
useEffect(() => {
const create = async () => {
if (isStartingCall && localStream) {
// Add tracks
localStream.getTracks().forEach((track) => {
addTrack(track, localStream);
});
// Create offer
const offer = await createOffer();
if (offer) {
// Wait for ICE gathering
await new Promise((resolve) => setTimeout(resolve, 1000));
const fullOffer = {
sdp: peerConnection?.localDescription,
ice: iceCandidates,
};
setOfferSdp(JSON.stringify(fullOffer, null, 2));
setStep(2);
setIsStartingCall(false);
}
}
};
void create();
}, [
isStartingCall,
localStream,
createOffer,
peerConnection,
iceCandidates,
addTrack,
]);
// Set Remote Answer
const handleSetAnswer = async () => {
try {
const parsed = JSON.parse(answerInput);
// Handle wrapper format
const description = parsed.sdp || parsed;
await setRemoteDescription(description);
// Add ICE candidates
if (parsed.ice && Array.isArray(parsed.ice)) {
for (const ice of parsed.ice) {
await addIceCandidate(ice);
}
}
setStep(3);
} catch {
alert("Invalid answer format.");
}
};
// Utils
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="flex w-full max-w-2xl flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="rounded-full bg-blue-500 px-3 py-1 text-xs font-bold text-white">
TAB A
</div>
<span className="text-sm font-medium">
Caller (Starts Call)
</span>
</div>
<div
className={`rounded-full px-2 py-0.5 text-xs ${
connectionState === "connected"
? "bg-green-500/20 text-green-600"
: "bg-yellow-500/20 text-yellow-600"
}`}
>
{connectionState}
</div>
</div>
{/* Video Grid */}
<div className="grid gap-4 md:grid-cols-2">
{/* Local Video */}
<div className="relative aspect-video overflow-hidden rounded-lg bg-zinc-900">
{isActive && isVideoEnabled ? (
<video
ref={localVideoRef}
autoPlay
playsInline
muted
className="h-full w-full scale-x-[-1] object-cover"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-zinc-500">
<VideoOff className="h-8 w-8" />
<span className="text-xs">
{isActive ? "Camera Off" : "Not Started"}
</span>
</div>
)}
<span className="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-0.5 text-xs text-white">
You
</span>
{/* Controls Overlay */}
{isActive && (
<div className="absolute bottom-2 right-2 flex gap-1">
<Button
size="icon"
variant={
isAudioEnabled ? "secondary" : "destructive"
}
className="h-6 w-6"
onClick={toggleAudio}
>
{isAudioEnabled ? (
<Mic className="h-3 w-3" />
) : (
<MicOff className="h-3 w-3" />
)}
</Button>
<Button
size="icon"
variant={
isVideoEnabled ? "secondary" : "destructive"
}
className="h-6 w-6"
onClick={toggleVideo}
>
{isVideoEnabled ? (
<Video className="h-3 w-3" />
) : (
<VideoOff className="h-3 w-3" />
)}
</Button>
</div>
)}
</div>
{/* Remote Video */}
<div className="relative aspect-video overflow-hidden rounded-lg bg-zinc-900">
<video
ref={remoteVideoRef}
autoPlay
playsInline
className="h-full w-full object-cover"
/>
<span className="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-0.5 text-xs text-white">
Remote
</span>
{connectionState !== "connected" && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<span className="text-sm text-white">
{connectionState === "disconnected"
? "Peer Disconnected"
: "Waiting for connection..."}
</span>
</div>
)}
</div>
</div>
{/* Workflow Steps */}
<div className="space-y-4 rounded-lg border p-4">
{step === 1 && (
<div className="text-center">
<Button
onClick={handleCreateOffer}
size="lg"
className="w-full sm:w-auto"
>
<Video className="mr-2 h-4 w-4" />
{isStartingCall
? "Starting..."
: "Start Call (Create Offer)"}
</Button>
<p className="text-muted-foreground mt-2 text-xs">
This will turn on your camera and generate an offer.
</p>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium">
1. Copy Offer (Send to Tab B - Example 2)
</label>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(offerSdp)}
className="h-7 gap-1"
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? "Copied" : "Copy"}
</Button>
</div>
<textarea
readOnly
value={offerSdp}
className="h-24 w-full rounded-md border bg-zinc-50 p-2 font-mono text-xs dark:bg-zinc-900"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium">
2. Paste Answer (From Tab B)
</label>
<textarea
value={answerInput}
onChange={(e) => setAnswerInput(e.target.value)}
placeholder="Paste the answer JSON here..."
className="bg-background h-24 w-full rounded-md border p-2 font-mono text-xs"
/>
</div>
<Button
onClick={handleSetAnswer}
isDisabled={!answerInput.trim()}
className="w-full"
>
<ArrowRight className="mr-2 h-4 w-4" />
Connect Call
</Button>
</div>
)}
{step === 3 && (
<div className="flex items-center justify-center gap-2 text-green-600">
<Check className="h-5 w-5" />
<span className="font-medium">Call Connected!</span>
<Button
variant="outline"
size="sm"
onClick={() => {
closeConnection();
stopCamera();
onReset();
}}
className="ml-4 gap-1"
>
<RotateCcw className="h-3 w-3" />
End Call
</Button>
</div>
)}
</div>
</div>
);
};
Tab B: The Callee (Joins Call)
Open this example in Tab B (side-by-side with Tab A).
- Paste the offer from Tab A.
- Click Join Call to generate an answer.
- Paste the answer back into Tab A.
This will turn on your camera and generate an answer.
"use client";
import { useState, useRef, useEffect } from "react";
import { useUserMedia } from "@repo/hooks/webrtc/use-user-media";
import { usePeerConnection } from "@repo/hooks/webrtc/use-peer-connection";
import { useTrackToggle } from "@repo/hooks/webrtc/use-track-toggle";
import { Button } from "@repo/ui/components/button";
import {
Copy,
Check,
ArrowRight,
Video,
VideoOff,
Mic,
MicOff,
Clipboard,
RotateCcw,
} from "lucide-react";
/**
* REMOTE VIDEO CALL - Tab B (Callee)
*/
export const Example2 = () => {
const [key, setKey] = useState(0);
const handleReset = () => {
setKey((k) => k + 1);
};
return <CalleeComponent key={key} onReset={handleReset} />;
};
const CalleeComponent = ({ onReset }: { onReset: () => void }) => {
const [step, setStep] = useState<1 | 2 | 3>(1);
const [offerInput, setOfferInput] = useState("");
const [answerSdp, setAnswerSdp] = useState("");
const [copied, setCopied] = useState(false);
const [isJoiningCall, setIsJoiningCall] = useState(false);
const [iceCandidates, setIceCandidates] = useState<RTCIceCandidateInit[]>(
[],
);
// Video refs
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
// 1. Get User Media
const {
stream: localStream,
start: startCamera,
stop: stopCamera,
isActive,
} = useUserMedia();
// 2. Mute Controls
const { isAudioEnabled, isVideoEnabled, toggleAudio, toggleVideo } =
useTrackToggle(localStream);
// 3. Peer Connection (Callee)
const {
peerConnection,
connectionState,
createAnswer,
setRemoteDescription,
addIceCandidate,
addTrack,
close: closeConnection,
} = usePeerConnection({
onIceCandidate: (candidate) => {
if (candidate) {
setIceCandidates((prev) => [...prev, candidate.toJSON()]);
}
},
onTrack: (event) => {
if (remoteVideoRef.current && event.streams[0]) {
remoteVideoRef.current.srcObject = event.streams[0];
}
},
});
// Attach local stream
useEffect(() => {
if (localVideoRef.current && localStream) {
localVideoRef.current.srcObject = localStream;
}
}, [localStream]);
// Handle Create Answer
const handleCreateAnswer = async () => {
setIsJoiningCall(true);
if (!localStream) {
await startCamera();
}
};
// Effect to create answer once stream is ready
useEffect(() => {
const join = async () => {
if (isJoiningCall && localStream) {
try {
// Add tracks
localStream.getTracks().forEach((track) => {
addTrack(track, localStream);
});
const parsed = JSON.parse(offerInput);
// Handle wrapper format
const description = parsed.sdp || parsed;
await setRemoteDescription(description);
// Add ICE candidates
if (parsed.ice && Array.isArray(parsed.ice)) {
for (const ice of parsed.ice) {
await addIceCandidate(ice);
}
}
// Create answer
const answer = await createAnswer();
if (answer) {
// Wait for ICE gathering
await new Promise((resolve) =>
setTimeout(resolve, 1000),
);
const fullAnswer = {
sdp: peerConnection?.localDescription,
ice: iceCandidates,
};
setAnswerSdp(JSON.stringify(fullAnswer, null, 2));
setStep(2);
setIsJoiningCall(false);
}
} catch (e) {
console.error(e);
alert("Invalid offer format.");
setIsJoiningCall(false);
}
}
};
void join();
}, [
isJoiningCall,
localStream,
offerInput,
addTrack,
setRemoteDescription,
addIceCandidate,
createAnswer,
peerConnection,
iceCandidates,
]);
// Utils
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="flex w-full max-w-2xl flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="rounded-full bg-green-500 px-3 py-1 text-xs font-bold text-white">
TAB B
</div>
<span className="text-sm font-medium">
Callee (Joins Call)
</span>
</div>
<div
className={`rounded-full px-2 py-0.5 text-xs ${
connectionState === "connected"
? "bg-green-500/20 text-green-600"
: "bg-yellow-500/20 text-yellow-600"
}`}
>
{connectionState}
</div>
</div>
{/* Video Grid */}
<div className="grid gap-4 md:grid-cols-2">
{/* Local Video */}
<div className="relative aspect-video overflow-hidden rounded-lg bg-zinc-900">
{isActive && isVideoEnabled ? (
<video
ref={localVideoRef}
autoPlay
playsInline
muted
className="h-full w-full scale-x-[-1] object-cover"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-zinc-500">
<VideoOff className="h-8 w-8" />
<span className="text-xs">
{isActive ? "Camera Off" : "Not Started"}
</span>
</div>
)}
<span className="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-0.5 text-xs text-white">
You
</span>
{/* Controls Overlay */}
{isActive && (
<div className="absolute bottom-2 right-2 flex gap-1">
<Button
size="icon"
variant={
isAudioEnabled ? "secondary" : "destructive"
}
className="h-6 w-6"
onClick={toggleAudio}
>
{isAudioEnabled ? (
<Mic className="h-3 w-3" />
) : (
<MicOff className="h-3 w-3" />
)}
</Button>
<Button
size="icon"
variant={
isVideoEnabled ? "secondary" : "destructive"
}
className="h-6 w-6"
onClick={toggleVideo}
>
{isVideoEnabled ? (
<Video className="h-3 w-3" />
) : (
<VideoOff className="h-3 w-3" />
)}
</Button>
</div>
)}
</div>
{/* Remote Video */}
<div className="relative aspect-video overflow-hidden rounded-lg bg-zinc-900">
<video
ref={remoteVideoRef}
autoPlay
playsInline
className="h-full w-full object-cover"
/>
<span className="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-0.5 text-xs text-white">
Remote
</span>
{connectionState !== "connected" && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<span className="text-sm text-white">
{connectionState === "disconnected" ? (
<div className="flex flex-col items-center gap-2">
<span>Remote Disconnected</span>
<Button
size="sm"
variant="outline"
onClick={() => {
closeConnection();
stopCamera();
onReset();
}}
>
Start New
</Button>
</div>
) : (
"Waiting for connection..."
)}
</span>
</div>
)}
</div>
</div>
{/* Workflow Steps */}
<div className="space-y-4 rounded-lg border p-4">
{step === 1 && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">
1. Paste Offer (From Tab A - Example 1)
</label>
<textarea
value={offerInput}
onChange={(e) => setOfferInput(e.target.value)}
placeholder="Paste the offer JSON here..."
className="bg-background h-24 w-full rounded-md border p-2 font-mono text-xs"
/>
</div>
<Button
onClick={handleCreateAnswer}
isDisabled={!offerInput.trim()}
className="w-full"
>
<Clipboard className="mr-2 h-4 w-4" />
{isJoiningCall
? "Joining..."
: "Join Call (Create Answer)"}
</Button>
<p className="text-muted-foreground mt-2 text-center text-xs">
This will turn on your camera and generate an
answer.
</p>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium">
2. Copy Answer (Send to Tab A)
</label>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(answerSdp)}
className="h-7 gap-1"
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? "Copied" : "Copy"}
</Button>
</div>
<textarea
readOnly
value={answerSdp}
className="h-24 w-full rounded-md border bg-zinc-50 p-2 font-mono text-xs dark:bg-zinc-900"
/>
</div>
<div className="flex items-center justify-center gap-2 pt-2 text-blue-600">
<ArrowRight className="h-4 w-4" />
<span className="text-sm">
Paste this answer back in Tab A!
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setStep(3)}
className="w-full"
>
Done
</Button>
</div>
)}
{step === 3 && (
<div className="flex items-center justify-center gap-2 text-green-600">
<Check className="h-5 w-5" />
<span className="font-medium">Call Connected!</span>
<Button
variant="outline"
size="sm"
onClick={() => {
closeConnection();
stopCamera();
onReset();
}}
className="ml-4 gap-1"
>
<RotateCcw className="h-3 w-3" />
End Call
</Button>
</div>
)}
</div>
</div>
);
};
Manual Signaling Tester (Example 3)
A powerful debugging tool that lets you understand exactly how WebRTC connections are established step-by-step. Toggle between Caller and Receiver roles to simulate a connection manually.
"use client";
import { useState } from "react";
import { usePeerConnection } from "@repo/hooks/webrtc/use-peer-connection";
import { Button } from "@repo/ui/components/button";
import { Copy, Check, ArrowRight, ArrowDown, RotateCcw } from "lucide-react";
/* MANUAL SIGNALING - Monitor States & Test Connectivity */
export const Example3 = () => {
// We use a key to force re-mounting of the tester component
// whenever the role changes or user clicks reset.
// This ensures we get a fresh RTCPeerConnection instance (since closed PCs cannot be reopened).
const [testerKey, setTesterKey] = useState(0);
const [role, setRole] = useState<"offerer" | "answerer">("offerer");
const handleRoleChange = (newRole: "offerer" | "answerer") => {
setRole(newRole);
setTesterKey((k) => k + 1);
};
const handleReset = () => {
setTesterKey((k) => k + 1);
};
return (
<div className="flex w-full max-w-lg flex-col gap-4">
{/* Header / Mode Selection */}
<div className="flex items-center justify-between rounded-lg border bg-zinc-50 p-3 dark:bg-zinc-900/50">
<div className="text-sm font-medium">I want to:</div>
<div className="flex gap-2">
<Button
size="sm"
variant={role === "offerer" ? "default" : "outline"}
onClick={() => handleRoleChange("offerer")}
>
Call (Offer)
</Button>
<Button
size="sm"
variant={role === "answerer" ? "default" : "outline"}
onClick={() => handleRoleChange("answerer")}
>
Receive (Answer)
</Button>
</div>
</div>
<ConnectionTester
key={`${role}-${testerKey}`}
role={role}
onReset={handleReset}
/>
</div>
);
};
const ConnectionTester = ({
role,
onReset,
}: {
role: "offerer" | "answerer";
onReset: () => void;
}) => {
const [remoteSdp, setRemoteSdp] = useState("");
const [copied, setCopied] = useState(false);
// We need to track ICE candidates for the manual copy-paste to work perfectly
const [iceCandidates, setIceCandidates] = useState<RTCIceCandidateInit[]>(
[],
);
const {
connectionState,
iceConnectionState,
iceGatheringState,
signalingState,
localDescription,
remoteDescription,
createOffer,
createAnswer,
setRemoteDescription,
addIceCandidate,
} = usePeerConnection({
onIceCandidate: (candidate) => {
if (candidate) {
setIceCandidates((prev) => [...prev, candidate.toJSON()]);
}
},
});
const handleCreateLocalDescription = async () => {
setIceCandidates([]);
let success = false;
if (role === "offerer") {
const offer = await createOffer();
success = !!offer;
} else {
const answer = await createAnswer();
success = !!answer;
}
if (success) {
// Wait for ICE gathering to have a better chance of a complete SDP
await new Promise((resolve) => setTimeout(resolve, 1000));
}
};
const handleSetRemoteDescription = async () => {
try {
const parsed = JSON.parse(remoteSdp);
// Handle both raw SDP and the wrapper format
// Wrapper: { sdp: RTCSessionDescriptionInit, ice: RTCIceCandidateInit[] }
const description = parsed.sdp || parsed;
const success = await setRemoteDescription(description);
if (!success) {
alert(
"Failed to set remote description. Check console for details.",
);
return;
}
// If using wrapper format, add ICE candidates
if (parsed.ice && Array.isArray(parsed.ice)) {
for (const ice of parsed.ice) {
await addIceCandidate(ice);
}
}
} catch (e) {
console.error(e);
alert("Invalid SDP JSON. Please paste the full JSON object.");
}
};
const handleCopySDP = async () => {
if (localDescription) {
// Bundle SDP + ICE for better compatibility
const fullData = {
sdp: localDescription,
ice: iceCandidates,
};
await navigator.clipboard.writeText(
JSON.stringify(fullData, null, 2),
);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const stateColor = (state: string) => {
switch (state) {
case "connected":
case "complete":
case "stable":
return "text-green-500";
case "checking":
case "new":
case "have-local-offer":
case "have-remote-offer":
return "text-yellow-500";
case "failed":
case "disconnected":
case "closed":
return "text-red-500";
default:
return "text-muted-foreground";
}
};
return (
<>
{/* State Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border p-3">
<div className="text-muted-foreground mb-1 text-xs">
Connection
</div>
<div
className={`font-mono text-sm ${stateColor(connectionState)}`}
>
{connectionState}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-muted-foreground mb-1 text-xs">
ICE State
</div>
<div
className={`font-mono text-sm ${stateColor(iceConnectionState)}`}
>
{iceConnectionState}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-muted-foreground mb-1 text-xs">
Gathering
</div>
<div
className={`font-mono text-sm ${stateColor(iceGatheringState)}`}
>
{iceGatheringState}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-muted-foreground mb-1 text-xs">
Signaling
</div>
<div
className={`font-mono text-sm ${stateColor(signalingState)}`}
>
{signalingState}
</div>
</div>
</div>
{/* Step 1: Remote SDP Input (Only for Answerer initially) */}
{role === "answerer" && !remoteDescription && (
<div className="rounded-lg border p-3">
<div className="mb-2 text-sm font-medium">
1. Paste Remote Offer
</div>
<textarea
value={remoteSdp}
onChange={(e) => setRemoteSdp(e.target.value)}
placeholder="Paste Offer JSON from other tab..."
className="bg-background h-24 w-full rounded-md border p-2 font-mono text-xs"
/>
<Button
onClick={handleSetRemoteDescription}
isDisabled={!remoteSdp.trim()}
className="mt-2 w-full"
>
<ArrowDown className="mr-2 h-4 w-4" />
Set Remote Offer
</Button>
</div>
)}
{/* Step 2: Create Local Description (Offer or Answer) */}
{((role === "offerer" && !localDescription) ||
(role === "answerer" &&
remoteDescription &&
!localDescription)) && (
<div className="rounded-lg border p-3">
<div className="mb-2 text-sm font-medium">
{role === "offerer"
? "1. Create Connection Offer"
: "2. Generate Answer"}
</div>
<Button
onClick={handleCreateLocalDescription}
className="w-full"
>
{role === "offerer" ? "Create Offer" : "Create Answer"}
</Button>
</div>
)}
{/* Display Local SDP */}
{localDescription && (
<div className="rounded-lg border bg-blue-50/50 p-3 dark:bg-blue-950/20">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">
{role === "offerer"
? "2. Your Offer (Copy This)"
: "3. Your Answer (Copy This)"}
</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-[10px]">
{iceCandidates.length} Candidates
</span>
<Button
variant="ghost"
size="sm"
onClick={handleCopySDP}
className="h-6 gap-1 px-2 text-xs"
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? "Copied" : "Copy"}
</Button>
</div>
</div>
<div className="text-muted-foreground max-h-32 overflow-y-auto break-all rounded border bg-white p-2 font-mono text-xs dark:bg-black">
{JSON.stringify(localDescription)}
</div>
</div>
)}
{/* Step 3: Remote SDP Input (Only for Offerer finally) */}
{role === "offerer" && localDescription && !remoteDescription && (
<div className="rounded-lg border p-3">
<div className="mb-2 text-sm font-medium">
3. Paste Remote Answer
</div>
<textarea
value={remoteSdp}
onChange={(e) => setRemoteSdp(e.target.value)}
placeholder="Paste Answer JSON from other tab..."
className="bg-background h-24 w-full rounded-md border p-2 font-mono text-xs"
/>
<Button
onClick={handleSetRemoteDescription}
isDisabled={!remoteSdp.trim()}
className="mt-2 w-full"
>
<ArrowRight className="mr-2 h-4 w-4" />
Connect
</Button>
</div>
)}
{/* Reset */}
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={onReset}
className="text-muted-foreground gap-1 text-xs"
>
<RotateCcw className="h-3 w-3" />
Reset Demo
</Button>
</div>
</>
);
};
API Reference
Hook Signature
function usePeerConnection(
options?: UsePeerConnectionOptions,
): UsePeerConnectionReturn;Options
| Property | Type | Default | Description |
|---|---|---|---|
iceServers | IceServer[] | Google STUN | STUN/TURN servers |
iceTransportPolicy | "all" | "relay" | "all" | ICE transport policy |
onTrack | (event) => void | - | Called when remote track arrives |
onIceCandidate | (candidate) => void | - | Called for each ICE candidate |
onConnectionStateChange | (state) => void | - | Called when connection changes |
onDataChannel | (channel) => void | - | Called when data channel opens |
Return Value
| Property | Type | Description |
|---|---|---|
peerConnection | RTCPeerConnection | null | The connection instance |
connectionState | RTCPeerConnectionState | Connection state |
iceConnectionState | RTCIceConnectionState | ICE connection state |
iceGatheringState | RTCIceGatheringState | ICE gathering state |
signalingState | RTCSignalingState | Signaling state |
localDescription | RTCSessionDescription | null | Local SDP |
remoteDescription | RTCSessionDescription | null | Remote SDP |
remoteStreams | MediaStream[] | Received remote streams |
createOffer | () => Promise<RTCSessionDescriptionInit | null> | Create offer |
createAnswer | () => Promise<RTCSessionDescriptionInit | null> | Create answer |
setRemoteDescription | (desc) => Promise<boolean> | Set remote SDP |
addIceCandidate | (candidate) => Promise<boolean> | Add ICE candidate |
addTrack | (track, ...streams) => RTCRtpSender | null | Add track |
createDataChannel | (label, options?) => RTCDataChannel | null | Create channel |
close | () => void | Close connection |
Hook Source Code
import { useState, useEffect, useCallback, useRef } from "react";
/**
* ICE server configuration
*/
export interface IceServer {
urls: string | string[];
username?: string;
credential?: string;
}
/**
* Options for the usePeerConnection hook
*/
export interface UsePeerConnectionOptions {
/** ICE servers for STUN/TURN */
iceServers?: IceServer[];
/** ICE transport policy: "all" or "relay" */
iceTransportPolicy?: RTCIceTransportPolicy;
/** Bundle policy for media */
bundlePolicy?: RTCBundlePolicy;
/** Callback when remote track is received */
onTrack?: (event: RTCTrackEvent) => void;
/** Callback when ICE candidate is generated */
onIceCandidate?: (candidate: RTCIceCandidate | null) => void;
/** Callback when connection state changes */
onConnectionStateChange?: (state: RTCPeerConnectionState) => void;
/** Callback when data channel is received */
onDataChannel?: (channel: RTCDataChannel) => void;
}
/**
* Return type for the usePeerConnection hook
*/
export interface UsePeerConnectionReturn {
/** The RTCPeerConnection instance */
peerConnection: RTCPeerConnection | null;
/** Current connection state */
connectionState: RTCPeerConnectionState;
/** Current ICE connection state */
iceConnectionState: RTCIceConnectionState;
/** Current ICE gathering state */
iceGatheringState: RTCIceGatheringState;
/** Current signaling state */
signalingState: RTCSignalingState;
/** Local session description */
localDescription: RTCSessionDescription | null;
/** Remote session description */
remoteDescription: RTCSessionDescription | null;
/** Remote streams received */
remoteStreams: MediaStream[];
/** Whether the API is supported */
isSupported: boolean;
/** Create an SDP offer */
createOffer: (
options?: RTCOfferOptions,
) => Promise<RTCSessionDescriptionInit | null>;
/** Create an SDP answer */
createAnswer: (
options?: RTCAnswerOptions,
) => Promise<RTCSessionDescriptionInit | null>;
/** Set the local description */
setLocalDescription: (desc: RTCSessionDescriptionInit) => Promise<boolean>;
/** Set the remote description */
setRemoteDescription: (desc: RTCSessionDescriptionInit) => Promise<boolean>;
/** Add an ICE candidate */
addIceCandidate: (candidate: RTCIceCandidateInit) => Promise<boolean>;
/** Add a track to the connection */
addTrack: (
track: MediaStreamTrack,
...streams: MediaStream[]
) => RTCRtpSender | null;
/** Remove a track sender */
removeTrack: (sender: RTCRtpSender) => void;
/** Create a data channel */
createDataChannel: (
label: string,
options?: RTCDataChannelInit,
) => RTCDataChannel | null;
/** Close the connection */
close: () => void;
/** Restart ICE */
restartIce: () => void;
}
const DEFAULT_ICE_SERVERS: IceServer[] = [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
];
/**
* A React hook for managing RTCPeerConnection lifecycle.
* Handles SDP negotiation, ICE candidates, and media tracks.
*
* @param options - Configuration options for the peer connection
* @returns UsePeerConnectionReturn object with connection, states, and methods
*
* @example
* ```tsx
* const {
* peerConnection,
* connectionState,
* createOffer,
* setRemoteDescription,
* addIceCandidate,
* addTrack,
* } = usePeerConnection({
* onIceCandidate: (candidate) => sendToRemote(candidate),
* onTrack: (event) => setRemoteStream(event.streams[0]),
* });
*
* // Create and send offer
* const offer = await createOffer();
* sendToRemote({ type: "offer", sdp: offer });
* ```
*/
export function usePeerConnection(
options: UsePeerConnectionOptions = {},
): UsePeerConnectionReturn {
const {
iceServers = DEFAULT_ICE_SERVERS,
iceTransportPolicy = "all",
bundlePolicy = "balanced",
onTrack,
onIceCandidate,
onConnectionStateChange,
onDataChannel,
} = options;
const [peerConnection, setPeerConnection] =
useState<RTCPeerConnection | null>(null);
const [connectionState, setConnectionState] =
useState<RTCPeerConnectionState>("new");
const [iceConnectionState, setIceConnectionState] =
useState<RTCIceConnectionState>("new");
const [iceGatheringState, setIceGatheringState] =
useState<RTCIceGatheringState>("new");
const [signalingState, setSignalingState] =
useState<RTCSignalingState>("stable");
const [remoteStreams, setRemoteStreams] = useState<MediaStream[]>([]);
// Store callbacks in refs to avoid dependency issues
const onTrackRef = useRef(onTrack);
const onIceCandidateRef = useRef(onIceCandidate);
const onConnectionStateChangeRef = useRef(onConnectionStateChange);
const onDataChannelRef = useRef(onDataChannel);
onTrackRef.current = onTrack;
onIceCandidateRef.current = onIceCandidate;
onConnectionStateChangeRef.current = onConnectionStateChange;
onDataChannelRef.current = onDataChannel;
// Check if API is supported
const isSupported =
typeof window !== "undefined" && "RTCPeerConnection" in window;
// Initialize peer connection
useEffect(() => {
if (!isSupported) return;
const pc = new RTCPeerConnection({
iceServers,
iceTransportPolicy,
bundlePolicy,
});
// Event handlers
pc.onconnectionstatechange = () => {
setConnectionState(pc.connectionState);
onConnectionStateChangeRef.current?.(pc.connectionState);
};
pc.oniceconnectionstatechange = () => {
setIceConnectionState(pc.iceConnectionState);
};
pc.onicegatheringstatechange = () => {
setIceGatheringState(pc.iceGatheringState);
};
pc.onsignalingstatechange = () => {
setSignalingState(pc.signalingState);
};
pc.onicecandidate = (event) => {
onIceCandidateRef.current?.(event.candidate);
};
pc.ontrack = (event) => {
onTrackRef.current?.(event);
setRemoteStreams((prev) => {
const newStreams = event.streams.filter(
(s) => !prev.some((p) => p.id === s.id),
);
return [...prev, ...newStreams];
});
};
pc.ondatachannel = (event) => {
onDataChannelRef.current?.(event.channel);
};
setPeerConnection(pc);
return () => {
pc.close();
};
}, [isSupported, iceTransportPolicy, bundlePolicy]); // eslint-disable-line react-hooks/exhaustive-deps
// Create offer
const createOffer = useCallback(
async (
offerOptions?: RTCOfferOptions,
): Promise<RTCSessionDescriptionInit | null> => {
if (!peerConnection || peerConnection.signalingState === "closed")
return null;
try {
const offer = await peerConnection.createOffer(offerOptions);
await peerConnection.setLocalDescription(offer);
return offer;
} catch (err) {
console.error("Failed to create offer:", err);
return null;
}
},
[peerConnection],
);
// Create answer
const createAnswer = useCallback(
async (
answerOptions?: RTCAnswerOptions,
): Promise<RTCSessionDescriptionInit | null> => {
if (!peerConnection || peerConnection.signalingState === "closed")
return null;
try {
const answer = await peerConnection.createAnswer(answerOptions);
await peerConnection.setLocalDescription(answer);
return answer;
} catch (err) {
console.error("Failed to create answer:", err);
return null;
}
},
[peerConnection],
);
// Set local description
const setLocalDescription = useCallback(
async (desc: RTCSessionDescriptionInit): Promise<boolean> => {
if (!peerConnection || peerConnection.signalingState === "closed")
return false;
try {
await peerConnection.setLocalDescription(desc);
return true;
} catch (err) {
console.error("Failed to set local description:", err);
return false;
}
},
[peerConnection],
);
// Set remote description
const setRemoteDescription = useCallback(
async (desc: RTCSessionDescriptionInit): Promise<boolean> => {
if (!peerConnection || peerConnection.signalingState === "closed")
return false;
try {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(desc),
);
return true;
} catch (err) {
console.error("Failed to set remote description:", err);
return false;
}
},
[peerConnection],
);
// Add ICE candidate
const addIceCandidate = useCallback(
async (candidate: RTCIceCandidateInit): Promise<boolean> => {
if (!peerConnection || peerConnection.signalingState === "closed")
return false;
try {
await peerConnection.addIceCandidate(
new RTCIceCandidate(candidate),
);
return true;
} catch (err) {
console.error("Failed to add ICE candidate:", err);
return false;
}
},
[peerConnection],
);
// Add track
const addTrack = useCallback(
(
track: MediaStreamTrack,
...streams: MediaStream[]
): RTCRtpSender | null => {
if (!peerConnection) return null;
// Check if track already exists
const senders = peerConnection.getSenders();
const existingSender = senders.find((s) => s.track === track);
if (existingSender) {
return existingSender;
}
try {
return peerConnection.addTrack(track, ...streams);
} catch (err) {
console.error("Failed to add track:", err);
return null;
}
},
[peerConnection],
);
// Remove track
const removeTrack = useCallback(
(sender: RTCRtpSender): void => {
if (!peerConnection) return;
try {
peerConnection.removeTrack(sender);
} catch (err) {
console.error("Failed to remove track:", err);
}
},
[peerConnection],
);
// Create data channel
const createDataChannel = useCallback(
(
label: string,
channelOptions?: RTCDataChannelInit,
): RTCDataChannel | null => {
if (!peerConnection) return null;
try {
return peerConnection.createDataChannel(label, channelOptions);
} catch (err) {
console.error("Failed to create data channel:", err);
return null;
}
},
[peerConnection],
);
// Close connection
const close = useCallback(() => {
if (peerConnection) {
peerConnection.close();
}
setRemoteStreams([]);
}, [peerConnection]);
// Restart ICE
const restartIce = useCallback(() => {
if (peerConnection) {
peerConnection.restartIce();
}
}, [peerConnection]);
return {
peerConnection,
connectionState,
iceConnectionState,
iceGatheringState,
signalingState,
localDescription: peerConnection?.localDescription ?? null,
remoteDescription: peerConnection?.remoteDescription ?? null,
remoteStreams,
isSupported,
createOffer,
createAnswer,
setLocalDescription,
setRemoteDescription,
addIceCandidate,
addTrack,
removeTrack,
createDataChannel,
close,
restartIce,
};
}
export default usePeerConnection;