useDataChannel
A hook for WebRTC data channels (P2P messaging)
A React hook for managing WebRTC data channels. Provides P2P messaging for chat, file transfer, or game state synchronization with low latency.
Source Code
View the full hook implementation in the Hook Source Code section below.
Low Latency
Data channels use UDP-like transport, making them ideal for real-time applications like gaming or live collaboration.
Features
- Text Messaging - Send/receive string messages
- Binary Data - Send ArrayBuffer or Blob for files
- JSON Support - Built-in JSON serialization with
sendJSON() - Message History - Track all received messages
- Ready State - Monitor channel open/closed state
Cross-Tab Chat (Manual Signaling)
Data channels require a signaling process (exchanging SDP offers/answers) to establish a connection. In a real app, this happens automatically via a server (like WebSocket).
For this demo, we can test P2P communication between two browser tabs by manually copying and pasting the signaling data.
Tab A: The Caller (Offers Connection)
Open this example in Tab A. It will generate an SDP offer that you need to copy.
- Click Create Offer and copy the generated JSON.
- Paste it into the "Paste Offer" box in Tab B (Example 2).
- Copy the answer from Tab B and paste it back here.
Step 1: Create Offer
Click the button to generate an SDP offer. You'll copy this and paste it in Tab B.
- Open this example in two tabs side by side
- In Tab A: Click "Create Offer" and copy the SDP
- In Tab B: Paste the SDP and click "Create Answer"
- Copy Tab B's answer and paste in Tab A
- Chat between tabs!
"use client";
import { useState, useRef } from "react";
import { usePeerConnection } from "@repo/hooks/webrtc/use-peer-connection";
import { useDataChannel } from "@repo/hooks/webrtc/use-data-channel";
import { Button } from "@repo/ui/components/button";
import { Copy, Check, Send, ArrowRight, MessageSquare } from "lucide-react";
/**
* CROSS-TAB CHAT - Tab A (Caller/Offerer)
*
* Instructions:
* 1. Click "Create Offer" - this generates your offer SDP
* 2. Copy the SDP and paste it in another tab's "Paste Offer" box
* 3. Copy the Answer SDP from that tab and paste it here
* 4. Now you can chat between tabs!
*/
export const Example1 = () => {
const [step, setStep] = useState<1 | 2 | 3>(1);
const [offerSdp, setOfferSdp] = useState("");
const [answerInput, setAnswerInput] = useState("");
const [copied, setCopied] = useState(false);
const [message, setMessage] = useState("");
const [iceCandidates, setIceCandidates] = useState<RTCIceCandidateInit[]>(
[],
);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Peer connection (as the caller/offerer)
const {
peerConnection,
connectionState,
createOffer,
setRemoteDescription,
addIceCandidate,
} = usePeerConnection({
onIceCandidate: (candidate) => {
if (candidate) {
setIceCandidates((prev) => [...prev, candidate.toJSON()]);
}
},
});
// Data channel
const { send, messages, isOpen } = useDataChannel(peerConnection, {
label: "chat",
autoCreate: true,
});
// Step 1: Create Offer
const handleCreateOffer = async () => {
const offer = await createOffer();
if (offer) {
// Wait a moment for ICE gathering to complete
await new Promise((resolve) => setTimeout(resolve, 1000));
const fullOffer = {
sdp: peerConnection?.localDescription,
ice: iceCandidates,
};
setOfferSdp(JSON.stringify(fullOffer, null, 2));
setStep(2);
}
};
// Step 2: Set Remote Answer
const handleSetAnswer = async () => {
try {
const parsed = JSON.parse(answerInput);
await setRemoteDescription(parsed.sdp);
// 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. Make sure to paste the complete JSON.",
);
}
};
// Copy SDP to clipboard
const handleCopy = async () => {
await navigator.clipboard.writeText(offerSdp);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Send message
const handleSend = () => {
if (message.trim() && isOpen) {
send(`Tab A: ${message}`);
setMessage("");
}
};
return (
<div className="flex w-full max-w-lg flex-col gap-4">
{/* 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 (Creates Offer)
</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>
{/* Step 1: Create Offer */}
{step === 1 && (
<div className="rounded-lg border p-4">
<h3 className="mb-2 font-medium">Step 1: Create Offer</h3>
<p className="text-muted-foreground mb-3 text-sm">
Click the button to generate an SDP offer. You'll
copy this and paste it in Tab B.
</p>
<Button onClick={handleCreateOffer}>Create Offer</Button>
</div>
)}
{/* Step 2: Share Offer & Get Answer */}
{step === 2 && (
<div className="space-y-4">
{/* Generated Offer */}
<div className="rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-medium">
Your Offer (Copy This)
</h3>
<Button
size="sm"
variant="outline"
onClick={handleCopy}
className="gap-1"
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-xs dark:bg-zinc-900">
{offerSdp.slice(0, 500)}...
</pre>
</div>
{/* Paste Answer */}
<div className="rounded-lg border p-4">
<h3 className="mb-2 font-medium">
Paste Answer from Tab B
</h3>
<p className="text-muted-foreground mb-2 text-sm">
After pasting the offer in Tab B, copy the answer
and paste it here.
</p>
<textarea
value={answerInput}
onChange={(e) => setAnswerInput(e.target.value)}
placeholder="Paste the answer JSON here..."
className="bg-background mb-2 h-24 w-full rounded-md border p-2 font-mono text-xs"
/>
<Button
onClick={handleSetAnswer}
isDisabled={!answerInput.trim()}
>
<ArrowRight className="mr-1 h-4 w-4" />
Connect
</Button>
</div>
</div>
)}
{/* Step 3: Chat */}
{step === 3 && (
<div className="space-y-3">
{/* Messages */}
<div className="flex h-48 flex-col gap-1 overflow-y-auto rounded-lg border bg-zinc-50 p-3 dark:bg-zinc-900">
{messages.length === 0 ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
<MessageSquare className="mr-2 h-4 w-4" />
{isOpen
? "Connected! Send a message."
: "Waiting for connection..."}
</div>
) : (
messages.map((msg, i) => (
<div
key={i}
className={`rounded px-2 py-1 text-sm ${
String(msg.data).startsWith("Tab A")
? "ml-auto bg-blue-500 text-white"
: "bg-white dark:bg-zinc-800"
}`}
>
{String(msg.data)}
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Type a message..."
className="bg-background flex-1 rounded-md border px-3 py-2 text-sm"
disabled={!isOpen}
/>
<Button
size="icon"
onClick={handleSend}
isDisabled={!isOpen || !message.trim()}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Instructions */}
<div className="text-muted-foreground rounded-lg bg-blue-50 p-3 text-xs dark:bg-blue-950/30">
<strong className="text-blue-600 dark:text-blue-400">
How to test:
</strong>
<ol className="mt-1 list-inside list-decimal space-y-0.5">
<li>Open this example in two tabs side by side</li>
<li>
In Tab A: Click "Create Offer" and copy the
SDP
</li>
<li>
In Tab B: Paste the SDP and click "Create
Answer"
</li>
<li>Copy Tab B's answer and paste in Tab A</li>
<li>Chat between tabs!</li>
</ol>
</div>
</div>
);
};
Tab B: The Callee (Answers Connection)
Open this example in Tab B (side-by-side with Tab A).
- Paste the offer from Tab A.
- Click Create Answer and copy the generated JSON.
- Paste this answer back into Tab A to complete the connection.
Step 1: Paste Offer from Tab A
Copy the offer SDP from Tab A and paste it here.
- Paste the offer from Tab A above
- Click "Create Answer"
- Copy your answer and paste it in Tab A
- Click "Go to Chat" and start messaging!
"use client";
import { useState, useRef } from "react";
import { usePeerConnection } from "@repo/hooks/webrtc/use-peer-connection";
import { useDataChannel } from "@repo/hooks/webrtc/use-data-channel";
import { Button } from "@repo/ui/components/button";
import {
Copy,
Check,
Send,
ArrowRight,
MessageSquare,
Clipboard,
} from "lucide-react";
/**
* CROSS-TAB CHAT - Tab B (Callee/Answerer)
*
* Instructions:
* 1. Paste the Offer SDP from Tab A
* 2. Click "Create Answer" - this generates your answer SDP
* 3. Copy the answer and paste it back in Tab A
* 4. Now you can chat between tabs!
*/
export const Example2 = () => {
const [step, setStep] = useState<1 | 2 | 3>(1);
const [offerInput, setOfferInput] = useState("");
const [answerSdp, setAnswerSdp] = useState("");
const [copied, setCopied] = useState(false);
const [message, setMessage] = useState("");
const [iceCandidates, setIceCandidates] = useState<RTCIceCandidateInit[]>(
[],
);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Peer connection (as the callee/answerer)
const {
peerConnection,
connectionState,
setRemoteDescription,
createAnswer,
addIceCandidate,
} = usePeerConnection({
onIceCandidate: (candidate) => {
if (candidate) {
setIceCandidates((prev) => [...prev, candidate.toJSON()]);
}
},
});
// Data channel (receive from offerer)
const { send, messages, isOpen } = useDataChannel(peerConnection, {
label: "chat",
autoCreate: false, // We receive the channel from the offerer
});
// Step 1: Set Remote Offer and Create Answer
const handleCreateAnswer = async () => {
try {
const parsed = JSON.parse(offerInput);
// Set remote description (the offer)
await setRemoteDescription(parsed.sdp);
// Add ICE candidates from offer
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);
}
} catch {
alert(
"Invalid offer format. Make sure to paste the complete JSON from Tab A.",
);
}
};
// Copy SDP to clipboard
const handleCopy = async () => {
await navigator.clipboard.writeText(answerSdp);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Move to chat step
const goToChat = () => {
setStep(3);
};
// Send message
const handleSend = () => {
if (message.trim() && isOpen) {
send(`Tab B: ${message}`);
setMessage("");
}
};
return (
<div className="flex w-full max-w-lg flex-col gap-4">
{/* 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 (Receives Offer)
</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>
{/* Step 1: Paste Offer */}
{step === 1 && (
<div className="rounded-lg border p-4">
<h3 className="mb-2 font-medium">
Step 1: Paste Offer from Tab A
</h3>
<p className="text-muted-foreground mb-2 text-sm">
Copy the offer SDP from Tab A and paste it here.
</p>
<textarea
value={offerInput}
onChange={(e) => setOfferInput(e.target.value)}
placeholder="Paste the offer JSON here..."
className="bg-background mb-3 h-32 w-full rounded-md border p-2 font-mono text-xs"
/>
<Button
onClick={handleCreateAnswer}
isDisabled={!offerInput.trim()}
>
<Clipboard className="mr-1 h-4 w-4" />
Create Answer
</Button>
</div>
)}
{/* Step 2: Share Answer */}
{step === 2 && (
<div className="space-y-4">
<div className="rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-medium">
Your Answer (Copy This)
</h3>
<Button
size="sm"
variant="outline"
onClick={handleCopy}
className="gap-1"
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-xs dark:bg-zinc-900">
{answerSdp.slice(0, 500)}...
</pre>
<p className="text-muted-foreground mt-3 text-sm">
Copy this answer and paste it in Tab A, then click
below to start chatting.
</p>
</div>
<Button onClick={goToChat} className="w-full">
<ArrowRight className="mr-1 h-4 w-4" />
Go to Chat
</Button>
</div>
)}
{/* Step 3: Chat */}
{step === 3 && (
<div className="space-y-3">
{/* Messages */}
<div className="flex h-48 flex-col gap-1 overflow-y-auto rounded-lg border bg-zinc-50 p-3 dark:bg-zinc-900">
{messages.length === 0 ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
<MessageSquare className="mr-2 h-4 w-4" />
{isOpen
? "Connected! Send a message."
: "Waiting for connection..."}
</div>
) : (
messages.map((msg, i) => (
<div
key={i}
className={`rounded px-2 py-1 text-sm ${
String(msg.data).startsWith("Tab B")
? "ml-auto bg-green-500 text-white"
: "bg-white dark:bg-zinc-800"
}`}
>
{String(msg.data)}
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Type a message..."
className="bg-background flex-1 rounded-md border px-3 py-2 text-sm"
disabled={!isOpen}
/>
<Button
size="icon"
onClick={handleSend}
isDisabled={!isOpen || !message.trim()}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Instructions */}
<div className="text-muted-foreground rounded-lg bg-green-50 p-3 text-xs dark:bg-green-950/30">
<strong className="text-green-600 dark:text-green-400">
This is Tab B (Callee):
</strong>
<ol className="mt-1 list-inside list-decimal space-y-0.5">
<li>Paste the offer from Tab A above</li>
<li>Click "Create Answer"</li>
<li>Copy your answer and paste it in Tab A</li>
<li>Click "Go to Chat" and start messaging!</li>
</ol>
</div>
</div>
);
};
API Reference
Hook Signature
function useDataChannel(
peerConnection: RTCPeerConnection | null,
options: UseDataChannelOptions,
): UseDataChannelReturn;Options
| Property | Type | Default | Description |
|---|---|---|---|
label | string | Required | Channel identifier |
channelOptions | RTCDataChannelInit | - | RTCDataChannel configuration |
autoCreate | boolean | true | Create channel (for offerer) |
onMessage | (data) => void | - | Called when message received |
onOpen | () => void | - | Called when channel opens |
onClose | () => void | - | Called when channel closes |
Return Value
| Property | Type | Description |
|---|---|---|
channel | RTCDataChannel | null | The data channel instance |
readyState | RTCDataChannelState | Channel state |
isOpen | boolean | Whether channel is open |
send | (data: string) => boolean | Send text message |
sendBinary | (data) => boolean | Send binary data |
sendJSON | <T>(data: T) => boolean | Send JSON-serialized data |
messages | DataChannelMessage[] | Received messages |
lastMessage | DataChannelMessage | null | Most recent message |
clearMessages | () => void | Clear message history |
bufferedAmount | number | Bytes waiting to be sent |
Hook Source Code
import { useState, useEffect, useCallback, useRef } from "react";
/**
* Options for the useDataChannel hook
*/
export interface UseDataChannelOptions {
/** Label for the data channel */
label: string;
/** RTCDataChannel configuration options */
channelOptions?: RTCDataChannelInit;
/** Whether to create the channel immediately (default: true for offerer) */
autoCreate?: boolean;
/** Callback when a message is received */
onMessage?: (data: string | ArrayBuffer) => void;
/** Callback when channel opens */
onOpen?: () => void;
/** Callback when channel closes */
onClose?: () => void;
/** Callback when error occurs */
onError?: (error: Event) => void;
}
/**
* Message with metadata
*/
export interface DataChannelMessage {
/** Message data (string or binary) */
data: string | ArrayBuffer;
/** Timestamp when received */
timestamp: number;
/** Whether this is a binary message */
isBinary: boolean;
}
/**
* Return type for the useDataChannel hook
*/
export interface UseDataChannelReturn {
/** The RTCDataChannel instance */
channel: RTCDataChannel | null;
/** Current ready state of the channel */
readyState: RTCDataChannelState;
/** Whether the channel is open and ready to send */
isOpen: boolean;
/** Send a string message */
send: (data: string) => boolean;
/** Send binary data */
sendBinary: (data: ArrayBuffer | Blob) => boolean;
/** Send JSON data */
sendJSON: <T>(data: T) => boolean;
/** Received messages history */
messages: DataChannelMessage[];
/** Most recent message */
lastMessage: DataChannelMessage | null;
/** Clear message history */
clearMessages: () => void;
/** Buffered amount of data waiting to be sent */
bufferedAmount: number;
}
/**
* A React hook for managing WebRTC data channels.
* Provides P2P messaging for chat, file transfer, or game state.
*
* @param peerConnection - The RTCPeerConnection to use
* @param options - Configuration options for the data channel
* @returns UseDataChannelReturn object with channel, states, and methods
*
* @example
* ```tsx
* const { createDataChannel } = usePeerConnection();
* const { send, messages, isOpen } = useDataChannel(peerConnection, {
* label: "chat",
* onMessage: (data) => console.log("Received:", data),
* });
*
* // Send a message
* if (isOpen) {
* send("Hello, peer!");
* }
* ```
*/
export function useDataChannel(
peerConnection: RTCPeerConnection | null,
options: UseDataChannelOptions,
): UseDataChannelReturn {
const {
label,
channelOptions,
autoCreate = true,
onMessage,
onOpen,
onClose,
onError,
} = options;
const [channel, setChannel] = useState<RTCDataChannel | null>(null);
const [readyState, setReadyState] =
useState<RTCDataChannelState>("connecting");
const [messages, setMessages] = useState<DataChannelMessage[]>([]);
const [bufferedAmount, setBufferedAmount] = useState(0);
// Store callbacks in refs
const onMessageRef = useRef(onMessage);
const onOpenRef = useRef(onOpen);
const onCloseRef = useRef(onClose);
const onErrorRef = useRef(onError);
onMessageRef.current = onMessage;
onOpenRef.current = onOpen;
onCloseRef.current = onClose;
onErrorRef.current = onError;
// Setup channel event handlers
const setupChannel = useCallback((dc: RTCDataChannel) => {
dc.onopen = () => {
setReadyState("open");
onOpenRef.current?.();
};
dc.onclose = () => {
setReadyState("closed");
onCloseRef.current?.();
};
dc.onerror = (event) => {
onErrorRef.current?.(event);
};
dc.onmessage = (event) => {
const message: DataChannelMessage = {
data: event.data,
timestamp: Date.now(),
isBinary: event.data instanceof ArrayBuffer,
};
setMessages((prev) => [...prev, message]);
onMessageRef.current?.(event.data);
};
dc.onbufferedamountlow = () => {
setBufferedAmount(dc.bufferedAmount);
};
setChannel(dc);
setReadyState(dc.readyState);
}, []);
// Create or receive data channel
useEffect(() => {
if (!peerConnection) return;
// Handle incoming data channel from remote peer
const handleDataChannel = (event: RTCDataChannelEvent) => {
if (event.channel.label === label) {
setupChannel(event.channel);
}
};
peerConnection.addEventListener("datachannel", handleDataChannel);
// Create channel if we're the offerer
if (autoCreate && !channel) {
try {
const dc = peerConnection.createDataChannel(
label,
channelOptions,
);
setupChannel(dc);
} catch (err) {
console.error("Failed to create data channel:", err);
}
}
return () => {
peerConnection.removeEventListener(
"datachannel",
handleDataChannel,
);
};
}, [
peerConnection,
label,
channelOptions,
autoCreate,
channel,
setupChannel,
]);
// Send string message
const send = useCallback(
(data: string): boolean => {
if (!channel || channel.readyState !== "open") return false;
try {
channel.send(data);
setBufferedAmount(channel.bufferedAmount);
return true;
} catch (err) {
console.error("Failed to send message:", err);
return false;
}
},
[channel],
);
// Send binary data
const sendBinary = useCallback(
(data: ArrayBuffer | Blob): boolean => {
if (!channel || channel.readyState !== "open") return false;
try {
if (data instanceof Blob) {
// For Blob, we need to convert to ArrayBuffer
data.arrayBuffer().then((buffer) => {
channel.send(buffer);
});
} else {
channel.send(data);
}
setBufferedAmount(channel.bufferedAmount);
return true;
} catch (err) {
console.error("Failed to send binary data:", err);
return false;
}
},
[channel],
);
// Send JSON data
const sendJSON = useCallback(
<T>(data: T): boolean => {
try {
return send(JSON.stringify(data));
} catch (err) {
console.error("Failed to serialize JSON:", err);
return false;
}
},
[send],
);
// Clear messages
const clearMessages = useCallback(() => {
setMessages([]);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (channel) {
channel.close();
}
};
}, [channel]);
return {
channel,
readyState,
isOpen: readyState === "open",
send,
sendBinary,
sendJSON,
messages,
lastMessage: messages[messages.length - 1] ?? null,
clearMessages,
bufferedAmount,
};
}
export default useDataChannel;