Skip to main content

Example — React Integration

A useMeteredPeer hook + a <VideoCall> component. Drop into any React 17+ app.

npm: @metered-ca/peer

What it demonstrates

  • Constructing MeteredPeer once 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

useMeteredPeer.tsx
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

VideoCall.tsx
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

  1. 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 uses setRemotes([...peer.remotePeers]) (functional-equivalent: snapshot from SDK source-of-truth) so it doesn't depend on previous React state.

  2. Re-mounting on every render. If your <VideoCall apiKey={...} /> props change identity every render (e.g. you pass apiKey={getKey()} from a parent that re-renders), the hook rebuilds the peer every time. Stabilize the props with useMemo at the parent.

  3. Not stopping local tracks. When the component unmounts, peer.close() closes the WS but doesn't stop the camera. The camera light stays on. Explicit localStream.getTracks().forEach(t => t.stop()) is required.

  4. useEffect cleanup not awaiting peer.close. React's cleanup function isn't async. The void 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.

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

  6. Iterating peer.remotePeers for rendering. The array is a snapshot; mutating it does nothing. Always derive your React state from peer-joined / peer-left events (the hook does this), not by polling peer.remotePeers in 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