8 min readUpdated Dec 19, 2023

How to add a typing indicator to an in-game chat room with React

How to add a typing indicator to an in-game chat room with React
Nate EagleNate Eagle

In a previous article, I took you through how to add a chat room to a simple game of tic-tac-toe. This post is the second in the series, in which we will look at how to build out the functionality of that chat room with additional features.

Across the series, we’ll look at how to manage presence and how to enable emoji reactions – in this post, the focus is on typing indicators.

Follow on to find out how to display an indicator when the other person is typing. Or head back to the last post to start the tutorial from scratch.

Adding a typing indicator

Typing indicators are an important feature, because it might let us know if the person is not, in fact, ignoring us, just taking a while to compose a message. It makes a chat feel more interactive, because it gives us a little more information about when the other person is getting ready to speak, just like we have in real life when we can see a person's body language.

To let the channel know when a user is typing, we could send messages with a different name. One downside of that approach, however, is that those messages become part of the channel history, and get counted against the quota of how many messages can be replayed via the rewind param. It also feels a little disorganized to have these mixed in with regular messages, especially because there can be quite a few messages about starting and stopping typing in a conversation.

I recommend using the member data feature of Ably's Presence service.

Presence lets clients "be aware of other clients that are currently 'present' on a channel". That's perfect for a chat window and a lot of other contexts. Presence also has a member data feature that is basically a status object, allowing the client to store information about its state—whatever that means for your application—in the channel itself.

The other thing we'll want to be careful about is how often we send status updates: typing involves a lot of stopping and starting, and we don't want to be unnecessarily inefficient. We should only send updates when the user has stopped typing for a meaningful length of time: that means we'll use a debounce.

A custom hook: useTypingStatus

We should also be careful about mixing up a lot of new logic into our chat. If we can, it'd be great to keep that component relatively simple. If you want to encapsulate logic that needs its own state, that uses other hooks, or that isn't a display component, a great solution is a custom hook.

In our case, we could separate almost everything about sending and receiving updates about typing status into a useTypingStatus hook that we could re-use anywhere.

Let's create a new file in the chat directory called useTypingStatus.ts.

We're going to use three important methods from the channel.presence API:

  • enter: When the component mounts, we'll enter the channel, which registers our clientId with the channel (the corresponding method is leave).
  • update: This lets us update the member.data object associated with our presence in the channel.
  • subscribe: Lets us receive messages when someone's member.data is updated and react however we would like to changes in that data (the corresponding method is unsubscribe).

Here's the plan:

  • Attach to the channel when the component mounts.
  • Send update events when our user starts/stops typing.
  • Subscribe to update events from the opponent's start/stop typing events so that we can display a message near our chat window when they're typing.
  • Use a debounce so that we economize the number of updates being sent.
import { useEffect, useState } from "react";
import * as Ably from "ably/promises";

// This hook is used to track which players are currently typing in a chat
// Example Usage:
//  const { onType, whoIsCurrentlyTyping } = useTypingStatus(channel, playerId);
// <input onChange={(e) => onType(e.target.value)} />
// {whoIsCurrentlyTyping.map((playerId) => (
//   <p key={playerId}>{playerId} is typing...</p>
// ))}
const useTypingStatus = (
  channel: Ably.Types.RealtimeChannelPromise,
  playerId: string,
  // How long to wait before considering a player to have stopped typing
  timeoutDuration = 2000,
) => {
  const [startedTyping, setStartedTyping] = useState(false);
  const [whoIsCurrentlyTyping, setWhoIsCurrentlyTyping] = useState<string[]>(
    [],
  );
  const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);

  useEffect(() => {
    // Declare the user present on the channel
    void channel.presence.enter("");
  }, [channel]);

  const stopTyping = () => {
    setStartedTyping(false);
    void channel.presence.update({ typing: false });
  };

  const onType = (inputValue: string) => {
    if (!startedTyping) {
      setStartedTyping(true);
      void channel.presence.update({ typing: true });
    }

    if (timer) {
      clearTimeout(timer);
    }

    if (inputValue === "") {
      // Allow the typing indicator to be turned off immediately -- an empty
      // string usually indicates either a sent message or a cleared input
      stopTyping();
    } else {
      const newTimer = setTimeout(stopTyping, timeoutDuration);
      setTimer(newTimer);
    }
  };

  useEffect(() => {
    const handlePresenceUpdate = (
      update: Ably.Types.PresenceMessage,
    ) => {
      const { data, clientId } = update;

      if (data.typing) {
        setWhoIsCurrentlyTyping((currentlyTyping) => [
          ...currentlyTyping,
          clientId,
        ]);
      } else {
        setWhoIsCurrentlyTyping((currentlyTyping) =>
          currentlyTyping.filter((id) => id !== clientId)
        );
      }
    };

    void channel.presence.subscribe(
      "update",
      handlePresenceUpdate,
    );

    // Clean up function
    return () => {
      channel.presence.unsubscribe("update");
      if (timer) {
        clearTimeout(timer);
      }
    };
  }, [channel, timer]);

  return { onType, whoIsCurrentlyTyping };
};

export default useTypingStatus;

The timer logic delays an update from being sent for two seconds (2000ms). If it receives another update during that time, it resets the timer. So if you type a whole sentence and fire the onType function 100 times, it won't say that you've stopped typing until two seconds after the last time it's called.

One important thing to note is that this code includes a means of turning off the indicator immediately - if you call onType and pass in an empty string. That's because there's one time when it's usually desirable to send a stop typing update immediately, and that's right after a message is sent.

Adding the useTypingStatus custom hook to the Chat component

First, let's import our new custom hook into Chat.tsx:

import useTypingStatus from "./useTypingStatus";

Next, we need to get playerId from our appContext.

const { game, playerId } = useAppContext();

Then, let's get the two important things our custom hook returns inside the component:

// Maintain a list of who is currently typing so we can indicate that in the UI
const { onType, whoIsCurrentlyTyping } = useTypingStatus(channel, playerId);

Our custom hook returns:

  1. onType, a function to fire every time the user types.
  2. whoIsCurrentlyTyping, an array of clientIds that are typing.

First, let’s add onType to two places in our component: the handler for a change in the input's value, and the handler for when a message is sent.

const onSend = () => {
    channel.publish("message", inputValue);
    // When a message is sent, we want to turn off the typing indicator
    // immediately.
    onType("");
    setInputValue("");
  };

  const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const text = event.target.value;
    setInputValue(text);
    onType(text);
  };

Create a Status component

Now, we need to create some place to actually display something when the other person is typing. There's just enough logic here that it's probably worth creating another component called Status to hold this logic. Create a new file in the chat directory called Status.tsx:

// src/app/components/chat/Status.tsx
import { useMemo } from "react";
import { useAppContext } from "../../app";
import { playerName } from "../../../gameUtils";

type StatusProps = {
  whoIsCurrentlyTyping: string[];
  defaultText?: string;
  className?: string;
};

const Status = ({
  whoIsCurrentlyTyping,
  defaultText = "Chat",
  className = "",
}: StatusProps) => {
  const { game, playerId } = useAppContext();

  // whoIsCurrentlyTyping tracks both players, but we only care about showing if
  // the opponent is currently typing.
  const opponentIsTyping = useMemo(
    () => whoIsCurrentlyTyping.filter((id: string) => id !== playerId),
    [whoIsCurrentlyTyping, playerId]
  );

  return (
    <div className={`${className}`}>
      {opponentIsTyping.length > 0
        ? `${playerName(opponentIsTyping[0], game.players)} is typing…`
        : defaultText}
    </div>
  );
};

export default Status;

Then import the Status component into Chat and display it below our list of messages:

import Status from "./Status";

<Status whoIsCurrentlyTyping={whoIsCurrentlyTyping} />

Try it out!

Now, you should be able to see a message showing that the other person is typing that starts and stops along with their activity.

(Any issues? Compare what you have against the typing-status branch.)

Wrapping up

Congratulations! We now have a functional typing indicator inside a working chat application that sits alongside the tic-tac-toe game.

In the next post, I’ll take you through how to use Presence to tell when your opponent is active or if they leave the game.

Until then, you can tweet Ably @ablyrealtime, drop them a line in /r/ablyrealtime, or follow me @neagle and let us know how you’re getting on.

Join the Ably newsletter today

1000s of industry pioneers trust Ably for monthly insights on the realtime data economy.
Enter your email