1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React Native × Genkit】AIチャットのストリーミングUI実装とFirestore同期のチラつき対策

1
Last updated at Posted at 2026-03-19

はじめに

現在、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のチラつきに悩んでいる方

アーキテクチャの概要

本アプリのチャット機能は、以下のような流れで処理されています。

  1. React Native (Frontend): ユーザーの入力を受け取り、SSE(Server-Sent Events)でバックエンドにリクエストを送信
  2. Firebase Genkit (Backend): LLMからチャンク(文字の断片)を受け取るたびに、sendChunk でフロントエンドへ逐次送信。最後にFirestoreへメッセージ全体を保存
  3. Firestore: チャット履歴を永続化し、フロントエンドは onSnapshot で常に最新の履歴をリッスン

バックエンド(Genkit)のストリーミング実装

まずはバックエンド側の実装です。
GenkitのFlowを定義し、generateStream を使ってLLMの応答をストリーミング処理します。

mainAgent.ts
(抜粋)
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)の実装例です。

ChatContext.tsx
(抜粋)
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を実現することができました。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?