Reactで、届いた文章を少しずつ表示する方法

​​React​AbortController,fetch,Playwright,Streams API,TextDecoderStream,TypeScript,カスタムフック

React で、サーバーから少しずつ届く文章を届いた分から表示するには、fetch の応答の本文(response.body)を TextDecoderStream に通して文字列で受け取り、届くたびに state に足します。生成 AI のチャットの返答のように、全部そろうまで待たせずに、書かれていく途中から見せたいときに使える方法です。

ただ足すだけだと、日本語が文字化けしたり、再描画が届いた回数だけ起きたり、前の返答と次の返答が混ざったりします。それぞれを避ける書き方をカスタムフック useStreamedText にまとめ、止めるボタンと、読んでいる位置に合わせた自動スクロールも付けました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド、StrictMode あり)、Chromium 153 を Playwright 1.63.0 で操作しました。サーバーは同じマシンの中で動かした Node.js の HTTP/1.1(暗号化なし)で、UTF-8 の文章を決まったバイト数ずつ、間を空けて書き出します。実際の回線、HTTPS や HTTP/2、Chromium 以外のブラウザ、スクリーンリーダーでの読み上げ、生成 AI の API が返す JSON などの形式の読み分けは試していません。

上の帯に「操作 1回目/2回」などの表示が出る。「受け取る」を押すと、枠の中に文章が少しずつ増え、下へ送られながら完了になる。もう一度押して「止める」を押すと、途中までの文章を残して「止めました」と出る
「受け取る」を押して文章が届き終わるまでと、2回目を途中で止める動き

バイトのかたまりのまま文字に直すと、日本語が化ける

response.body から読めるのは、文字ではなくバイトのかたまりです。かなや、ふだん使う漢字は UTF-8 で1文字3バイトなので、かたまりの境目が文字の途中に来ることがあります。

検証用のサーバーから、248バイトの文章(JavaScript で [...文字列].length として数えると83文字)を7バイトずつ送りました。read() は36回で、そのうち24回は、かたまりの最後が文字の途中で終わっていました。これを3つの方法で文字列に直した結果です。

text
read() の回数 36、1回の大きさ 7,3 バイト、文字の途中で切れていたかたまり 24
かたまりごとに new TextDecoder().decode():元の文章と同じ=false、U+FFFD の数=61
1つの TextDecoder で decode(c, { stream: true }):元の文章と同じ=true、U+FFFD の数=0
pipeThrough(new TextDecoderStream()):元の文章と同じ=true、U+FFFD の数=0
かたまりごとに直した文章の先頭:"猫の���も��りたいほ���忙��い日でも���届��た文章を���し��つ表"

かたまりごとに別々に直すと、途中で切れた文字が置き換え文字(U+FFFD、画面では「�」)になりました。1つの TextDecoder で decode() に { stream: true } を渡すか、TextDecoderStream に通すと、切れた文字は次のかたまりとつなげてから直されるので、元の文章と同じになりました。TextDecoder で書く場合は、かたまりをすべて渡し終えたあとに、引数なしで decode() をもう一度呼び、残ったバイトを出し切ります。この記事のフックでは、pipeThrough() で TextDecoderStream に通す書き方にしています。

届いた文章を state に足していくフック

フックの全体です。表示する文章(text)と状態(status)を state に持ち、受信を始める start と、止める stop を返します。

ts
import { useCallback, useEffect, useRef, useState } from 'react';

export type StreamStatus = 'idle' | 'streaming' | 'done' | 'stopped' | 'error';

export function useStreamedText() {
  const [text, setText] = useState('');
  const [status, setStatus] = useState<StreamStatus>('idle');
  const controllerRef = useRef<AbortController | null>(null);

  const stop = useCallback(() => {
    controllerRef.current?.abort();
  }, []);

  const start = useCallback(async (url: string, init?: RequestInit) => {
    // 前の受信が続いていたら止める
    controllerRef.current?.abort();
    const controller = new AbortController();
    controllerRef.current = controller;
    const isCurrent = () => controllerRef.current === controller;

    setText('');
    setStatus('streaming');

    // 届いた文字はいったん pending にため、1フレームに1回だけ state に足す
    let pending = '';
    let frame = 0;
    const flush = () => {
      frame = 0;
      if (!isCurrent() || pending === '') return;
      const add = pending;
      pending = '';
      setText((prev) => prev + add);
    };

    try {
      const res = await fetch(url, { ...init, signal: controller.signal });
      if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
      // バイト列を文字列に直す。文字の途中で切れたバイトは、次のかたまりとつなげてから直す
      const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        pending += value;
        if (frame === 0) frame = requestAnimationFrame(flush);
      }
      cancelAnimationFrame(frame);
      flush();
      if (isCurrent()) setStatus('done');
    } catch {
      cancelAnimationFrame(frame);
      if (!isCurrent()) return; // 新しい受信に切り替わった後なので、何もしない
      flush(); // 止める前に届いていた分は残す
      setStatus(controller.signal.aborted ? 'stopped' : 'error');
    }
  }, []);

  // コンポーネントを外したら受信を止める
  useEffect(() => () => controllerRef.current?.abort(), []);

  return { text, status, start, stop };
}

setText には、前の文章に届いた分を足して返す関数(更新用の関数)を渡しています。React はこの関数を順に並べておき、次のレンダーで前の値に順番に当てはめます。そのため、短い間に何度足しても、届いた順に文章がつながります。

7バイトずつ30ミリ秒ごとに送ったときの表示です。押してから0.3秒後には21文字、さらに0.6秒後には69文字が表示され、最後は元の文章と同じ83文字になりました。

text
押す前:status=待機中、表示=""
途中:status=受信中…、表示の文字数=21、末尾="日でも、届いた文"
途中:status=受信中…、表示の文字数=69、末尾="も、途中で切れず"
途中:status=完了、表示の文字数=83、末尾="か確かめます。\n"
終わり:status=完了、元の文章と同じ=true、U+FFFD の数=0

1フレームに1回まとめて足すと、再描画が1,476回から111回に減った

細かいかたまりが短い間にたくさん届くと、かたまりごとに setText していては再描画がその回数近く起きます。そこで、フックでは届いた文字を pending という変数にため、requestAnimationFrame() で次に画面を描き直す前に1回だけ state に足しています。画面の描き直しより細かく state を変えても、見た目は変わらないからです。

かたまりごとに setText する版と比べました。再描画の回数として、React の <Profiler> の onRender が呼ばれた回数(更新のときだけ)を数えました。

送り方 かたまりごとに足す 1フレームに1回足す
3バイトずつ、1ミリ秒ごと(4,960バイト) 1,476回(read() は 1,524回) 111回(read() は 1,557回)
200バイトずつ、1ミリ秒ごと(24,800バイト) 79回(read() は 116回) 9回(read() は 116回)

read() の回数は、同じ条件でもう一度受け取って数えたものです。かたまりごとに足す版でも、再描画の回数は read() の回数より少なくなりました。それでも、3バイトずつの送り方では1,476回の再描画が起きました。1フレームに1回足す版では111回に減りました。届き終わるまでの時間はどちらも2秒弱で、表示された文章はどちらも正しいものでした。

届き終わったときと止めたときは、次のフレームを待たずに flush() を呼び、ためていた分を足してから状態を変えています。

受信中にもう一度押したら、前の受信を止める

チャットで返答の途中に次の質問を送ったときのように、受信中にもう一度 start が呼ばれることがあります。前の受信を止めずに放っておくと、2つの受信が同じ state に文字を足し続けます。

比べるために、前の受信を止めず、新しい受信に切り替わったかも確かめない版を作りました。どの回の文章かを見分けられるように、この版だけは文章の頭に「【1回目】」「【2回目】」を付けています。受信を始めてから0.5秒後にもう一度押すと、1回目と2回目の文章が混ざりました。正しければ「【2回目】」と元の文章を合わせた88文字になるところが、136文字になりました。

text
前の受信を止めない版:status=done、文字数=136(元の文章は 83 文字)、元の文章と同じ=false
  表示の先頭:"ている時間【2回が短目】く感猫のじられ手も借ますりた。絵いほ文字ど忙し🐈‍⬛い日や「でも漢字、届い"

useStreamedText では、start の最初で前の AbortController の abort() を呼び、前の fetch と本文の読み込みを止めます。止めた受信の read() は例外になりますが、catch の中の isCurrent() で「自分がいまの受信か」を確かめ、違えば何もしません。flush() の中でも同じ確認をしているので、前の受信でためた文字が、新しい受信の文章に足されることはありません。

同じ操作をしたところ、表示は2回目の文章だけ(83文字)になりました。サーバーの記録でも、1回目の接続はブラウザから閉じられていました。

text
useStreamedText:status=完了、文字数=83(元の文章は 83 文字)、元の文章と同じ=true
  サーバーの記録:[{"id":1,"tag":"","finished":false,"closedByClient":true},{"id":2,"tag":"","finished":true,"closedByClient":false}]

止めるボタンと、コンポーネントを外したとき

stop も、同じく abort() を呼ぶだけです。abort() で止めた fetch や本文の読み込みは、AbortError という例外になります(MDN の fetch() の例外の説明)。止める前に届いた分は消さずに残したいので、catch の中でも、ためていた分を flush() で足します。そのうえで controller.signal.aborted を見て、自分で止めたのか(stopped)、それ以外のエラーなのか(error)を分けています。

受信を始めて0.5秒後に止めると、それまでに届いた42文字が残り、1秒待っても増えませんでした。サーバーの記録では、248バイトのうち126バイトを送ったところで、ブラウザから接続が閉じられていました。

text
止めた直後:status=止めました、文字数=42、1秒後の文字数=42、止めるボタンの disabled=true
サーバーの記録:[{"id":1,"total":248,"sent":126,"finished":false,"closedByClient":true}]

受信中にコンポーネントを外した(アンマウントした)ときも、useEffect のクリーンアップで abort() を呼ぶので、サーバーの記録では、止めたときと同じく途中で接続が閉じられていました。サーバーが 404 を返したときは、res.ok が false になるので error になり、文章は空のままでした。

表示する側は、読んでいる位置に合わせて下へ送る

フックを使うコンポーネントです。文章が増えて枠からあふれたら、いちばん下が見えるように下へ送ります。ただし、読み手が上へスクロールして読み返しているときに下へ引き戻すと読めなくなるので、下端からの距離が 24px 未満のときだけ送ります。また、「受け取る」を押し直したときは、それまで上を読んでいても、下へ送る状態に戻します。

tsx
import { useLayoutEffect, useRef } from 'react';
import { useStreamedText } from './useStreamedText';

const labels = {
  idle: '待機中',
  streaming: '受信中…',
  done: '完了',
  stopped: '止めました',
  error: 'エラーになりました',
} as const;

export function StreamDemo({ src }: { src: string }) {
  const { text, status, start, stop } = useStreamedText();
  const boxRef = useRef<HTMLDivElement>(null);
  // 下端の近くを見ているときだけ、新しい文字に合わせて下へ送る
  const stickRef = useRef(true);

  useLayoutEffect(() => {
    const box = boxRef.current;
    if (box && stickRef.current) box.scrollTop = box.scrollHeight;
  }, [text]);

  return (
    <div>
      <button
        data-testid="start"
        onClick={() => {
          // 受け取り直すときは、読んでいた位置にかかわらず下へ送る状態に戻す
          stickRef.current = true;
          start(src);
        }}
      >
        受け取る
      </button>
      <button data-testid="stop" onClick={stop} disabled={status !== 'streaming'}>
        止める
      </button>
      <span data-testid="status">{labels[status]}</span>
      <div
        ref={boxRef}
        data-testid="box"
        onScroll={(e) => {
          const el = e.currentTarget;
          stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
        }}
        aria-busy={status === 'streaming'}
        style={{ height: 160, overflowY: 'auto', whiteSpace: 'pre-wrap', border: '1px solid #9aa5ab', padding: 8, marginTop: 8 }}
      >
        {text}
      </div>
    </div>
  );
}

下へ送るときは、useLayoutEffect の中で scrollTop を変えます。useLayoutEffect は、ブラウザが画面を描き直す前に動きます。下端の近くを見ているかどうかは、onScroll で毎回計算して ref に入れておきます。

高さ 160px の枠に、同じ文章を8回分流して確かめました。gap は、枠のいちばん下から、内容のいちばん下までの残りの高さ(px)です。受信中は gap が0のまま、内容が伸びるのに合わせて top(scrollTop)が増えました。いちばん上までスクロールすると、その後も文章は増えましたが、top は0のままでした。そのまま「受け取る」を押し直すと、また下へ送られました。

text
受信中:{"top":0,"gap":0,"height":176}
受信中:{"top":73,"gap":0,"height":249}
受信中:{"top":191,"gap":0,"height":367}
いちばん上までスクロールした直後:{"top":0,"gap":191,"height":367}
その後も受信中:status=受信中…、{"top":0,"gap":293,"height":469}
その後も受信中:status=完了、{"top":0,"gap":376,"height":552}
完了:{"top":0,"gap":376,"height":552}
上にスクロールしたまま受け取り直す:{"top":0,"gap":0,"height":176}
上にスクロールしたまま受け取り直す:{"top":73,"gap":0,"height":249}
上にスクロールしたまま受け取り直す:{"top":191,"gap":0,"height":367}

文章の枠には、受信中だけ aria-busy="true" を付けています。MDN の aria-busy の説明によると、この属性は、要素の中身がまだ変わっている途中であることを支援技術に伝え、変わり終わるまで待ってから伝えるようにするためのものです。

​Posted by 86