Fiber UI LogoFiberUI

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.

  1. Click Start Call and copy the offer JSON.
  2. Paste it into Tab B (Example 2) and create an answer.
  3. Paste the answer back here to connect.
TAB A
Caller (Starts Call)
new
Not Started
You
Remote
Waiting for connection...

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).

  1. Paste the offer from Tab A.
  2. Click Join Call to generate an answer.
  3. Paste the answer back into Tab A.
TAB B
Callee (Joins Call)
new
Not Started
You
Remote
Waiting for connection...

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.

I want to:
Connection
new
ICE State
new
Gathering
new
Signaling
stable
1. Create Connection Offer
"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

PropertyTypeDefaultDescription
iceServersIceServer[]Google STUNSTUN/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

PropertyTypeDescription
peerConnectionRTCPeerConnection | nullThe connection instance
connectionStateRTCPeerConnectionStateConnection state
iceConnectionStateRTCIceConnectionStateICE connection state
iceGatheringStateRTCIceGatheringStateICE gathering state
signalingStateRTCSignalingStateSignaling state
localDescriptionRTCSessionDescription | nullLocal SDP
remoteDescriptionRTCSessionDescription | nullRemote SDP
remoteStreamsMediaStream[]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 | nullAdd track
createDataChannel(label, options?) => RTCDataChannel | nullCreate channel
close() => voidClose 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;