Reactでチャットアプリにおける自動スクロールを実装する

# はじめに
チャット画面においてやり取りが長くなると、会話履歴が画面内に収まり切れなくなりスクロールが必要になります。自分が値を入力したときや、相手からレスポンスが返ってきたときに自動でチャット画面一番したまでスクロールする実装を本記事では行います。


# 作成するもの

![chatscroll.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/55b88b51-6842-02de-ce95-e22c0643c940.gif)


# 環境
- React：18.3.1
- Vite：5.3.1


# ソース

ユーザーが入力したメッセージは、画面に表示されるリスト（messages）に追加されます。
また、ボットからの返信はユーザーが入力した1秒後に自動的に生成されリスト（messages）に追加されます。
メッセージリスト（messages）が更新されると `useEffect` で検知し、画面は自動的に最下部までスクロールします。

```tsx:App.tsx
import { useState, useRef, useEffect } from 'react';
import './App.css';

interface Message {
  id: number;
  text: string;
  sender: 'user' | 'bot';
}

const ChatApp: React.FC = () => {
  // メッセージリスト
  const [messages, setMessages] = useState<Message[]>([]);
  // ユーザの入力値
  const [input, setInput] = useState<string>('');
  // メッセージ末尾の参照
  const messagesEndRef = useRef<HTMLDivElement | null>(null);
  // メッセージid
  const idCounter = useRef<number>(0);

  // メッセージを送信する関数
  const sendMessage = () => {
    if (input.trim()) {
      // ユーザーのメッセージをmessagesに追加
      const newMessage: Message = { id: idCounter.current++, text: input, sender: 'user' };
      setMessages(prevMessages => [...prevMessages, newMessage]);
      setInput('');

      // ボットからの返信をイメージ。返信内容をmessagesに追加
      setTimeout(() => {
        const botReply: Message = { id: idCounter.current++, text: `Bot返信: ${input}`, sender: 'bot' };
        setMessages(prevMessages => [...prevMessages, botReply]);
      }, 1000);
    }
  };

  // messagesリストが更新された時に最下部にスクロールする
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  return (
    <div className="chat-container">
      <div className="messages">
        {/* messagesリストをmapで展開 */}
        {messages.map((message) => (
          <div key={message.id} className={`message ${message.sender}`}>
            <div className="message-content">
              {message.text}
            </div>
          </div>
        ))}
        {/* 画面下部にスクロールするためのダミー要素 */}
        <div ref={messagesEndRef} />
      </div>
      {/* 入力欄と送信ボタン */}
      <div className="input-container">
        <input 
          type="text" 
          value={input} 
          onChange={(e) => setInput(e.target.value)} 
          onKeyDown={(e) => e.key === 'Enter' && sendMessage()} 
        />
        <button onClick={sendMessage}>送信</button>
      </div>
    </div>
  );
}

export default ChatApp;
```

<details><summary>App.css</summary>

```css:App.css
body {
  padding: 0;
  margin: 0;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

.chat-container {
  display: flex;
  flex-direction: column;
  height: 95vh;
  width: 400px;
  border: 1px solid #ccc;
  margin: 0 auto;
}

.messages {
  display: flex;
  flex-direction: column;
  flex: 1;
  padding: 10px;
  overflow-y: auto;
  background-color: #f4f4f4;
}

.message {
  display: flex;
  margin-bottom: 10px;
}

.message-content {
  padding: 10px;
  border-radius: 5px;
  max-width: 70%;
}

.message.user {
  justify-content: flex-end;
}

.message.bot {
  justify-content: flex-start;
}

.message.user .message-content {
  background-color: #dcf8c6;
}

.message.bot .message-content {
  background-color: #fff;
}

.input-container {
  display: flex;
  padding: 10px;
  background-color: #fff;
  border-top: 1px solid #ccc;
}

input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 5px;
}

button {
  padding: 10px 20px;
  margin-left: 10px;
  border: none;
  font-size: 1rem;
  font-weight: bold;
  border-radius: 10px;
  background-color: #007bff;
  color: #ffffff;
  cursor: pointer;
}
```

</details>



# 実行結果

サーバを立ち上げてブラウザで挙動を確認します。

```
npm run dev
```

会話履歴がチャット画面に収まり切れない状態でユーザが入力、もしくはBotからのレスポンスを表示すると、最新の入力値が表示される一番下の場所まで自動でスクロールされました。


![chatscroll.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247619/55b88b51-6842-02de-ce95-e22c0643c940.gif)


# 参考

https://developer.mozilla.org/ja/docs/Web/API/Element/scrollIntoView

https://zenn.dev/luvmini511/articles/f7b22d93e9c182