Stream Videos with WebRTC API and React

Christian Nwamba

Few years ago, when COVID-19 hit the world, we depended on video conferencing software such as Zoom, and Google Meet for remote work. One technology that makes video conferencing on the web possible is WebRTC. In this article, we’ll be learning what WebRTC is, how it works, and we will be building a basic video chat app using Reactjs.

What is WebRTC

WebRTC (stands for Web Real Time Connection) is a technology that that allows you to create peer to peer connections using real time communications without needing a server. It supports video, voice, and generic data to be sent between peers, allowing developers to build powerful voice- and video-communication solutions.

You might ask, if WebRTC is establishing p2p connections, what’s differs it from web sockets?

With web sockets, there’s a connection only between the client and server. If a client wants something, it makes request to the server and the server responds to establish the connection. Now imagine that with multiple clients and a server. If one of the clients has an update, it sends it to the server, the server processes the data and sends the update to the other clients. It’s important to note that there’s a some sort of delay in this process. This delay becomes very noticeable when it comes down to applications such as live video streaming, voice chat, and every other application where a second changes a lot of things.

With WebRTC, the client can directly communicate with each other and completely bypass the server. This in turn decreases latency by a lot because the receiving client doesn’t have to wait for the server. You can think of it this way, client 1 asks the server for information from client 2, the server informs client 2 that client 1 needs information from it, a connection is established between client 1 and client 2 and the server becomes obselete.

Pre-requisites

I recommend you have the following to flow with this tutorial

  • Knowledge of JavaScript and React
  • Nodejs >v14 installed
  • A code editor (VS Code preferably)

The complete code and demo is on Codesandbox

What we will be building

Now we’ve gotten a brief overview of WebRTC, we will be building a web application that utilizes the technology. We will use express and socket.io for our server and on the frontend, we’ll use socket.io-client and simple-peer. simple-peer is a JavaScript wrapper for WebRTC and we’ll be using it to simplify the WebRTC integration in our app.

Let’s get started already.

Getting Started

We will start by setting up our server. Run this command to create a folder and node project

1#bash
2mkdir webrtc-react-video-chat && cd webrtc-react-video-chat
3npm init --y

npm init --``y will generate a package.json. Open the directory in your code editor and install these dependencies.

1npm i cors express nodemon socket.io

cors: this package will enable cors on our server, so we won’t have cross-origin issues when we try connecting the frontend. express: express is a nodejs web framework. nodemon: nodemon is a tool that helps develop Node.js based applications by automatically restarting the node application when file changes in the directory are detected. We will use it to watch live changes. socket.io: this is the server library for Socket IO. Socket.IO enables real-time bidirectional event-based communication. Go ahead to create an index.js and add these lines of code

1//index.js
2const app = require("express")();
3const server = require("http").createServer(app);
4const cors = require("cors");
5
6app.use(cors());
7const PORT = process.env.PORT || 8080;
8
9app.get('/', (req, res) => {
10 res.send('Hello World');
11});
12
13server.listen(PORT, () => console.log(`Server is running on port ${PORT}`));

If you’ve worked with JavaScript at some level, you will find this familiar. We’ve just created a simple express server. To run this server, run this command on your terminal.

1node index.js

You should get something like this:

1➜ webrtc-react-video-chat node index.js
2Server is running on port 8080

Now, add this line to the scripts block in your package.json

1"start": "nodemon index.js"

This ensures we use nodemon to run our server. You can now use npm run start to start the server. Awesome! Let’s go ahead to integrate Socket.IO to our server. Update your index.js to look like this:

1#index.js
2const app = require("express")();
3const server = require("http").createServer(app);
4const cors = require("cors");
5const io = require("socket.io")(server, {
6 cors: {
7 origin: "*",
8 methods: ["GET", "POST"]
9 }
10});
11
12app.use(cors());
13const PORT = process.env.PORT || 8080;
14app.get('/', (req, res) => {
15 res.send('Hello World');
16});
17
18io.on("connection", (socket) => {
19 socket.emit("me", socket.id);
20 socket.on("disconnect", () => {
21 socket.broadcast.emit("callEnded")
22 });
23 socket.on("callUser", ({ userToCall, signalData, from, name }) => {
24 io.to(userToCall).emit("callUser", { signal: signalData, from, name });
25 });
26 socket.on("answerCall", (data) => {
27 io.to(data.to).emit("callAccepted", data.signal)
28 });
29});
30server.listen(PORT, () => console.log(`Server is running on port ${PORT}`));

Firstly, we initialize the socket.io Server library. Because of this, we have to use the express HTTP module and then the HTTP.createServer() method for our server instance. Now we have initialized the socket server instance, we can now emit and listen to events between the server and client. We start with emitting a me message and passing the socket.id. This is going to be our id (we will get to see this soon). Next, we create a disconnect handler and broadcast a callEnded message. Next, we have a callUser handler, the callback function will receive the following data from the frontend: userToCall, signalData, from, name. Finally, the last handler is for answering calls. We emit a message callAccepted and pass data.signal as a callback. Awesome! that’s our entire server for this project. Let’s go ahead to build the frontend with WebRTC.

Implementing Frontend

We will start by installing a React project in our directory. Run this command to do that, make sure you’re in the project directory.

1npx create-react-app ./frontend

This will create a frontend directory with our React app in it. Change directory to frontend and install these dependencies.

1#bash
2npm i @chakra-ui/react @emotion/react @emotion/styled framer-motion react-icons react-copy-to-clipboard simple-peer socket.io-client

chakra-ui/react: Chakra UI is a simple, modular and accessible component library that gives you the building blocks you need to build your React applications. react-icons: We will be using some icons from the react-icons library. react-copy-to-clipboard: We will be using this package to copy our socket id to clipboard. simple-peer: Nodejs wrapper for WebRTC. socket.io-client: Client side JavaScript wrapper for Socket IO.

Let’s flesh out the structure of our project. Delete everything in the src folder except App.js and index.js. Update your index.js to add Chakra UI so we can use Chakra UI globally.

1//src/index.js
2
3import React from 'react';
4import ReactDOM from 'react-dom/client';
5import App from './App';
6import { ChakraProvider } from '@chakra-ui/react'
7
8const root = ReactDOM.createRoot(document.getElementById('root'));
9root.render(
10 <React.StrictMode>
11 <ChakraProvider>
12 <App />
13 </ChakraProvider>
14 </React.StrictMode>
15);

Next, create a components folder in the src directory and add these 3 files; Notifications.jsx, VideoPlayer.jsx, and Options.jsx. The components should initially look like this:

1#src/components/VideoPlayer.jsx
2
3const VideoPlayer = () => {
4 return (
5 <div>VideoPlayer</div>
6 )
7}
8export default VideoPlayer
1#src/components/Options.jsx
2const Options = () => {
3 return (
4 <div>Options</div>
5 )
6}
7export default Options
1#src/components/Notifications.jsx
2const Notifications = () => {
3 return (
4 <div>Notifications</div>
5 )
6}
7export default Notifications

Update your App.js to look like this:

1#src/App.js
2import { Box, Heading, Container } from '@chakra-ui/react';
3import Notifications from './components/Notifications';
4import Options from './components/Options';
5import VideoPlayer from './components/VideoPlayer';
6
7function App() {
8 return (
9 <Box>
10 <Container maxW="1200px" mt="8">
11 <Heading as="h2" size="2xl"> Video Chat App </Heading>
12 <VideoPlayer />
13 <Options />
14 <Notifications />
15 </Container>
16 </Box>
17 );
18}
19export default App;

Navigate to https://localhost:3000 on your browser and you should see something like this:

Awesome. Now let’s write the logic for our application. We will be using React Context API for this. If you haven’t used it before, pause here and read the documentation on Context API. Create a Context.js in your src folder and add these lines of code.

1#src/Context.js
2import { createContext, useState, useRef, useEffect } from 'react';
3import { io } from 'socket.io-client';
4import Peer from 'simple-peer';
5
6const SocketContext = createContext();
7const socket = io('http://localhost:8080');
8const ContextProvider = ({ children }) => {
9 const [callAccepted, setCallAccepted] = useState(false);
10 const [callEnded, setCallEnded] = useState(false);
11 const [stream, setStream] = useState();
12 const [name, setName] = useState('');
13 const [call, setCall] = useState({});
14 const [me, setMe] = useState('');
15 const myVideo = useRef();
16 const userVideo = useRef();
17 const connectionRef = useRef();
18
19 useEffect(() => {
20 navigator.mediaDevices.getUserMedia({ video: true, audio: true })
21 .then((currentStream) => {
22 setStream(currentStream);
23 myVideo.current.srcObject = currentStream;
24 });
25
26 socket.on('me', (id) => setMe(id));
27 socket.on('callUser', ({ from, name: callerName, signal }) => {
28 setCall({ isReceivingCall: true, from, name: callerName, signal });
29 });
30 }, []);
31
32 const answerCall = () => {
33 setCallAccepted(true);
34 const peer = new Peer({ initiator: false, trickle: false, stream });
35 peer.on('signal', (data) => {
36 socket.emit('answerCall', { signal: data, to: call.from });
37 });
38 peer.on('stream', (currentStream) => {
39 userVideo.current.srcObject = currentStream;
40 });
41 peer.signal(call.signal);
42 connectionRef.current = peer;
43 };
44
45 const callUser = (id) => {
46 const peer = new Peer({ initiator: true, trickle: false, stream });
47 peer.on('signal', (data) => {
48 socket.emit('callUser', { userToCall: id, signalData: data, from: me, name });
49 });
50 peer.on('stream', (currentStream) => {
51 userVideo.current.srcObject = currentStream;
52 });
53 socket.on('callAccepted', (signal) => {
54 setCallAccepted(true);
55 peer.signal(signal);
56 });
57 connectionRef.current = peer;
58 };
59
60 const leaveCall = () => {
61 setCallEnded(true);
62 connectionRef.current.destroy();
63 window.location.reload();
64 };
65
66 return (
67 <SocketContext.Provider value={{
68 call,
69 callAccepted,
70 myVideo,
71 userVideo,
72 stream,
73 name,
74 setName,
75 callEnded,
76 me,
77 callUser,
78 leaveCall,
79 answerCall,
80 }}
81 >
82 {children}
83 </SocketContext.Provider>
84 );
85};
86export { ContextProvider, SocketContext };

Let’s break down this code snippet into chunks. First, we create our initial context with const *SocketContext* = *createContext*``();. Next, we create the initial instance of Socket IO with const *socket* = *io*``('http://localhost:8080');. You can recall that’s our server address. Next, we call a useEffect hook that asks for permission to use the camera and microphone. We do that using *navigator.mediaDevices.getUserMedia*``({ *video*``: *true*``, *audio*``: *true* }).

1const myVideo = useRef();
2.then((currentStream) => {
3 setStream(currentStream);
4 myVideo.current.srcObject = currentStream;
5});

Here, we set the current stream. Furthermore, since we want to populate the video iframe with the src of our stream we introduce a myVideo ref.

1const [me, setMe] = useState('');
2const [call, setCall] = useState({});
3socket.on('me', (id) => setMe(id));
4 socket.on('callUser', ({ from, name: callerName, signal }) => {
5 setCall({ isReceivingCall: true, from, name: callerName, signal });
6});

Remember in the server when we emitted the socket.id with a me action, we listen to it here and set it to setMe. We have 3 functions here, let’s deal with each of them.

1const answerCall = () => {
2 setCallAccepted(true);
3 const peer = new Peer({ initiator: false, trickle: false, stream });
4 peer.on('signal', (data) => {
5 socket.emit('answerCall', { signal: data, to: call.from });
6 });
7 peer.on('stream', (currentStream) => {
8 userVideo.current.srcObject = currentStream;
9 });
10 peer.signal(call.signal);
11 connectionRef.current = peer;
12};

We set a boolean state to check if the call has been accepted. Next, we introduce WebRTC by initializing a Peer using the simple-peer package. Peer has actions and handlers just like Socket IO. Once we receive a signal, we execute the data as callback function. In the function, we emit the answer call event and pass in the signal data, and who we are answering the call from. Next, we call the stream handler. Here, we get the current stream and pass the ref for the userVideo(we will be creating this soon). Finally, we create a connectionRef. This means our current connection is equal to the current peer.

Let’s take a look at the callUser()

1const callUser = (id) => {
2 const peer = new Peer({ initiator: true, trickle: false, stream });
3 peer.on('signal', (data) => {
4 socket.emit('callUser', { userToCall: id, signalData: data, from: me, name });
5 });
6 peer.on('stream', (currentStream) => {
7 userVideo.current.srcObject = currentStream;
8 });
9 socket.on('callAccepted', (signal) => {
10 setCallAccepted(true);
11 peer.signal(signal);
12 });
13 connectionRef.current = peer;
14};

This is similar to the answerCall(). Notice that the initiator key here is set to true. This is because we are the user initiating the call. The signal handler here is emitting the callUser event and we pass in the following { *userToCall*``: **``id, *signalData*``: **``data, *from*``: **``me, **``name}. Finally, the callAccepted action which has signal passed as callback function enables the user to accept our call.

Next is the leaveCall(). This function contains logic for leaving a call.

1const leaveCall = () => {
2 setCallEnded(true);
3 connectionRef.current.destroy();
4 window.location.reload();
5};

We set callEnded to be true here, then we destroy the connection and stop receiving input from user camera and audio. That’s it for the functions.

1return (
2 <SocketContext.Provider value={{
3 call,
4 callAccepted,
5 myVideo,
6 userVideo,
7 stream,
8 name,
9 setName,
10 callEnded,
11 me,
12 callUser,
13 leaveCall,
14 answerCall,
15 }}
16 >
17 {children}
18 </SocketContext.Provider>
19);

Finally for our SocketContext, we return all the state values, refs and functions.

Update your index.js to look like this so we have access to the SocketContext all over the app.

1#src/index.js
2import React from 'react';
3import ReactDOM from 'react-dom/client';
4import App from './App';
5import { ChakraProvider } from '@chakra-ui/react'
6import { ContextProvider } from './Context';
7const root = ReactDOM.createRoot(document.getElementById('root'));
8root.render(
9 <React.StrictMode>
10 <ContextProvider>
11 <ChakraProvider>
12 <App />
13 </ChakraProvider>
14 </ContextProvider>
15 </React.StrictMode>
16);

If you’ve reached this point, you’re awesome! We’re almost done, let’s go ahead to implement our components.

Implementing the components

Let’s start with implementing the VideoPlayer component.

1#src/components/VideoPlayer
2
3import { Grid, Box, Heading } from "@chakra-ui/react"
4import { SocketContext } from "../Context"
5import { useContext } from "react"
6
7const VideoPlayer = () => {
8 const { name, callAccepted, myVideo, userVideo, callEnded, stream, call } = useContext(SocketContext)
9
10return (
11 <Grid justifyContent="center" templateColumns='repeat(2, 1fr)' mt="12">
12 {/* my video */}
13 {
14 stream && (
15 <Box>
16 <Grid colSpan={1}>
17 <Heading as="h5">
18 {name || 'Name'}
19 </Heading>
20 <video playsInline muted ref={myVideo} autoPlay width="600" />
21 </Grid>
22 </Box>
23 )
24 }
25 {/* user's video */}
26 {
27 callAccepted && !callEnded && (
28 <Box>
29 <Grid colSpan={1}>
30 <Heading as="h5">
31 {call.name || 'Name'}
32 </Heading>
33 <video playsInline ref={userVideo} autoPlay width="600" />
34 </Grid>
35 </Box>
36 )
37 }
38 </Grid>
39)
40}
41 export default VideoPlayer

Let’s go through the working parts of this snippet. Firstly, we get state, and refs values from our SocketContext. Then, we created two grids. The first grid will be our video, while the other will be the user’s video. We then state that if the stream is active, show my video. Notice the myVideo ref in the <video> tag there. However, we want to show the user when they have accepted the call and when the call hasn’t ended. We also display the name of the user, if there’s none we set a placeholder. That’s it! Navigate to your browser and allow access to the camera and audio, your page should look like this:

It works fine! Awesome. Let’s move on to create our Options component. Add these lines of code to Options.jsx

1#src/components/Options.jsx
2
3import { useState, useContext } from "react"
4import { Button, Input, FormLabel, Heading, Grid, Box, Container, FormControl } from "@chakra-ui/react"
5import { CopyToClipboard } from 'react-copy-to-clipboard';
6import { BiClipboard, BiPhoneCall, BiPhoneOff } from "react-icons/bi";
7import { SocketContext } from "../Context";
8
9const Options = () => {
10 const { me, callAccepted, name, setName, callEnded, leaveCall, callUser } = useContext(SocketContext);
11 const [idToCall, setIdToCall] = useState('');
12
13return (
14 <Container maxW="1200px" m="35px 0" p="0">
15 <Box p="10px" border="2px" borderColor="black" borderStyle="solid">
16 <FormControl display="flex" flexDirection="column" noValidate aria-autocomplete="none">
17 <Grid templateColumns='repeat(2, 1fr)' mt="12">
18 <Grid colSpan={1} p="6">
19 <Heading as="h6"> Account Info </Heading>
20 <FormLabel>Username</FormLabel>
21 <Input type='text' value={name} onChange={(e) => setName(e.target.value)} width="100%" />
22 <CopyToClipboard text={me} mt="20">
23 <Button leftIcon={<BiClipboard />} colorScheme='teal' variant='solid'>
24 Copy ID
25 </Button>
26 </CopyToClipboard>
27 </Grid>
28 <Grid colSpan={1} p="6">
29 <Heading as="h6"> Make a Call </Heading>
30 <FormLabel> User id to call </FormLabel>
31 <Input type='text' value={idToCall} onChange={(e) => setIdToCall(e.target.value)} width="100%" />
32 {
33 callAccepted && !callEnded ? (
34 <Button leftIcon={<BiPhoneOff />} onClick={leaveCall} mt="20" colorScheme='teal' variant='info'>
35 Hang up
36 </Button>
37 ) : (
38 <Button leftIcon={<BiPhoneCall />} onClick={() => callUser(idToCall)} mt="20" colorScheme='teal' variant='solid'>
39 Call
40 </Button>
41 )
42 }
43 </Grid>
44 </Grid>
45 </FormControl>
46 </Box>
47 </Container>
48)
49}
50export default Options

Let’s go through the moving parts in this code snippet. Here, we get functions, handlers, and state from our SocketContext. We have two Grids, the first allows the user to type their username and copy the socket id. In the other grid, we have an input that accepts an idToCall. Finally, we state that if the call is accepted and ongoing, show the Hang Up button else, show the Call button.

Save and Navigate to your browser and you will see something like this:

Awesome! Right now, when we click on the call button nothing happens, let’s fix that with the Notifications component. Add these lines of code to Navigation.jsx

1#src/components/Navigation.jsx
2import { useContext } from "react"
3import { Box, Button, Heading } from "@chakra-ui/react"
4import { SocketContext } from "../Context"
5
6const Notifications = () => {
7 const { answerCall, call, callAccepted } = useContext(SocketContext);
8
9return (
10 <>
11 {call.isReceivingCall && !callAccepted && (
12 <Box display="flex" justifyContent="space-around" mb="20">
13 <Heading as="h3"> {call.name} is calling </Heading>
14 <Button variant="outline" onClick={answerCall} border="1px" borderStyle="solid" borderColor="black">
15 Answer Call
16 </Button>
17 </Box>
18 )}
19 </>
20)
21}
22export default Notifications

Nothing much is going here. We just get a notification to answer the call. Navigate to your browser, you should have something like this

Awesome!

Conclusion

We’ve come to the end of this tutorial. We discussed about WebRTC and how it works, we then went on to build a React video chat app with Socket and Peer (a JS wrapper for WebRTC). I hope you learned one or two things from this.

Further Reading

Happy Coding!

Christian Nwamba

Developer Advocate at AWS

A software engineer and developer advocate. I love to research and talk about web technologies and how to delight customers with them.