第四章:パフォーマンス最適化:複雑な履歴会話記録の状態管理(Zustand)とキャッシュメカニズム
AIチャットルームが前章のような単純な「一問一答」ではなく、コンテキストを記憶し、異なる「チャットセッション」を切り替える必要がある場合、単純なuseStateはすぐに破綻します。
すべての会話データ、ロード状態、履歴記録を1つのComponentに詰め込むと、1つの状態が変更されるだけでページ全体が激しく再レンダリング(Re-render)され、タイプライター効果の遅延さえ引き起こす可能性があります。
本章では、React界で現在最も人気のある軽量グローバル状態管理ライブラリZustandを使用して、高性能な会話記録管理システムを構築する方法を紹介します。
なぜReduxやContext APIを使わないのか?
過去において、このようなコンポーネントを跨ぐ状態管理には通常2つの選択肢がありました:
- Context API:深刻な「レンダリング地獄」を引き起こしやすい。Providerの値が変更されると、内部のすべての子コンポーネントが無条件に再レンダリングされ、Streamingのように10ミリ秒ごとに状態を更新するシナリオではパフォーマンス上の災難となります。
- Redux:重すぎる。大量のAction、Reducer、Dispatchのボイラープレートコードを書く必要があり、Vibe Codingの開発速度を大幅に低下させます。
Zustandの利点は:極めてシンプル、Context Providerに依存せず、非常に精密な「セレクター(Selectors)」をサポートして不必要なレンダリングを防ぐことです。
ステップ1:状態ストア(Store)の定義
まずZustandをインストール:npm install zustand
src/store/useChatStore.tsファイルを作成します。このStoreがチャットルームの頭脳となります:
import { create } from 'zustand'
export type Message = {
id: string
role: 'user' | 'assistant'
content: string
}
export type ChatSession = {
id: string
title: string
messages: Message[]
}
interface ChatState {
sessions: ChatSession[]
activeSessionId: string | null
// アクション(Actions)
setActiveSession: (id: string) => void
addMessage: (sessionId: string, msg: Message) => void
updateLastMessage: (sessionId: string, chunk: string) => void
createNewSession: () => string
}
export const useChatStore = create<ChatState>((set, get) => ({
// 初期状態
sessions: [],
activeSessionId: null,
// 現在アクティブなチャットウィンドウの切り替え
setActiveSession: (id) => set({ activeSessionId: id }),
// 新しい会話の開始
createNewSession: () => {
const newId = crypto.randomUUID()
set((state) => ({
// 新しい会話を配列の先頭に追加
sessions: [{ id: newId, title: '新しい探求', messages: [] }, ...state.sessions],
activeSessionId: newId
}))
return newId
},
// 完全な新しいメッセージの追加
addMessage: (sessionId, msg) => set((state) => ({
sessions: state.sessions.map(session =>
session.id === sessionId
? { ...session, messages: [...session.messages, msg] }
: session
)
})),
// 最後のメッセージの更新(Streamingタイプライター用に設計されたパフォーマンス最適化手法!)
updateLastMessage: (sessionId, chunk) => set((state) => ({
sessions: state.sessions.map(session => {
if (session.id !== sessionId) return session;
const lastMsg = session.messages[session.messages.length - 1];
if (!lastMsg || lastMsg.role !== 'assistant') return session;
// 配列をコピーし、新しい文字列チャンクを最後のメッセージ内容に追加
const newMessages = [...session.messages];
newMessages[newMessages.length - 1] = {
...lastMsg,
content: lastMsg.content + chunk
};
return { ...session, messages: newMessages };
})
})),
}))
ステップ2:コンポーネントでの状態の効率的な使用
Storeが準備できたので、UIでどのように使用するかを見てみましょう。Zustandの最も強力な点は、「必要なものだけを取得できる」ことであり、他の部分が変更されてもこのコンポーネントは再レンダリングされません。
例えば、「履歴リスト」を表示する左サイドバーがある場合:
"use client"
import { useChatStore } from "@/store/useChatStore"
import { PlusCircle, MessageSquare } from "lucide-react"
export function Sidebar() {
// ここで必要な状態とアクションを正確に抽出
// これにより、messagesがタイプライターアニメーションを更新しても、Sidebarは再レンダリングされない!
const sessions = useChatStore(state => state.sessions)
const activeSessionId = useChatStore(state => state.activeSessionId)
const setActiveSession = useChatStore(state => state.setActiveSession)
const createNewSession = useChatStore(state => state.createNewSession)
return (
<div className="w-72 bg-gray-900 text-white h-screen p-4 flex flex-col gap-4 shadow-2xl z-10">
<button
onClick={createNewSession}
className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white font-bold py-3 px-4 rounded-xl transition-all border border-white/5"
>
<PlusCircle className="w-5 h-5" />
新しい会話
</button>
<div className="flex-1 overflow-y-auto mt-2 space-y-2 pr-2 custom-scrollbar">
{sessions.map(s => (
<div
key={s.id}
onClick={() => setActiveSession(s.id)}
className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all ${
activeSessionId === s.id
? 'bg-blue-600 font-bold shadow-lg shadow-blue-500/30'
: 'hover:bg-white/5 text-white/70'
}`}
>
<MessageSquare className="w-4 h-4 opacity-50" />
<span className="truncate flex-1">{s.title}</span>
</div>
))}
</div>
</div>
)
}
このコードの巧妙な点は:右側のチャットボックスでStreamingタイプライター効果が動作している間(updateLastMessageがトリガーされる)、左側のSidebarは完全に再レンダリングされないことです!
localStorageとの統合によるデータ永続化(Persist)
さらに驚くべきことに、Zustandは強力なMiddlewareを内蔵しています。
ユーザーが非常に価値のあるAI会話を行っている最中に誤ってF5を押してページを更新してしまった場合、チャット記録がすべて消えてしまったら、ユーザーは確実に激怒するでしょう。
useChatStore.tsでpersistを少し追加するだけで、ブラウザのlocalStorageにデータを自動保存できます:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useChatStore = create<ChatState>()(
persist(
(set, get) => ({
// ... 上記の状態とロジック ...
}),
{
// localStorageに保存する際のキー名
name: 'vibe-tutor-chat-storage',
// オプション:activeSessionIdを保存したくない場合、partializeでフィルタリング可能
partialize: (state) => ({ sessions: state.sessions }),
}
)
)
たったこれだけのコードで!これで、ユーザーがページを更新したり、タブを閉じて翌日再度開いたりしても、以前の「すべての会話記録」と「チャットグループ」が魔法のように画面に復元されます。
Zustandのグローバル状態管理とPersistキャッシュにより、大規模なデータベースの読み書きに依存することなく、非常にスムーズでローカル記憶機能を備えたAIチャットルームのコアを構築できました!これはサーバーデータベースの接続コストを大幅に削減するだけでなく、稲妻のように速いユーザー体験を提供します。