React useReducer の責務の分離を考える
Gaji-Labo フロントエンドエンジニアの茶木です。
GajiLabo は スタートアップのプロダクト開発支援をしています。息の長いプロジェクトもあり、追加や改善により徐々にコードが複雑になっていきます。一般にコードは書く時間より、読む時間のほうが多いとも言われるので、読みやすいコードライティングを心がけています。
リーダブルなコードで重要なもののひとつが、責務の分離です。
useReducer
取り上げるには今更感もありますが、いつ使っても少しずつ発見があるのが useReducer
です。一見、複雑なので敬遠してた時期もありましたが、ロジックとそれ以外の責務を分離する強力なツールです。
なぜ使うのか?
コンポーネントからロジックを分離するためです。
以下 PasswordForm.tsx
はパスワードフォームを想定したコンポーネントです。
インプット、エラーメッセージ、サブミットボタンといった、DOMツリーライクな構造だけになっています。
実際には、password
と rePassword
の入力と表示、バリデーションとエラーメッセージの表示、パスワード強度の算出などの複雑なロジックが別にありますが、それらは usePassword
に逃されており、コンポーネント自体は非常にリーダブルです。
また usePassword
の ロジックもリーダブルで、それは内部の useReducer
の効用です。後ほど説明します。
const PasswordForm = () => {
const [state, action] = usePassword();
const disabled = !state.password || state.errorMessages.length || state.strength <= 1;
return (
<form>
<label>
パスワード
<Input value={state.password} onChange={(e) => action.setPassword(e.target.value)} />
</label>
<label>
パスワード再入力
<Input value={state.rePassword} onChange={(e) => action.setRePassword(e.target.value)} />
</label>
<Text>{ errorMessages.map(msg => <p>{msg}</p> )}</Text>
<Text>パスワード強度: {state.strength} </Text>
<SubmitButton label="登録" disabled={disabled} />
</form>
);
}
useReducer の設計手順
以下の順番で記述すると、設計しやすいと思います。
State
の型定義- 初期値の定義
- Action の型定義
- reducer
- カスタムフック
以下、汎用的な useReducer
によるカスタムフックのテンプレートです。
type State = {};
const initialState: State = {};
type Action =
| {}
| {}
const reducer = (state: State, action: Action): State => {
switch (action.type) {}
};
export const useXxxxx = (initState: State) => {
const [state, dispatch] = useReducer(reducer, initialState);
const actions = useMemo(() => ({
switch(action.type){
case xxx: {}
default: {
const unreachable: never = action;
throw new Error(unreachable);
}
}
}),[]);
return [state, action];
}
State の型定義
State
の型定義です。ロジックで管理し、呼び出し側に公開する値を書きます。
ステータスとして定義するものと定義しないものの決定は、useReducer
の中で設計でいちばん重要な箇所かもしれません。
ユーザー入力や、APIから取得する値は、他で取得できないためステータスの対象になるでしょう。それ以外のステータスから計算できるような値を定義するかは判断が難しいところです。
この判断についてはのちほど、私見を述べます。
type Strength = 0 | 1 | 2 | 3 ; // 未判定 | 弱い | 普通 | 強い
type State = {
password: string;
rePassword: string;
errorMessage: string;
strength: Strength;
usingWeakPassword: boolean; // 弱いパスワードを使うときは、確定にユーザーの確認が必要になる
};
初期値の定義
初期値は素直に書きます。useReducer
で動的に初期値設定を行う場合もあります。
const initialState: State = {
password: "",
rePassword: "",
errorMessage: "";
strength: 0,
};
Action の型定義
Action
定義のポイントは、ひとつのアクションで必要なステータスの書き換えを達成できる定義にすることです。複数のアクションを組み合わせて目的を達成するように書くと、呼び出し側にどのように組み合わせるか?というロジックができてしまいます。
type Action =
| {
type: "update";
payload: { password: string, rePassword: string };
}
| {
type: "setStrength";
payload: { strength: Strength };
};
reducer
下記コードの update
は payload
と現在のステータスを組み合わせて、複数のステータスを更新しています。特に errorMessage
は password
や rePassword
と連動することが読み取れます。
また、reducer は、ステータス更新だけを責務にするのが良いです。
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "update": {
const { password, rePassword } = action.payload;
return {
...state,
password,
rePassword,
errorMessage: validate( password, rePassword ),
strength: 0,
};
}
case "setStrength": {
const { strength } = action.payload;
return {
...state,
strength,
};
}
default: {
const unreachable: never = action;
throw new Error(unreachable);
}
}
};
};
カスタムフック
reducer
ではステータス更新を責務にしました。useReducer
をラップしたカスタムフックでは、action
に dispatch
をラップして使いやすくすることと、reducer
では実現できない非同期処理などを記述します。
この例では、await checkStrengthApi(password)
の箇所です。
export const usePassword = (initState: State) => {
const [state, dispatch] = useReducer(reducer, initialState);
const actions = useMemo(() => ({
setPassword: async(password: string) => {
dispatch({
type: "update"
payload: { password, rePassword: state.rePassword }
});
if( password !== state.password) {
const strength = await checkStrengthApi(password):
dispatch({
type: "setStrength"
payload: { strength }
});
}
},
setRePassword: (password: string) => {
dispatch({
type: "update"
payload: { password: state.password, rePassword }
});
},
}),[]);
return [state, action];
}
まとめ
以下に、useReducer
の勘所を少しまとめます。やはり、State
の型定義が重要です。
state の型定義に必ず含めるもの
今回の例では、ユーザーのインプットの password
と rePassword
とAPIから取得する strength
です。これらは他から取得できないためです。
state の型定義に含めるかどうか考慮するもの
errorMessage
は計算から求められるため、含めなくとも取得は可能です。 validate(password)
で計算できるため、呼び出し側で求める方法も考えられましたが定義に加えました。
もうひとつは submit ボタンの有効/無効を判定する disabled です。こちらは定義に加えず、呼び出し側で計算から求めていますが、reducer で計算させる方法もありました。
const disabled = !state.password || state.errorMessages.length || state.strength <= 1;
判断基準
errorMessage
を定義した理由は、usePassword
の汎用性と拡張性を考慮したとき、errorMessage
は state
以外の要素の影響をうけることは将来を通しておそらくなく、呼び出し側によらないだろうという点です。
disabled
を定義しない理由は disabled
は呼び出し側の理由で条件が変わる可能性を想像したためです。
課題
非同期処理
dispatch
は値を返さず、変化後のステートは参照できません。そのため、dispatch
はひとつにまとめるのが良いわけですが、非同期処理が加わると dispatch
をひとつにできないことが起きます。
今回、この不都合はカスタムフック側に吸収させていましたが、非同期で dispatch
が扱える方法を模索するべきかもしれません。たとえば、 React19 から使える useActionState も使えそうです。
おわりに
今回は useReducer
を使って、責務の分離するならどのように設計するか、どこに気をつけなければいけないか述べました。
プロダクトは時間とともに成長していくものなので、変更に強くなくてはいけません。今回取り上げた useReducer
は基本的な機能ながら、責務の分離からリーダブルコードを目指す手立てとなるものです。
Gaji-Labo は支援しているスタートアップに対して、成長支援といえる仕事のやり方を大切にしていて、読みやすいリーダブルなコードを書くこともその考え方につながっています。これからも「成長支援」といえるような技術提供ができるよう頑張っていきたいと思っています。
Gaji-Laboでは、React経験が豊富なフロントエンドエンジニアを募集しています
弊社ではReactの知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違うGaji-Laboを味わいに来ませんか?
もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!
求人応募してみる!