Most "add calling to your app" tutorials stop at "the audio connects". Then you still have to build the hard part: the incoming call screen, ringing, the call timer, mute, hang up, "Call declined", and what happens when someone's network drops mid-call.
In this post we'll build a 1:1 voice calling app in React Native where all of that comes ready-made, in about 60 lines of code:
- incoming call screen with Answer / Decline
- ringing, an in-call screen with a timer, mute and hang up
- "Call ended / Declined / Call failed" messages
- a "Reconnecting…" state when a phone loses its network for a few seconds
- a green "Ongoing call · Tap to return" bar when the user minimizes the call
We'll use RTCExpress, a hosted voice/video/chat SDK I'm building at Devsquare Labs. Signaling, TURN relays and media servers are hosted for you, and the Free plan covers everything here.
1. What you need
- A working React Native Android setup (Node.js, JDK 17, Android Studio), React Native 0.73+
- Two Android devices, or a phone and an emulator (Android 7.0 / API 24+)
- A free RTCExpress project for the App ID: create one here (no credit card)
The SDK includes native code, so it won't run in Expo Go. Use a React Native CLI project. Android is supported today; iOS is experimental.
2. Create the app and install the SDK
npx @react-native-community/cli@latest init VoiceCallDemo cd VoiceCallDemo npm install rtcexpress-react-native Autolinking adds the native module. You don't need to edit Gradle files or AndroidManifest.xml; the package already declares the internet and microphone permissions.
3. Create two test tokens
In the RTCExpress console, open Token tools and create two short-lived tokens for the same room:
- user
alice, roomvoice-test - user
bob, roomvoice-test
Put them in config.ts next to App.tsx:
// Test values from the console's Token tools. // In a real app, fetch the token from your own backend. Never ship an App Secret. export const APP_ID = 'YOUR_APP_ID'; export const ROOM_ID = 'voice-test'; export const TOKENS: Record<'alice' | 'bob', string> = { alice: 'ALICE_TOKEN', bob: 'BOB_TOKEN', }; 4. The whole app
Replace App.tsx with this:
import React, { useEffect, useRef, useState } from 'react'; import { Button, SafeAreaView, StyleSheet, Text, View } from 'react-native'; import RTCExpress, { useRTCCall, CallScreen, OngoingCallBar } from 'rtcexpress-react-native'; import { APP_ID, ROOM_ID, TOKENS } from './config'; type User = 'alice' | 'bob'; export default function App() { const rtc = useRef(new RTCExpress()).current; const call = useRTCCall(rtc); // all the call state the screens need const [me, setMe] = useState<User | null>(null); const [joined, setJoined] = useState(false); const [status, setStatus] = useState('Pick a user'); const peer: User = me === 'alice' ? 'bob' : 'alice'; useEffect(() => { rtc.on('roomJoined', () => { setJoined(true); setStatus(`Joined ${ROOM_ID}`); }); rtc.on('error', (p) => setStatus(`Error: ${(p as { message: string }).message}`)); return () => rtc.destroy(); }, [rtc]); async function connect(user: User) { setMe(user); setStatus('Connecting...'); try { await rtc.init({ serverUrl: 'https://api.devsquarelabs.com', appId: APP_ID, userId: user, token: TOKENS[user], mediaMode: 'p2p', // 1:1 audio goes straight between the two phones }); rtc.joinRoom(ROOM_ID); } catch (e) { setStatus(`Error: ${(e as Error).message}`); } } return ( <View style={{ flex: 1 }}> {/* Green "Ongoing call · Tap to return" bar while a call is minimized */} <OngoingCallBar call={call} /> <SafeAreaView style={styles.screen}> <Text style={styles.title}>{me ? `Signed in as ${me}` : 'Voice call demo'}</Text> <Text style={styles.status}>{status}</Text> {!me && ( <View style={styles.buttons}> <Button title="I am Alice" onPress={() => connect('alice')} /> <Button title="I am Bob" onPress={() => connect('bob')} /> </View> )} {joined && <Button title={`Call ${peer}`} onPress={() => call.startCall(peer)} />} </SafeAreaView> {/* Full-screen call UI. Renders nothing when there's no call. Keep it last. */} <CallScreen controller={call} /> </View> ); } const styles = StyleSheet.create({ screen: { flex: 1, padding: 24, justifyContent: 'center' }, title: { fontSize: 22, fontWeight: '600', textAlign: 'center' }, status: { marginTop: 12, marginBottom: 24, textAlign: 'center' }, buttons: { gap: 12 }, }); That's it. Notice what's not here: no permission code, no incoming-call state, no timer, no mute button, no "call declined" handling.
How it works
-
rtc.initconnects with a short-lived user token, andjoinRoomjoins the room. TheroomJoinedevent shows the Call button. -
useRTCCall(rtc)listens to the SDK's call events and turns them into onecallobject:phase(ringing,connecting,connected,ended), who the peer is, why it ended, whether it's muted or reconnecting. -
call.startCall('bob')asks for microphone permission, then rings Bob. On Bob's phone,CallScreenpops up with Answer and Decline. -
<CallScreen />draws everything: ringing, the call timer, Mute, End, and "Call ended / Declined / Call failed". - The Android back button minimizes the call instead of leaving it, and
<OngoingCallBar />brings it back.
5. Calls that survive a network drop
People walk into lifts and switch from Wi-Fi to mobile data. From SDK version 0.1.6, a call doesn't die when that happens:
- The SDK reconnects on its own and keeps the call open for 30 seconds.
- The call screen shows "Reconnecting…" on both phones, so nobody stares at a frozen screen.
- If the network isn't back in 30 seconds, the call ends on both sides.
There's nothing to add to the code above. Try it: during a call, turn on airplane mode on one phone for 5 seconds, then turn it off.
6. Run it on two phones
List your devices with adb devices. Start Metro:
npx react-native start In another terminal, install on both devices:
npx react-native run-android --deviceId FIRST_DEVICE_ID npx react-native run-android --deviceId SECOND_DEVICE_ID - Tap I am Alice on one phone and I am Bob on the other. Wait for Joined voice-test.
- On Alice's phone, tap Call bob and allow the microphone.
- Bob's phone shows the incoming call screen. Tap Answer.
Two phones next to each other will echo, so use headphones or move apart.
Make it look like your app
The screens take a displayName, theme and labels, so you can show real names and translate the text:
<CallScreen controller={call} displayName={(id) => contacts[id]?.name ?? id} labels={{ reconnecting: 'Reconnecting… please wait' }} /> Need a completely custom design? Skip CallScreen and build your own UI from call.call, call.accept(), call.decline(), call.hangUp() and call.toggleMute().
Troubleshooting
- "rtcexpress-react-native doesn't seem to be linked": stop the app and run
npx react-native run-androidagain. A Metro reload isn't enough, and Expo Go won't work. - "Unable to load script" on one device: run
adb -s DEVICE_ID reverse tcp:8081 tcp:8081and reload. - Error after connecting: check the App ID and that the tokens haven't expired.
- No incoming call: both tokens must be for the same room, with user IDs exactly
aliceandbob.
Before you ship
- Get tokens from your backend, not
config.ts. Your server signs the user in, then requests a short-lived token fromPOST https://api.devsquarelabs.com/v1/token. The App Secret never goes into the APK. - Ringing a closed app needs push notifications through your own Firebase project.
- Video is one option away:
call.startCall('bob', { video: true })on the Pro plan, and the sameCallScreenadds Camera and Flip buttons.
There's also a Flutter version of this tutorial, and the full React Native guide is on the RTCExpress site.
What would you want in a ready-made call screen that isn't here? Tell me in the comments 👇