DEV Community

Ajay
Ajay

Posted on

Voice calling in React Native with a ready-made call screen (about 60 lines of code)

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
Enter fullscreen mode Exit fullscreen mode

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, room voice-test
  • user bob, room voice-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',
};
Enter fullscreen mode Exit fullscreen mode

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 },
});
Enter fullscreen mode Exit fullscreen mode

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.init connects with a short-lived user token, and joinRoom joins the room. The roomJoined event shows the Call button.
  • useRTCCall(rtc) listens to the SDK's call events and turns them into one call object: 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, CallScreen pops 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  1. Tap I am Alice on one phone and I am Bob on the other. Wait for Joined voice-test.
  2. On Alice's phone, tap Call bob and allow the microphone.
  3. 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' }}
/>
Enter fullscreen mode Exit fullscreen mode

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-android again. 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:8081 and 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 alice and bob.

Before you ship

  • Get tokens from your backend, not config.ts. Your server signs the user in, then requests a short-lived token from POST 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 same CallScreen adds 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 👇

Top comments (0)