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 の設計手順

以下の順番で記述すると、設計しやすいと思います。

  1. State の型定義
  2. 初期値の定義
  3. Action の型定義
  4. reducer
  5. カスタムフック

以下、汎用的な 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を味わいに来ませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!

投稿者 Chaki Hironori

webライターもやってるフロントエンドエンジニアです。Reactは自信があります。またデザイン畑の出身で、気持ちのいいアニメーションやインタラクティブな表現は丁寧に手掛けます。好きなものは中南米の遺跡で、スペイン語が少しできます。