← ブログ一覧に戻る

Reactを基礎から学び直す #4: 状態管理の整理とuseReducerの実践

2026.07.29

フリーランスでWeb制作をしている矢野です。

前回は、useStateとuseEffectの実践的な使い方、特にESLintのルールをきっかけに設計を見直した話を書きました。

今回は、複数の画面をまたぐStateをどう管理するか、RE:FITで実際に採用したuseReducerを中心に整理します。

診断アプリのように画面数が増えてくると、useStateだけではStateの管理がだんだん苦しくなってくる、という話です。

画面が増えるとuseStateだけでは辛くなる

RE:FITの診断UIは、開始画面・4つの質問・結果画面・予約・確認・完了画面という、全部で9つのステップで構成されています。

それぞれの画面は、「今どのステップにいるか」「質問への回答」「予約の入力内容」という3種類のStateを共有しながら動いています。

最初のうちは、これらを1つずつuseStateで管理しようとしていました。

BEFORE(画面ごとにuseStateを並べる)

const [step, setStep] = useState("start");
const [goal, setGoal] = useState(null);
const [experience, setExperience] = useState(null);
const [frequency, setFrequency] = useState(null);
const [concerns, setConcerns] = useState([]);
const [date, setDate] = useState(null);
const [time, setTime] = useState(null);

質問が1つ増えるたびに、useStateもセットで増えていきます。

さらに「次のステップに進む」という単純な操作のために、setStepと関連するsetXxx系の関数を、あちこちのコンポーネントで呼ぶ必要が出てきました。

これでは、どの操作がどのStateを変えるのか、コードを読むだけでは追いにくくなってしまいます。

useReducerでStateの変化を1箇所にまとめる

そこで導入したのが、useReducerです。

useReducerは、「現在のState」と「actionという名前の操作」を受け取って「次のState」を返す、reducerという1つの関数にStateの変化をまとめて任せるHookです。

RE:FITでは、Stateの形とreducer関数を、こう定義しています。

AFTER

type DiagnosisState = {
  step: Step;
  answers: Answers;
  booking: BookingInfo;
};

type DiagnosisAction =
  | { type: "START" }
  | { type: "SET_GOAL"; goal: Goal }
  | { type: "TOGGLE_CONCERN"; concern: Concern }
  | { type: "NEXT" }
  | { type: "BACK" }
  | { type: "SUBMIT_BOOKING" };

const diagnosisReducer = (state: DiagnosisState, action: DiagnosisAction): DiagnosisState => {
  switch (action.type) {
    case "START":
      return { ...state, step: "q-goal" };

    case "SET_GOAL":
      return {
        ...state,
        answers: { ...state.answers, goal: action.goal },
        step: "q-experience",
      };

    case "TOGGLE_CONCERN": {
      const has = state.answers.concerns.includes(action.concern);
      const concerns = has
        ? state.answers.concerns.filter((c) => c !== action.concern)
        : [...state.answers.concerns, action.concern];
      return { ...state, answers: { ...state.answers, concerns } };
    }

    default:
      return state;
  }
};

コンポーネント側は、Stateを直接書き換えるのではなく、「何が起きたか」を表すactiondispatchするだけになります。

const [state, dispatch] = useReducer(diagnosisReducer, initialState);
const { step, answers, booking } = state;

dispatch({ type: "SET_GOAL", goal });

dispatch({ type: "NEXT" });

useReducerに変えてよかったこと

1つ目は、Stateが変化するロジックがすべてdiagnosisReducerの中に集まったことです。

「このactionが来たら何が起きるか」を、コンポーネントを1つずつ追わなくても、reducer関数を読むだけで把握できるようになりました。

2つ目は、reducerが純粋関数であることのメリットです。

画面を描画しなくても、Stateとactionを渡すだけでテストできるので、動作確認がぐっと楽になったんです。

3つ目は、コンポーネント側の関心が「何が起きたかをdispatchすること」だけに減った点です。

Stateの中身がどう変わるかを、画面側のコードは知らなくてよくなりました。

useReducerを使うかどうかの判断基準

とはいえ、なんでもかんでもuseReducerにすればいいわけではないとも感じています。

Stateの数が少なく、更新のロジックも単純なら、useStateで十分だと思います。

逆に、複数のStateが連動して変化する、更新のパターンがactionとして列挙できる、という条件が揃ってきたらuseReducerを検討する、というのが今のところの自分の判断基準です。

まとめ

今回は、複数の画面をまたぐStateの管理を、useStateの集まりからuseReducerに切り替えた話を書きました。

個々のsetStateがあちこちに散らばっていく感覚から、actionreducerで整理された感覚に変わったのが、自分でも大きな収穫でした。

次回は、Next.jsとの組み合わせ方、特にApp Routerでのレンダリング戦略について整理します。