useState
, useCallback
, useEffect
and useRef
hooks.git clone -b twilio [email protected]:philnash/react-express-starter.git twilio-video-react-hooks cd twilio-video-react-hooks npm install
.env.example
file to .env
cp .env.example .env
npm run dev
.env
file as the TWILIO_ACCOUNT_SID
.TWILIO_API_KEY
and TWILIO_API_SECRET
to the .env
file.src/App.css
with it.App
component where we can lay out a header and footer for the app as well as a VideoChat
component. Within the VideoChat
component we'll want to show a Lobby
component where the user can enter their name and the room they want to join. Once they have entered those details we'll replace the Lobby
with a Room
component that will handle connecting to the room and displaying the participants in the video chat. Finally, for each participant in the room we will render a Participant
component that will handle displaying their media.src/App.js
, there's a lot of code here from the initial example app that we can remove. Also, the App
component is a class based component. We said we'd build the entire app with functional components, so we better change that.Component
and the import of the logo.svg. Replace the entire App class with a function that renders our application skeleton. The whole file should look like this:import React from 'react'; import './App.css'; const App = () => { return ( <div className="app"> <header> <h1>Video Chat with Hooks</h1> </header> <main> <p>VideoChat goes here.</p> </main> <footer> <p> Made with{' '} <span role="img" aria-label="React"> ⚛ </span>{' '} by <a href="https://twitter.com/philnash">philnash</a> </p> </footer> </div> ); }; export default App;
src/VideoChat.js
and start it off with the following boilerplate:import React from 'react'; const VideoChat = () => { return <div></div> // we'll build up our response later }; export default VideoChat;
VideoChat
component is going to be the top level component for handling the data about the chat. We're going to need to store a username for the user that is joining the chat, a room name for the room they are going to connect to, and their access token once it has been fetched from the server. We will be building up a form to input some of this data in the next component.useState
hook to store this data.useState
is a function that takes a single argument, the initial state, and returns an array containing the current state and a function to update that state. We'll destructure that array to give us two distinct variables like state
and setState
. We're going to use setState
to track the username, room name and token within our component.useState
from react and set up states for the username, room name and token:import React, { useState } from 'react'; const VideoChat = () => { const [username, setUsername] = useState(''); const [roomName, setRoomName] = useState(''); const [token, setToken] = useState(null); return <div></div> // we'll build up our response later };
username
and roomName
when the user enters them in their respective input elements.import React, { useState } from 'react'; const VideoChat = () => { const [username, setUsername] = useState(''); const [roomName, setRoomName] = useState(''); const [token, setToken] = useState(null); const handleUsernameChange = event => { setUsername(event.target.value); }; const handleRoomNameChange = event => { setRoomName(event.target.value); }; return <div></div> // we'll build up our response later };
useCallback
handleXXX
functions are redefined. They need to be part of the component because they rely on the setUsername
and setRoomName
functions, but they will be the same every time. useCallback
is a React hook that allows us to memoize the functions. That is, if they are the same between function invocations, they won't get redefined.useCallback
takes two arguments, the function to be memoized and an array of the function's dependencies. If any of the function's dependencies change, that implies the memoized function is out of date and the function is then redefined and memoized again.setState
functions from the useState
hook are deemed to be constant within the function). Rewriting this function we need to add useCallback
to the import at the top of the file and then wrap each of these functions.import React, { useState, useCallback } from 'react'; const VideoChat = () => { const [username, setUsername] = useState(''); const [roomName, setRoomName] = useState(''); const [token, setToken] = useState(null); const handleUsernameChange = useCallback(event => { setUsername(event.target.value); }, []); const handleRoomNameChange = useCallback(event => { setRoomName(event.target.value); }, []); return <div></div> // we'll build up our response later };
setToken
to store the token in our state. We'll also wrap this function with useCallback
too, but in this case the function will depend on the username
and roomName
, so we add those as the dependencies to useCallback
.const handleRoomNameChange = useCallback(event => { setRoomName(event.target.value); }, []); const handleSubmit = useCallback(async event => { event.preventDefault(); const data = await fetch('/video/token', { method: 'POST', body: JSON.stringify({ identity: username, room: roomName }), headers: { 'Content-Type': 'application/json' } }).then(res => res.json()); setToken(data.token); }, [username, roomName]); return <div></div> // we'll build up our response later };
null
. Once again, we wrap this up in useCallback
with no dependencies.const handleLogout = useCallback(event => { setToken(null); }, []); return <div></div> // we'll build up our response later };
src/Lobby.js
. This component doesn't need to store any data as it will pass all events up to its parent, the VideoChat component. When the component is rendered it will be passed the username
and roomName
as well as the functions to handle changes to each and handle submitting the form. We can destructure those props to make it easier to use them later.Lobby
component is to render the form using those props, like this:import React from 'react'; const Lobby = ({ username, handleUsernameChange, roomName, handleRoomNameChange, handleSubmit }) => { return ( <form onSubmit={handleSubmit}> <h2>Enter a room</h2> <div> <label htmlFor="name">Name:</label> <input type="text" id="field" value={username} onChange={handleUsernameChange} required /> </div> <div> <label htmlFor="room">Room name:</label> <input type="text" id="room" value={roomName} onChange={handleRoomNameChange} required /> </div> <button type="submit">Submit</button> </form> ); }; export default Lobby;
VideoChat
component to render the Lobby
unless we have a token
, otherwise we'll render the username
, roomName
and token
. We'll need to import the Lobby
component at the top of the file and render some JSX at the bottom of the component function:import React, { useState, useCallback } from 'react'; import Lobby from './Lobby'; const VideoChat = () => { // ... const handleLogout = useCallback(event => { setToken(null); }, []); let render; if (token) { render = ( <div> <p>Username: {username}</p> <p>Room name: {roomName}</p> <p>Token: {token}</p> </div> ); } else { render = ( <Lobby username={username} roomName={roomName} handleUsernameChange={handleUsernameChange} handleRoomNameChange={handleRoomNameChange} handleSubmit={handleSubmit} /> ); } return render; };
VideoChat
component into the App
component and render it. Open src/App.js
again and make the following changes:import React from 'react'; import './App.css'; import VideoChat from './VideoChat'; const App = () => { return ( <div className="app"> <header> <h1>Video Chat with Hooks</h1> </header> <main> <VideoChat /> </main> <footer> <p> Made with{' '} <span role="img" aria-label="React"> ⚛️ </span>{' '} by <a href="https://twitter.com/philnash">philnash</a> </p> </footer> </div> ); }; export default App;
npm run dev
) and open it up in the browser and you will see a form. Fill in a username and room name and submit and the view will change to show you the names you chose plus the token retrieved from the server.npm install twilio-video --save
src
directory called Room.js
. Start it off with the following boilerplate. We're going to be using the Twilio Video SDK in this component as well as the useState
and useEffect
hooks. We're also going to get roomName
, token
and handleLogout
as props from the parent VideoChat
component:import React, { useState, useEffect } from 'react'; import Video from 'twilio-video'; const Room = ({ roomName, token, handleLogout }) => { }); export default Room;
room
object, which we will want to store. The room also includes a list of participants which will change over time, so we'll store them too. We'll use useState
to store these, the initial values will be null
for the room and an empty array for the participants:const Room = ({ roomName, token, handleLogout }) => { const [room, setRoom] = useState(null); const [participants, setParticipants] = useState([]); });
const Room = ({ roomName, token, handleLogout }) => { const [room, setRoom] = useState(null); const [participants, setParticipants] = useState([]); const remoteParticipants = participants.map(participant => ( <p key={participant.sid}>participant.identity</p> )); return ( <div className="room"> <h2>Room: {roomName}</h2> <button onClick={handleLogout}>Log out</button> <div className="local-participant"> {room ? ( <p key={room.localParticipant.sid}>{room.localParticipant.identity}</p> ) : ( '' )} </div> <h3>Remote Participants</h3> <div className="remote-participants">{remoteParticipants}</div> </div> ); });
VideoChat
component to render this Room
component in place of the placeholder information we had earlier.import React, { useState, useCallback } from 'react'; import Lobby from './Lobby'; import Room from './Room'; const VideoChat = () => { // ... const handleLogout = useCallback(event => { setToken(null); }, []); let render; if (token) { render = ( <Room roomName={roomName} token={token} handleLogout={handleLogout} /> ); } else { render = ( <Lobby username={username} roomName={roomName} handleUsernameChange={handleUsernameChange} handleRoomNameChange={handleRoomNameChange} handleSubmit={handleSubmit} /> ); } return render; };
componentDidMount
and componentWillUnmount
lifecycle methods. With React hooks, we'll be using the
useEffect hook.useEffect
is a function that takes a method and runs it once the component has rendered. When our component loads we want to connect to the video service, we'll also need functions we can run whenever a participant joins or leaves the room to add and remove participants from the state respectively.Room.js
:useEffect(() => { const participantConnected = participant => { setParticipants(prevParticipants => [...prevParticipants, participant]); }; const participantDisconnected = participant => { setParticipants(prevParticipants => prevParticipants.filter(p => p !== participant) ); }; Video.connect(token, { name: roomName }).then(room => { setRoom(room); room.on('participantConnected', participantConnected); room.on('participantDisconnected', participantDisconnected); room.participants.forEach(participantConnected); }); });
token
and roomName
to connect to the Twilio Video service. When the connection is complete we set the room state, set up a listener for other participants connecting or disconnecting and loop through any existing participants adding them to the participants array state using the participantConnected
function we wrote earlier.useEffect
, it will be run when the component is unmounted. When a component that uses useEffect
is rerendered, this function is also called to clean up the effect before it is run again.Video.connect(token, { name: roomName }).then(room => { setRoom(room); room.on('participantConnected', participantConnected); room.participants.forEach(participantConnected); }); return () => { setRoom(currentRoom => { if (currentRoom && currentRoom.localParticipant.state === 'connected') { currentRoom.disconnect(); return null; } else { return currentRoom; } }); }; });
setRoom
function that we got from useState
earlier. If you pass a function to setRoom
then it will be called with the previous value, in this case the existing room which we'll call currentRoom
, and it will set the state to whatever you return.useCallback
we do this by passing an array of variables that the effect depends on. If the variables have changed, we want to clean up first, then run the effect again. If they haven't changed there's no need to run the effect again.roomName
or token
to change we'd expect to connect to a different room or as a different user. Let's pass those variables as an array to useEffect
as well:return () => { setRoom(currentRoom => { if (currentRoom && currentRoom.localParticipant.state === 'connected') { currentRoom.disconnect(); return null; } else { return currentRoom; } }); }; }, [roomName, token]);
useCallback
as we did earlier, but that's not the case. Since they are part of the effect they will only be run when the dependencies update. You also can't use hooks within callback functions, they must be used directly within components or a custom hook.src
called Participant.js
. We'll start with the usual boilerplate, although in this component we're going to use three hooks, useState
and useEffect
, which we've seen, and useRef
. We'll also be passing a participant
object in the props and keeping track of the participant's video and audio tracks with useState
:import React, { useState, useEffect, useRef } from 'react'; const Participant = ({ participant }) => { const [videoTracks, setVideoTracks] = useState([]); const [audioTracks, setAudioTracks] = useState([]); }; export default Participant;
<video>
or <audio>
element. As JSX is declarative, we don't get direct access to the DOM (Document Object Model), so we need to get a reference to the HTML element some other way.
useRef hook. To use refs we declare them up front then reference them within the JSX. We create our refs using the useRef
hook, before we render anything:const Participant = ({ participant }) => { const [videoTracks, setVideoTracks] = useState([]); const [audioTracks, setAudioTracks] = useState([]); const videoRef = useRef(); const audioRef = useRef(); });
ref
attribute.const Participant = ({ participant }) => { const [videoTracks, setVideoTracks] = useState([]); const [audioTracks, setAudioTracks] = useState([]); const videoRef = useRef(); const audioRef = useRef(); return ( <div className="participant"> <h3>{participant.identity}</h3> <video ref={videoRef} autoPlay={true} /> <audio ref={audioRef} autoPlay={true} muted={true} /> </div> ); });
<video>
and <audio>
tags to autoplay (so that they play as soon as they have a media stream) and muted (so that I don't deafen myself with feedback during testing, you'll thank me for this if you ever make this mistake)useEffect
hook three times in this component, you'll see why soon.useEffect
hook will set the video and audio tracks in the state and set up listeners to the participant object for when tracks are added or removed. It will also need to clean up and remove those listeners and empty the state when the component is unmounted.useEffect
hook, we'll add two functions that will run either when a track is added or removed from the participant. These functions both check whether the track is an audio or video track and then add or remove it from the state using the relevant state function.const videoRef = useRef(); const audioRef = useRef(); useEffect(() => { const trackSubscribed = track => { if (track.kind === 'video') { setVideoTracks(videoTracks => [...videoTracks, track]); } else { setAudioTracks(audioTracks => [...audioTracks, track]); } }; const trackUnsubscribed = track => { if (track.kind === 'video') { setVideoTracks(videoTracks => videoTracks.filter(v => v !== track)); } else { setAudioTracks(audioTracks => audioTracks.filter(a => a !== track)); } }; // more to come
useEffect(() => { const trackSubscribed = track => { // implementation }; const trackUnsubscribed = track => { // implementation }; setVideoTracks(Array.from(participant.videoTracks.values())); setAudioTracks(Array.from(participant.audioTracks.values())); participant.on('trackSubscribed', trackSubscribed); participant.on('trackUnsubscribed', trackUnsubscribed); return () => { setVideoTracks([]); setAudioTracks([]); participant.removeAllListeners(); }; }, [participant]); return ( <div className="participant">
participant
object and won't be cleaned up and re-run unless the participant changes.useEffect
hook to attach the video and audio tracks to the DOM, I'll show just one of them here, the video version, but the audio is the same if you substitute video for audio. The hook will get the first video track from the state and, if it exists, attach it to the DOM node we captured with a ref earlier. You can refer to the current DOM node in the ref using videoRef.current
. If we attach the video track we'll also need to return a function to detach it during cleanup.}, [participant]); useEffect(() => { const videoTrack = videoTracks[0]; if (videoTrack) { videoTrack.attach(videoRef.current); return () => { videoTrack.detach(); }; } }, [videoTracks]); return ( <div className="participant">
import React, { useState, useEffect } from 'react'; import Video from 'twilio-video'; import Participant from './Participant'; // hooks here const remoteParticipants = participants.map(participant => ( <Participant key={participant.sid} participant={participant} /> )); return ( <div className="room"> <h2>Room: {roomName}</h2> <button onClick={handleLogout}>Log out</button> <div className="local-participant"> {room ? ( <Participant key={room.localParticipant.sid} participant={room.localParticipant} /> ) : ( '' )} </div> <h3>Remote Participants</h3> <div className="remote-participants">{remoteParticipants}</div> </div> ); });
<video>
and <audio>
elements, there's quite a bit to get your head around. In this post we've seen how to use useState
, useCallback
, useEffect
and useRef
to control these side effects and build our app using just functional components.useEffect
(it's a long post, but worth it, I promise).