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.

  1. A Home component which will manage sending and receiving messages and rendering the inner components.
  2. A Messages component which will be a list of messages.
  3. 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 Homecomponent. 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:

  1. Each message is linked to a specific member (user), and every member is identified by an ID, username, avatar, and a personalized color.
  2. 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.
  3. 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 the head 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:

  1. 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.
  2. If one person is typing, the div portrays their action, like "mysteriousrabbit is typing."
  3. 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.