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