【Rails × React】WebSocketを使ったタイピングインジケーターの実装手法

はじめに 🚀

こんにちは!2 回目の登場です!

YOUTRUST で Web エンジニアをしている林(YOUTRUST)です。

今月末で入社してから半年が経ちました。 なんとこの半年の間に 4 つもの YOUTRUST 主催のイベントが開催されました!😲 (小さいものを含めるともっと)

日々の開発業務だけでなく、こういうイベント運営側も経験できるのが弊社の良いところだと感じています!

ちょうど先週末にも放課後フェスというイベントが行われ大盛況でした!そのイベントの模様はこちら ↓

さて今回は、YOUTRUST のメッセージ機能に追加した「タイピングインジケーター」について、その実装方法をご紹介します。 タイピングインジケーターとは、チャット画面で相手が文字を打っている時に表示される「入力中...」等の表示のことです。 この機能があるだけで、相手の入力状況がリアルタイムに分かり、コミュニケーション体験がグッと良くなります。

実際のYOUTRUSTのタイピングインジケーター

今回の実装を通して、WebSocket 通信や React でのタイマー管理について得られた知見を共有します。

背景と要件 📋

今回、タイピングインジケーターを導入することになった背景には、ユーザー同士の会話のテンポ感を高めたいという UX 的な狙いがありました。 相手が返信を書いていることが可視化されれば、「待たされている」感覚が軽減され、会話が途切れにくくなります。その結果、メッセージの送受信率向上にも繋がると考えました。

技術的には、以下の要件を満たす必要がありました。

  • リアルタイム性: 相手の入力開始を即座(1 秒以内)に伝えたい
  • 自然な消失: 入力をやめたり(一定時間経過)、送信が完了したら速やかに表示を消したい
  • エッジケースへの対応: ブラウザを閉じるなどして通信が切れた場合も、表示が残り続けないようにしたい

実装の全体像 🧩

タイピングインジケーターは「相手がいま入力している」という状態を、サーバーからリアルタイムに届ける必要があります。 もし通常の API(HTTP)だけで実装しようとすると、クライアント側から定期的に「いま誰か入力してる?」とポーリング*1する必要があり、次のような問題が出てきます。

  • 遅延: ポーリング間隔ぶんだけ、どうしてもラグが出てしまう
  • サーバー負荷・通信量: 変化がなくてもリクエストが飛び続ける

これらの問題から上記の要件、特に「高いリアルタイム性」と「切断時の確実な状態同期」を満たすアプローチとして、今回は WebSocket を採用しました。

WebSocket って何?

WebSocket は、クライアントとサーバーのあいだに一本のコネクションを張りっぱなしにして、双方向にデータをやりとりできるプロトコルです。 一度接続してしまえば、クライアント発・サーバー発どちらからでも自由なタイミングでメッセージを送れます。

HTTP と WebSocket の違い

簡単に比較すると、次のようなイメージです。

特徴 HTTP WebSocket
通信方式 リクエスト/レスポンス 双方向通信
接続 都度接続・切断 接続を維持
サーバー通知 クライアント起因 サーバーから直接送信可
用途 一般的な API 通信 リアルタイム通信(チャット等)

タイピングインジケーターのような「誰かが入力を始めた瞬間を他ユーザーに知らせたい」ケースには、サーバーからプッシュできる WebSocket のほうが相性が良い、というわけです。

タイピングインジケーターの処理の流れ

ユーザー A の入力開始時に start_typing をサーバーへ通知し、サーバーが同ルーム内の他ユーザー(B)へ ブロードキャスト します。

ブロードキャストとは、同じチャットルームに接続している全ユーザーへ一度にメッセージを送ることです。

入力停止時は stop_typing が通知され、インジケーターが消える仕組みです。

今回の技術スタックと WebSocket 実装方針

技術スタックは、YOUTRUST で使用している Rails(バックエンド)と React(フロントエンド)です。 WebSocket の実現には、Rails 標準の ActionCable と、フロントエンド側では @rails/actioncable を利用しています。

  • バックエンド: Ruby on Rails(ActionCable で WebSocket チャネルを実装)
  • フロントエンド: React(@rails/actioncable でチャネルに接続)

以降のセクションでは、この方針に沿ってバックエンド・フロントエンドそれぞれのサンプル実装を詳しく見ていきます。

※これ以降のサンプルコードは、簡易版で実際はセキュリティを考慮した実装になっております。

バックエンド実装(ActionCable)🛠

Rails の ActionCable を使っていきます。

Channel 実装

# app/channels/chat_room_channel.rb
class ChatRoomChannel < ApplicationCable::Channel
  # クライアントがこのチャネルを購読したときに呼ばれる
  def subscribed
    stream_from stream_name
  end

  # 切断時に呼ばれ、「入力中」状態をリセットする
  def unsubscribed
    notify_typing_stopped if current_user
  end

  # フロントからの「入力開始」イベントを受け取り、他ユーザーに通知する
  def start_typing
    return unless current_user
    notify_typing_started
  rescue StandardError => e
    logger.error(e)
  end

  # フロントからの「入力終了」イベントを受け取り、他ユーザーに通知する
  def stop_typing
    return unless current_user
    notify_typing_stopped
  rescue StandardError => e
    logger.error(e)
  end

  private

  def stream_name
    "chat_room_#{params[:chat_room_id]}"
  end

  # 「入力開始」を同じルームの全クライアントにブロードキャスト
  def notify_typing_started
    ActionCable.server.broadcast(stream_name, {
      type: 'TYPING_STARTED',
      user_id: current_user.id
    })
  end

  # 「入力終了」を同じルームの全クライアントにブロードキャスト
  def notify_typing_stopped
    ActionCable.server.broadcast(stream_name, {
      type: 'TYPING_STOPPED',
      user_id: current_user.id
    })
  end
end

実装のポイント

  1. subscribedstream_from

    接続時に呼ばれ、チャットルームごとのストリームを指定してメッセージを受け取るようにします。

  2. unsubscribed での自動停止

    ブラウザを閉じるなどして切断された際、自動的に停止処理を行います。「入力中」表示が残り続けるのを防ぐ、かなり重要なポイントです。

  3. エラーハンドリング

    例外をキャッチしてログに残し、アプリ全体への影響を抑えるようにしています。

フロントエンド実装(React)⚛️

ActionCable の接続とチャネルへの参加

@rails/actioncable で接続し、コンポーネントのマウント/アンマウントに合わせて接続/切断する形にします。

import { useEffect, useRef } from "react";
import * as ActionCable from "@rails/actioncable";

// ActionCable の WebSocket 接続を生成
const cable = ActionCable.createConsumer("ws://localhost:3000/cable");

const useChatRoomChannel = (chatRoomId: number) => {
  const subscriptionRef = useRef<ActionCable.Channel | null>(null);

  useEffect(() => {
    // チャットルームごとのチャネルに参加
    const channel = cable.subscriptions.create(
      { channel: "ChatRoomChannel", chat_room_id: chatRoomId },
      {
        // 接続時のハンドラ
        connected: () => {},
        // 切断時のハンドラ
        disconnected: () => {},
        // データ受信時のハンドラ
        received: (data) => {},
      }
    );

    subscriptionRef.current = channel;

    // アンマウント時に購読を解除してクリーンアップ
    return () => {
      channel.unsubscribe();
      subscriptionRef.current = null;
    };
  }, [chatRoomId]);

  // サーバー側のアクションを呼び出すためのヘルパー
  const send = (action: string, payload?: object) => {
    subscriptionRef.current?.perform(action, payload);
  };

  return { send };
};
  • createConsumer: WebSocket サーバーへの接続インスタンスを作成。
  • subscriptions.create: 特定ルームのチャネルに参加し、connected / disconnected / received などのハンドラを登録。
  • useEffect + useRef: 購読(Subscription)を 1 つだけ保持し、クリーンアップで unsubscribe してメモリリークや不要な通知を防止。

カスタムフック (useTypingIndicator)

ロジックを分離し、以下の責務を持たせました。

  1. デバウンス: 短時間の連続送信を抑制
  2. タイムアウト: 一定時間入力がなければ自動停止

デバウンス処理

文字入力ごとの送信を防ぐため、一定時間(例: 1 秒)の連続入力は 1 回の通知にまとめています。

// デバウンスに使用するインターバル
const DEBOUNCE_MS = 1000;

const startTyping = () => {
  if (!debounceTimerRef.current) {
    send("start_typing");
    debounceTimerRef.current = setTimeout(() => {
      debounceTimerRef.current = null;
    }, DEBOUNCE_MS);
  }
};

タイムアウト処理

入力放置などの場合に「入力中」の表示が残り続けるのを防ぐため、一定時間(例: 5 秒)入力がなければ自動停止するようにしました。

// 入力が止まったとみなすまでの時間
const TIMEOUT_MS = 5000;

const startTyping = () => {
  if (timeoutTimerRef.current) {
    clearTimeout(timeoutTimerRef.current);
  }
  timeoutTimerRef.current = setTimeout(() => {
    stopTyping();
  }, TIMEOUT_MS);
};

カスタムフック全体

ポイント: デバウンスとタイムアウトの制御を useTypingIndicator に閉じ込めることで、呼び出し側は入力イベントごとに startTyping / stopTyping を呼ぶだけでよくなり、UI コンポーネントの責務をシンプルに保てます。

import { useEffect, useRef } from "react";

// start_typing の最小間隔(この時間内は連続で送らない)
const DEBOUNCE_MS = 1000;
// 入力が止まったとみなすまでの時間
const TIMEOUT_MS = 5000;

type SendFn = (action: "start_typing" | "stop_typing") => void;

export const useTypingIndicator = (send: SendFn) => {
  // 直近の start_typing 送信から一定時間は再送しないためのタイマー
  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  // 「入力中」を自動的に解除するためのタイマー
  const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const stopTyping = () => {
    // 「入力中」終了をサーバーに通知
    send("stop_typing");
    if (timeoutTimerRef.current) {
      clearTimeout(timeoutTimerRef.current);
      timeoutTimerRef.current = null;
    }
  };

  const startTyping = () => {
    // デバウンス: 一定間隔内では start_typing を 1 回だけ送る
    if (!debounceTimerRef.current) {
      send("start_typing");
      debounceTimerRef.current = setTimeout(() => {
        debounceTimerRef.current = null;
      }, DEBOUNCE_MS);
    }

    // タイムアウト: 入力が続くあいだはタイマーを延長し続ける
    if (timeoutTimerRef.current) {
      clearTimeout(timeoutTimerRef.current);
    }
    timeoutTimerRef.current = setTimeout(stopTyping, TIMEOUT_MS);
  };

  useEffect(() => {
    // コンポーネントのアンマウント時にタイマーを後片付け
    return () => {
      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
      if (timeoutTimerRef.current) clearTimeout(timeoutTimerRef.current);
    };
  }, []);

  // 呼び出し側は startTyping / stopTyping だけを意識すればよい
  return { startTyping, stopTyping };
};

さいごに 📚

WebSocket を使った実装は初めてだったのですが、今回の経験により理解を深めることができました。 普段業務で使用している Slack などにも存在する機能ですが、それを自分で実装する経験は非常に面白かったです。 この機能を一つのキッカケに YOUTRUST のメッセージ機能をより多くの方に使っていただけると嬉しいです!

ここまで読んでいただきありがとうございました!


YOUTRUST ではエンジニアを募集しておりますので、この環境で働きたい!と思った方や一緒にプロダクトをより良くしていきたいという方はぜひご応募ください!!

株式会社YOUTRUST の全ての求人一覧

*1:クライアントが一定間隔でサーバーに対し、状態が変化したかどうかを問い合わせる通信方式のこと。状態に変化がない場合でもリクエストが送信され続ける。