AIアプリでUI、セッション、データ状態がフロントエンド/バックエンド間でどのように流れるかを学び、同期、永続化、キャッシュ、セキュリティに関する実践的パターンを解説します。

「状態」とは、アプリが次の瞬間に正しく動作するために覚えておくべきすべてです。
ユーザーがチャット画面で送信を押したとき、アプリは入力した文、アシスタントが既に返した内容、リクエストが実行中かどうか、設定(トーン、モデル、ツール)が有効かどうかを忘れてはいけません。これらすべてが状態です。
状態を考える便利な視点は:アプリの現在の事実—ユーザーに見せる内容やシステムが次に何をするかに影響する値、です。フォーム入力のような明白なものに加えて、以下のような「目に見えない」事実も含まれます:
従来のアプリはデータを読み表示し更新を保存することが多いですが、AIアプリは追加のステップや中間出力を伴います:
この余計な動きが、AIアプリで状態管理が隠れた複雑さになりやすい理由です。
以下では状態を実用的なカテゴリ(UI 状態、セッション状態、永続データ、モデル/ランタイム状態)に分け、それぞれがどこに置くべきか(フロントエンド vs バックエンド)を示します。さらに同期、キャッシュ、長時間ジョブ、ストリーミング更新、セキュリティについても扱います—状態は正しく保護されて初めて役に立ちます。
ユーザーが「先月の請求書を要約して、異常な点をマークして」と尋ねるチャットアプリを想像してください。バックエンドは (1) 請求書を取得し、(2) 分析ツールを実行し、(3) 要約を UI にストリーミングし、(4) 最終レポートを保存するかもしれません。
これがシームレスに感じられるためには、メッセージ、ツール結果、進捗、保存された出力を追跡しつつ、会話を混同したりユーザー間でデータを漏らしたりしてはなりません。
人々が AI アプリで「状態」と言うとき、非常に異なるものを混同することが多いです。状態を UI、セッション、データ、モデル/ランタイムの4つに分けると、どこに置くべきか、誰が変更できるか、どう保存すべきかを判断しやすくなります。
UI 状態はブラウザやモバイルアプリのライブな瞬間的状態:テキスト入力、トグル、選択項目、どのタブが開いているか、ボタンが無効かどうか、などです。
AI アプリは UI 特有のいくつかの詳細を追加します:
UI 状態はリセットしやすく、失っても安全であるべきです。ページをリフレッシュすると失われることがあり、通常それで問題ありません。
セッション状態はユーザーを継続的なやり取りに結びつけます:ユーザー識別、会話 ID、メッセージ履歴の一貫したビューなど。
AI アプリではこれに以下が含まれることが多いです:
この層はフロントエンドとバックエンドの両方にまたがることが多いです:フロントエンドは軽量な識別子を保持し、バックエンドがセッションの継続性とアクセス制御の権威になります。
データ状態は意図的にデータベースに保存するものです:プロジェクト、ドキュメント、埋め込み、設定、監査ログ、課金イベント、保存された会話記録など。
UI やセッション状態とは異なり、データ状態は:
べきです。
モデル/ランタイム状態は回答を生成するために使われる操作設定です:システムプロンプト、許可されたツール、temperature/max tokens、安全設定、レート制限、一時キャッシュなど。
その一部は設定(安定したデフォルト)で、一部はエフェメラル(短命キャッシュやリクエストごとのトークン予算)です。多くはバックエンドに置くのが妥当で、一貫して制御され不必要に公開されないようにします。
これらの層が混ざると、典型的な失敗が起きます:UI に保存されていないテキストが表示される、バックエンドがフロントエンドの期待と異なるプロンプト設定を使う、会話メモリがユーザー間で「漏れる」など。明確な境界は真実の所在を明確にし、何を永続化すべきか、何を再計算できるか、何を保護すべきかを分かりやすくします。
AI アプリでバグを減らす信頼できる方法は、状態の各要素について、それがブラウザ(フロントエンド)にあるべきか、サーバー(バックエンド)にあるべきか、あるいは両方かを決めることです。この選択は信頼性、セキュリティ、ユーザーがリフレッシュや新しいタブを開いたときの「驚き」に影響します。
フロントエンド状態は素早く変わり、リフレッシュをまたいで残す必要のないものに最適です。ローカルに置くことで UI は応答性が高くなり、不要な API 呼び出しを避けられます。
フロントエンド専用の一般例:
リフレッシュで失っても許容されることが多いです。
バックエンドには信頼されるべきもの、監査されるべきもの、強制されるべきものを置くべきです。他のデバイスやタブが見る必要があるもの、あるいはクライアントを改変されても正しく保たれるべきものが該当します。
バックエンドのみの一般例:
間違った状態が金銭的損失やデータ漏洩、アクセス制御の破綻につながるなら、それはバックエンドに置くべき、という心構えが有効です。
共有される状態もあります:
共有される場合でも「真実の所在(source of truth)」を選びましょう。通常、バックエンドが権威で、フロントエンドは速度のためにキャッシュを持ちます。
状態を必要な場所に近づけつつ、リフレッシュやデバイス変更、中断をまたいで残すべきものは永続化しましょう。
クライアントだけに機密や権威的な状態を置くアンチパターン(例:クライアント側の isAdmin フラグやプラン階層、ジョブ完了状態を真実として扱う)は避けてください。UI はそれらを表示してよいですが、検証はバックエンドが行うべきです。
AI 機能は「1つの操作」のように感じられますが、実際はブラウザとサーバーにまたがる一連の状態遷移です。ライフサイクルを理解すると、UI の不一致、コンテキストの欠落、重複請求を避けやすくなります。
ユーザーが 送信 をクリックします。UI はローカル状態を即座に更新します:「保留中」のメッセージバブルを追加したり、送信ボタンを無効化したり、現在の入力(テキスト、添付、選択されたツール)をキャプチャしたりします。
この時点でフロントエンドは相関識別子を生成または添付すべきです:
これらの ID により、応答が遅れたり二重に届いたりしても双方が同じイベントについて話せます。
フロントエンドはユーザーメッセージと ID を含む API リクエストを送ります。サーバーは権限、レート制限、ペイロード形式を検証し、ユーザーメッセージを(少なくとも不変のログレコードとして)conversation_id と message_id をキーに永続化します。
この永続化により、リクエスト中にページをリフレッシュしても「幻の履歴」が生じるのを防げます。
モデルを呼ぶためにサーバーは信頼できるソースからコンテキストを再構築します:
conversation_id の最近のメッセージを取得重要な考え方:クライアントに完全な履歴を頼らないこと。クライアントは古くなり得ます。
サーバーはモデル呼び出しの前や途中でツール(検索、DB 参照)を呼ぶかもしれません。各ツール呼び出しは request_id に紐づけて追跡し、監査や安全なリトライができるようにします。
ストリーミングではサーバーが部分的なトークン/イベントを送ります。UI は保留中のアシスタントメッセージを逐次更新しますが、最終イベントで完了とマークされるまで「進行中」と扱います。
リトライ、二重送信、順序違いの応答は起きます。サーバー側での重複排除には request_id を使い、UI 側では message_id で整合させて(アクティブなリクエストに合わない遅延チャンクを無視するなど)扱ってください。常に「失敗」状態をわかりやすく表示して、安全なリトライで重複メッセージを作らないようにします。
セッションはユーザーのアクションをつなぐスレッドです:どのワークスペースにいるか、最後に検索したもの、編集中の下書き、AI の返信をどの会話に続けるかなど。良いセッション状態はページ間でアプリを連続的に感じさせ、理想的にはデバイス横断でも自然に動作しますが、バックエンドをすべての発言のゴミ箱にしてはいけません。
目指すのは:(1) 継続性(ユーザーが離れて戻れる)、(2) 正確性(AI が正しい会話コンテキストを使う)、(3) 分離(セッション同士が漏れない)です。複数デバイスをサポートするなら、セッションをユーザースコープ+デバイススコープとして扱いましょう:「同じアカウント」=「同じ開いている作業」ではない場合があります。
セッション識別には通常どれかを選びます:
HttpOnly、Secure、SameSite などのセキュアフラグを設定し、CSRF に対処する必要がある。「メモリ」はモデルに送り返す状態を指します。
実用的なパターンは要約+ウィンドウです:予測可能で、モデルの驚きの挙動を抑えます。
AI がツール(検索、DB クエリ、ファイル読み取り)を使うなら、各呼び出しを入力、タイムスタンプ、ツールバージョン、戻り値(またはその参照)と共に保存しましょう。これにより「なぜ AI がそう言ったか」を説明でき、デバッグのために実行を再生でき、ツールやデータセットが変わったときに結果が変わった原因を検出できます。
長期的メモリをデフォルトで保存しないでください。継続性に必要なもの(会話 ID、要約、ツールログ)のみを保持し、保持期間を設定し、製品上の明確な理由とユーザー同意がない限り生のユーザーテキストを永続化しないでください。
同じ「もの」が複数箇所で編集可能になると状態が危険になります—UI、別タブ、バックグラウンドジョブなど。解決策は巧妙なコードというよりは所有権を明確にすることです。
各状態項目についてどのシステムが権威かを決めてください。多くの AI アプリでは会話設定、ツール権限、メッセージ履歴、課金限度、ジョブ状態などはバックエンドが正規のレコードを持つべきです。フロントエンドは速度のためにキャッシュと派生状態を持てますが、矛盾があればバックエンドが正しいと仮定すべきです。
実用的なルール:リフレッシュで失うと困るなら、それはバックエンドにある可能性が高い。
楽観的更新はアプリを即時に感じさせます:設定を切り替えたら UI を即更新し、その後サーバーで確認する。これは低リスクで可逆な操作(例:会話をスターする)には有効です。
ただし、サーバー側が拒否したり変更を加える可能性がある場合(権限チェック、クオータ、バリデーション、サーバー側デフォルト)、混乱を招きます。その場合は「保存中…」を表示してサーバー確認後に UI を更新してください。
競合は、異なる開始バージョンに基づいて二つのクライアントが同じレコードを更新するときに起きます。例:タブ A とタブ B が両方モデルの temperature を変更する。
軽量なバージョニングを使い、バックエンドが古い書き込みを検出できるようにします:
updated_at タイムスタンプ(単純で人間がデバッグしやすい)If-Match ヘッダ(HTTP ネイティブ)バージョンが一致しない場合は競合レスポンス(多くは HTTP 409)を返し、最新のサーバーオブジェクトを返しましょう。
書き込みの後は、保存されたオブジェクト(サーバー生成のデフォルト、正規化フィールド、新しいバージョンを含む)を API が返すと良いです。これによりフロントエンドはキャッシュをすぐ置き換えられ、何が変わったかを推測する必要がなくなります。
キャッシュは AI アプリを高速にする最短の方法の一つですが、状態の二重コピーも生みます。間違ったものを、あるいは間違った場所でキャッシュすると、高速だが混乱する UI を出荷してしまいます。
クライアント側キャッシュは体験を重視すべきで、権威ではありません。適切な候補は最近の会話プレビュー(タイトル、最後のメッセージの抜粋)、UI 設定(テーマ、選択モデル、サイドバー状態)、楽観的 UI 状態(「送信中」のメッセージ)などです。
クライアントキャッシュは小さく破棄可能に保ち、消えてもサーバーから再取得すればアプリが動くようにしてください。
サーバーキャッシュは高コストまたは頻繁に繰り返される作業に焦点を当てるべきです:
トークン数、モデレーション判定、ドキュメント解析結果など、決定論的で高コストな派生状態もここでキャッシュできます。
実用的なルールは三つ:
user_id, model, tool パラメータ, document バージョン)。キャッシュエントリがいつ不正確になるか説明できないなら、キャッシュしてはいけません。
API キー、認証トークン、生のプロンプトなど機密情報を CDN 等の共有レイヤーに入れないでください。ユーザーデータをキャッシュする必要がある場合はユーザー単位で分離し、保存時に暗号化するか、主要なデータベースに保持してください。
キャッシュは仮定ではなく実証すべきです。p95 レイテンシ、キャッシュヒット率、そしてユーザーに見えるエラー(例:「レンダリング後にメッセージが更新された」)を追跡してください。高速だが後で UI と矛盾する応答は、少し遅くても一貫した応答より悪い場合があります。
一部の AI 機能は数秒で終わりますが、PDF のアップロードと解析、ナレッジベースの埋め込みとインデックス作成、多段階のツールワークフローは数分かかることがあります。これらでは「状態」は画面上だけでなく、リフレッシュ、リトライ、時間をまたいで生き残るものです。
実際にプロダクト価値を解放するものだけを永続化してください。
会話履歴:メッセージ、タイムスタンプ、ユーザー識別、どのモデル/ツールが使われたかは明白な保存対象です。再開、監査、サポートに役立ちます。
ユーザー/ワークスペース設定:好みのモデル、temperature のデフォルト、機能トグル、システムプロンプト、デバイス間で追従すべき UI 設定はデータベースに置きます。
ファイルと成果物:アップロード、抽出テキスト、生成レポートは通常オブジェクトストレージに置き、データベースにメタデータ(所有者、サイズ、コンテンツタイプ、処理状態)を保持します。
リクエストが通常の HTTP タイムアウト内に確実に終わらない場合は作業をキューに移してください。
典型パターン:
POST /jobs のような API を呼び、入力(ファイル ID、conversation ID、パラメータ)を渡す。job_id を返す。これにより UI は応答性を保ち、リトライが安全になります。
ジョブ状態は明示的にし問い合わせ可能にしてください:queued → running → succeeded/failed(オプションで canceled)。これらの遷移をサーバー側にタイムスタンプやエラー詳細と共に保存します。
フロントエンドでの反映例:
GET /jobs/{id}(ポーリング)や SSE/WebSocket のようなストリーム更新を提供し、UI が推測しないようにします。
ネットワークタイムアウトは起きます。フロントエンドが POST /jobs をリトライするとき、同じジョブが二重に作られるのは避けたいです。
論理的アクションごとに Idempotency-Key を要求してください。バックエンドはキーを結果と共に保存し、同じキーの繰り返し要求には同じ結果を返します。
長時間稼働する AI アプリは急速にデータを蓄積します。早めに保持ルールを定義してください:
クリーンアップは状態管理の一部です:リスク、コスト、混乱を減らします。
ストリーミングでは「答え」は単一の塊ではなくなります。部分トークン(単語ごとに到着するテキスト)や部分的なツール作業があることを扱う必要があります。これにより UI とバックエンドで何が一時的で何が最終なのか合意しておく必要があります。
クリーンなパターンは、型とペイロードを持つ小さなイベントのシーケンスをストリームすることです。例:
token:増分テキスト(または小さなチャンク)tool_start:ツール呼び出しが始まった(例:「検索中…」、id 付き)tool_result:ツール出力が準備完了(同じ id)done:アシスタントメッセージが完了error:何かが失敗した(ユーザー向けメッセージとデバッグ id を含む)このイベントストリームは、生テキストストリーミングよりバージョン管理やデバッグが容易で、フロントエンドはツール状況を推測せずに正確に表示できます。
クライアントではストリーミングを追記専用として扱います:「下書き」のアシスタントメッセージを作り、token イベントが届くたびに拡張します。done を受け取ったらコミットを行い:メッセージを最終としてマークし、(ローカルに保存するなら)永続化して、コピーや評価、再生成などの操作を解放します。
これによりストリーム中に履歴を書き換えるようなことを避け、UI を予測可能に保てます。
ストリーミングは半端な作業の発生確率を高めます:
ページがストリーム中にリロードされた場合は、最後にコミットされたメッセージと保存された下書きメタデータ(message id、これまでの蓄積テキスト、ツール状況)から再構築してください。ストリームを再開できない場合は下書きを中断表示にしてユーザーにリトライさせる方が、完了したように見せかけるより良いです。
状態は単なる「保存するデータ」ではなく、ユーザーのプロンプト、アップロード、設定、生成物、それらを結びつけるメタデータです。AI アプリではその状態が通常より機密性が高い(個人情報、専有ドキュメント、内部判断)ことが多く、各層でセキュリティを設計する必要があります。
クライアントがアプリを偽装できるようなものはサーバー専用にしてください:API キー、プライベートコネクタ(Slack/Drive/DB の資格情報)、内部システムプロンプトやルーティングロジックなど。フロントエンドは「このファイルを要約して」と要求できますが、どの資格情報でどう実行するかはバックエンドが決めるべきです。
各状態変異を特権操作として扱ってください。クライアントがメッセージを作る、会話名を変更する、ファイルを添付する際、バックエンドは検証すべきです:
これにより conversation_id を推測して別のユーザーの履歴にアクセスする攻撃を防げます。
クライアント提供の状態はすべて信頼できない入力として扱ってください。スキーマや制約(型、長さ、許可される列挙)を検証し、保存先(SQL/NoSQL、ログ、HTML レンダリング)に応じてサニタイズしてください。状態更新(設定やツールパラメータ)を受け付ける際は、任意の JSON をマージするのではなくホワイトリストで許可フィールドを限定してください。
恒久的な状態を変える操作(共有、エクスポート、削除、コネクタアクセス)については、誰がいつ何をしたかを記録してください。軽量な監査ログはインシデント対応、サポート、コンプライアンスに役立ちます。
機能提供に必要なものだけを保存してください。プロンプト全文を永続化する必要がないなら、保持期間や抹消を検討しましょう。機密データ(トークン、コネクタ資格情報、アップロードされたドキュメント)は保存時に暗号化し、転送は TLS を使ってください。運用メタデータとコンテンツを分離して、アクセス制御をより厳密にできるようにすると良いです。
実用的なデフォルトはシンプルです:バックエンドが真実の所在、フロントエンドは速い楽観キャッシュ。UI は即時に感じられますが、失ったら困るもの(メッセージ、ジョブ状態、ツール出力、課金に関わるイベント)はサーバー側で確認・保存してください。
「vibe-coding」ワークフロー(プロダクト表面が迅速に生成されるような状況)では、状態モデルの重要性はさらに高まります。Koder.ai のようなプラットフォームはチャットからウェブ/バックエンド/モバイルアプリを迅速に出せますが、同じルールが当てはまります:真実の所在、ID、ステータス遷移を先に設計するほど安全に素早く開発できます。
フロントエンド(ブラウザ/モバイル)
session_id, conversation_id, 新しい request_id。バックエンド(API + ワーカー)
注:一貫性を保つ実用的な方法はバックエンドスタックを早期に標準化することです。例として Koder.ai が生成するバックエンドは Go と PostgreSQL、フロントエンドは React を使うことが多く、権威ある状態を SQL に集中させつつクライアントキャッシュを破棄可能に保つのが容易になります。
画面を作る前に、各層で依存するフィールドを定義してください:
user_id, org_id, conversation_id, message_id, request_id。created_at, updated_at、メッセージの明示的 sequence。queued | running | streaming | succeeded | failed | canceled(ジョブとツール呼び出し用)。etag または version。これにより UI が「見た目は正しいがリトライ、リフレッシュ、同時編集を整合できない」という古典的バグを防げます。
機能間でエンドポイントを予測可能に保ってください:
GET /conversations(一覧)GET /conversations/{id}(取得)POST /conversations(作成)POST /conversations/{id}/messages(追加)PATCH /jobs/{id}(ステータス更新)GET /streams/{request_id} または POST .../stream(ストリーム)どこでも同じエンベロープスタイル(エラー含む)を返すと、フロントエンドは一様に状態を更新できます。
すべての AI 呼び出しに request_id を付けてログに残し、ツール呼び出しの入出力(マスク化して)やレイテンシ、リトライ、最終ステータスを記録してください。「モデルが何を見たか、どのツールが実行されたか、どの状態を永続化したか」を簡単に答えられることが重要です。
request_id(および/または Idempotency-Key)を使う。queued から succeeded への勝手なジャンプなし)。version/etag かサーバー側のマージルールで扱う。高速なビルドサイクル(AI 支援のコード生成を含む)を採用する場合は、スキーマ検証、冪等性、イベントストリーミングなどチェックリスト項目を自動的に強制するガードレールを追加することを検討してください。そうすれば「早く動く」ことが状態のドリフトにつながりにくくなります。実務では、Koder.ai のようなエンドツーエンドプラットフォームが役立つ場面があり、配信スピードを上げつつ状態処理パターンを一貫させることができます。