Reactで、届いた文章を少しずつ表示する方法
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 などの形式の読み分けは試していません。

バイトのかたまりのまま文字に直すと、日本語が化ける
response.body から読めるのは、文字ではなくバイトのかたまりです。かなや、ふだん使う漢字は UTF-8 で1文字3バイトなので、かたまりの境目が文字の途中に来ることがあります。
検証用のサーバーから、248バイトの文章(JavaScript で [...文字列].length として数えると83文字)を7バイトずつ送りました。read() は36回で、そのうち24回は、かたまりの最後が文字の途中で終わっていました。これを3つの方法で文字列に直した結果です。
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 を返します。
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文字になりました。
押す前: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文字になりました。
前の受信を止めない版:status=done、文字数=136(元の文章は 83 文字)、元の文章と同じ=false
表示の先頭:"ている時間【2回が短目】く感猫のじられ手も借ますりた。絵いほ文字ど忙し🐈⬛い日や「でも漢字、届い"
useStreamedText では、start の最初で前の AbortController の abort() を呼び、前の fetch と本文の読み込みを止めます。止めた受信の read() は例外になりますが、catch の中の isCurrent() で「自分がいまの受信か」を確かめ、違えば何もしません。flush() の中でも同じ確認をしているので、前の受信でためた文字が、新しい受信の文章に足されることはありません。
同じ操作をしたところ、表示は2回目の文章だけ(83文字)になりました。サーバーの記録でも、1回目の接続はブラウザから閉じられていました。
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バイトを送ったところで、ブラウザから接続が閉じられていました。
止めた直後:status=止めました、文字数=42、1秒後の文字数=42、止めるボタンの disabled=true
サーバーの記録:[{"id":1,"total":248,"sent":126,"finished":false,"closedByClient":true}]
受信中にコンポーネントを外した(アンマウントした)ときも、useEffect のクリーンアップで abort() を呼ぶので、サーバーの記録では、止めたときと同じく途中で接続が閉じられていました。サーバーが 404 を返したときは、res.ok が false になるので error になり、文章は空のままでした。
表示する側は、読んでいる位置に合わせて下へ送る
フックを使うコンポーネントです。文章が増えて枠からあふれたら、いちばん下が見えるように下へ送ります。ただし、読み手が上へスクロールして読み返しているときに下へ引き戻すと読めなくなるので、下端からの距離が 24px 未満のときだけ送ります。また、「受け取る」を押し直したときは、それまで上を読んでいても、下へ送る状態に戻します。
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のままでした。そのまま「受け取る」を押し直すと、また下へ送られました。
受信中:{"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 の説明によると、この属性は、要素の中身がまだ変わっている途中であることを支援技術に伝え、変わり終わるまで待ってから伝えるようにするためのものです。





