フリーランスで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を直接書き換えるのではなく、「何が起きたか」を表すactionをdispatchするだけになります。
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があちこちに散らばっていく感覚から、actionとreducerで整理された感覚に変わったのが、自分でも大きな収穫でした。
次回は、Next.jsとの組み合わせ方、特にApp Routerでのレンダリング戦略について整理します。