Skip to main content

Example — React Native

A useMeteredPeer hook + <RTCView> render components for a multi-peer video call on React Native. The React-Native twin of the React Integration example.

npm: @metered-ca/realtime · react-native-webrtc

For install + platform setup (Expo config plugin, iOS/Android permissions, the registerGlobals-vs-factory choice), see the React Native guide. This page is the code.

What it demonstrates

  • Driving the same MeteredPeer API on RN via rtcPeerConnectionFactory
  • Mapping SDK events into React state (rosters, status, remote streams)
  • Rendering streams with <RTCView> instead of <video>
  • Re-binding the view on stream-added so it survives reconnect/reconcile

The hook

The only React-Native-specific line is the rtcPeerConnectionFactory — everything else matches the web hook.

useMeteredPeer.native.tsx
import { useEffect, useRef, useState } from "react";
import {
MeteredPeer,
type MeteredPeerOptions,
type RemotePeer,
type RTCPeerConnectionLike,
} from "@metered-ca/realtime";
import { RTCPeerConnection } from "react-native-webrtc";

type CallState =
| "idle" | "joining" | "joined" | "reconnecting" | "leaving" | "closed";

export function useMeteredPeer(
channel: string,
options: Omit<MeteredPeerOptions, "rtcPeerConnectionFactory">,
) {
const [state, setState] = useState<CallState>("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,
// react-native-webrtc's RTCPeerConnection is runtime-compatible
// with the SDK's RTCPeerConnectionLike; the cast bridges the types.
rtcPeerConnectionFactory: (cfg) =>
new RTCPeerConnection(cfg as object) as unknown as RTCPeerConnectionLike,
});
peerRef.current = peer;

peer.on("state-change", ({ to }) => { if (!cancelled) setState(to); });
const refresh = () => { if (!cancelled) setRemotes([...peer.remotePeers]); };
peer.on("peer-joined", refresh);
peer.on("peer-left", refresh);
peer.on("error", ({ err }) => { if (!cancelled) setError(err); });

peer.join(channel).catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
});

return () => { cancelled = true; void peer.close(); peerRef.current = null; };
}, [channel]); // memoize `options` at the caller — see React Integration

return { state, remotes, peer: peerRef.current, error };
}

The render components

Browsers use <video>.srcObject; React Native uses <RTCView>.

MeteredVideo.tsx
import { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { RTCView, type MediaStream } from "react-native-webrtc";
import type { RemotePeer, StreamMetadata } from "@metered-ca/realtime";

export function LocalVideo({ stream }: { stream: MediaStream }) {
return <RTCView streamURL={stream.toURL()} objectFit="cover" mirror style={styles.video} />;
}

export function RemoteTile({ remote }: { remote: RemotePeer }) {
const [streamURL, setStreamURL] = useState<string | null>(null);
const [label, setLabel] = useState(remote.id);

useEffect(() => {
// `stream-added` re-fires after reconnect with a FRESH MediaStream
// (same id, new object) — re-read .toURL() every time.
const onAdded = (ev: { stream: MediaStream; metadata?: StreamMetadata }) => {
setStreamURL(ev.stream.toURL());
if (typeof ev.metadata?.label === "string") setLabel(ev.metadata.label);
};
const onRemoved = () => setStreamURL(null);
// Casts: the SDK types events against the DOM MediaStream.
remote.on("stream-added", onAdded as never);
remote.on("stream-removed", onRemoved as never);
return () => {
remote.off("stream-added", onAdded as never);
remote.off("stream-removed", onRemoved as never);
};
}, [remote]);

return (
<View style={styles.tile}>
{streamURL && <RTCView streamURL={streamURL} objectFit="cover" style={styles.video} />}
<Text style={styles.label}>{label}</Text>
</View>
);
}

const styles = StyleSheet.create({
video: { flex: 1, backgroundColor: "#000", borderRadius: 8 },
tile: { width: "48%", aspectRatio: 3 / 4, margin: "1%" },
label: { position: "absolute", bottom: 6, left: 8, color: "#fff", fontSize: 12 },
});

The screen

CallScreen.tsx
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, StyleSheet, Text, View } from "react-native";
import { mediaDevices, type MediaStream } from "react-native-webrtc";
import { useMeteredPeer } from "./useMeteredPeer.native";
import { LocalVideo, RemoteTile } from "./MeteredVideo";

export function CallScreen({ apiKey, channel }: { apiKey: string; channel: string }) {
const { state, remotes, peer, error } = useMeteredPeer(channel, { apiKey });
const [localStream, setLocalStream] = useState<MediaStream | null>(null);

// Acquire camera + mic once joined, then publish it.
useEffect(() => {
if (state !== "joined" || localStream) return;
let cancelled = false;
mediaDevices.getUserMedia({ audio: true, video: true }).then((stream) => {
const s = stream as MediaStream;
if (cancelled) { s.getTracks().forEach((t) => t.stop()); return; }
setLocalStream(s);
peer?.addStream(s as never, { role: "camera" });
}).catch((e) => console.error("getUserMedia failed:", e));
return () => { cancelled = true; };
}, [state, peer, localStream]);

// Stop the camera on unmount — peer.close() won't.
useEffect(() => () => { localStream?.getTracks().forEach((t) => t.stop()); }, [localStream]);

return (
<SafeAreaView style={styles.root}>
<Text style={styles.status}>{state}{error ? `${error.message}` : ""}</Text>
<ScrollView contentContainerStyle={styles.grid}>
{localStream && (
<View style={styles.tile}><LocalVideo stream={localStream} /></View>
)}
{remotes.map((r) => <RemoteTile key={r.id} remote={r} />)}
</ScrollView>
</SafeAreaView>
);
}

const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#111" },
status: { color: "#9f9", padding: 10 },
grid: { flexDirection: "row", flexWrap: "wrap", paddingHorizontal: 4 },
tile: { width: "48%", aspectRatio: 3 / 4, margin: "1%" },
});

Mount it

Expo (reads the key from an EXPO_PUBLIC_… env var):

App.tsx (Expo)
import { CallScreen } from "./CallScreen";
const API_KEY = process.env.EXPO_PUBLIC_METERED_KEY ?? "pk_live_REPLACE_ME";
export default function App() {
return <CallScreen apiKey={API_KEY} channel="rn-demo-room" />;
}

Bare RN registers the same component via AppRegistry in index.js. See the guide for both project setups.

Pitfalls

  1. Running in Expo Go. react-native-webrtc needs native code — use a dev build with the config plugin, not Expo Go.
  2. Not re-reading .toURL(). Bind it on every stream-added, not once — the stream object changes across reconcile.
  3. Forgetting to stop local tracks. peer.close() ends the connection but leaves the camera on; stop tracks on unmount.
  4. Runtime permissions. Request camera/mic before getUserMedia on Android 6+ / iOS.

See also