Tutorial: Build a React.js Chat App
Tutorial updated and completely rewritten 3. September 2023.
Check out the live example đ. As well as the full source code on GitHub đ©đœâđ».
Adding chat functionality to web apps may have been a nice-to-have option in the past, but now it's a modern-day necessity. Imagine having an online marketplace, a social network, or a collaboration tool without the ability to chat in real-time. It's like having a phone without the call feature; it just doesn't cut it.
Picture this: a user is browsing your web app and wants to share something with their friend. With chat, they don't need to break their engagement stride by exiting the app. Instead, they can have real-time communication within the same platform.
In this tutorial, we'll show you how you can achieve that. We'll build a React.js chat application using the Scaledrone, a real-time messaging API which makes building chat rooms a breeze. The app will work similarly to apps like Messenger, WhatsApp or Telegram.
By the end, this is what our React chat app will look like:
Aside from sending text messages to a web service, it will feature showing avatars and usernames, chat message bubbles, a list of online members, as well as a typing indicator.
We'll achieve this using modern React web app best practices, including using Next.js, functional components and React Hooks.
And here's the bonus: this app will also be cross-platform compatible with our iOS and Android chat tutorials. Let's go!
Setting Up a React Chat App
We'll start by creating a new React app using Next.js, an easy tool to get started with React projects.
To use Next.js and React, you need Node.js. Follow the instructions on the link to install if you haven't already. Once that's sorted, creating our starter app becomes a breeze. Open Terminal and navigate to a folder where you'd like your app to exist. Type in the following command:
npx create-next-app --example https://github.com/ScaleDrone/react-chat-tutorial/tree/starter app-name
cd app-name
Note: The
app-name
can be anything you like.
This command will download starter code with an empty React app to make this tutorial easier to follow. The starter code includes an empty App
component and CSS to make your app look nice.
Open the newly created app-name
folder in your favorite editor. Now we can dive in for real.
Showing Chat Messages Using React Components
We'll use three main components to make our chat app.
- A
Home
component which will manage sending and receiving messages and rendering the inner components. - A
Messages
component which will be a list of messages. - An
Input
component with a text field and button so that we can send our messages.
create-next-app
has already made a Home
component for you in src/pages/index.js, and we'll modify it later. But first, we'll create a Messages
component.
Create a new file in your src/components folder called Messages.js. Create a new component in this file called Messages
like in the following code:
import {useEffect, useRef} from 'react';
import React from 'react';
import styles from '@/styles/Home.module.css'
export default function Messages({messages, me}) {
const bottomRef = useRef(null);
useEffect(() => {
if (bottomRef && bottomRef.current) {
bottomRef.current.scrollIntoView({behavior: 'smooth'});
}
});
return (
<ul className={styles.messagesList}>
{messages.map(m => Message(m, me))}
<div ref={bottomRef}></div>
</ul>
);
}
The component will receive the messages it should display as a prop from the Home
component. We'll render a list containing each message. The list items themselves will contain the sender's name and avatar, as well as the text of the message.
Looking at the code, you might be wondering what the useEffect
function and bottomRef
variables are.
When a new message comes in, you don't want to waste time scrolling through a sea of previous messages to the bottom of the chat each time. Instead, your app should automatically scroll down to the latest message whenever a new one pops in. You can do that with bottomRef
, a reference to an empty div below the latest message.
The way you do this in React is with the useEffect
function. With it, you register a function that gets called each time the component is rendered. In the function, you tell React to scroll down to the previously mentioned div
, effectively scrolling all the way to the last message.
Messages
returns a list of Message
components, which is not yet implemented. So, our next step is implementing it by adding the following code to the bottom of the file:
function Message({member, data, id}, me) {
// 1
const {username, color} = member.clientData;
// 2
const messageFromMe = member.id === me.id;
const className = messageFromMe ?
`${styles.messagesMessage} ${styles.currentMember}` : styles.messagesMessage;
// 3
return (
<li key={id} className={className}>
<span
className={styles.avatar}
style={{backgroundColor: color}}
/>
<div className={styles.messageContent}>
<div className={styles.username}>
{username}
</div>
<div className={styles.text}>{data}</div>
</div>
</li>
);
}
This will render the JSX for each individual message as follows:
- Each message is linked to a specific
member
(user), and every member is identified by an ID, username, avatar, and a personalized color. - Next, you check whether the message came from you or another user. This distinction is helpful because it allows us to display our own messages on the left side.
- Finally, you construct JSX for the component. The JSX shows the user's avatar, their name, and the message's content, all stored inside the passed arguments.
Now that we have the Messages
component, we need to make sure the Home
component actually renders it. Head over to src/pages/index.js and add an import of the Messages
component to the top of the file:
import Messages from '@/components/Messages'
Earlier, we mentioned that we would show usernames and avatars in our app. Typically, you would have a login screen where you would get this information.
However, for the sake of this tutorial, we'll generate random names and colors for the avatars. You can do this by adding the following top-level functions to the top of the file:
function randomName() {
const adjectives = [
'autumn', 'hidden', 'bitter', 'misty', 'silent', 'empty', 'dry', 'dark',
'summer', 'icy', 'delicate', 'quiet', 'white', 'cool', 'spring', 'winter',
'patient', 'twilight', 'dawn', 'crimson', 'wispy', 'weathered', 'blue',
'billowing', 'broken', 'cold', 'damp', 'falling', 'frosty', 'green', 'long',
'late', 'lingering', 'bold', 'little', 'morning', 'muddy', 'old', 'red',
'rough', 'still', 'small', 'sparkling', 'shy', 'wandering',
'withered', 'wild', 'black', 'young', 'holy', 'solitary', 'fragrant',
'aged', 'snowy', 'proud', 'floral', 'restless', 'divine', 'polished',
'ancient', 'purple', 'lively', 'nameless'
];
const nouns = [
'waterfall', 'river', 'breeze', 'moon', 'rain', 'wind', 'sea', 'morning',
'snow', 'lake', 'sunset', 'pine', 'shadow', 'leaf', 'dawn', 'glitter',
'forest', 'hill', 'cloud', 'meadow', 'sun', 'glade', 'bird', 'brook',
'butterfly', 'bush', 'dew', 'dust', 'field', 'fire', 'flower', 'firefly',
'feather', 'grass', 'haze', 'mountain', 'night', 'pond', 'darkness',
'snowflake', 'silence', 'sound', 'sky', 'shape', 'surf', 'thunder',
'violet', 'water', 'wildflower', 'wave', 'water', 'resonance', 'sun',
'wood', 'dream', 'cherry', 'tree', 'fog', 'frost', 'voice', 'paper', 'frog',
'smoke', 'star'
];
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
return adjective + noun;
}
function randomColor() {
return '#' + Math.floor(Math.random() * 0xFFFFFF).toString(16);
}
As we've said, each user will have a username and color. The usernames are generated by adding a random adjective to a noun, leading to cool-sounding names like "hiddensnow" or "wanderingfrog". The colors are assigned by generating a random hex number.
Now that we have those functions, we can use them to set our initial state in the Home
component. Add the following to the top of Home
:
const [messages, setMessages] = useState([{
id: '1',
data: 'This is a test message!',
member: {
id: '1',
clientData: {
color: 'blue',
username: 'bluemoon',
},
},
}]);
const [me, setMe] = useState({
username: randomName(),
color: randomColor(),
});
For the time being, our initial state will be one test message from a mysterious hard-coded user called bluemoon. As for our own identity, we'll use a randomly generated name and color.
Next, update the return
by adding the following inside the <div className={styles.appContent}>
tag:
<Messages messages={messages} me={me}/>
This will allow you to display a list of chat messages, using the component you created earlier.
Run the app by navigating to the project folder in Terminal and typing in:
npm run dev
Head over to localhost:3000 and take a look at the first message in your chat app!
Adding a Text Field Input
Now that we have a way to display our messages, we need a way to type them! You'll create a new React component called Input
that contains a text field and a send button. The component itself won't send any messages, but will call a callback whenever someone clicks the send button.
Create a new file in the src/components folder called Input.js. In this file, add a new component called Input
:
import React from 'react';
import { useEffect, useState } from 'react';
import styles from '@/styles/Home.module.css'
export default function Input({onSendMessage}) {
const [text, setText] = useState('');
This component will have a text field to enter the message along with a button to send it. We'll keep track of the currently entered text in our state
. Continue writing the component:
function onChange(e) {
const text = e.target.value;
setText(text);
}
This will trigger a render each time there's a change in the text field. Next, we need to handle sending the message, so add the following inner function:
function onSubmit(e) {
e.preventDefault();
setText('');
onSendMessage(text);
}
To send the message, we'll use a callback inside the component's props which we'll receive from Home
. We don't want the app to refresh every time a new message is sent, so we'll prevent the default behavior. We'll also update the state so that we clear the textfield.
Finally, it's time to connect the functions with the state and render everything using JSX:
return (
<div className={styles.input}>
<form onSubmit={e => onSubmit(e)}>
<input
onChange={e => onChange(e)}
value={text}
type='text'
placeholder='Enter your message and press ENTER'
autoFocus
/>
<button>Send</button>
</form>
</div>
);
}
Now Input
is all done!
Just like we did with Messages
, we need to make sure App
will render Input
. Head over to src/components/index.js and add the import for your new component to the top of the file:
import Input from '@/components/Input'
Next, update the contents of the render
method to show the Input
component:
<Messages messages={messages} me={me}/>
<Input
onSendMessage={onSendMessage}
/>
Notice that you pass a callback (onSendMessage
) to Input
. Input
will call this each time the user presses the send button. Finally, you'll implement that callback to "send" a message. Add the following function just above the return
statement:
function onSendMessage(message) {
const newMessage = {
data: message,
member: me
}
setMessages([...messages, newMessage])
}
For now, we'll just add it to the messages array in our state
. Leter, we'll connect this to a web service to actually send messages back and forth.
If you head back to your web browser you should see an input field. Type in a message, hit Enter, and watch your message display in the list.
Look at this, your chat app is taking shape! Kudos to you!
Showing a List of Online Members
Before we connect everything to a web service, there is one more part of our chat UI that we'll tackle. In a group chat setting, it's useful to show a list of currently active users. We'll do that with a simple functional React component.
Create a new file in src/components called Members.js. There, you can add the following component:
import React from 'react';
import styles from '@/styles/Home.module.css'
export default function Members({members, me}) {
return (
<div className={styles.members}>
<div className={styles.membersCount}>
{members.length} user{members.length === 1 ? '' : 's'} online
</div>
<div className={styles.membersList}>
{members.map(m => Member(m, m.id === me.id))}
</div>
</div>);
}
This is a simple, stateless component that takes a list of members and the current member. Using those two pieces of data, it renders a list of Member
components, which you'll add to the bottom of the file:
function Member({id, clientData}, isMe) {
const {username, color} = clientData;
return (
<div key={id} className={styles.member}>
<div className={styles.avatar} style={{backgroundColor: color}}/>
<div className={styles.username}>{username} {isMe ? ' (you)' : ''}</div>
</div>
);
}
This component shows the member as text, adding "(you)" to the end of the text if it's the current user's username.
Next, we'll show this component from src/pages/index.js. Head on over there and add a new import to the top of the file:
import Members from '@/components/Members'
Next, add a new state variable right after where [messages, newMessage]
are declared:
const [members, setMembers] = useState([{
id: "1",
clientData: {
color: 'blue',
username: 'bluemoon',
},
}]);
This state variable will keep track of active members. For now, we'll hard-code a member. We'll fetch them from a web service in the next section.
Finally, add your new component to the JSX, right above Messages
:
<Members members={members} me={me}/>
<Messages messages={messages} me={me}/>
Run the app again and you'll see bluemoon in the list of chat members!
Now, let's roll up our sleeves and connect everything to a real web service using Scaledrone.
Connecting to Scaledrone
Initially, the idea of crafting a chat application that smoothly shuttles messages in real-time might seem a tad intimidating. Thankfully, Scaledrone steps in to streamline the business logic behind developing a chat app.
Before we dive into its usage, though, we have to ensure that Scaledrone is part of our app. Here's how.
Open src/pages/index.js. Inside the JSX head
tag, add the following line to the bottom of the tag:
<script type='text/javascript' src='https://cdn.scaledrone.com/scaledrone.min.js' />
Note: If you are using a plain index.html file, simply add the same
script
tag inside thehead
tag in the file.
This will make sure Scaledrone's code is included in our app. We're almost ready to start using it, but we need to do one more thing first: create a Scaledrone channel.
To successfully connect to Scaledrone, you need to get your own channel ID from Scaledrone's dashboard. If you haven't already, head over to Scaledrone.com and register an account. Once you've done that, go to your dashboard and click the big green +Create Channel button to get started. You can choose Never require authentication for now. Note down the channel ID from the just created channel, you'll need it in a bit.
Head back to index.js. We'll add a function to connect to Scaledrone, but first we need to do a bit of prep work.
Add a new top-level object to the file:
let drone = null
We'll use this variable to store a global Scaledrone instance that we can access in the component.
Next, Scaledrone will need access to the current state, including messages, members and the current user. To allow this, we'll create a reference using useRef
to each of those objects. Add the following code right below the definition of [me, setMe]
:
const messagesRef = useRef();
messagesRef.current = messages;
const membersRef = useRef();
membersRef.current = members;
const meRef = useRef();
meRef.current = me;
This creates references to messages, members and the current user. It also makes sure the references are updated during each render. Now, we can use these references when we are processing Scaledrone events.
Add a new function to the top of the component to connect to Scaledrone:
function connectToScaledrone() {
drone = new window.Scaledrone('YOUR-CHANNEL-ID', {
data: meRef.current,
});
drone.on('open', error => {
if (error) {
return console.error(error);
}
meRef.current.id = drone.clientId;
setMe(meRef.current);
});
}
This will create a new instance of Scaledrone. Just remember to replace the string with the channel ID you got earlier. Since we're loading the script inside our HTML head
tag, the script's contents will be attached to the global window
object. That's where we'll ultimately fetch our Scaledrone instance from.
We'll also pass Scaledrone the data for the currently logged-in member. If you have additional data about the user or the client, this is a good way to supply that data, instead of having to send it with each message.
Moving on, we need to connect to a room. A room is a group of users that we can send messages to. You listen to those messages by subscribing to a room of a specific name. Add the following code to the end of the function you just created:
const room = drone.subscribe('observable-room');
We'll subscribe to a room.
Note: You might have noticed that we named our name Scaledrone room
observable-room
. You can name the room anything you want, a single user can actually connect to an infinite amount of rooms for all sorts of application scenarios. However, in order for messages to contain the info of the sender, you need to prefix the room name with"observable-"
. Read more...
We also need to know when the messages arrive, so we'll subscribe to the "message" event on the room. Add the following code to the end of the constructor:
room.on('message', message => {
const {data, member} = message;
setMessages([...messagesRef.current, message]);
});
When we get a new message, we'll add the message's text as well as the client data to our state.
Similarly, we need to track the logged-in members:
room.on('members', members => {
setMembers(members);
});
room.on('member_join', member => {
setMembers([...membersRef.current, member]);
});
room.on('member_leave', ({id}) => {
const index = membersRef.current.findIndex(m => m.id === id);
const newMembers = [...membersRef.current];
newMembers.splice(index, 1);
setMembers(newMembers);
});
Scaledrone gives us the logged-in members when we join and then keeps us updated when someone else joins or leaves. We'll make sure to keep our state variables updated with the correct members.
Now you just need to make sure the function is called when the component loads. Add this code right below the function definition:
useEffect(() => {
if (drone === null) {
connectToScaledrone();
}
}, []);
You can do a small check to make sure Scaledrone is not already connected and then call the function.
Next, we need to modify onSendMessage
to publish a new message to all the other users in the room:
function onSendMessage(message) {
drone.publish({
room: 'observable-room',
message
});
}
Scaledrone makes this a simple matter of calling the publish
function with the data.
Finally, we'll remove the test message from our app's state
so that the initial state looks like the following:
const [messages, setMessages] = useState([]);
const [members, setMembers] = useState([]);
const [me, setMe] = useState({
username: randomName(),
color: randomColor(),
});
If you switch to your web browser at this point, you'll notice that the behavior remains the same: when you send a new message, it should appear in the list. The difference is that this time it's actually being sent to Scaledrone. Feel free to open the app in another tab and chat with yourself. :)
Adding a Typing Indicator
When you use a chat app, you want to know what's happening on the other side of the conversation. Has the person you're talking to read your message? Are they typing a reply? These are some of the questions that chat apps can answer with features like âseenâ confirmation, typing indicator, and more.
Fear not; Scaledrone makes this a walk in the park. Let's see how it's done.
Creating a React Typing Indicator Component
As you can assume, we'll now add a typing indicator. Ours will display a message like "hiddenstar is typing."
The first step includes creating a new React component dedicated to these subtle yet informative dots. To begin, create a new file in src/components called TypingIndicator.js. Then, add the following code:
import React from 'react';
import styles from '@/styles/Home.module.css'
export default function({members}) {
// 1
const names = members.map(m => m.clientData.username);
if (names.length === 0) {
return <div className={styles.typingIndicator}></div>;
}
// 2
if (names.length === 1) {
return <div className={styles.typingIndicator}>{names[0]} is typing</div>;
}
// 3
const string = names.slice(0, -1).join(', ') + ' and ' + names.slice(-1);
return <div className={styles.typingIndicator}>{string} are typing</div>;
}
This component adds a small div
that gets filled with text when someone is typing. You will pass a list of users that are currently typing to this component. Here's how it works:
- First, you get a list of usernames of all the members that are currently typing. If nobody is typing, an empty
div
without any text is returned. - If one person is typing, the
div
portrays their action, like "mysteriousrabbit is typing." - In case more people are typing, their names are joined using a comma as a separator. The text then reads something like: "mysteriousrabbit, coolfrog and bluecar are typing."
Now that we have the component, we need code to show it on the website. Open src/pages/index.js and import your new component at the top of the file:
import TypingIndicator from '@/components/TypingIndicator'
Next, you have to modify the JSX to show the typing indicator right under the Messages
component, like so:
<Messages messages={messages} me={me}/>
<TypingIndicator members={members.filter(m => m.typing && m.id !== me.id)}/>
<Input
onSendMessage={onSendMessage}
onChangeTypingState={onChangeTypingState}
/>
The code above shows the new component and passes it a list of users that are typing by checking the typing
property.
Your web app will now show a textual sign whenever a user is composing a message.
Tracking if the User is Currently Typing
However, at this moment, the typing state of users is not being tracked. The situation isn't as clear as merely typing/not typing, either.
People might start typing a message and then suddenly get distractedâlike deciding to make themselves a cup of coffeeâleaving the message halfway written. On the flip side, if you trigger the typing indicator each time someone takes a moment to find the perfect emoji, well, that might just annoy your users.
And here is where Scaledrone and its optional library called Typing Indicator step in to handle all of this for you.
Note: While Typing Indicator is created by and for Scaledrone, it is framework-agnostic and works with any JavaScript application or website.
To install typing indicator, navigate to the project folder in Terminal and type in:
npm i typing-indicator
This little command will install the library.
You'll track the typing state from src/components/Input.js, so navigate there and import the library at the top of the file:
import TypingIndicator from 'typing-indicator';
let typingIndicator = null;
You also create a top-level variable to hold an instance of the typing indicator, accessible across re-renders of the component.
Next, change the definition of Input
to include a new parameter:
export default function Input({onSendMessage, onChangeTypingState}) {
onChangeTypingState
is a callback that you'll define in index.js, but Input
will call it when the current user changes their typing state.
Then, you'll add a new typing indicator when the component loads by adding the following code inside the component, right below where the state is declared:
useEffect(() => {
if (typingIndicator === null) {
typingIndicator = new TypingIndicator();
typingIndicator.listen(isTyping => onChangeTypingState(isTyping));
}
}, []);
When this component loads, you will register a callback to get called whenever a user begins or stops typing.
Next, we need to tell the typing indicator when the text changes, so update onChange
to the following:
function onChange(e) {
const text = e.target.value;
typingIndicator.onChange(text);
setText(text);
}
The typing indicator does the heavy lifting of tracking typing states for you.
All you need to do is connect it to a function, which, in this case, is onChangeTypingState
.
Now, let's wrap things up in src/pages/index.js by passing the callback in. Navigate there and, first, import your new component at the top of the file:
import TypingIndicator from '@/components/TypingIndicator'
Then, modify the Input
declaration inside the JSX to the following:
<TypingIndicator members={members.filter(m => m.typing && m.id !== me.id)}/>
<Input
onSendMessage={onSendMessage}
onChangeTypingState={onChangeTypingState}
/>
Alongside showing your new component with members that are currently typing, this will ensure that Input
calls onChangeTypingState
, which you'll implement next.
Sending and Receiving the Typing State
Right now, you are passing onChangeTypingState
to Input
but you haven't yet created the function. The function needs to send a message to Scaledrone, letting other users know that the current user is typing.
To continue, add the following function above the return
statement:
function onChangeTypingState(isTyping) {
drone.publish({
room: 'observable-room',
message: {typing: isTyping}
});
}
onChangeTypingState
receives a boolean that indicates whether the user is typing or not. Using Scaledrone, you'll publish a new message with the updated typing state whenever it changes.
Next, we need to process the incoming typing messages and show the typing indicator. You'll do this in the connectToScaledrone
. Modify the call to room.on('message', ...)
to the following:
room.on('message', message => {
const {data, member} = message;
if (typeof data === 'object' && typeof data.typing === 'boolean') {
const m = membersRef.current.find(m => m.id === member.id);
m.typing = data.typing;
setMembers(membersRef.current);
} else {
setMessages([...messagesRef.current, message]);
}
});
Here's the breakdown of what this code does:
The incoming message type is checked. If the message is about a typing state, the code finds the members that are typing and changes their typing
property to what was passed in the message. If the message isn't about typing, it's treated as a regular message and added to state.messages
.
So, with these changes, you can track the typing state of users and send it to Scaledrone. When you receive a typing message from Scaledrone, you will set the typing
property on the user. As the last step, the TypingIndicator
component will read that state and show a typing indicator for those users for whom typing
is true.
Head back to your browser to see the typing indicator in action!
And We're Done!
And voilĂ ! You've just built your very own React chat app, and with the magic of Scaledrone, you've made it look easy. No more fussing over the backendâyou got to focus on creating a sleek and functional user interface that's ready to impress.
With Scaledrone, you get to focus on making your UI beautiful, and on the functionality of your app, without worrying about the back end. You can find the full source code on GitHub.
Of course, there can be much more to your chat app. If you'd like to build on this app, consider these feature ideas to implement using Scaledrone's APIs:
- Sending images and attachments
- Authentication
- Replies to specific messages
- Stickers and GIFs
Sounds exciting, right?
And if you're ready to explore further, you can find the full source code or run the working prototype on GitHub. For any questions or feedback, feel free to contact us; we'd love to hear from you.