🐡

Laravel公式 useStream(@laravel/stream-react)完全解説― AIチャット風UIを最短で実装するための

に公開
2025/12/14

Laravel useStream(@laravel/stream-react)とは

useStreamとはlaravel公式の「フロントエンド」ストリーミングライブラリーであり、現在では

の2つが用意されている。いずれを選んでもuseStreamという名前のフックを使う事になる。ここではReact版で解説する。


このようなタイピング風(AI)チャットサポートなどを作る要件があるなら採用一択

以降、概要を簡単に整理する

登場時期と公式な発表経路

Laravel の useStream(React / Vue 向け)は、2025年5月前後に初期リリース(v0.1.0)された、かなり新しい機能である。その後 Laracon US 2025 の総括ブログや公式資料の中で、useStream はLaravel 12世代の新しいリアルタイム体験を支える要素として明確に言及されるようになる。公式デモアプリ Larachat(ChatGPT風UI)を公開し動画まで作っている。

https://www.youtube.com/watch?v=BuUbTRHuvAw

利用状況・コミュニティでの広がり

数値的な現状(2025年末時点)

  • GitHub Star:約90前後
  • npm DL:週1000前後

実運用事例:限定的(主にデモ・AI系・実験的プロジェクト)

技術的な便利さ・従来手法との比較

何が「楽」なのか

従来、LaravelでストリーミングUIを作る場合:

  • fetch + ReadableStream
  • EventSource(生JS)
  • WebSocket + Echo
  • ポーリング

などを自前で組み合わせる必要があったが、これを簡素化してコード量を一気に減らせる。

  • SSE を前提にした 完成されたHook
  • 状態管理(isStreaming / data)が最初から揃っている
  • send() による疑似双方向フロー
  • ストリームID共有による複数コンポーネント連携

以上を踏まえて、これ以降、実際の実装手順を見てみよう。

事前準備

Laravel12 Starter Kitを展開した後、まずは外枠を作っていく。

とりあえずここではStreamingDemoControllerを作る。

まずroute定義

+    // useStreamデモ用ルート
+    Route::get('stream-demo', [\App\Http\Controllers\StreamDemoController::class, 'index'])->name('stream.demo');
+    Route::post('stream-demo', [\App\Http\Controllers\StreamDemoController::class, 'stream'])->name('stream.demo.send');

これ一応認証済みを想定(レイアウトの関係上)

コントローラー

app/Http/Controllers/StreamDemoController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Inertia;

class StreamDemoController extends Controller
{
    /**
     * デモページを表示
     */
    public function index()
    {
        return Inertia::render('stream-demo');
    }

    /**
     * ストリーミングレスポンスを返す
     */
    public function stream(Request $request)
    {
        // TODO: 実装予定
    }
}

view

resources/js/pages/stream-demo.tsx
import AppLayout from '@/layouts/app-layout';
import { type BreadcrumbItem } from '@/types';
import { Head } from '@inertiajs/react';
import stream from '@/routes/stream';

const breadcrumbs: BreadcrumbItem[] = [
    {
        title: 'Stream Demo',
        href: stream.demo.url(),
    },
];

export default function StreamDemo() {
    return (
        <AppLayout breadcrumbs={breadcrumbs}>
            <Head title="useStream Demo" />
            <div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
                <h1 className="text-2xl font-bold">useStream Demo</h1>
            </div>
        </AppLayout>
    );
}


まだ何ひとつ起きてはいない

ライブラリーのインストールと準備

今回はReactを採用するので

npm install @laravel/stream-react

importする

resources/js/pages/stream-demo.tsx
@@ -1,6 +1,7 @@
 import AppLayout from '@/layouts/app-layout';
 import { type BreadcrumbItem } from '@/types';
 import { Head } from '@inertiajs/react';
+import { useStream } from "@laravel/stream-react";

 const breadcrumbs: BreadcrumbItem[] = [
     {
@@ -10,6 +11,9 @@ const breadcrumbs: BreadcrumbItem[] = [
 ];

 export default function StreamDemo() {
+
+    const { data, isFetching, isStreaming, send } = useStream("chat");
+
     return (
         <AppLayout breadcrumbs={breadcrumbs}>
             <Head title="useStream Demo" />

ここで

  • data
  • isFetching
  • isStreaming
  • send

の4つが利用できるようになった(全部使う必要はないが)

デモ

要件
ここではフォームに送った文字列をそのままストリームして返すというデモを作成する

送信フォーム

resources/js/pages/stream-demo.tsx
import AppLayout from '@/layouts/app-layout';
import { type BreadcrumbItem } from '@/types';
import { Head } from '@inertiajs/react';
import { useStream } from "@laravel/stream-react";
import { useState } from 'react';
import stream from '@/routes/stream';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

const breadcrumbs: BreadcrumbItem[] = [
    {
        title: 'Stream Demo',
        href: stream.demo.url(),
    },
];

export default function StreamDemo() {
    const [message, setMessage] = useState('');
    const { data, isFetching, isStreaming, send } = useStream(stream.demo.send.url());

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        if (message.trim()) {
            send({ message });
        }
    };

    return (
        <AppLayout breadcrumbs={breadcrumbs}>
            <Head title="useStream Demo" />
            <div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
                <h1 className="text-2xl font-bold">useStream Demo</h1>

                <div className="max-w-2xl space-y-4">
                    {data && (
                        <div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
                            <h2 className="text-lg font-semibold mb-2">ストリーミング結果:</h2>
                            <div className="whitespace-pre-wrap">
                                {data}
                                {isStreaming && (
                                    <span className="ml-1 inline-block h-4 w-1 animate-pulse bg-current" />
                                )}
                            </div>
                        </div>
                    )}

                    {isFetching && (
                        <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
                            接続中...
                        </div>
                    )}

                    <form onSubmit={handleSubmit} className="space-y-4">
                        <div className="space-y-2">
                            <Label htmlFor="message">
                                メッセージを入力
                            </Label>
                            <Input
                                type="text"
                                id="message"
                                value={message}
                                onChange={(e) => setMessage(e.target.value)}
                                placeholder="ここに入力したテキストがストリーミングで返されます..."
                                disabled={isStreaming}
                            />
                        </div>

                        <Button
                            type="submit"
                            disabled={!message.trim() || isStreaming}
                        >
                            {isStreaming ? '送信中...' : '送信'}
                        </Button>
                    </form>
                </div>
            </div>
        </AppLayout>
    );
}

isStreamingでの送信中/送信 分岐でボタンの状態あるいはフォームの許可不許可を制御している。

さらにisFetchingで取得に時間がかかっている時の表示を制御している。

ここまではビジュアル的なものであるが

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        if (message.trim()) {
            send({ message });
        }
    };

ここが送信部分であり、フォームの内容(message)を「そのまま」send()を使って送信している。このsendも先に見たように重要なuseStreamの機能である。

受信部分

{data && (
    <div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
        <h2 className="text-lg font-semibold mb-2">ストリーミング結果:</h2>
        <div className="whitespace-pre-wrap">
            {data}
            {isStreaming && (
                <span className="ml-1 inline-block h-4 w-1 animate-pulse bg-current" />
            )}
        </div>
    </div>
)}

このようにdataの変化に基いて表示可能になっている、なお

{isStreaming && (
    <span className="ml-1 inline-block h-4 w-1 animate-pulse bg-current" />
)}

これは「カーソル」的なビジュアルをTailwind CSSで提供するだけのものだ。

バックエンド

class StreamDemoController extends Controller
{
    /**
     * デモページを表示
     */
    public function index()
    {
        return Inertia::render('stream-demo');
    }

    /**
     * ストリーミングレスポンスを返す
     */
    public function stream(Request $request)
    {
        $validated = $request->validate([
            'message' => 'required|string|max:5000',
        ]);

        $message = $validated['message'];

        // メッセージを文字単位で分割
        $chunks = mb_str_split($message, 1);

        // useStream用のシンプルなテキストストリーム
        return response()->stream(function () use ($chunks) {
            // 接続中の演出(1.5秒待機)
            usleep(1500000);

            foreach ($chunks as $chunk) {
                echo $chunk;

                if (ob_get_level() > 0) {
                    ob_flush();
                }
                flush();

                // タイピング効果のための遅延(30ms)
                usleep(30000);
            }
        }, 200, [
            'Content-Type' => 'text/event-stream',
            'Cache-Control' => 'no-cache',
            'X-Accel-Buffering' => 'no',
        ]);
    }
}

ここでは勿論stream()アクションに注目していただきたい。

Inertia.jsではSSEを処理しようとすると無効なミドルウェアを挟んでくるが、send()ではX-Inertiaヘッダーを送信しないため、これは通常のリクエストとして処理され、Laravelのstreamed responseにより処理される

ここでの$chunkには「メッセージのみ」のstring含めており追加情報は含められない。

もし追加の情報が欲しいならば、一旦まずデーターベースに保存しておいて復元時に戻すという実装が考えられるが、少なくともストリームボタンを押した時点のたとえば時間情報を与えたいなら欲しいのであればフロントエンド側で「一旦」付けておくという事になるだろうか

再度アクセスした場合は履歴をDBから書き戻すとかで対応するのがよいだろう。

CSRFに関して

まずはデモなので、動作確認を第一とするため、以下のようにとりあえず無効にする

bootstrap/app.php
+         $middleware->validateCsrfTokens(except: [
+            'stream-demo',
+         ]);

デモレベルはこれで完成

後は必要に応じてDBに保存したり、実際にAIへのAPI送受信などすれば即座に利用できる。結局send()で必要な情報を送信しdataで受けるという、非常にシンプルな作りだ。

完成イメージはトップに置いたものと同じだけど一応再掲しときます


完成

CSRFについて

今、CSRFを無効にしているが、これを有効にし、適切に処理する。useStreamにはcsrfに関する処理オプションがあるものの

type StreamOptions = {
    id?: string;
    initialInput?: Record<string, any>;
    headers?: Record<string, string>;
    csrfToken?: string; // <----------------- これ!
    json?: boolean;
    credentials?: RequestCredentials;
    onResponse?: (response: Response) => void;
    onData?: (data: string) => void;
    onCancel?: () => void;
    onFinish?: () => void;
    onError?: (error: Error) => void;
    onBeforeSend?: (request: RequestInit) => boolean | RequestInit | void;
};

受け渡しはどうにかして考えないといけない。ここではHandleInertiaRequestsで共有プロップスとして送信した。

app/Http/Middleware/HandleInertiaRequests.php
@@ -46,6 +46,7 @@ public function share(Request $request): array
                 'user' => $request->user(),
             ],
             'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
+            'csrf_token' => csrf_token(),
         ];
     }
 }

そうしたらviewの中で

+ const { csrf_token } = usePage<{ csrf_token: string }>().props;

で取得する。取得できたら

+     const { data, isFetching, isStreaming, send } = useStream(stream.demo.send  .url(), {
+         csrfToken: csrf_token,
+     });

でtokenをくっつければok

Discussion