
React+TypeScriptで型安全なチャット画面を作る
HowTo13分で読めます
このシリーズ: 全10回
状態管理のベストプラクティス
はじめに
AIチャットアプリのフロントエンドを作るとき、「データの流れ」が複雑になりがちです。
- ユーザーがメッセージを送る
- AIからストリーミングで返ってくる
- 会話履歴を管理する
- ログイン状態を保持する
これらを整理するために、TypeScriptの型安全性とReactの状態管理パターンが役立ちます。
本記事では、実際のプロジェクトで使用した設計パターンを解説します。
なぜTypeScriptか
型があると何が嬉しいか
JavaScriptだけで開発していると、こんなバグに悩まされます。
javascript
// JavaScript
function sendMessage(message) {
api.post('/chat', { msg: message }); // typo: message → msg
}
// 実行時エラー:サーバーが「message がない」と言うTypeScriptなら、書いた瞬間にエラーが分かります。
typescript
// TypeScript
type ChatRequest = {
message: string;
imageUrl?: string;
};
function sendMessage(request: ChatRequest) {
api.post('/chat', { msg: request.message });
// ↑ エラー: 'msg' は ChatRequest に存在しません
}メリット:
- 実行前にバグを発見
- IDEの補完が効く
- リファクタリングが安全
プロジェクト構成
使用技術
| 技術 | バージョン | 用途 |
|---|---|---|
| React | 19 | UIフレームワーク |
| TypeScript | 5.x | 型安全性 |
| Vite | 6.x | ビルドツール |
| Tailwind CSS | 3.x | スタイリング |
フォルダ構成
text
frontend/src/
├── components/ # UIコンポーネント
│ ├── ChatInterface.tsx
│ ├── MessageList.tsx
│ ├── MessageInput.tsx
│ └── ImageUpload.tsx
├── contexts/ # React Context
│ └── AuthContext.tsx
├── hooks/ # カスタムフック
│ └── useChat.ts
├── services/ # API呼び出し
│ ├── apiClient.ts
│ └── auth.ts
├── types/ # 型定義
│ └── index.ts
└── App.tsx型定義から始める
メッセージの型
まず、アプリで扱うデータの型を定義します。
typescript
// frontend/src/types/index.ts
// メッセージの役割
export type MessageRole = 'user' | 'assistant';
// 1つのメッセージ
export type Message = {
id: string;
role: MessageRole;
content: string;
imageUrl?: string;
timestamp: number;
};
// チャットセッション
export type Session = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
};
// API レスポンス
export type ChatResponse = {
sessionId: string;
message: Message;
};
// ストリーミングのチャンク
export type StreamChunk =
| { type: 'text'; content: string }
| { type: 'ping' }
| { type: 'done'; usage?: { input_tokens: number; output_tokens: number } }
| { type: 'error'; message: string };なぜ型を先に決めるか
- 設計が明確になる - どんなデータを扱うか整理できる
- 実装時のミスが減る - 型がガイドラインになる
- チーム開発に強い - 共通認識ができる
Context + useReducerで状態管理
なぜReduxを使わないか
Reduxは強力ですが、このアプリには少し大げさです。
- セットアップが複雑
- 学習コストが高い
- 小〜中規模には過剰
React標準のContext + useReducerで十分対応できます。
Contextとは
Contextは、コンポーネントツリーの深い場所にデータを渡す仕組みです。
text
❌ Props のバケツリレー
App → Header → Nav → UserName
(user) (user) (user)
✅ Context
App [user in Context]
├── Header
│ └── Nav
│ └── UserName [user from Context]
└── Main
└── ...useReducerとは
useReducerは、複雑な状態更新を整理するフックです。
typescript
// 状態
type State = {
count: number;
};
// アクション
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'set'; value: number };
// Reducer:アクションに応じて状態を更新
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'set':
return { count: action.value };
}
}実装してみよう
ステップ1:型を定義
typescript
// frontend/src/types/chat.ts
// チャットの状態
export type ChatState = {
sessions: Session[];
currentSessionId: string | null;
messages: Message[];
isLoading: boolean;
streamingContent: string;
error: string | null;
};
// アクション
export type ChatAction =
| { type: 'SET_SESSIONS'; sessions: Session[] }
| { type: 'SELECT_SESSION'; sessionId: string }
| { type: 'ADD_MESSAGE'; message: Message }
| { type: 'START_LOADING' }
| { type: 'STOP_LOADING' }
| { type: 'APPEND_STREAMING'; content: string }
| { type: 'FINISH_STREAMING' }
| { type: 'SET_ERROR'; error: string }
| { type: 'CLEAR_ERROR' };ステップ2:Reducerを作成
typescript
// frontend/src/reducers/chatReducer.ts
import { ChatState, ChatAction } from '../types/chat';
export const initialState: ChatState = {
sessions: [],
currentSessionId: null,
messages: [],
isLoading: false,
streamingContent: '',
error: null,
};
export function chatReducer(state: ChatState, action: ChatAction): ChatState {
switch (action.type) {
case 'SET_SESSIONS':
return {
...state,
sessions: action.sessions,
};
case 'SELECT_SESSION':
return {
...state,
currentSessionId: action.sessionId,
messages: [], // 新しいセッションのメッセージを読み込む前にクリア
};
case 'ADD_MESSAGE':
return {
...state,
messages: [...state.messages, action.message],
};
case 'START_LOADING':
return {
...state,
isLoading: true,
error: null,
};
case 'STOP_LOADING':
return {
...state,
isLoading: false,
};
case 'APPEND_STREAMING':
return {
...state,
streamingContent: state.streamingContent + action.content,
};
case 'FINISH_STREAMING':
// ストリーミング完了:確定メッセージとして追加
const newMessage: Message = {
id: `msg-${Date.now()}`,
role: 'assistant',
content: state.streamingContent,
timestamp: Date.now(),
};
return {
...state,
messages: [...state.messages, newMessage],
streamingContent: '',
isLoading: false,
};
case 'SET_ERROR':
return {
...state,
error: action.error,
isLoading: false,
};
case 'CLEAR_ERROR':
return {
...state,
error: null,
};
default:
return state;
}
}ステップ3:Providerを作成
typescript
// frontend/src/contexts/ChatContext.tsx
import {
createContext,
useContext,
useReducer,
ReactNode,
} from 'react';
import { chatReducer, initialState } from '../reducers/chatReducer';
import { ChatState, ChatAction } from '../types/chat';
// Contextの型
type ChatContextType = {
state: ChatState;
dispatch: React.Dispatch<ChatAction>;
};
// Context作成
const ChatContext = createContext<ChatContextType | undefined>(undefined);
// Provider
export function ChatProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(chatReducer, initialState);
return (
<ChatContext.Provider value={{ state, dispatch }}>
{children}
</ChatContext.Provider>
);
}
// カスタムフック
export function useChat(): ChatContextType {
const context = useContext(ChatContext);
if (!context) {
throw new Error('useChat must be used within ChatProvider');
}
return context;
}ステップ4:フックで使う
typescript
// frontend/src/hooks/useChatActions.ts
import { useCallback } from 'react';
import { useChat } from '../contexts/ChatContext';
import { fetchStreaming } from '../services/apiClient';
import { Message } from '../types';
export function useChatActions() {
const { state, dispatch } = useChat();
// メッセージ送信
const sendMessage = useCallback(
async (content: string, imageUrl?: string) => {
// ユーザーメッセージを追加
const userMessage: Message = {
id: `msg-${Date.now()}`,
role: 'user',
content,
imageUrl,
timestamp: Date.now(),
};
dispatch({ type: 'ADD_MESSAGE', message: userMessage });
dispatch({ type: 'START_LOADING' });
try {
await fetchStreaming(
'/api/analyze',
{
prompt: content,
imageUrl,
sessionId: state.currentSessionId,
},
(chunk) => {
switch (chunk.type) {
case 'text':
dispatch({ type: 'APPEND_STREAMING', content: chunk.content || '' });
break;
case 'done':
dispatch({ type: 'FINISH_STREAMING' });
break;
case 'error':
dispatch({ type: 'SET_ERROR', error: chunk.message || 'エラー' });
break;
}
}
);
} catch (e) {
dispatch({ type: 'SET_ERROR', error: '通信エラーが発生しました' });
}
},
[dispatch, state.currentSessionId]
);
// セッション選択
const selectSession = useCallback(
(sessionId: string) => {
dispatch({ type: 'SELECT_SESSION', sessionId });
},
[dispatch]
);
return {
messages: state.messages,
streamingContent: state.streamingContent,
isLoading: state.isLoading,
error: state.error,
sendMessage,
selectSession,
};
}コンポーネント設計
分割の考え方
1つのコンポーネントに詰め込みすぎず、責務を明確に分けます。
text
ChatInterface(全体の枠)
├── SessionList(セッション一覧)
├── MessageList(メッセージ表示)
│ └── MessageBubble(1つのメッセージ)
├── ImageUpload(画像アップロード)
└── MessageInput(入力欄)主要コンポーネント
ChatInterface:全体の枠
typescript
// frontend/src/components/ChatInterface.tsx
import { ChatProvider } from '../contexts/ChatContext';
import { SessionList } from './SessionList';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
export function ChatInterface() {
return (
<ChatProvider>
<div className="flex h-screen">
{/* サイドバー */}
<aside className="w-64 bg-gray-100 p-4">
<SessionList />
</aside>
{/* メインエリア */}
<main className="flex-1 flex flex-col">
<div className="flex-1 overflow-y-auto p-4">
<MessageList />
</div>
<div className="border-t p-4">
<MessageInput />
</div>
</main>
</div>
</ChatProvider>
);
}MessageList:メッセージ一覧
typescript
// frontend/src/components/MessageList.tsx
import { useRef, useEffect } from 'react';
import { useChatActions } from '../hooks/useChatActions';
import { MessageBubble } from './MessageBubble';
export function MessageList() {
const { messages, streamingContent, isLoading } = useChatActions();
const bottomRef = useRef<HTMLDivElement>(null);
// 新しいメッセージで自動スクロール
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingContent]);
return (
<div className="space-y-4">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{/* ストリーミング中 */}
{streamingContent && (
<MessageBubble
message={{
id: 'streaming',
role: 'assistant',
content: streamingContent,
timestamp: Date.now(),
}}
isStreaming
/>
)}
{/* ローディング */}
{isLoading && !streamingContent && (
<div className="text-gray-500">考え中...</div>
)}
<div ref={bottomRef} />
</div>
);
}MessageInput:入力欄
typescript
// frontend/src/components/MessageInput.tsx
import { useState } from 'react';
import { useChatActions } from '../hooks/useChatActions';
import { ImageUpload } from './ImageUpload';
export function MessageInput() {
const [input, setInput] = useState('');
const [imageUrl, setImageUrl] = useState<string>();
const { sendMessage, isLoading } = useChatActions();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() && !imageUrl) return;
sendMessage(input, imageUrl);
setInput('');
setImageUrl(undefined);
};
return (
<form onSubmit={handleSubmit} className="space-y-2">
{/* 画像プレビュー */}
{imageUrl && (
<div className="relative inline-block">
<img src={imageUrl} alt="" className="h-20 rounded" />
<button
type="button"
onClick={() => setImageUrl(undefined)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6"
>
×
</button>
</div>
)}
<div className="flex gap-2">
<ImageUpload onUpload={setImageUrl} />
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="メッセージを入力..."
className="flex-1 border rounded px-3 py-2"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || (!input.trim() && !imageUrl)}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
送信
</button>
</div>
</form>
);
}TypeScriptの恩恵
コンパイル時にエラー発見
typescript
// 存在しないプロパティ
dispatch({ type: 'INVALID_ACTION' });
// ↑ エラー: 'INVALID_ACTION' は ChatAction に存在しません
// 型の不一致
const msg: Message = {
id: '1',
role: 'system', // ← エラー: 'system' は MessageRole に存在しません
content: 'hello',
timestamp: Date.now(),
};IDEの補完が効く
typescript
function handleChunk(chunk: StreamChunk) {
chunk. // ← type, content?, message? が候補に出る
if (chunk.type === 'text') {
chunk.content // ← string | undefined と推論される
}
}よくある問題と解決
型エラーが消えない
as const の活用
typescript
// ❌ string[] と推論される
const roles = ['user', 'assistant'];
// ✅ readonly ['user', 'assistant'] と推論される
const roles = ['user', 'assistant'] as const;型ガードの書き方
typescript
function isTextChunk(chunk: StreamChunk): chunk is { type: 'text'; content: string } {
return chunk.type === 'text';
}
// 使用例
if (isTextChunk(chunk)) {
console.log(chunk.content); // string として扱える
}anyを使いたくなったら
typescript
// ❌ any を使う
const data: any = await response.json();
// ✅ unknown で受けて型ガード
const data: unknown = await response.json();
if (isValidResponse(data)) {
// 型が絞り込まれる
}
function isValidResponse(data: unknown): data is ChatResponse {
return (
typeof data === 'object' &&
data !== null &&
'sessionId' in data &&
'message' in data
);
}まとめ
型安全のメリット
| 観点 | JavaScript | TypeScript |
|---|---|---|
| バグ発見 | 実行時 | コンパイル時 |
| リファクタリング | 怖い | 安全 |
| ドキュメント | 別途必要 | 型が説明 |
| IDE補完 | 限定的 | 強力 |
状態管理のパターン
text
小規模: useState のみ
↓
中規模: Context + useReducer(本記事)
↓
大規模: Redux / Zustand / Jotaiチェックリスト
- 型定義ファイルの作成
- Reducerの実装
- Context Providerの作成
- カスタムフックの作成
- コンポーネントの分割
- strict modeの有効化