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.
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
- Yarn
- pnpm
- Bun
npm install react-native-moq
yarn add react-native-moq
pnpm add react-native-moq
bun add react-native-moq
Then install the iOS pods:
cd ios && pod install
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
- Yarn
- pnpm
- Bun
npm install react-native-moq-ui @react-native-vector-icons/material-icons
yarn add react-native-moq-ui @react-native-vector-icons/material-icons
pnpm add react-native-moq-ui @react-native-vector-icons/material-icons
bun add 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.
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?.
To obtain a MoQ connection URL you'll need your Sandbox API URL. If you don't have it already, see Sandbox API URL.
- React Native
- TS
The useSandbox hook from @fishjam-cloud/react-native-client wraps the Sandbox API request for you:
import {useSandbox } from "@fishjam-cloud/react-native-client"; constSUBSCRIBER_PATH = "stream-alice"; constSANDBOX_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 } = awaitgetSandboxMoqSubscriberAccess (SUBSCRIBER_PATH );
If you don't want to pull in the whole client library just for the useSandbox hook, you can call the Sandbox API directly with fetch:
constSUBSCRIBER_PATH = "stream-alice"; constSANDBOX_API_URL = "YOUR_SANDBOX_API_URL"; constresponse = awaitfetch ( `${SANDBOX_API_URL }/moq/${SUBSCRIBER_PATH }/subscriber`, ); const {connection_url :subscribeUrl } = awaitresponse .json ();
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"; constSUBSCRIBER_PATH = "stream-alice"; constsubscribeUrl = ""; // from the step above functionWatchScreen () { // Connect to the Fishjam MoQ relay on mount using the subscriber connection URL constsession =useSession (subscribeUrl , (s ) =>s .connect ()); // Discover broadcasts under the subscriber path constbroadcasts =useBroadcasts (session ,SUBSCRIBER_PATH ); return ( <> {broadcasts .map ((broadcast ) => ( <BroadcastPlayer key ={broadcast .path }broadcast ={broadcast } /> ))} </> ); } functionBroadcastPlayer ({broadcast }: {broadcast :BroadcastInfo }) { // Create a reactive player and start playback constplayer =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.
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"; functionBroadcastPlayer ({broadcast }: {broadcast :BroadcastInfo }) { constplayer =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:
- TypeScript
- Python
import {FishjamClient } from '@fishjam-cloud/js-server-sdk'; constfishjamClient = newFishjamClient ({fishjamId ,managementToken , }); conststreamPath = 'stream-alice'; // Generate a connection URL that allows subscribing to 'stream-alice' const {connection_url :subscribeUrl } = awaitfishjamClient .createMoqAccess ({subscribePath :streamPath , });
from fishjam import FishjamClient fishjam_client = FishjamClient( fishjam_id=fishjam_id, management_token=management_token, ) stream_path = 'stream-alice' # Generate a connection URL that allows subscribing to 'stream-alice' subscribe_url = fishjam_client.create_moq_access(subscribe_path=stream_path).connection_url
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.
- TypeScript
- Python
import {FishjamClient } from '@fishjam-cloud/js-server-sdk'; constfishjamClient = newFishjamClient ({fishjamId ,managementToken }); constroomName = 'my-room'; const {connection_url :alicePublisherUrl } = awaitfishjamClient .createMoqAccess ({publishPath :roomName + "/alice", }); const {connection_url :bobPublisherUrl } = awaitfishjamClient .createMoqAccess ({publishPath :roomName + "/bob", }); const {connection_url :namespaceUrl } = awaitfishjamClient .createMoqAccess ({subscribePath :roomName , });
from fishjam import FishjamClient fishjam_client = FishjamClient( fishjam_id=fishjam_id, management_token=management_token, ) room_name = 'my-room' alice_publisher_url = fishjam_client.create_moq_access( publish_path=room_name + "/alice", ).connection_url bob_publisher_url = fishjam_client.create_moq_access( publish_path=room_name + "/bob", ).connection_url namespace_url = fishjam_client.create_moq_access(subscribe_path=room_name).connection_url
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"; constROOM_NAME = "my-room"; constnamespaceUrl = ""; functionRoomGrid () { constsession =useSession (namespaceUrl , (s ) =>s .connect ()); // Every broadcast published under the 'my-room' prefix, kept live constbroadcasts =useBroadcasts (session ,ROOM_NAME ); return ( <> {broadcasts .map ((broadcast ) => ( <Tile key ={broadcast .path }broadcast ={broadcast } /> ))} </> ); } functionTile ({broadcast }: {broadcast :BroadcastInfo }) { constplayer =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​
- React Native Publishing — broadcast a MoQ stream from mobile
- Web Subscribing — subscribe from the browser instead
- MoQ with Fishjam — how MoQ works in Fishjam
- Livestreaming — the WebRTC (WHIP/WHEP) approach