Reactで、チャットの新しいメッセージまで自動でスクロールする方法

​​React​Playwright,ResizeObserver,TypeScript

チャットの一覧で新しいメッセージまでスクロールするには、useLayoutEffect の中で、一覧の要素の scrollTop に scrollHeight を入れます。ただし、いつもスクロールすると、上の会話を読んでいる人が一番下に引き戻されてしまいます。そこで、一番下にいたかどうかをスクロールのたびに覚えておき、下にいたときだけスクロールします。上を読んでいる間に届いた分は、「新着○件」のボタンで知らせます。

ChatLog という部品を作り、メッセージが届いたとき、上を読んでいるとき、画像があとから読み込まれたときの動きを確かめました。一覧の最後の要素の scrollIntoView を呼ぶ書き方や、CSS の flex-direction: column-reverse だけで下に貼りつける書き方とも比べています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。サーバーとの通信の代わりに、ページから関数を呼んでメッセージを足しています。マウスホイールの操作は Playwright の mouse.wheel で再現しました。スマートフォンのタッチ操作、スクリーンリーダーでの読み上げ、Firefox と Safari は試していません。

一番下にいるときに届いたメッセージは、届くたびに一番下まで表示される。ホイールで上にスクロールしたあとに届くと、表示はそのままで「↓ 新着 1件」「↓ 新着 2件」のボタンが出る。ボタンを押すと一番下まで移り、ボタンが消える
メッセージが届く、上にスクロールする、新着ボタンを押す、の3つの操作でのスクロールの動き

GIF のカーソル、操作の回数、操作の名前(「ホイール ↑」「クリック」)、メッセージとボタンに付けたピンクは、撮影のために重ねたものです。メッセージとボタンのピンクは、メッセージが届いた瞬間、新着のボタンの件数が変わった瞬間(ボタン全体をピンクにしています)、ボタンを押した瞬間(押したボタンと、移った先の最後のメッセージ)にだけ付けています。

一番下にいたときだけ、一覧の最後までスクロールする

作った部品の全体です。メッセージの配列を props で受け取り、一覧を表示します。

tsx
import { useLayoutEffect, useRef, useState } from 'react';

export type Message = { id: number; author: 'me' | 'cat'; text: string; image?: string };

// 下端からこの距離(px)以内なら「いちばん下を見ている」とみなす
const THRESHOLD = 40;

export function ChatLog({ messages }: { messages: Message[] }) {
  const listRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  // 最後にスクロールしたとき、いちばん下にいたか
  const atBottomRef = useRef(true);
  const prevCountRef = useRef(messages.length);
  const [unread, setUnread] = useState(0);

  const scrollToBottom = () => {
    const el = listRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  };

  const handleScroll = () => {
    const el = listRef.current;
    if (!el) return;
    atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= THRESHOLD;
    if (atBottomRef.current) setUnread(0);
  };

  // メッセージが増えたら、画面に描かれる前にスクロールする
  useLayoutEffect(() => {
    const added = messages.length - prevCountRef.current;
    prevCountRef.current = messages.length;
    if (added <= 0) return;

    const last = messages[messages.length - 1];
    if (atBottomRef.current || last.author === 'me') {
      scrollToBottom();
    } else {
      setUnread((n) => n + added);
    }
  }, [messages]);

  // 画像の読み込みなどで中身の高さが変わったときも、下にいたなら下に合わせる
  useLayoutEffect(() => {
    const content = contentRef.current;
    if (!content) return;
    const observer = new ResizeObserver(() => {
      if (atBottomRef.current) scrollToBottom();
    });
    observer.observe(content);
    return () => observer.disconnect();
  }, []);

  return (
    <div className="chat">
      <div ref={listRef} className="chat-list" role="log" aria-label="メッセージ" onScroll={handleScroll}>
        <div ref={contentRef}>
          {messages.map((m) => (
            <div key={m.id} className={`msg msg-${m.author}`}>
              {m.text}
              {m.image && <img src={m.image} alt="" />}
            </div>
          ))}
        </div>
      </div>
      {unread > 0 && (
        <button type="button" className="new-badge" onClick={scrollToBottom}>
          ↓ 新着 {unread}件
        </button>
      )}
    </div>
  );
}

一覧の要素には、高さと overflow-y: auto を指定して、その中だけがスクロールするようにしています。新着のボタンは、一覧の下のほうに重ねて表示します。

css
.chat { position: relative; width: 360px; }
.chat-list { height: 300px; overflow-y: auto; border: 1px solid #9aa5ab; border-radius: 8px; padding: 8px; box-sizing: border-box; background: #fff; }
.new-badge { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); padding: 6px 14px; border: none; border-radius: 16px; background: #067a98; color: #fff; font-size: 14px; cursor: pointer; }

スクロールは、scrollTop に scrollHeight(中身全体の高さ)を入れるだけです。scrollHeight は、スクロールできる最大の量より、一覧の見えている高さ(clientHeight)の分だけ大きい値です。それでも検証環境では、スクロールの位置は一番下で止まり、下端までの残りは0になりました。一番下にいるかどうかは、MDN の scrollHeight の説明にある、scrollHeight - clientHeight - scrollTop で下端までの残りを求める方法で判定しています。MDN の例では残りが1px以下かどうかを見ていますが、ここでは少し余裕を持たせて40px以下にしました。

スクロールを useLayoutEffect の中で行っているのは、React の公式リファレンスにある useLayoutEffect の説明のとおり、ブラウザが画面を描き直す前に実行されるからです。新しいメッセージが下に追加された状態が描かれてからスクロールする、という順番にならないようにしています。useEffect との見た目の違いは、この検証では比べていません。

メッセージの一覧は、role="log" の要素にしています。MDN の log ロールの説明によると、log ロールの要素は、何も指定しなくても aria-live が polite として扱われ、スクリーンリーダーは利用者の操作が落ち着いたときに中の変化を読み上げます。検証では、アクセシビリティツリーに log "メッセージ" として出ていることだけを確かめました。

検証環境で、高さ300pxの一覧に12件のメッセージを表示し、一番下にいるときに相手のメッセージを3件届けた結果です。「下端までの残り」は scrollHeight - clientHeight - scrollTop の値です。

操作 scrollTop 下端までの残り
最初の表示 228 0
1件届く 270 0
2件目が届く 312 0
3件目が届く 354 0

最初の表示の時点で一番下までスクロールされているのは、ResizeObserver の働きです。最初の表示ではメッセージの数が増えていないので、1つ目の useLayoutEffect は何もしません。一方、Resize Observer の仕様には、見張りを始めたときにも通知が来ると書かれています。最初は atBottomRef が true なので、その通知で一番下までスクロールします。

下にいるかは、新しいメッセージが届く前に判定しておく

ChatLog では、一番下にいるかどうかを、スクロールしたときに handleScroll で判定して ref に入れています。メッセージが届いたときには、その ref を見るだけで、判定し直しません。

届いたときに判定すればよいように見えますが、useLayoutEffect が動くのは、新しいメッセージが一覧に追加されたあとです。追加された分だけ中身が高くなっているので、一番下を見ていた人でも、下端までの残りはメッセージの高さの分だけ増えています。比べるために、次の書き方も試しました。

tsx
useLayoutEffect(() => {
  const el = listRef.current;
  if (!el) return;
  // 新しいメッセージが追加されたあとに、下にいるかを判定している
  if (el.scrollHeight - el.scrollTop - el.clientHeight <= 40) {
    el.scrollTop = el.scrollHeight;
  }
}, [messages]);

一覧を一番下までスクロールしてから、1行のメッセージを1件届けると、下端までの残りは42pxになり、スクロールしませんでした。1行のメッセージの高さは、前のメッセージとの間隔を含めて42pxで、判定に使った40pxより大きかったからです。判定の幅を広げても、もっと長いメッセージが届けば、同じことが起きるはずです。

atBottomRef を state ではなく ref にしているのは、スクロールのたびに値が変わっても、表示には関係しないからです。React の公式リファレンスにある useRef の説明のとおり、ref の current を書き換えても、再レンダーは起きません。

上を読んでいる間は動かさず、新着の件数を出す

一番下にいないときに相手のメッセージが届いたら、スクロールはせず、unread に届いた件数を足します。unread が1以上のときだけ、「↓ 新着○件」のボタンを出します。ボタンを押すと一番下までスクロールし、そのときのスクロールで handleScroll が unread を0に戻すので、ボタンは消えます。

一覧を上に200pxスクロールしてから、相手のメッセージを2件届けた結果です。

操作 scrollTop 下端までの残り 新着のボタン
上に200pxスクロール 154 200 なし
1件届く 154 242 ↓ 新着 1件
2件目が届く 154 284 ↓ 新着 2件
ボタンを押す 438 0 なし

メッセージが届いても scrollTop は154のままで、一覧の一番上に見えているメッセージも変わりませんでした。比べるために、メッセージが増えたときに、下にいたかどうかに関係なくいつも一番下までスクロールする書き方でも試しました。上に200pxスクロールしてから1件届けると、scrollTop は28から270に変わり、一番下に引き戻されました。

自分で送ったメッセージは、上を読んでいる途中でも一番下までスクロールして、送った内容が見えるようにします。ChatLog では、最後のメッセージの author が 'me' のときは、下にいたかどうかに関係なくスクロールしています。上に200pxスクロールしてから、入力欄に文字を入れて送信すると、下端までの残りは0になりました。

画像があとから読み込まれても、一番下に合わせ直す

メッセージに画像が入っていると、メッセージが届いたときにはまだ画像が読み込まれておらず、読み込まれたあとで高さが増えます。メッセージが増えたときに、いつも一番下までスクロールする書き方では、画像の分だけ一番下からずれてしまいます。

検証では、500ms待ってから縦200pxの画像を返すようにして、画像つきのメッセージを1件届けました。

書き方 届いた直後の下端までの残り 画像の読み込み後の下端までの残り
メッセージが増えたときに、いつも一番下までスクロールする 0 200
ChatLog(ResizeObserver でも合わせる) 0 0

ChatLog では、メッセージを並べた内側の要素を ResizeObserver で見張り、高さが変わったときに、一番下にいたならスクロールし直しています。表の、いつも一番下までスクロールする書き方の検証では、画像が読み込まれて中身が高くなっても、scrollTop は変わりませんでした。atBottomRef を書き換えるのは、スクロールしたときに呼ばれる handleScroll だけです。スクロールの位置が変わらなければ handleScroll は呼ばれないので、atBottomRef は true のまま残ります。そのため、一番下に合わせ直せます。

画像の大きさが前もって分かっているなら、img に width と height を指定しておく方法もあります。MDN の img 要素の説明によると、両方を指定すると、ブラウザは読み込む前に縦横の比を求めて場所を確保するので、読み込んだあとのレイアウトのずれを減らせます。この方法は試していません。

scrollIntoView を使うと、ページ全体もスクロールする

一覧の最後に空の要素を置き、その要素の scrollIntoView() を呼ぶ書き方もあります。

tsx
const endRef = useRef<HTMLDivElement>(null);

useLayoutEffect(() => {
  endRef.current?.scrollIntoView();
}, [messages]);

// 一覧の最後に <div ref={endRef} /> を置く

MDN の scrollIntoView() の説明にあるとおり、scrollIntoView() は、その要素が見えるように、要素を囲んでいるスクロールできる要素をすべてスクロールします。一覧だけでなく、ページそのものもスクロールの対象です。

チャットの一覧の上下に、高さ900pxの別の内容を置いたページで試しました。ページを一番上までスクロールして、チャットの一覧が画面の外にある状態でメッセージを1件届けると、一覧の中だけでなくページ全体もスクロールし、window.scrollY が0から1207に変わりました。ChatLog のように一覧の scrollTop を変える書き方では、同じページで window.scrollY は0のままでした。

MDN には、scrollIntoView に container: 'nearest' を渡すと、一番近いスクロールできる要素だけをスクロールする、と書かれています。検証環境の Chromium 153 で { block: 'end', container: 'nearest' } を渡すと、window.scrollY は0のままでした。ただし、typescript 7.0.2 の型定義には container がまだなく、そのまま渡すと「ScrollIntoViewOptions に container はない」という型エラーになりました。そのため、動きの検証では型を広げて渡しています。また、合わせる先が空の要素の下端なので、一覧の下端までは7px残りました。

CSS の column-reverse だけで、下に貼りつける

一覧に display: flex と flex-direction: column-reverse を指定すると、スクロールの位置が最初から一番下になります。配列は新しい順に並べ替えて渡します。

tsx
<div className="chat-list chat-reverse" role="log" aria-label="メッセージ">
  {[...messages].reverse().map((m) => (
    <div key={m.id} className={`msg msg-${m.author}`}>{m.text}</div>
  ))}
</div>
css
.chat-reverse { display: flex; flex-direction: column-reverse; }

検証環境では、JavaScript でスクロールしなくても、最初の表示とメッセージが届いたときに一番下が見えていました。このとき scrollTop は0です。上に200pxスクロールすると scrollTop は-200になり、そこでメッセージが届くと-248に変わりましたが、一覧の一番上に見えているメッセージは同じでした。MDN の scrollTop の説明にも、column-reverse の要素では、一番下で scrollTop が0になり、上にスクロールするほど負の値になると書かれています。

コードは短くなりますが、HTML の並びは画面と逆になります。検証では、一覧の最初の要素が、一番新しいメッセージでした。MDN の flex-direction の説明では、column-reverse で見た目と HTML の並びがずれると、スクリーンリーダーを使う人が正しい順番で読めなくなることがあると注意されています。scrollTop の値の向きも逆になるので、下にいるかの判定や新着のボタンを足すときは、この向きに合わせて書く必要があります。新着のボタンまで作るなら、ChatLog のようにスクロールの位置を JavaScript で決める書き方のほうが、値の向きで迷わずに済みます。

​Posted by 86