React useReducer 実務での使い方|複雑な状態管理を実装するパターン集

React / Next.js

React useReducer 実務での使い方|複雑な状態管理を実装するパターン集

Reactで状態管理を行う際、useStateが基本となりますが、複数の関連する状態を扱う場合や状態遷移が複雑な場合にはuseReducerが活躍します。本記事では、実務で頻繁に遭遇する状況を想定した、実践的なuseReducerの使い方を解説します。

useReducerの基本的な考え方

useReducerはシンプルに言えば、状態と行動(Action)を組み合わせて新しい状態を作り出す関数です。複数の関連する状態をまとめて管理できるため、状態間の関連性が強い場合に有効です。

// 基本的な使い方
const [state, dispatch] = useReducer(reducer, initialState);

// reducerは以下のような形
function reducer(state: State, action: Action): State {
  switch(action.type) {
    case 'ACTION_TYPE':
      return { ...state, /* 変更内容 */ };
    default:
      return state;
  }
}

実務で遭遇する複雑な状態管理

それでは、実務で実際に使用しているパターンを紹介します。

1. フォーム入力と検証エラーの管理

ユーザー情報編集フォームのような、複数のフィールドと対応する検証エラーを管理する場合、useReducerが有効です。

// ユーザーフォーム管理の実装例
interface FormState {
  values: {
    name: string;
    email: string;
    phone: string;
    address: string;
  };
  errors: {
    name?: string;
    email?: string;
    phone?: string;
    address?: string;
  };
  touched: {
    name: boolean;
    email: boolean;
    phone: boolean;
    address: boolean;
  };
  isSubmitting: boolean;
  submitError?: string;
}

type FormAction =
  | { type: 'CHANGE_FIELD'; payload: { field: keyof FormState['values']; value: string } }
  | { type: 'BLUR_FIELD'; payload: { field: keyof FormState['values'] } }
  | { type: 'SET_ERRORS'; payload: Record }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_SUCCESS' }
  | { type: 'SUBMIT_ERROR'; payload: string }
  | { type: 'RESET_FORM' };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'CHANGE_FIELD':
      return {
        ...state,
        values: {
          ...state.values,
          [action.payload.field]: action.payload.value,
        },
      };

    case 'BLUR_FIELD':
      return {
        ...state,
        touched: {
          ...state.touched,
          [action.payload.field]: true,
        },
      };

    case 'SET_ERRORS':
      return {
        ...state,
        errors: action.payload,
      };

    case 'SUBMIT_START':
      return {
        ...state,
        isSubmitting: true,
        submitError: undefined,
      };

    case 'SUBMIT_SUCCESS':
      return {
        ...state,
        isSubmitting: false,
        values: initialFormState.values,
        touched: initialFormState.touched,
        errors: {},
      };

    case 'SUBMIT_ERROR':
      return {
        ...state,
        isSubmitting: false,
        submitError: action.payload,
      };

    case 'RESET_FORM':
      return initialFormState;

    default:
      return state;
  }
}

const initialFormState: FormState = {
  values: { name: '', email: '', phone: '', address: '' },
  errors: {},
  touched: { name: false, email: false, phone: false, address: false },
  isSubmitting: false,
};

// コンポーネント内での使用
function UserEditForm() {
  const [state, dispatch] = useReducer(formReducer, initialFormState);

  const handleChange = (e: React.ChangeEvent) => {
    const { name, value } = e.currentTarget;
    dispatch({
      type: 'CHANGE_FIELD',
      payload: { field: name as keyof FormState['values'], value },
    });
  };

  const handleBlur = (e: React.FocusEvent) => {
    const { name } = e.currentTarget;
    dispatch({
      type: 'BLUR_FIELD',
      payload: { field: name as keyof FormState['values'] },
    });
  };

  const validateForm = () => {
    const newErrors: Record = {};
    if (!state.values.name.trim()) newErrors.name = '名前は必須です';
    if (!state.values.email.match(/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/)) {
      newErrors.email = 'メールアドレスが無効です';
    }
    dispatch({ type: 'SET_ERRORS', payload: newErrors });
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!validateForm()) return;

    dispatch({ type: 'SUBMIT_START' });

    try {
      await fetch('/api/users', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(state.values),
      });
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (error) {
      dispatch({
        type: 'SUBMIT_ERROR',
        payload: '送信に失敗しました。もう一度お試しください。',
      });
    }
  };

  return (
    
{state.touched.name && state.errors.name && ( {state.errors.name} )}
{state.touched.email && state.errors.email && ( {state.errors.email} )}
{state.submitError &&

{state.submitError}

}
); }

2. 非同期データ取得とローディング状態の管理

APIからデータを取得する際、ローディング状態、成功時のデータ、エラーメッセージなど複数の状態が必要になります。これらをuseReducerで統括管理するのが実務的です。

// データ取得管理の実装例
interface FetchState {
  data: T | null;
  loading: boolean;
  error: string | null;
  lastFetched: Date | null;
  retryCount: number;
}

interface User {
  id: number;
  name: string;
  email: string;
  role: string;
}

type FetchAction =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: T }
  | { type: 'FETCH_ERROR'; payload: string }
  | { type: 'RETRY' }
  | { type: 'RESET' };

function fetchReducer(state: FetchState, action: FetchAction): FetchState {
  switch (action.type) {
    case 'FETCH_START':
      return {
        ...state,
        loading: true,
        error: null,
      };

    case 'FETCH_SUCCESS':
      return {
        ...state,
        data: action.payload,
        loading: false,
        error: null,
        lastFetched: new Date(),
        retryCount: 0,
      };

    case 'FETCH_ERROR':
      return {
        ...state,
        loading: false,
        error: action.payload,
        retryCount: state.retryCount + 1,
      };

    case 'RETRY':
      return {
        ...state,
        error: null,
        loading: true,
      };

    case 'RESET':
      return {
        data: null,
        loading: false,
        error: null,
        lastFetched: null,
        retryCount: 0,
      };

    default:
      return state;
  }
}

// コンポーネント内での使用
function UserList() {
  const [state, dispatch] = useReducer(fetchReducer, {
    data: null,
    loading: false,
    error: null,
    lastFetched: null,
    retryCount: 0,
  });

  useEffect(() => {
    const fetchUsers = async () => {
      dispatch({ type: 'FETCH_START' });

      try {
        const response = await fetch('/api/users');
        if (!response.ok) throw new Error('ユーザー取得に失敗しました');
        const data = await response.json();
        dispatch({ type: 'FETCH_SUCCESS', payload: data });
      } catch (error) {
        dispatch({
          type: 'FETCH_ERROR',
          payload: error instanceof Error ? error.message : '不明なエラー',
        });
      }
    };

    fetchUsers();
  }, []);

  const handleRetry = () => {
    dispatch({ type: 'RETRY' });
    // 再度fetch処理を実行
  };

  if (state.loading) return 

読み込み中...

; if (state.error) { return (

{state.error}

); } return (
    {state.data?.map((user) => (
  • {user.name} - {user.email}
  • ))}
); }

実務でよくある応用パターン

3. ページング + フィルタリングの状態管理

リスト表示ページでページングやフィルタリング機能を実装する際、複数の状態を一元管理するのが重要です。

interface ListState {
  items: T[];
  totalCount: number;
  currentPage: number;
  pageSize: number;
  filter: {
    searchQuery: string;
    sortBy: 'name' | 'date' | 'relevance';
    sortOrder: 'asc' | 'desc';
  };
  loading: boolean;
  error: string | null;
}

type ListAction =
  | { type: 'SET_ITEMS'; payload: { items: T[]; totalCount: number } }
  | { type: 'NEXT_PAGE' }
  | { type: 'PREV_PAGE' }
  | { type: 'GO_TO_PAGE'; payload: number }
  | { type: 'SET_SEARCH'; payload: string }
  | { type: 'SET_SORT'; payload: { sortBy: string; sortOrder: 'asc' | 'desc' } }
  | { type: 'LOADING' }
  | { type: 'ERROR'; payload: string }
  | { type: 'RESET' };

function listReducer(state: ListState, action: ListAction): ListState {
  switch (action.type) {
    case 'SET_ITEMS':
      return {
        ...state,
        items: action.payload.items,
        totalCount: action.payload.totalCount,
        loading: false,
        error: null,
      };

    case 'NEXT_PAGE':
      return {
        ...state,
        currentPage: Math.min(
          state.currentPage + 1,
          Math.ceil(state.totalCount / state.pageSize)
        ),
      };

    case 'PREV_PAGE':
      return {
        ...state,
        currentPage: Math.max(state.currentPage - 1, 1),
      };

    case 'GO_TO_PAGE':
      return {
        ...state,
        currentPage: Math.max(1, action.payload),
      };

    case 'SET_SEARCH':
      return {
        ...state,
        filter: { ...state.filter, searchQuery: action.payload },
        currentPage: 1, // 検索時はページを1に戻す
      };

    case 'SET_SORT':
      return {
        ...state,
        filter: {
          ...state.filter,
          sortBy: action.payload.sortBy as any,
          sortOrder: action.payload.sortOrder,
        },
      };

    case 'LOADING':
      return { ...state, loading: true };

    case 'ERROR':
      return { ...state, loading: false, error: action.payload };

    case 'RESET':
      return {
        items: [],
        totalCount: 0,
        currentPage: 1,
        pageSize: 10,
        filter: { searchQuery: '', sortBy: 'name', sortOrder: 'asc' },
        loading: false,
        error: null,
      };

    default:
      return state;
  }
}

4. 複雑なワークフロー状態の管理

例えば、注文処理のような複数ステップを持つワークフローでは、状態遷移ルールを厳密に定義することが重要です。

type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';

interface OrderState {
  status: OrderStatus;
  items: OrderItem[];
  totalAmount: number;
  paymentMethod?: string;
  trackingNumber?: string;
  estimatedDelivery?: Date;
  cancellationReason?: string;
  history: Array<{ status: OrderStatus; timestamp: Date }>;
}

interface OrderItem {
  id: string;
  name: string;
  quantity: number;
  price: number;
}

type OrderAction =
  | { type: 'CREATE_ORDER'; payload: OrderItem[] }
  | { type: 'PROCESS_PAYMENT'; payload: string }
  | { type: 'SHIP_ORDER'; payload: { trackingNumber: string; estimatedDelivery: Date } }
  | { type: 'DELIVER_ORDER' }
  | { type: 'CANCEL_ORDER'; payload: string }
  | { type: 'LOAD_ORDER'; payload: OrderState };

function orderReducer(state: OrderState, action: OrderAction): OrderState {
  const addHistory = (newStatus: OrderStatus) => [
    ...state.history,
    { status: newStatus, timestamp: new Date() },
  ];

  switch (action.type) {
    case 'CREATE_ORDER':
      return {
        status: 'pending',
        items: action.payload,
        totalAmount: action.payload.reduce((sum, item) => sum + item.price * item.quantity, 0),
        history: [{ status: 'pending', timestamp: new Date() }],
      };

    case 'PROCESS_PAYMENT':
      if (state.status !== 'pending') {
        throw new Error('Invalid state transition: payment can only be processed from pending');
      }
      return {
        ...state,
        status: 'processing',
        paymentMethod: action.payload,
        history: addHistory('processing'),
      };

    case 'SHIP_ORDER':
      if (state.status !== 'processing') {
        throw new Error('Invalid state transition: can only ship from processing');
      }
      return {
        ...state,
        status: 'shipped',
        trackingNumber: action.payload.trackingNumber,
        estimatedDelivery: action.payload.estimatedDelivery,
        history: addHistory('shipped'),
      };

    case 'DELIVER_ORDER':
      if (state.status !== 'shipped') {
        throw new Error('Invalid state transition: can only deliver from shipped');
      }
      return {
        ...state,
        status: 'delivered',
        history: addHistory('delivered'),
      };

    case 'CANCEL_ORDER':
      if (!['pending', 'processing'].includes(state.status)) {
        throw new Error('Invalid state transition: can only cancel from pending or processing');
      }
      return {
        ...state,
        status: 'cancelled',
        cancellationReason: action.payload,
        history: addHistory('cancelled'),
      };

    case 'LOAD_ORDER':
      return action.payload;

    default:
      return state;
  }
}

useReducerの注意点と実務的なTips

エラーハンドリング

useReducer内でエラーをスローする際は注意が必要です。状態遷移ルール違反はtry-catchで適切に処理しましょう。

function OrderComponent() {
  const [state, dispatch] = useReducer(orderReducer, initialOrderState);

  const handlePayment = async (paymentMethod: string) => {
    try {
      dispatch({ type: 'PROCESS_PAYMENT', payload: paymentMethod });
      // API呼び出しなど
    } catch (error) {
      console.error('Payment processing failed:', error);
      // ユーザーにエラー通知
    }
  };
}

パフォーマンス最適化

useCallbackと組み合わせて、dispatch関数を安定化することで不要な再レンダリングを防ぎます。

function SearchComponent() {
  const [state, dispatch] = useReducer(listReducer, initialState);

  const handleSearch = useCallback(
    (query: string) => {
      dispatch({ type: 'SET_SEARCH', payload: query });
    },
    []
  );

  return ;
}

DevTools連携

複雑な状態管理では、Redux DevToolsの活用が便利です。Chrome拡張機能で状態遷移を可視化できます。

// Redux DevToolsの基本的な活用
const composeEnhancers =
  typeof window === 'object' && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
    ? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
    : () => (f: any) => f;

// useReducerそのものではなく、グローバル状態管理と組み合わせる際に有効

useState vs useReducer の選択基準

実務では以下のような基準で使い分けています。

  • useStateを選ぶ場合:単純な状態(toggle、単一の値など)、状態間の関連性が低い場合
  • useReducerを選ぶ場合:複数の関連した状態、状態遷移が複雑、アンドゥ・リドゥが必要、テストが重要な場合

実務的なベストプラクティス

1. Action型を厳密に定義

TypeScriptを活用して、型安全なActionを定義することで、ランタイムエラーを未然に防げます。

2. Reducerは純粋関数に

副作用を持たず、同じInputに対して常に同じOutputを返すように実装します。

3. Action Creatorの活用

// Action Creatorでボイラープレートを削減
const formActions = {
  changeField: (field: string, value: string) => ({
    type: 'CHANGE_FIELD' as const,
    payload: { field, value },
  }),
  submitStart: () => ({ type: 'SUBMIT_START' as const }),
  submitSuccess: () => ({ type: 'SUBMIT_SUCCESS' as const }),
  submitError: (error: string) => ({
    type: 'SUBMIT_ERROR' as const,
    payload: error,
  }),
};

// 使用例
dispatch(formActions.changeField('email', 'user@example.com'));

4. Custom Hooks化による再利用性向上

// フォーム管理をCustom Hookにまとめる
function useForm>(initialValues: T) {
  const [state, dispatch] = useReducer(formReducer, {
    values: initialValues,
    errors: {},
    touched: Object.keys(initialValues).reduce((acc, key) => ({
      ...acc,
      [key]: false,
    }), {}),
    isSubmitting: false,
  });

  return {
    values: state.values,
    errors: state.errors,
    touched: state.touched,
    isSubmitting: state.isSubmitting,
    handleChange: (e: React.ChangeEvent) => {
      dispatch({
        type: 'CHANGE_FIELD',
        payload: { field: e.target.name, value: e.target.value },
      });
    },
    handleBlur: (e: React.FocusEvent) => {
      dispatch({
        type: 'BLUR_FIELD',
        payload: { field: e.target.name },
      });
    },
    setErrors: (errors: Record) => {
      dispatch({ type: 'SET_ERRORS', payload: errors });
    },
  };
}

// コンポーネントでの使用
function MyForm() {
  const form = useForm({ name: '', email: '' });
  // form.values, form.handleChange など使用可能
}

まとめ

useReducerは複雑な状態管理が必要なコンポーネントで強力なツールです。実務では以下のポイントを意識して導入することが重要です。

  • 複数の関連した状態をまとめて管理し、一貫性を保つ
  • TypeScriptで型安全なAction定義を行う
  • 状態遷移ルールを明確にして、無効な遷移を防ぐ
  • Custom Hooksで再利用性を高める
  • 過度に複雑化した場合は、Reduxなどの専門的なライブラリの導入を検討する

これらのパターンを習得することで、保守性の高いReactアプリケーションを構築できます。

タイトルとURLをコピーしました