With video chat apps on the rise for, well, obvious reasons, it’s increasingly important to be able to add video call capabilities to apps and websites quickly. The more customizable those video calls can be, the better for building unique user experiences.
This post walks you through how to build a custom video chat app with React and the Daily API.
In our app, when a user clicks to start a call, the app will create a meeting room, pass the room’s URL to a new Daily call object, and join the call. The call object is something that keeps track of important information about the meeting, like other participants (including their audio and video tracks) and the things they do on the call (e.g. muting their mic or leaving), and provides methods for interacting with the meeting. The app leverages this object to update its state accordingly, and to carry out user actions like muting or screen-sharing. When the user leaves the meeting room, the call object is destroyed.
- Daily account: Sign up for an account if you don’t have one already.
- Cloned daily-demos Github repository: The fastest way to follow along with this tutorial and get a demo app up and running is by cloning this repo.
- Familiarity with React: In this post we skip over a lot of the code that isn’t related to Daily, so it could be worth brushing up on React and/or hooks .
Once you have those things, we’re ready to get started!
After you’ve cloned the repository:
cd daily-demos nvm i cd react-demo npm i npm run dev
Now, open your browser and head to
localhost:<port>, using the port printed in the terminal after running the above. You should have an app running locally that mirrors the live demo: you can click to start a call, and share the link with another participant or yourself in a new tab.
It’s great that it’s working...but how is it working?
Before diving into the code, let’s cover some Daily fundamentals.
A Daily call object is like a direct line to the Daily API. It gives us the finest-grained control over a video call, letting us access its lowest level foundations, like participant video and audio tracks. Invoking
DailyIframe.createCallObject() creates a call object. Once created, we pass a meeting room URL to the call object to join a call.
In addition to all of that, the call object keeps track of our call’s state, both meeting state and participant state.
Meeting state tracks where a current (often called "local") participant is in the life of a call. A participant can start to join a call, be in a call, have left a call, or be experiencing an error.
We can check a call’s meeting state via the call object with
callObject.meetingState(). If a participant is joining a meeting, the "joining-meeting" event will be returned, for example.
Meeting state changes trigger events like "joining-meeting." We can add event listeners for those state changes with
Participant state monitors all participants on the call (including the current user) and the video, audio, or other media they’re sharing with everybody else.
callObject.participants() returns a set of participant objects, keyed by an ID (or "local", for the current user). Each participant object includes fields like
The events "participant-joined", "participant-left", and "participant-updated" broadcast changes to participant state. The first two are only sent when participants other than the current local participant join or leave, while the latter fires on changes like camera and microphone toggling for any participant, including local.
Now that we’ve covered the Daily call object and its states, we’re ready to look at our app.
Before we get into the details of each stage of a meeting, let’s look at how we wire up our top-level event listeners. In App.js, we listen for changes to
callObject.meetingState(), so we can update the UI for the local participant depending on where they’re at in their user journey: in a call, out of a call, or experiencing errors:
When a local participant leaves a meeting, we call
callObject.destroy(). We do this to clean up our call object's global footprint, to open the door for our app to create another call object in the future with different create-time options.
When a participant clicks to start a call, they invoke the
createCall() function to create a short-lived, demo-only room.
Once we have a room, we’ll join it by invoking the
.join() method on the call object .
When a participant clicks the “Leave” button, we’ll initiate that process by invoking the
leave() method on the call object [3, 4].
We now know how different operations in a call take place, so the next step is to know how those operations affect our display. This involves keeping tabs on participant state in order to display the call's participants and their video and audio tracks.
While App.js listened to
callObject.meetingState(), in Call.js we’ll listen for
callObject.participantState() and update our component state accordingly .
Our demo app displays each participant (including the current user) as their own "tile", and also displays any screen share as its own tile independent of the participant doing the sharing.
To accomplish this, we map
callObject.participantState() to the call’s component state, specifically into a set of "call items" in callState.js:
Each call item corresponds to a call participant, storing the participant’s video track, audio track, and a boolean that notes whether or not a participant is in the process of joining a call .
To populate the call items, we call our
getCallItems() function, which loops over participant state:
We import callState in Call.js, where we invoke the
getTiles() function to pass participant video and audio tracks to their respective tile components.
Now let’s take a closer look at those tiles.
Each of our tile components contains either a
<video> and/or an
<audio> element. Each tag references its respective DOM element . Note the
autoPlay muted playsInline attribute. These are the set of attributes that will let your audio and video play automatically on Chrome, Safari, and Firefox.
Next up: give participants control over whether or not they display their videos and share their audio or screens.
Once again we'll use participant state to determine whether we're actively sharing audio, video, and our screen.
We’ll look specifically at
callObject.participants().local, since we’re concerned about adjusting the user interface for the current, or local, user. The only event we need to listen to is "participant-updated" .
With our event listener handling state updates, we can wire up our buttons to handle the relevant
callObject methods to control user input:
Congratulations! If you’ve read this far, you now have an overview of your custom video chat app. To dig even deeper into the code, have a look at how the demo handles edge cases over on the Daily blog. Or, dive into our demo repository.
To see everything else the Daily APIs have to offer, grab a cup of tea and head over to docs.daily.co for some fun evening reading.
Thanks for reading! As always, we'd love to know what you think and how we can better help you to build that next great video chat app, so please don't hesitate to reach out.
 If you’d like to get familiar with React and come back later, check out the many great resources on DEV (like Ali Spittel’s intro), or the React docs for more on hooks.
 Our teammate wrote an excellent post on how to set up an instant Daily server on Glitch.
 Note that, because we invoke
destroy() on our call object after each call ends, we need to create a new call object in order to join a room. This is not strictly necessary — you could hold onto a single call object for the lifetime of your app if you so desired, but, as we mentioned earlier, we prefer this approach to leave the door open for a future differently-configured call object.
 You might’ve noticed that both the
leave() call object operations are asynchronous, as is
destroy(). To avoid undefined behavior and app errors, like leaving and destroying a call object simultaneously, it's important to prevent triggering one call object operation while another is pending. A straightforward way to do this is to use meeting state to update relevant buttons' idle states so that the user can't start an operation until it's safe, like we do in our demo app.
destroy() is asynchronous, the DailyIframe.createCallObject() must only be invoked once destroy()'s Promise has been resolved.
 In the demo app, we use a reducer to update the component state.
 We only set
isLoading to true if we’ve never received audio or video tracks for a participant.
 We did that so we could programmatically set their
srcObject properties whenever our media tracks change (see lines 18-31 in Tile.js).
 You might remember that "participant-joined" and "participant-left" are only ever about other (not local) participants.