はじめに
現在、Firebase Genkitをバックエンドに採用し、React NativeでパーソナルAIエージェントアプリを開発しています。
AIチャットアプリにおいて、ChatGPTのように 「AIの回答が1文字ずつパラパラと表示されるUI(ストリーミング応答)」 は、ユーザーの体感待ち時間を減らし、UXを向上させるために必須の機能です。
しかし、React Native側の状態(State)でストリーミング表示を行いながら、同時にFirestoreのリアルタイムリスナー(onSnapshot)でチャット履歴を同期しようとすると、 「ストリーミング中の文字が一瞬消える(チラつく・フリッカー現象)」 という特有の課題に直面しました。
この記事では、Firebase GenkitとReact Nativeを使ったストリーミングUIの基本的な実装方法と、Firestoreの同期ラグによって生じるチラつきを解消する実践的なアプローチについてまとめます。
対象読者
- React Native (Expo) でAIチャットアプリを開発している方
- Firebase Genkitのストリーミング応答(
sendChunk)の実装方法を知りたい方 - Firestoreの
onSnapshotとローカルStateの競合によるUIのチラつきに悩んでいる方
アーキテクチャの概要
本アプリのチャット機能は、以下のような流れで処理されています。
- React Native (Frontend): ユーザーの入力を受け取り、SSE(Server-Sent Events)でバックエンドにリクエストを送信
-
Firebase Genkit (Backend): LLMからチャンク(文字の断片)を受け取るたびに、
sendChunkでフロントエンドへ逐次送信。最後にFirestoreへメッセージ全体を保存 -
Firestore: チャット履歴を永続化し、フロントエンドは
onSnapshotで常に最新の履歴をリッスン
バックエンド(Genkit)のストリーミング実装
まずはバックエンド側の実装です。
GenkitのFlowを定義し、generateStream を使ってLLMの応答をストリーミング処理します。
(抜粋)
import { z } from 'genkit';
import { ai, db } from '../core/bootstrap';
import { vertexAI } from '@genkit-ai/google-genai';
export const mainAgentFlow = ai.defineFlow({
name: 'mainAgentFlow',
inputSchema: z.object({
query: z.string(),
userId: z.string(),
threadId: z.string(),
}),
streamSchema: z.object({
content: z.string(),
}),
outputSchema: z.object({
response: z.string(),
}),
}, async ({ query, userId, threadId }, { sendChunk }) => {
// 1. LLMのストリーミング呼び出し
const llmResponseStream = await ai.generateStream({
prompt: `ユーザーからの質問: ${query}\n回答してください。`,
model: vertexAI.model('gemini-3-flash-preview'),
});
let accumulatedResponseText = "";
// 2. チャンクを受け取るたびにフロントエンドへ送信
for await (const chunk of llmResponseStream.stream) {
if (chunk.text) {
accumulatedResponseText += chunk.text;
sendChunk({ content: chunk.text }); // ここでSSE経由でフロントへ送られる
}
}
await llmResponseStream.response;
// 3. 最後にFirestoreへ確定したメッセージを保存(履歴用)
await saveHistoryToFirestore(threadId, query, accumulatedResponseText);
return { response: accumulatedResponseText };
});
ここでのポイントは、生成中のテキストは sendChunk を使って逐次フロントエンドに返しつつ、FirestoreへのDB保存は文章が完全に完成した後(最後)に行う という点です。
フロントエンドでのストリーミング受信と課題
React Native側では、react-native-sse などのライブラリを使用してこのストリームを受け取ります。
同時に、過去のチャット履歴を表示するために Firestore の onSnapshot をリッスンします。
発生した課題:UIのチラつき(フリッカー)
ストリーミング中は、フロントエンドのローカルState(画面表示用の配列)に文字をどんどん追加していきます。
しかし、バックエンドの処理が終わり、確定したメッセージがFirestoreに保存されると、onSnapshot が発火して最新のDB状態がフロントエンドに降ってきます。
この時、「画面上でストリーミング追加されて生成された最新のメッセージ配列」が、「DBから降ってきた一瞬古い(あるいは同期中の)スナップショット」で上書きされてしまい、AIの回答が画面から一瞬フッと消える という現象が発生しました。
解決策:データ件数による上書きガード
この問題を解決するためには、onSnapshot のコールバック内で 「DBから降ってきたデータが、現在画面に表示されているデータよりも古い(少ない)場合は、上書きを無視する」 というガード処理を入れるのが最もシンプルで確実です。
以下が、そのガード処理を組み込んだReact Native側のコンテキスト(Context)の実装例です。
(抜粋)
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { collection, onSnapshot, query, orderBy, Timestamp } from 'firebase/firestore';
import EventSource from "react-native-sse";
import { db } from '../../firebaseConfig';
export function ChatProvider({ children }) {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const currentThreadId = "sample-thread-id";
// 1. Firestoreのリアルタイム同期(チラつき対策済み)
useEffect(() => {
if (!currentThreadId) return;
const messagesRef = collection(db, 'threads', currentThreadId, 'messages');
const q = query(messagesRef, orderBy('timestamp', 'asc'));
const unsubscribe = onSnapshot(q, (snapshot) => {
// ストリーミング中(AIが考え中)はDBからの上書きを一旦無視する
if (isLoading) return;
const dbMessages = snapshot.docs.map(doc => ({
id: doc.id,
content: doc.data().content,
sender: doc.data().role,
}));
if (dbMessages.length > 0) {
setMessages(prev => {
// フリッカー対策:Firestoreの同期ラグをブロック
// 画面に表示されている数より、DBから来た数が少ない場合は、
// 「AIの回答がDBに保存される前の、古いスナップショット」を掴まされている状態。
// ここで上書きするとAIの回答が一瞬消えるため、DBが追いつくまで無視する。
if (dbMessages.length < prev.length) {
return prev;
}
// 数が同じ、または増えた場合のみ、DBの確定データで上書きする
return dbMessages;
});
}
});
return () => unsubscribe();
}, [currentThreadId, isLoading]);
// 2. メッセージ送信とSSEによるストリーミング受信
const sendMessage = useCallback(async (inputText: string) => {
if (!inputText.trim() || isLoading) return;
// 一時的なローカルIDを発行して、ユーザーとAIのプレースホルダーを画面に追加(楽観的UI更新)
const tempUserMsgId = `temp-user-${Date.now()}`;
const tempAiMsgId = `temp-ai-${Date.now()}`;
setMessages(prev => [
...prev,
{ id: tempUserMsgId, content: inputText, sender: 'user' },
{ id: tempAiMsgId, content: '', sender: 'ai' } // AIの回答枠
]);
setIsLoading(true);
try {
// EventSourceを使ってGenkitのFlowを呼び出し
const es = new EventSource('https://your-genkit-flow-url', {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: { query: inputText, threadId: currentThreadId } }),
});
es.addEventListener("message", (event) => {
if (!event.data) return;
const chunk = JSON.parse(event.data);
const contentChunk = chunk.message?.content;
// 終了シグナル
if (chunk.result) {
setIsLoading(false);
es.close();
return;
}
// チャンクを受信したら、AIのプレースホルダーの文字を更新していく
if (contentChunk) {
setMessages(prev => prev.map(msg =>
msg.id === tempAiMsgId
? { ...msg, content: msg.content + contentChunk }
: msg
));
}
});
} catch (error) {
console.error("Streaming error:", error);
setIsLoading(false);
}
}, [isLoading, currentThreadId]);
return (
<ChatContext.Provider value={{ messages, sendMessage, isLoading }}>
{children}
</ChatContext.Provider>
);
}
おわりに
Firebase Genkitを使えば、バックエンドのストリーミング処理自体は sendChunk を呼び出すだけで非常に簡単に実装できます。
しかし、それをReact Nativeのようなフロントエンドと繋ぎこみ、Firestoreと連携させる際には「ローカルの楽観的更新」と「DBからの同期」のタイミングのズレ(競合)を意識する必要があります。
今回のように、ローカル配列の長さ(件数)を監視して古いスナップショットを弾く というシンプルなガード処理を入れるだけで、ユーザー体験を損なわない滑らかなタイピングUIを実現することができました。