コンポーネントの状態
適切な状態管理パターンを理解することで、ClaudeCodeは複雑なUIの状態を効率的に管理する実装を自動生成できます。弊社では状態の種類に応じて最適な管理手法を選択します。
弊社の推奨ルール
- ローカル状態は最小限に留めuseStateを必要な箇所のみ使用
- グローバル状態はContext APIで管理し不要なライブラリ依存を避ける
- サーバー状態はTanStack Queryでキャッシュと同期を自動化
- 複雑な状態は値オブジェクトやクラスインスタンスで管理
- 状態遷移が複雑な場合は有限オートマトンとしてクラスで実装
ClaudeCodeでの利用
Context APIでの状態管理
グローバルな状態管理が必要な場合の実装依頼
「この状態管理をContext APIで実装して、TypeScript対応も含めて」
サーバー状態の管理
APIからのデータ取得とキャッシュ管理を自動化する場合
「TanStack Queryでデータフェッチとキャッシュを実装」
値オブジェクトの実装
複数の関連する値をまとめて管理する場合
「住所情報を値オブジェクトのクラスで管理するよう実装」
状態遷移の実装
複雑な状態遷移を型安全に管理する場合
「注文ステータスの状態遷移を有限オートマトンのクラスで実装して」
複雑な状態管理で値オブジェクトを使う
複数の関連する値をまとめて管理し、安全に更新したい場合に値オブジェクトを使用します。単純な数値や文字列では必要ありません。
// 推奨しない:関連する値が分散
function AddressForm() {
const [zipCode, setZipCode] = useState("");
const [prefecture, setPrefecture] = useState("");
const [city, setCity] = useState("");
// バリデーションロジックが分散
const isValid = zipCode.length === 7 && prefecture && city;
}
// 推奨:値オブジェクトで管理
class Address {
constructor(
readonly zipCode: string,
readonly prefecture: string,
readonly city: string
) {
if (!this.isValidZipCode(zipCode)) {
throw new Error('郵便番号は7桁である必要があります');
}
Object.freeze(this);
}
private isValidZipCode(code: string): boolean {
return /^\d{7}$/.test(code);
}
// 特定のプロパティを更新して新しいインスタンスを返す
withZipCode(zipCode: string): Address {
return new Address(zipCode, this.prefecture, this.city);
}
withPrefecture(prefecture: string): Address {
return new Address(this.zipCode, prefecture, this.city);
}
get isComplete(): boolean {
return !!(this.zipCode && this.prefecture && this.city);
}
}
値オブジェクトは、関連する複数の値をまとめて管理し、withメソッドで安全に更新する場合に使用します。単純な値では不要です。
useEffectでのデータ取得ではなくTanStack Queryを使う
手動でのデータ取得は、ローディング状態、エラー処理、キャッシュ管理が複雑になります。TanStack Queryがこれらを自動化します。
// 推奨しない:手動のデータ管理
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Loading />;
if (error) return <Error />;
return <Profile user={user} />;
}
// 推奨:TanStack Query
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5分間キャッシュ
});
if (isLoading) return <Loading />;
if (error) return <Error />;
return <Profile user={user} />;
}
TanStack Queryは自動的にキャッシュ、再取得、バックグラウンド更新を管理します。
単純な状態管理ではなくカスタムフックを使う
3つ程度のuseStateをまとめるだけならカスタムフックで十分です。useReducerは複雑性を増すため、本当に必要な場合のみ使用します。
// ✅ 推奨:少数の状態はカスタムフックで管理
function useUserForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
setLoading(true);
// API呼び出し
setLoading(false);
};
return { name, setName, email, setEmail, loading, submit };
}
// ❌ 推奨しない:単純な状態でReducerを使う
type FormState = {
name: string;
email: string;
loading: boolean;
};
type FormAction =
| { type: 'SET_NAME'; value: string }
| { type: 'SET_EMAIL'; value: string }
| { type: 'SET_LOADING'; value: boolean };
// 単純な状態管理でReducerは過剰
弊社では、useReducerはProps Drilling(バケツリレー)を回避する目的や、複数の更新パターンが存在する場合に使用します。状態の個数だけでuseReducerを選択すると、不要な複雑性が増して保守性が低下します。
複数の更新手段がある場合はuseReducerを使う
配列の追加・削除・並び替えなど、複数の状態更新パターンが存在する場合、setStateでは更新ロジックがコンポーネントに散在します。
// 推奨しない:更新ロジックがコンポーネントに散在
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
const newTodo = { id: Date.now(), text, completed: false };
setTodos([...todos, newTodo]);
};
const toggleTodo = (id: number) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
// 他にも削除、並び替え、一括更新などが散在...
}
useReducerを使うことで、更新ロジックを純粋関数として一箇所に集約できます。
// 推奨:アクション型を定義
type TodoAction =
| { type: 'ADD'; text: string }
| { type: 'TOGGLE'; id: number }
| { type: 'DELETE'; id: number }
| { type: 'CLEAR_COMPLETED' };
Reducerは純粋関数として、各アクションに対応する状態更新を定義します。if文を使って各ケースを処理します。
// 純粋関数として更新ロジックを集約
function todoReducer(state: Todo[], action: TodoAction): Todo[] {
if (action.type === 'ADD') {
const newTodo = {
id: Date.now(),
text: action.text,
completed: false
};
return [...state, newTodo];
}
if (action.type === 'TOGGLE') {
return state.map(todo =>
todo.id === action.id
? { ...todo, completed: !todo.completed }
: todo
);
}
if (action.type === 'DELETE') {
return state.filter(todo => todo.id !== action.id);
}
if (action.type === 'CLEAR_COMPLETED') {
return state.filter(todo => !todo.completed);
}
return state;
}
コンポーネントではuseReducerを使って、dispatchで更新を実行します。
function TodoList() {
const [todos, dispatch] = useReducer(todoReducer, []);
const addTodo = (text: string) => {
dispatch({ type: 'ADD', text });
};
const toggleTodo = (id: number) => {
dispatch({ type: 'TOGGLE', id });
};
return (
<div>
{todos.map(todo => (
<div key={todo.id}>
<input type="checkbox" onChange={() => toggleTodo(todo.id)} />
<span>{todo.text}</span>
</div>
))}
</div>
);
}
Reducerは純粋関数なので、ロジックを簡単にテストできます。弊社では、3つ以上の更新パターンが存在する場合、または配列操作のような複雑な更新がある場合にuseReducerを採用します。
深いネストではuseReducerを使う
コンポーネント階層が深くなると、propsのバケツリレーが発生します。useReducerとContextを組み合わせることで、アクションという文字列ベースのインターフェースを通じて疎結合な状態管理を実現します。
// 推奨しない:深いネストでのpropsバケツリレー
function App() {
const [cart, setCart] = useState(initialCart);
// 5階層下のコンポーネントにpropsを渡す必要がある
return (
<Layout cart={cart} onAddItem={handleAddItem}>
<ProductList cart={cart} onAddItem={handleAddItem}>
<ProductGrid cart={cart} onAddItem={handleAddItem}>
{/* 更に深いネスト... */}
</ProductGrid>
</ProductList>
</Layout>
);
}
// 推奨:useReducerとContextで疎結合化
type CartAction =
| { type: 'ADD_ITEM'; payload: { item: Item } }
| { type: 'REMOVE_ITEM'; payload: { id: string } }
| { type: 'CLEAR_CART' };
const CartContext = createContext<{
state: CartState;
dispatch: Dispatch<CartAction>;
} | null>(null);
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
// アクションの文字列がインターフェースとなる
const newCart = state.addItem(action.payload.item);
return newCart;
}
case 'REMOVE_ITEM': {
const newCart = state.removeItem(action.payload.id);
return newCart;
}
case 'CLEAR_CART': {
return new CartState();
}
}
}
// 深い階層のコンポーネント
function DeepNestedComponent() {
const { dispatch } = useContext(CartContext);
// 文字列ベースのアクションで疎結合
const handleAdd = (item: Item) => {
dispatch({ type: 'ADD_ITEM', payload: { item } });
};
return <button onClick={() => handleAdd(item)}>追加</button>;
}
弊社では、ネストが3階層を超える場合やバケツリレーが発生する場合にuseReducerを使用します。アクションタイプを文字列にすることで、コンポーネント間の依存を減らします。
複雑な状態遷移では有限オートマトンを使う
状態遷移を持つオブジェクトは、クラスを複数に分けて、各クラスのメソッドで別のクラスのインスタンスを返すことで有限オートマトンを実装します。
// 推奨しない:単一クラスでの状態管理
class Player {
constructor(
private readonly state: 'idle' | 'playing' | 'paused' | 'error'
) {}
// 複雑な条件分岐が必要
play() {
if (this.state === 'error') return this;
if (this.state === 'playing') return this;
return new Player('playing');
}
}
// 推奨:状態ごとにクラスを分割
// 基底となる抽象的な型定義
type PlayerState = IdlePlayer | PlayingPlayer | PausedPlayer | ErrorPlayer;
// 各状態を個別のクラスで実装
class IdlePlayer {
constructor() {
Object.freeze(this);
}
// 待機状態から可能な操作のみ定義
play(): PlayingPlayer {
return new PlayingPlayer();
}
get status() {
return 'idle' as const;
}
}
class PlayingPlayer {
constructor() {
Object.freeze(this);
}
// 再生中に可能な操作のみ定義
pause(): PausedPlayer {
return new PausedPlayer();
}
stop(): IdlePlayer {
return new IdlePlayer();
}
get status() {
return 'playing' as const;
}
}
class PausedPlayer {
constructor() {
Object.freeze(this);
}
// 一時停止中に可能な操作のみ定義
play(): PlayingPlayer {
return new PlayingPlayer();
}
stop(): IdlePlayer {
return new IdlePlayer();
}
get status() {
return 'paused' as const;
}
}
class ErrorPlayer {
constructor(private readonly error: Error) {
Object.freeze(this);
}
// エラー状態から可能な操作のみ定義
retry(): IdlePlayer {
return new IdlePlayer();
}
get status() {
return 'error' as const;
}
get message() {
return this.error.message;
}
}
// コンポーネントでの利用
function VideoPlayer() {
const [player, setPlayer] = useState<PlayerState>(
() => new IdlePlayer()
);
const handlePlay = () => {
// instanceofで型安全に状態を判定(推奨)
if (player instanceof IdlePlayer || player instanceof PausedPlayer) {
setPlayer(player.play());
}
};
const handlePause = () => {
if (player instanceof PlayingPlayer) {
setPlayer(player.pause());
}
};
const handleStop = () => {
if (player instanceof PlayingPlayer || player instanceof PausedPlayer) {
setPlayer(player.stop());
}
};
// 別の判定方法:in演算子を使った判定
const handlePlayAlternative = () => {
// 型チェックで状態を判定
if ('play' in player) {
// IdlePlayerまたはPausedPlayerの場合のみ
setPlayer(player.play());
}
};
// 状態に応じたUI表示
return (
<div>
{player.status === 'idle' && (
<button onClick={handlePlay}>再生</button>
)}
{player.status === 'playing' && (
<>
<PlayingUI />
<button onClick={handlePause}>一時停止</button>
<button onClick={handleStop}>停止</button>
</>
)}
{player.status === 'paused' && (
<>
<PausedUI />
<button onClick={handlePlay}>再生</button>
<button onClick={handleStop}>停止</button>
</>
)}
{player.status === 'error' && (
<ErrorUI message={player.message} />
)}
</div>
);
}
クラスを分割することで、各状態で可能な操作のみがメソッドとして定義され、不正な状態遷移をコンパイル時に防げます。TypeScriptの型チェックにより、存在しないメソッドの呼び出しはエラーとなります。
よくある問題
状態の初期化タイミング
コンポーネントマウント時に非同期で状態を初期化する場合は、TanStack QueryのinitialDataを活用します。
複数コンポーネント間の状態同期
異なるコンポーネント間で状態を同期する場合は、Context APIを使用します。距離が近い場合はpropsで渡します。
楽観的更新の実装
サーバー応答を待たずにUIを更新する場合は、TanStack QueryのoptimisticUpdatesまたは手動でのロールバック実装を行います。
メモリリークの防止
コンポーネントアンマウント後も購読が残る場合は、useEffectのクリーンアップを適切に実装します。
有限オートマトンが複雑になる
状態が10個を超える場合は、階層化された状態機械や複数の小さな状態機械への分割を検討します。必要に応じてXStateライブラリの導入も選択肢となります。
クラスインスタンスの等価性判定
useStateでクラスインスタンスを使う場合、React.memoやuseMemoでの等価性判定に注意が必要です。必要に応じてequals()メソッドを実装します。