Example — React Integration
A useMeteredPeer hook + a <VideoCall> component. Drop into any React 17+ app.
npm: @metered-ca/peer
What it demonstrates
- Constructing
MeteredPeeronce per component mount and disposing on unmount - Mapping SDK events into React state (rosters, connection status, remote streams)
- The standard reconnect UI driven by
state-change - Clean teardown — closing the peer + stopping local tracks on unmount
The hook
import { useEffect, useRef, useState } from "react";
import { MeteredPeer, type MeteredPeerOptions, type RemotePeer } from "@metered-ca/peer";
interface UseMeteredPeerResult {
state: "idle" | "joining" | "joined" | "reconnecting" | "leaving" | "closed";
remotes: RemotePeer[];
peer: MeteredPeer | null;
error: Error | null;
}
export function useMeteredPeer(
channel: string,
options: MeteredPeerOptions,
): UseMeteredPeerResult {
const [state, setState] = useState<UseMeteredPeerResult["state"]>("idle");
const [remotes, setRemotes] = useState<RemotePeer[]>([]);
const [error, setError] = useState<Error | null>(null);
const peerRef = useRef<MeteredPeer | null>(null);
useEffect(() => {
let cancelled = false;
const peer = new MeteredPeer(options);
peerRef.current = peer;
peer.on("state-change", ({ to }) => {
if (!cancelled) setState(to);
});
peer.on("peer-joined", () => {
if (!cancelled) setRemotes([...peer.remotePeers]);
});
peer.on("peer-left", () => {
if (!cancelled) setRemotes([...peer.remotePeers]);
});
peer.on("error", ({ err }) => {
if (!cancelled) setError(err);
});
peer.join(channel).catch((err) => {
if (!cancelled) setError(err);
});
return () => {
cancelled = true;
void peer.close();
peerRef.current = null;
};
}, [channel]); // intentionally not options — see below
return { state, remotes, peer: peerRef.current, error };
}
Dependency array — why only channel
The useEffect reruns whenever any dependency changes. If we included options (an object), it'd rerun on every render — tearing down + rebuilding the peer constantly. The right pattern is to use stable references for options (memoized at the parent) and only rerun when the channel itself changes.
If your app needs to react to options changes (different apiKey, different tokenProvider), call useEffect with a custom comparison or memoize the options at a higher level.
Cancellation flag
cancelled prevents stale setState calls if the component unmounts while async ops (like peer.join) are in flight. Without it, you'd see "Can't perform a React state update on an unmounted component" warnings.
The component
import { useEffect, useState } from "react";
import { useMeteredPeer } from "./useMeteredPeer";
export function VideoCall({ apiKey, channel }: { apiKey: string; channel: string }) {
const { state, remotes, peer, error } = useMeteredPeer(channel, { apiKey });
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
useEffect(() => {
if (state !== "joined" || localStream) return;
let cancelled = false;
navigator.mediaDevices
.getUserMedia({ video: true, audio: true })
.then((stream) => {
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
peer?.addStream(stream);
setLocalStream(stream);
})
.catch((err) => console.error("getUserMedia failed:", err));
return () => {
cancelled = true;
localStream?.getTracks().forEach((t) => t.stop());
};
}, [state, peer]);
return (
<div>
{state === "reconnecting" && <Banner text="Reconnecting…" />}
{error && <Banner text={error.message} variant="error" />}
{localStream && <LocalVideo stream={localStream} />}
<div className="remote-grid">
{remotes.map((remote) => (
<RemoteTile key={remote.id} remote={remote} />
))}
</div>
</div>
);
}
function LocalVideo({ stream }: { stream: MediaStream }) {
const ref = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (ref.current) ref.current.srcObject = stream;
}, [stream]);
return <video ref={ref} autoPlay playsInline muted />;
}
function RemoteTile({ remote }: { remote: RemotePeer }) {
const ref = useRef<HTMLVideoElement>(null);
useEffect(() => {
const onTrack = ({ streams }: { streams: ReadonlyArray<MediaStream> }) => {
if (ref.current) ref.current.srcObject = streams[0];
};
remote.on("track", onTrack);
return () => { remote.off("track", onTrack); };
}, [remote]);
return <video ref={ref} autoPlay playsInline data-peer-id={remote.id} />;
}
Pitfalls
Stale closures in event handlers. React's renders capture variables at render time. If your event handler refers to component state, use the ref pattern or the functional form of
setState. The hook above usessetRemotes([...peer.remotePeers])(functional-equivalent: snapshot from SDK source-of-truth) so it doesn't depend on previous React state.Re-mounting on every render. If your
<VideoCall apiKey={...} />props change identity every render (e.g. you passapiKey={getKey()}from a parent that re-renders), the hook rebuilds the peer every time. Stabilize the props withuseMemoat the parent.Not stopping local tracks. When the component unmounts,
peer.close()closes the WS but doesn't stop the camera. The camera light stays on. ExplicitlocalStream.getTracks().forEach(t => t.stop())is required.useEffectcleanup not awaitingpeer.close. React's cleanup function isn't async. Thevoid peer.close()schedules but doesn't wait; the close-handshake completes asynchronously. Usually fine, but if you immediately remount with the same channel, you may have two peers fighting briefly.Forgetting Strict Mode double-mount. React 18 strict-mode mounts components twice in development. Your peer will be built, disconnected, built again. The cleanup must be idempotent —
peer.close()is, by design.Iterating
peer.remotePeersfor rendering. The array is a snapshot; mutating it does nothing. Always derive your React state frompeer-joined/peer-leftevents (the hook does this), not by pollingpeer.remotePeersin a render loop.
Server-side rendering (Next.js / Remix)
The SDK uses WebSocket and RTCPeerConnection, which don't exist in Node's SSR environment. Either:
'use client'directive (Next.js App Router) on the component file- Dynamic import with
ssr: false:
import dynamic from "next/dynamic";
const VideoCall = dynamic(() => import("./VideoCall"), { ssr: false });
See also
MeteredPeerreference- Reconnect Best Practices — adapt the patterns to your React state
- WebRTC Video Call guide — same outcome, vanilla JS