Skip to main content
Version: Next

React Native Subscribing

This tutorial explains how to subscribe to a live stream and render it in a React Native mobile app using Media over QUIC (MoQ) with Fishjam. To broadcast a stream instead, see React Native Publishing.

It uses react-native-moq — React Native bindings for MoQKit, with a small, reactive hooks-based API. For the web equivalent, see Web Subscribing.

info

If you're new to MoQ, then we recommend getting familiar with the MoQ with Fishjam explanation.

Requirements

react-native-moq targets the React Native New Architecture (Fabric / TurboModules):

  • iOS 16+
  • Android API 30+

Installation

npm install react-native-moq

Then install the iOS pods:

cd ios && pod install
tip

The ready-made player chrome — <VideoPlayerView> with fullscreen controls, a volume slider, and matching context hooks — lives in a separate package so apps that build their own UI don't pay for it:

npm install react-native-moq-ui @react-native-vector-icons/material-icons

This tutorial uses the bare <VideoView> from the core package, but <VideoPlayerView> is a drop-in replacement if you want controls out of the box.

Quickstart with the Sandbox API

If you don't have a backend server set up, you can prototype subscribing using the Sandbox API.

tip

MoQ is a protocol with a well-defined negotiation, so a publisher and a subscriber don't need to use the same client library. A stream published from the browser with @moq/publish can be watched with react-native-moq, and vice versa.

Obtaining a subscriber connection URL

For more on what the Sandbox API is and its limitations, see What is the Sandbox API?.

info

To obtain a MoQ connection URL you'll need your Sandbox API URL. If you don't have it already, see Sandbox API URL.

The useSandbox hook from @fishjam-cloud/react-native-client wraps the Sandbox API request for you:

import { useSandbox } from "@fishjam-cloud/react-native-client"; const SUBSCRIBER_PATH = "stream-alice"; const SANDBOX_API_URL = "YOUR_SANDBOX_API_URL"; // Inside a React component: const { getSandboxMoqSubscriberAccess } = useSandbox({ sandboxApiUrl: SANDBOX_API_URL, }); // Request a subscriber connection URL scoped to the subscriber path const { connection_url: subscribeUrl } = await getSandboxMoqSubscriberAccess(SUBSCRIBER_PATH);

Connecting and subscribing

Open a session, discover the broadcast under its path with useBroadcasts, and render it with <VideoView>. useVideoPlayer turns a discovered broadcast into a reactive player; the decoding and rendering pipeline is handled natively — no canvas, no manual WebCodecs setup:

import { Button } from "react-native"; import { VideoView, useBroadcasts, useSession, useVideoPlayer, } from "react-native-moq"; import type { BroadcastInfo } from "react-native-moq"; const SUBSCRIBER_PATH = "stream-alice"; const subscribeUrl = ""; // from the step above function WatchScreen() { // Connect to the Fishjam MoQ relay on mount using the subscriber connection URL const session = useSession(subscribeUrl, (s) => s.connect()); // Discover broadcasts under the subscriber path const broadcasts = useBroadcasts(session, SUBSCRIBER_PATH); return ( <> {broadcasts.map((broadcast) => ( <BroadcastPlayer key={broadcast.path} broadcast={broadcast} /> ))} </> ); } function BroadcastPlayer({ broadcast }: { broadcast: BroadcastInfo }) { // Create a reactive player and start playback const player = useVideoPlayer(broadcast, (p) => p.play()); return ( <> <VideoView player={player} style={{ width: "100%", aspectRatio: 16 / 9 }} /> <Button title={player.isPlaying ? "Pause" : "Resume"} onPress={player.isPlaying ? player.pause : player.play} /> </> ); }

That's it! The stream appears in the <VideoView> once the publisher starts broadcasting, and useBroadcasts re-populates automatically on reconnect.

info

No canvas needed.
Unlike the web, where Watch.MultiBackend decodes frames through WebCodecs and paints them onto a <canvas>, the React Native player decodes and renders natively. You just hand the player to a <VideoView> and size it like any other view.

For audio-only streaming, use useAudioPlayer(broadcast) instead of useVideoPlayer — the video track is never subscribed, so no video bandwidth is consumed.

Using the <VideoPlayerView> component

If you don't need fine-grained control over the rendering surface, the react-native-moq-ui package ships a ready-to-use <VideoPlayerView> that wraps <VideoView> with platform-styled play/pause, a volume slider, and a fullscreen modal — the React Native counterpart of the web <moq-watch-ui> element.

import { VideoPlayerView } from "react-native-moq-ui"; function BroadcastPlayer({ broadcast }: { broadcast: BroadcastInfo }) { const player = useVideoPlayer(broadcast, (p) => p.play()); return ( <VideoPlayerView player={player} style={{ width: "100%", aspectRatio: 16 / 9 }} /> ); }

It exposes imperative enterFullscreen() / exitFullscreen() methods on its ref, and the chrome is fully customizable. See Default UI components in the API reference.

Production with Server SDKs

The Quickstart gets you watching quickly. In production, your backend generates tokens with proper authorization, so you control who can subscribe.

A subscriber connection URL grants read access to a specific path. Generate one on your backend and deliver it to the viewing client:

import { FishjamClient } from '@fishjam-cloud/js-server-sdk'; const fishjamClient = new FishjamClient({ fishjamId, managementToken, }); const streamPath = 'stream-alice'; // Generate a connection URL that allows subscribing to 'stream-alice' const { connection_url: subscribeUrl } = await fishjamClient.createMoqAccess({ subscribePath: streamPath, });

Deliver this connection URL to the mobile client, then use it to connect as described in Connecting and subscribing.

Subscribe to a namespace

When multiple publishers join a room, you won't know their exact paths in advance. Instead of consuming a single path, you can discover all broadcasts published under a namespace prefix and subscribe to each one as they appear.

To do this, generate a subscriber connection URL scoped to the room namespace instead of a single stream path.

import { FishjamClient } from '@fishjam-cloud/js-server-sdk'; const fishjamClient = new FishjamClient({ fishjamId, managementToken }); const roomName = 'my-room'; const { connection_url: alicePublisherUrl } = await fishjamClient.createMoqAccess({ publishPath: roomName + "/alice", }); const { connection_url: bobPublisherUrl } = await fishjamClient.createMoqAccess({ publishPath: roomName + "/bob", }); const { connection_url: namespaceUrl } = await fishjamClient.createMoqAccess({ subscribePath: roomName, });

On the client, pass the namespace connection URL to useSession, then call useBroadcasts(session, roomName) with the room prefix. It returns a reactive BroadcastInfo[] that re-renders every time a publisher joins or leaves — no manual diffing of an announce set. Map over it to mount a player per broadcast:

import { VideoView, useBroadcasts, useSession, useVideoPlayer, } from "react-native-moq"; import type { BroadcastInfo } from "react-native-moq"; const ROOM_NAME = "my-room"; const namespaceUrl = ""; function RoomGrid() { const session = useSession(namespaceUrl, (s) => s.connect()); // Every broadcast published under the 'my-room' prefix, kept live const broadcasts = useBroadcasts(session, ROOM_NAME); return ( <> {broadcasts.map((broadcast) => ( <Tile key={broadcast.path} broadcast={broadcast} /> ))} </> ); } function Tile({ broadcast }: { broadcast: BroadcastInfo }) { const player = useVideoPlayer(broadcast, (p) => p.play()); return ( <VideoView player={player} style={{ width: "100%", aspectRatio: 16 / 9 }} /> ); }

Because useBroadcasts starts the native subscription on mount and tears it down on unmount, players appear and disappear in lockstep with the publishers in the room — React's reconciliation handles mounting and unmounting tiles for you.

See also