状態管理設計
Jotaiを用いた状態管理の設計思想と実装詳細
03. 状態管理設計 (Jotai)
1. 概要
このドキュメントでは、本アプリケーションにおける状態管理の設計思想と、その実現に利用している Jotai の具体的な活用方法について、コードレベルの詳細を含めて解説します。
2. 基本理念: Atomic Stateと関心の分離
本プロジェクトの状態管理は、以下の2つの基本理念に基づいています。
-
Atomic State(アトミックな状態):
- 状態を可能な限り小さな独立した単位(Atom)に分割します。
- これにより、ある状態の更新が他の無関係なコンポーネントの再レンダリングを引き起こすことを防ぎ、アプリケーション全体のパフォーマンスを最適化します。
-
関心の分離(Separation of Concerns):
- 状態の定義 (
...StateAtoms.ts) - 永続化ロジック (
...PersistenceAtoms.ts) - ビジネスロジック (
aiGenerationAtoms.ts,chatAtoms.tsなど) - UIコンポーネント (
/src/components/*) - 上記の責務を明確に分離することで、コードの見通しを良くし、各機能のテストや改修を容易にします。
- 状態の定義 (
3. ディレクトリ構成
3.1. 状態管理 (next-app/src/stores/)
すべてのJotai Atomは、その責務に応じて以下のファイルに分類・格納されます。
projectStateAtoms.ts: プロジェクトのコアデータ状態(例:projectAtom)。globalChunkVersionAtomを含み、グローバルチャンクのバージョン管理を行う。authAtoms.ts: 認証およびユーザープロファイル状態(例:userAtom,userProfileAtom)。projectPersistenceAtoms.ts: プロジェクトの永続化(DB I/O)ロジック(例:loadProjectAtom)。レガシーデータの移行ロジックは削除済み。settingsAtoms.ts: アプリケーション全体の設定(テーマ、フォントなど)。AI関連の設定については、「チャット用」と「執筆用」に分離されており、それぞれが独立して永続化されます。chat/: AIチャット機能の状態とロジックを分割管理。atoms.ts: チャットセッション、メッセージ、UI状態(ローディング等)の純粋な状態定義。actions.ts: 通常チャットのメッセージ送信、再生成、圧縮などの操作ロジック。agent.ts: エージェントモードの状態と実行ロジック(LangGraph連携)。selectors.ts: アクティブなセッションやキャラクター情報の派生状態。
characterAtoms.ts: AIキャラクターのCRUD操作と状態。aiGenerationAtoms.ts: AIによるコンテンツ生成ロジック。jobAtoms.ts: バックグラウンドジョブの状態管理。uiStateAtoms.ts: UIの一時的な状態(ダイアログの開閉など)。主要なものとして、どのプロットアイテムを編集しているかを管理するeditingPlotItemAtomが含まれる。projectExportAtoms.ts: プロジェクトのエクスポート処理。projectMetadataAtoms.ts: プロジェクトメタデータ(PublishID関連)の状態管理。publishStateAtoms.ts: 作品公開ウィザードの状態管理。readingStateAtoms.ts: Reader Viewerにおける読書進捗の管理。localStorageと連携し、プロジェクトごとの最後の読書位置を保存・復元する。syncAtoms.ts: E2E暗号化完全バックアップ同期の状態管理。Sync Keyを使用したIndexedDB全体の同期、同期ステータスの管理、ローカルスナップショットの作成を行う。Supabase Storageとの連携により、デバイス間でのデータ同期を実現する。syncSettingsAtom.ts: クラウド同期のモード(リアルタイム、定期、手動)および同期間隔などのユーザー設定を管理する。dataManagementAtoms.ts: データベースのエクスポート・インポート処理。ローカルファイルシステムへのバックアップ機能を提供。aiGenerationAtoms.ts: AIによるコンテンツ生成ロジック。rewriteStateAtomは、リライト機能の状態(指示、選択されたプロンプト、生成されたプレビュー、進行状況など)をatomWithStorageを使用して永続化し、ブラウザをリロードしても作業を継続できるように設計されている。promptAssetAtoms.ts: プロンプトアセットの状態管理。promptAssetsAtom、fetchPromptAssetsAtom、addPromptAssetAtom、updatePromptAssetAtom、deletePromptAssetAtomを含み、プロンプトアセットのCRUD操作とグローバルチャンクの同期トリガーを管理する。
3.2. チャット機能の共通ロジック (next-app/src/lib/chat/)
チャット機能における重複コードを排除するため、ビジネスロジックを状態管理から分離しています。
api.ts: LLM API通信の共通化。callChatApi、processStreamResponse関数を提供。formatters.ts: メッセージの正規化・フォーマット。normalizeMessages、getContextMessages関数を提供。memory.ts: 長期記憶(Orama)との連携。retrieveMemoryContext関数を提供。persistence.ts: IndexedDBへの保存・更新・削除。addMessageToDb、updateMessageInDb、deleteMessageFromDb関数を提供。response.ts: ストリーミング/非ストリーミング応答の処理。handleChatResponse関数を提供。prompts.ts: システムプロンプトのテンプレート。圧縮プロンプト、エージェント行動指針(AGENT_AUTONOMY_GUIDELINE)を定義。
この設計により、通常チャットとエージェントチャットで同じビジネスロジックを再利用し、DRY原則を遵守しています。
4. データフローと主要Atomの連携
アプリケーションのコア機能であるプロジェクトデータのライフサイクルは、以下の主要Atom群の連携によって実現されています。
ステップ1: データ読み込み (Load)
- アプリケーション起動時、管理コンポーネント
AppManager.tsxがマウントされます。 useEffect内で、永続化AtomloadProjectAtomを呼び出します。loadProjectAtomはDexie.js(db.projects.get(...)) を使ってIndexedDBから最後に開いていたプロジェクトのProjectRecordを非同期で取得します。- 取得した
ProjectRecordを、アプリケーションのコア状態であるprojectAtom(projectStateAtoms.ts) にセットします。
ステップ2: UI表示と編集
projectAtom の更新は、主に2つの経路で行われます。
- AIによる生成:
generateSectionAtomなどのビジネスロジックAtomがAPIを呼び出し、その結果でprojectAtomの内容(主に原稿)を更新します。(詳細はステップ4を参照) - ユーザーによる直接編集:
- 各種UIコンポーネント(例:
LeftPanel.tsx)はprojectAtomを購読(useAtomValue(projectAtom))し、プロット構造や原稿内容を画面にレンダリングします。 - ユーザーがプロットの追加、名称変更、削除などの操作をUI上で行うと、
LeftPanel.tsxが対応する write-only atom (addPlotItemAtom,updatePlotItemAtom,deletePlotItemAtom) を呼び出します。 - これらのatomは
jotai-immerを利用してstructuredPlotAtomのdraftを安全に更新します。この変更はprojectAtomにも自動的に反映されます。
- 各種UIコンポーネント(例:
ステップ3: 自動保存 (Auto Save)
AutoSaveManager.tsxはprojectAtomの変更を監視しています。projectAtomに変更があると、use-debounceを利用して一定時間後(例: 2秒後)に永続化AtomsaveProjectAtomを呼び出します。saveProjectAtomは、現在のprojectAtomの状態からProjectRecordを構築し、Dexie.js(db.projects.put(...)) を使ってIndexedDBにデータを書き戻します。
ステップ4: AI機能連携(ジョブキュー経由)
本アプリケーションにおけるAIとの連携処理(原稿生成、プロットのリファインなど)は、ユーザー体験を損なわないよう、バックグラウンドのジョブキューシステムを介して実行されます。
- ユーザーがUI上(例:
LeftPanel.tsxの生成ボタン、PlotRefineDialog.tsxのリファインボタン)でAI機能をトリガーします。 - UIコンポーネントは、対応するビジネスロジックAtom(例:
generateSectionAtom,refinePlotItemAtom)を直接呼び出す代わりに、addJobAtom(jobAtoms.ts) を呼び出します。 addJobAtomは、実行すべきタスク(ビジネスロジックAtomを呼び出す関数)とジョブの名前をカプセル化し、ジョブキュー(jobsAtom)に追加します。- バックグラウンドで実行されている
JobQueueRunner.tsxがキューを監視し、新しいジョブを一つずつ取り出して実行します。 - 実行されたタスク(例:
refinePlotItemAtom)は、現在のprojectAtomの内容をget関数で読み取り、APIリクエストを構築してNext.js API Routeに送信します。 - APIから返されたデータ(生成されたテキストや更新されたJSON)を受け取り、
projectAtomの状態を更新します。 - この状態変更は、ステップ3の自動保存メカニズムによって、最終的にIndexedDBに永続化されます。
- ユーザーは
JobMonitor.tsxUIを通じて、キューに追加されたジョブの実行状況(待機中、処理中、完了、エラー)をリアルタイムで確認できます。
5. ベストプラクティス
- Atomは小さく、責務は単一に: 1つのAtomには1つの関心事のみを持たせます。
- 副作用はWrite-only Atomに: 非同期処理や複雑なロジックはWrite-only Atomにカプセル化し、UIコンポーネントをシンプルに保ちます。
- 管理コンポーネントの活用:
AppManager.tsx: アプリケーション起動時のデータロードなど、一度だけ実行したい副作用を管理します。AutoSaveManager.tsx: 状態の変更を監視し、自動保存を実行する責務を持ちます。
6. 具体的な設計パターン例
6.1. UI状態の永続化と動的ナビゲーション
グローバルヘッダーの「Writing」リンクは、最後に開いていたプロジェクトへ正しく遷移する必要があります。これは、UIの状態を永続化し、それを利用してUIを動的に構築するパターンで実現されています。
-
状態の永続化 (
uiStateAtoms.ts):- 最後にアクセスされたプロジェクトIDを、Jotaiのユーティリティ
atomWithStorageを使ってlocalStorageに保存します。 export const activeProjectIdAtom = atomWithStorage<string | null>('ui:activeProjectId', null);
- 最後にアクセスされたプロジェクトIDを、Jotaiのユーティリティ
-
状態の更新 (
ProjectPageClient.tsx):- ユーザーが執筆ページ (
/project/[id]) にアクセスした際、useEffect内でactiveProjectIdAtomに現在のプロジェクトIDをセットします。
- ユーザーが執筆ページ (
-
動的なUI構築 (
Header.tsx):HeaderコンポーネントはactiveProjectIdAtomの値を購読します。- 「Writing」リンクの
href属性を、このAtomの値に基づいて動的に生成します。(例:/project/some-uuid) - これにより、ユーザーがアプリケーションのどこにいても、「Writing」リンクは常に最後に編集していたプロジェクトを指し示すようになります。
6.2. AIチャットにおける動的プロンプト構築
AIチャット機能では、APIにリクエストを送信する直前に、複数の情報源から動的にシステムメッセージを構築しています。
-
情報源:
- キャラクターのペルソナ:
db.charactersから取得したキャラクターの基本設定プロンプト。 - 有効なチャットプロンプト:
chatPromptsAtomから取得した、ユーザーが有効化した追加指示プロンプト。
- キャラクターのペルソナ:
-
ロジックの実装 (
chatAtoms.ts内sendChatMessageAtom):- メッセージ送信Atom (
sendChatMessageAtom) 内で、上記の情報源をすべて取得します。 - キャラクターのペルソナと、有効なチャットプロンプトの
contentをすべて文字列として結合し、単一のシステムメッセージを生成します。 - この生成されたシステムメッセージを、実際の会話履歴(ユーザーとAIの発言)の先頭に追加し、最終的なAPIリクエストペイロードを作成します。
- メッセージ送信Atom (
このパターンにより、APIプロバイダー(特にAnthropicなど)が要求する「システムメッセージは必ず最初に1つだけ」というルールを遵守しつつ、柔軟で強力なプロンプトエンジニアリングを実現しています。
6.3. 非同期データ取得時のスケルトンUI連携パターン
IndexedDBからのデータ読み込みなど、完了までに時間がかかる可能性のある非同期処理において、UIのフリーズを防ぎUXを向上させることは重要です。本アプリケーションでは、専用のローディングatomとスケルトンUIを連携させるパターンを採用しています。
-
専用ローディングatomの定義 (
chatAtoms.ts):- データ取得処理ごとに、その状態を管理するための専用atomを定義します。(例:
isChatMessagesLoadingAtom) export const isChatMessagesLoadingAtom = atom<boolean>(false);
- データ取得処理ごとに、その状態を管理するための専用atomを定義します。(例:
-
データ取得atomの修正 (
chatAtoms.ts):- データ取得を実行するwrite-only atom(例:
loadActiveSessionMessagesAtom)内で、処理の開始から終了までをtry...finallyブロックで囲みます。 tryブロックの直前でローディングatomをtrueにセットし、finallyブロックでfalseにセットします。これにより、処理が成功してもエラーで中断しても、ローディング状態が確実に解除されます。
- データ取得を実行するwrite-only atom(例:
-
UIコンポーネントでの利用 (
ChatView.tsx):- UIコンポーネントは、ローディングatomを購読(
useAtomValue(isChatMessagesLoadingAtom))します。 - ローディング状態が
trueの間は、shadcn/uiのSkeletonコンポーネントを組み合わせたプレビューUIを表示します。 - ローディング状態が
falseになったら、実際のコンテンツを描画します。
- UIコンポーネントは、ローディングatomを購読(
このパターンにより、ユーザーはデータの読み込み中であることを直感的に理解でき、体感的なパフォーマンスが向上します。
6.4. ストリーミング応答におけるUI更新と永続化の分離
AIのストリーミング応答を扱う際、UIの滑らかな表示(ユーザー体験)と、バックエンドへのデータ永続化(パフォーマンスと安定性)を両立させることは重要な課題です。本アプリケーションでは、この2つの関心事を分離するパターンを採用しています。
-
UI状態の即時更新:
- ストリーミングAPIからデータチャンクが届くたびに、UIを描画しているJotai Atom (
activeSessionMessagesAtom) の状態を即座に更新します。 - この際、メッセージ配列の末尾の要素を新しいオブジェクトで置き換える (
[...prev.slice(0, -1), newMessage]) ことで、Reactに変更を確実に通知し、メモ化されたコンポーネントでも再描画がトリガーされるようにします。 - これにより、ユーザーはAIがリアルタイムでテキストを生成しているかのような、途切れのないスムーズな体験を得られます。
- ストリーミングAPIからデータチャンクが届くたびに、UIを描画しているJotai Atom (
-
永続化処理のデバウンス:
- IndexedDBへの書き込みは、比較的高コストな処理です。ストリーミング中に毎回書き込みを行うと、パフォーマンスのボトルネックとなり、UIのフリーズを引き起こす可能性があります。
- これを避けるため、データベースへの書き込み処理 (
db.chatMessages.put) をデバウンスします。 - 具体的には、UI状態を更新するたびにデバウンスされた書き込み関数を呼び出します。これにより、ストリーミングが完了し、一定時間(例: 500ms)新しいデータが来なくなってから、最終的な内容で一度だけデータベースへの書き込みが実行されます。
このパターンにより、アプリケーションは応答性とパフォーマンスを両立させています。ロジックの詳細は chatAtoms.ts の sendChatMessageAtom に実装されています。
6.5. 読書進捗の永続化とIntersectionObserver連携
Reader Viewerでは、ユーザーの読書進捗を自動的に保存・復元することで、シームレスな読書体験を提供しています。この機能は、atomWithStorageとIntersectionObserver APIを組み合わせたパターンで実現されています。
-
状態の永続化 (
readingStateAtoms.ts):- プロジェクトごとの最後の読書位置(storyId)を、
atomWithStorageを使ってlocalStorageに保存します。 export const readingProgressAtom = atomWithStorage<Record<string, string>>('reading-progress', {});- キーは
projectId、値は最後に表示していたstoryIdです。 - また、ユーザーが選択した言語設定も永続化します。
export const selectedLanguageAtom = atomWithStorage<string>('reading-language', 'ja');
- プロジェクトごとの最後の読書位置(storyId)を、
-
進捗の自動検知 (
useReadingProgress.ts):- IntersectionObserverを使用して、現在ビューポートに表示されているStoryを検知します。
rootMargin: "-40% 0px -40% 0px"により、画面中央付近のStoryを「現在読んでいる位置」として判定します。- 検知したstoryIdで
readingProgressAtomを更新し、自動的にlocalStorageに保存されます。
-
位置の自動復元 (
useReadingProgress.ts):- ページロード時、
readingProgressAtomから該当プロジェクトの最後の読書位置を取得します。 document.getElementById(storyId).scrollIntoView()で、その位置まで自動的にスクロールします。- 小さな遅延(100ms)を入れることで、DOMのレンダリング完了を待ちます。
- ページロード時、
このパターンにより、ユーザーは前回読んでいた位置から自然に読書を再開でき、ブックマーク機能を意識することなく快適な読書体験を得られます。実装の詳細は src/components/reader/useReadingProgress.ts と src/stores/readingStateAtoms.ts を参照してください。
6.6. E2E暗号化完全バックアップ同期
E2E暗号化完全バックアップ同期(Simple Full Sync)機能は、データの安全性、整合性、そしてユーザーの利便性を高めるために、以下のパターンを採用しています。
-
Sync Keyの永続化と初期ロード:
syncKeyAtomはlocalStorageに保存されたSync Keyを管理します。SyncSetupWizardでキーを設定すると、同期機能が有効になります。
-
最終同期日時の永続化:
lastSyncedAtAtomはatomWithStorageを使用してlocalStorageに永続化されます。- これにより、アプリをリロードしても「いつ同期したか」の情報を保持し、クラウド上のデータと比較することが可能になります。
-
完全バックアップ方式 (
syncDataAtom):syncDataAtomが同期処理の主要なエントリーポイントです。- アップロード時:
dexie-export-importを使用してIndexedDB全体をエクスポートし、AES-256-GCMで暗号化してSupabase Storageにアップロードします。 - ダウンロード時: クラウド上のバックアップファイルをダウンロード・復号化し、ローカルデータを完全に置き換えます。
- 競合解決にはタイムスタンプ比較を使用します。クラウドの
last_synced_atとローカルの最終更新日時を比較し、推奨アクションを提示します。
-
同期モード:
- 現在は手動同期のみを有効化しています。信頼性とテストの観点から、ユーザーが明示的にアップロード/復元を選択する設計としています。
- 自動同期(リアルタイム、定期実行)は将来的な拡張として検討されていますが、現時点ではUI上で無効化されています。
-
UI状態の強制リロード:
- 同期完了後、UIのデータ不整合(ステートの陳腐化)を回避するため、
loadCharactersAtom、loadProjectAtom、fetchPromptAssetsAtomといった関連するAtomを明示的に呼び出し、UIの状態をデータベースから強制的にリロードします。
- 同期完了後、UIのデータ不整合(ステートの陳腐化)を回避するため、
詳細は 10_sync_architecture.md を参照してください。
6.7. プロジェクトメタデータと公開状態の管理
プロジェクトの公開に関連する状態は、PublishIDを通じて管理されます。
-
Publish ID(公開枠)の概念:
- ローカルのプロジェクトID (
projectId) とは独立した「公開スロット」を識別するIDです。 - プロジェクトを複製しても同じPublishIDを保持でき、更新公開時に同一スロットを上書きできます。
- ローカルのプロジェクトID (
-
メタデータAtoms (
projectMetadataAtoms.ts):publishIdAtom: プロジェクトに紐づく公開スロットIDを管理。fetchPublishMetadataAtom: 指定されたPublishIDのメタデータをSupabaseから取得。
-
公開ウィザード状態Atoms (
publishStateAtoms.ts):- 公開ウィザードの各ステップ(Destination、Metadata、Translation、Review、Final)の状態を管理。
- カバー画像、メタデータ入力、AI生成、翻訳、査読の結果を保持します。
詳細は 05_api_specification.md およびコンポーネントドキュメント 13_publish_components.md を参照してください。
6.8. プロンプトシステムの状態管理と同期トリガー
プロンプトシステムは、2つの異なるテーブル(chatPrompts、prompt_assets)と、プロジェクトコンテンツ内の writingPrompts で管理され、それぞれ異なるスコープと用途を持ちます。
-
プロンプトシステムの構成:
- 執筆プロンプト (
writingPrompts): プロジェクト固有の執筆時に使用。スキーマバージョン12でプロジェクトのcontent.writingPromptsに統合されました。 Project Chunk に含まれます。 - チャットプロンプト (
chatPrompts): チャットにおけるAIの性格や振る舞いを定義。グローバルな設定として Global Chunk に含まれる。 - プロンプトアセット (
prompt_assets): グローバルで再利用可能なテンプレート。タグ、説明、公開設定などのメタデータを持ち、Global Chunk に含まれる。
- 執筆プロンプト (
-
同期トリガーの仕組み:
- プロジェクトコンテンツの変更時:
structuredPlot、manuscripts、writingPromptsの変更時に、プロジェクトのupdatedAtを更新し、Project Chunk の同期をトリガーする。manuscriptContentChangeAtom: 原稿内容の変更時にdb.projects.update(activeProjectId, { updatedAt: new Date() })を実行。deleteManuscriptVersionAtom: 原稿バージョンの削除時にプロジェクトのupdatedAtを更新。savePlotAtom、addPlotItemAtom、updatePlotItemAtom、deletePlotItemAtom、reorderPlotItemAtom: プロット構造の変更時にプロジェクトのupdatedAtを更新。addWritingPromptAtom、updateWritingPromptAtom、deleteWritingPromptAtom: 執筆プロンプトの変更時にプロジェクトのupdatedAtを更新。
- グローバルチャンクの変更時:
promptAssets、chatPrompts、charactersの変更時にglobalChunkVersionAtomを更新し、Global Chunk の同期をトリガーする。addPromptAssetAtom、updatePromptAssetAtom、deletePromptAssetAtom: プロンプトアセットの変更時にset(globalChunkVersionAtom, now.getTime())を実行。addChatPromptAtom、updateChatPromptAtom、deleteChatPromptAtom: チャットプロンプトの変更時にset(globalChunkVersionAtom, now.getTime())を実行。saveCharacterAtom、deleteCharacterAtom: キャラクターの変更時にset(globalChunkVersionAtom, now.getTime())を実行。
- プロジェクトコンテンツの変更時:
-
リビジョン管理による競合解決:
- プロジェクトは単調増加する
revision番号を持ち、保存操作の順序を一意に識別します。 - 同期時の競合解決にはリビジョンを第一指標、タイムスタンプを第二指標として使用します。
- これにより、並列保存時の競合を防止し、データの一貫性を保ちます。
- プロジェクトは単調増加する
このパターンにより、プロンプトシステムの変更が適切に同期され、デバイス間で一貫性が保たれます。実装の詳細は src/stores/projectStateAtoms.ts、src/stores/promptAssetAtoms.ts、src/stores/projectPersistenceAtoms.ts、src/stores/characterAtoms.ts を参照してください。
6.9. AIエージェント機能の状態管理
チャット機能は、通常モードに加えて、エージェントモードをサポートしています。エージェントモードでは、LangGraph.jsを使用した自律型エージェントがツール(Web検索、プロジェクトデータ参照)を活用してユーザーの質問に回答します。
6.9.1. エージェント状態Atoms (src/stores/chat/agent.ts)
isAgentModeAtom: エージェントモードのオン/オフを切り替えるトグル。localStorageに永続化されます。agentPhaseAtom: 現在のエージェントのフェーズ(idle、planning、researching、answering)を管理。agentActiveToolAtom: 現在実行中のツール名(web_search、read_project_data)を管理。
6.9.2. エージェントアクションAtoms
sendAgentMessageAtom: エージェントモードでメッセージを送信し、LangGraphワークフローを実行します。regenerateAgentMessageAtom: 指定したメッセージをエージェントモードで再生成します。
6.9.3. エージェントワークフロー
- 初期化:
createAssistantGraphでLangGraphグラフを作成し、ツール(Web検索、プロジェクトリーダー)をバインドします。 - 実行: ストリーミング実行により、各フェーズとツール使用をリアルタイムにUIに反映します。
- 思考ログ: エージェントの思考プロセス(フェーズ、ツール使用、中間結果)を
thoughtLogとしてメッセージメタデータに保存します。
6.9.4. エージェント用設定Atoms (src/stores/settingsAtoms.ts)
tavilyApiKeyAtom: Tavily Web検索APIのキーを管理。
エージェント機能の詳細は 12_ai_agent_architecture.md を参照してください。
関連ドキュメント:
01_architecture.md04_database_schema.md10_sync_architecture.md12_ai_agent_architecture.md