React useStateの実務的な使い方|実装パターンとベストプラクティス
はじめに:useStateの基本概念
React の useState は、関数コンポーネントで状態を管理するための最も基本的なフックです。しかし、実務プロジェクトでは単なる値の保持以上の工夫が必要になります。本記事では、教科書的な例ではなく、実際のプロダクション環境で活用される実装パターンを解説します。
useState の基本的な使い方:
- 状態値と更新関数をペアで取得
- 再レンダリングをトリガー
- 不変性を保つ更新が重要
業務でよく遭遇する実務ユースケース
1. フォーム入力管理の複雑化
実務では、単一の入力フィールドではなく、複数フィールドを持つフォームを扱うことがほとんどです。ユーザー登録フォーム、検索フィルター、設定画面など、複数の状態を一度に管理する必要があります。
2. API通信の状態管理
データ取得時のローディング状態、エラーハンドリング、成功時のデータ保持などを同時に管理する必要があります。
3. UI状態とビジネスロジックの分離
モーダルの開閉、タブの選択状態、ページネーション など UI に関わる状態と、ユーザー情報やショッピングカート内容などのビジネスロジック的な状態を分けて管理することが重要です。
4. 非同期処理と状態更新のタイミング
複数の非同期処理が走る環境で、状態の更新順序やレース条件への対応が必要になります。
実装コード:実務的なパターン
パターン1:複数フィールドを持つフォーム管理
典型的なユースケースとして、ユーザープロフィール更新フォームを例に挙げます。
// UserProfileForm.tsx
import React, { useState } from 'react';
interface UserProfile {
name: string;
email: string;
age: number;
bio: string;
isPublic: boolean;
}
interface FormErrors {
name?: string;
email?: string;
age?: string;
}
export const UserProfileForm: React.FC = () => {
// フォーム入力状態
const [formData, setFormData] = useState({
name: '',
email: '',
age: 0,
bio: '',
isPublic: false,
});
// バリデーションエラー状態
const [errors, setErrors] = useState({});
// 送信処理の状態
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
const [submitError, setSubmitError] = useState(null);
// 変更検知状態(未保存警告用)
const [isDirty, setIsDirty] = useState(false);
const handleInputChange = (
e: React.ChangeEvent
) => {
const { name, value, type } = e.currentTarget;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox'
? (e.currentTarget as HTMLInputElement).checked
: value,
}));
// 入力があれば未保存状態に
setIsDirty(true);
// 入力値に対するリアルタイムバリデーション
validateField(name, value);
};
const validateField = (name: string, value: string) => {
let newError: string | undefined;
switch (name) {
case 'name':
if (value.trim().length === 0) {
newError = '名前は必須です';
} else if (value.length > 50) {
newError = '名前は50文字以内で入力してください';
}
break;
case 'email':
const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
if (!emailRegex.test(value)) {
newError = '有効なメールアドレスを入力してください';
}
break;
case 'age':
const ageNum = parseInt(value, 10);
if (isNaN(ageNum) || ageNum < 0 || ageNum > 150) {
newError = '年齢は0〜150の数値で入力してください';
}
break;
}
setErrors(prev => ({
...prev,
[name]: newError,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// バリデーション確認
if (Object.keys(errors).length > 0) {
setSubmitError('入力内容をご確認ください');
return;
}
setIsSubmitting(true);
setSubmitError(null);
setSubmitSuccess(false);
try {
const response = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!response.ok) {
throw new Error('プロフィール更新に失敗しました');
}
setSubmitSuccess(true);
setIsDirty(false);
// 成功メッセージは3秒後に消える
setTimeout(() => setSubmitSuccess(false), 3000);
} catch (error) {
setSubmitError(
error instanceof Error ? error.message : 'エラーが発生しました'
);
} finally {
setIsSubmitting(false);
}
};
return (
);
};
パターン2:API呼び出しを伴う状態管理(カスタムフック化)
実務では、複数のコンポーネントで同じようなAPI通信ロジックが必要になります。カスタムフックに抽出することで、再利用性と保守性が向上します。
// useAsync.ts - 汎用的な非同期処理フック
import { useState, useCallback, useEffect } from 'react';
interface AsyncState {
status: 'idle' | 'pending' | 'success' | 'error';
data: T | null;
error: Error | null;
}
interface UseAsyncOptions {
skip?: boolean;
retryCount?: number;
retryDelay?: number;
}
export function useAsync(
asyncFunction: () => Promise,
options: UseAsyncOptions = {}
) {
const { skip = false, retryCount = 3, retryDelay = 1000 } = options;
const [state, setState] = useState>({
status: 'idle',
data: null,
error: null,
});
const [retries, setRetries] = useState(0);
const execute = useCallback(async () => {
setState({ status: 'pending', data: null, error: null });
try {
const response = await asyncFunction();
setState({ status: 'success', data: response, error: null });
setRetries(0);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
if (retries < retryCount) {
// リトライロジック
setTimeout(() => {
setRetries(prev => prev + 1);
}, retryDelay);
} else {
setState({ status: 'error', data: null, error: err });
}
}
}, [asyncFunction, retries, retryCount, retryDelay]);
useEffect(() => {
if (skip) return;
execute();
}, [execute, skip]);
return {
...state,
execute, // 手動で再実行するための関数
};
}
// 使用例
interface Product {
id: number;
name: string;
price: number;
}
export const ProductList: React.FC<{ categoryId: number }> = ({ categoryId }) => {
const fetchProducts = useCallback(
() => fetch(`/api/products?category=${categoryId}`).then(r => r.json()),
[categoryId]
);
const { status, data: products, error, execute: refetch } = useAsync(
fetchProducts,
{ retryCount: 2 }
);
if (status === 'pending') return 読み込み中...;
if (status === 'error') {
return (
エラーが発生しました: {error?.message}
);
}
return (
{products?.map(product => (
{product.name}
¥{product.price}
))}
);
};
パターン3:複雑な状態更新のベストプラクティス
実務では、状態更新が複雑になり、単純な setState では対応できないケースがあります。
// ShoppingCart.tsx - 複雑な状態管理の例
import React, { useState, useCallback } from 'react';
interface CartItem {
productId: number;
quantity: number;
price: number;
}
interface CartState {
items: CartItem[];
appliedCoupon: string | null;
discount: number;
tax: number;
}
export const ShoppingCart: React.FC = () => {
const [cart, setCart] = useState({
items: [],
appliedCoupon: null,
discount: 0,
tax: 0,
});
// ❌ 悪い例:複数の setState で状態を更新
// これは一度に複数回の再レンダリングが発生する
const addItemBad = (productId: number, quantity: number, price: number) => {
setCart(prev => ({
...prev,
items: [...prev.items, { productId, quantity, price }],
}));
// 再計算が必要だが別の setState になってしまう
};
// ✅ 良い例:一度の setState で完全な更新を行う
const addItem = useCallback((productId: number, quantity: number, price: number) => {
setCart(prev => {
const existingItem = prev.items.find(item => item.productId === productId);
let newItems: CartItem[];
if (existingItem) {
// 既存商品の場合は数量を追加
newItems = prev.items.map(item =>
item.productId === productId
? { ...item, quantity: item.quantity + quantity }
: item
);
} else {
// 新規商品として追加
newItems = [...prev.items, { productId, quantity, price }];
}
// 合計を再計算
const subtotal = newItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
const newTax = Math.round(subtotal * 0.1 * 100) / 100;
const newDiscount = prev.appliedCoupon ? calculateDiscount(subtotal, prev.appliedCoupon) : 0;
return {
...prev,
items: newItems,
tax: newTax,
discount: newDiscount,
};
});
}, []);
const removeItem = useCallback((productId: number) => {
setCart(prev => {
const newItems = prev.items.filter(item => item.productId !== productId);
// 削除後に再計算
const subtotal = newItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
const newTax = Math.round(subtotal * 0.1 * 100) / 100;
const newDiscount = prev.appliedCoupon ? calculateDiscount(subtotal, prev.appliedCoupon) : 0;
return {
...prev,
items: newItems,
tax: newTax,
discount: newDiscount,
};
});
}, []);
const applyCoupon = useCallback((couponCode: string) => {
setCart(prev => {
// クーポンの検証と割引計算
const subtotal = prev.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const newDiscount = validateAndCalculateDiscount(couponCode, subtotal);
if (newDiscount === null) {
// クーポンが無効な場合
return prev;
}
return {
...prev,
appliedCoupon: couponCode,
discount: newDiscount,
};
});
}, []);
const getTotalPrice = () => {
const subtotal = cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return Math.round((subtotal + cart.tax - cart.discount) * 100) / 100;
};
return (
ショッピングカート
{cart.items.length === 0 ? (
カートは空です
) : (
cart.items.map((item) => (
商品ID: {item.productId}
数量: {item.quantity}
金額: ¥{(item.price * item.quantity).toFixed(2)}
))
)}
小計: ¥{cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0).toFixed(2)}
税金: ¥{cart.tax.toFixed(2)}
{cart.appliedCoupon && 割引: -¥{cart.discount.toFixed(2)}
}
合計: ¥{getTotalPrice().toFixed(2)}
{
if (e.key === 'Enter' && e.currentTarget.value) {
applyCoupon(e.currentTarget.value);
e.currentTarget.value = '';
}
}}
/>
);
};
function calculateDiscount(subtotal: number, coupon: string): number {
// クーポン割引計算ロジック
const discountMap: Record = {
'SAVE10': 0.1,
'SAVE20': 0.2,
};
const rate = discountMap[coupon] || 0;
return Math.round(subtotal * rate * 100) / 100;
}
function validateAndCalculateDiscount(coupon: string, subtotal: number): number | null {
// クーポン検証ロジック
if (!coupon || coupon.length === 0) return null;
const discount = calculateDiscount(subtotal, coupon);
return discount > 0 ? discount : null;
}
よくある応用パターン
パターン4:複数の独立した状態の管理
タブ切り替え、モーダル開閉、フィルター条件など、複数の独立した UI 状態を管理するケースです。
// DataDashboard.tsx - 複数の独立した状態管理
import React, { useState } from 'react';
type TabType = 'overview' | 'analytics' | 'settings';
type SortOrder = 'asc' | 'desc';
export const DataDashboard: React.FC = () => {
// UI状態
const [activeTab, setActiveTab] = useState('overview');
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
// フィルター関連の状態
const [filters, setFilters] = useState({
searchTerm: '',
dateRange: { start: '', end: '' },
status: 'all' as const,
});
// ソート関連の状態
const [sortConfig, setSortConfig] = useState({
key: 'date' as const,
order: 'desc' as SortOrder,
});
// ページネーション状態
const [pagination, setPagination] = useState({
currentPage: 1,
pageSize: 20,
});
// リセット機能:フィルターをリセットする際は複数の状態をまとめてリセット
const handleResetFilters = () => {
setFilters({
searchTerm: '',
dateRange: { start: '', end: '' },
status: 'all',
});
setPagination({ currentPage: 1, pageSize: 20 });
};
// フィルター変更時
const handleFilterChange = (key: string, value: any) => {
setFilters(prev => ({
...prev,
[key]: value,
}));
// フィルター変更時は1ページ目にリセット
setPagination(prev => ({ ...prev, currentPage: 1 }));
};
return (
{(['overview', 'analytics', 'settings'] as const).map(tab => (
))}
{activeTab === 'overview' && (
{isFilterOpen && (
handleFilterChange('searchTerm', e.target.value)}
/>
)}
{/* テーブル表示など */}
ページ {pagination.currentPage}
)}
);
};
パターン5:useState と useReducer の使い分け
複雑な状態更新ロジックが必要になった場合、useReducer への移行を検討します。以下は判断基準です。
// 複雑さの判断基準
// useState を使う:
// - シンプルな値(文字列、数値、真偽値)
// - 状態の更新が単純(直接代入レベル)
// - コンポーネント内で完結している
// useReducer を使う:
// - 複数の関連した状態がある
// - 状態更新に複雑なロジックが必要
// - 過去のアクションを追跡したい(デバッグ)
// - 複数のイベントハンドラで同じロジックを共有
// 実例:useReducer への移行
interface OrderState {
items: OrderItem[];
status: 'pending' | 'processing' | 'completed' | 'failed';
totalPrice: number;
appliedDiscount: number;
error: string | null;
}
type OrderAction =
| { type: 'ADD_ITEM'; payload: OrderItem }
| { type: 'REMOVE_ITEM'; payload: number }
| { type: 'UPDATE_QUANTITY'; payload: { itemId: number; quantity: number } }
| { type: 'APPLY_DISCOUNT'; payload: number }
| { type: 'SET_STATUS'; payload: OrderState['status'] }
| { type: 'SET_ERROR'; payload: string }
| { type: 'RESET' };
interface OrderItem {
id: number;
name: string;
price: number;
quantity: number;
}
function orderReducer(state: OrderState, action: OrderAction): OrderState {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(item => item.id === action.payload.id);
const newItems = existing
? state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + action.payload.quantity }
: item
)
: [...state.items, action.payload];
return {
...state,
items: newItems,
totalPrice: calculateTotal(newItems, state.appliedDiscount),
};
}
case 'REMOVE_ITEM': {
const newItems = state.items.filter(item => item.id !== action.payload);
return {
...state,
items: newItems,
totalPrice: calculateTotal(newItems, state.appliedDiscount),
};
}
case 'UPDATE_QUANTITY': {
const newItems = state.items.map(item =>
item.id === action.payload.itemId
? { ...item, quantity: action.payload.quantity }
: item
);
return {
...state,
items: newItems,
totalPrice: calculateTotal(newItems, state.appliedDiscount),
};
}
case 'APPLY_DISCOUNT': {
return {
...state,
appliedDiscount: action.payload,
totalPrice: calculateTotal(state.items, action.payload),
};
}
case 'SET_STATUS': {
return {
...state,
status: action.payload,
error: null,
};
}
case 'SET_ERROR': {
return {
...state,
status: 'failed',
error: action.payload,
};
}
case 'RESET': {
return {
items: [],
status: 'pending',
totalPrice: 0,
appliedDiscount: 0,
error: null,
};
}
default:
return state;
}
}
function calculateTotal(items: OrderItem[], discount: number): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return Math.max(0, subtotal - discount);
}
// useReducer を使った実装
import { useReducer } from 'react';
export const OrderForm: React.FC = () => {
const [state, dispatch] = useReducer(orderReducer, {
items: [],
status: 'pending',
totalPrice: 0,
appliedDiscount: 0,
error: null,
});
const handleAddItem = (item: OrderItem) => {
dispatch({ type: 'ADD_ITEM', payload: item });
};
const handleRemoveItem = (itemId: number) => {
dispatch({ type: 'REMOVE_ITEM', payload: itemId });
};
const handleApplyDiscount = async (discountCode: string) => {
dispatch({ type: 'SET_STATUS', payload: 'processing' });
try {
const response = await fetch(`/api/discount/${discountCode}`);
const data = await response.json();
dispatch({ type: 'APPLY_DISCOUNT', payload: data.discountAmount });
dispatch({ type: 'SET_STATUS', payload: 'completed' });
} catch (error) {
dispatch({ type: 'SET_ERROR', payload: 'ディスカウント適用に失敗しました' });
}
};
return (
{state.items.map(item => (
{item.name} - ¥{item.price} x {item.quantity}
))}
合計: ¥{state.totalPrice}
{state.error && {state.error}}
);
};
useStateの注意点と実務での落とし穴
1. クロージャによる古い状態の参照
非同期処理内で状態を参照する場合、古い状態にアクセスしてしまう問題があります。

