useReducerは何者なのか?
この記事は、OPENLOGI Advent Calendar 2023の10日目の記事です。
先日、この記事の内容でLTしました。スライドを公開しているので、さっと読みたい方はどうぞ。
はじめに
useReducer って何者?
Reactの学習を始めると、大体 useState
useEffect
useCallback
といった主要なフックの解説は多く見られます。
しかし、あまり使わないuseReducer
についてはサラッと書かれていることが多く「いつどうやって使うのか?」と疑問に思って来ました。
そこでこの記事では、 useReducer
の使い所について、useState
の実装と見比べながら考えていこうと思います。
注意点
この記事では、フォームを題材にしてuseReducerを使って実装しています。
ですが、フォームの実装でuseReducerを推奨する意図をもって執筆したものではありません[1]🙏
あくまでユースケースの1つとしてご理解いただけると嬉しいです。
題材
以下のような配送フォームを考えます。
- 以下を入力項目とする
- 郵便番号
- 都道府県(プルダウンで選択)
- 市区町村
- 番地
- 建物名
- 包装オプション(プルダウンで選択)
- 「送信」ボタンを押すと、入力項目が送信される(今回はコンソールに吐き出す)
- 「内容を確認しました」チェックボックスを設け、チェックが押されている場合のみ送信ボタンを押せる
- 「リセット」ボタンで入力項目とチェックボックスを初期値に戻す
デモ画面を用意したので、よかったらみてください。
stateで実装する場合の課題
フォームヘルパーなどのライブラリを使わずvanilla reactで実装する場合、以下のように項目値ごとにstateを設ける方法が考えられます。
const UseStateForm: FC = () => {
const [zipcode, setZipcode] = useState<string>('');
const [prefecture, setPrefecture] = useState<Prefecture>();
const [city, setCity] = useState<string>('');
const [address, setAddress] = useState<string>('');
const [building, setBuilding] = useState<string>('');
const [wrapping, setWrapping] = useState<keyof typeof wrappingType>('none');
const [isConfirmed, setIsConfirmed] = useState<boolean>(false);
const handleSubmit = (e: SyntheticEvent) => {
e.preventDefault();
const data = {
zipcode,
prefecture,
city,
address,
building,
wrapping,
};
// 以下省略
};
const handleReset = (e: SyntheticEvent) => {
e.stopPropagation();
setZipcode('');
setPrefecture(undefined);
// 以下省略
};
この実装においては、次のようなメリット・デメリットがあります。
メリット
パッと思いつく限り
- 実装がシンプル
- フォームの入力による再レンダリングコストが小さい(入力したコンポーネントのみレンダーされる)
といったところでしょうか。
デメリット
フォームの型がない、言い方を変えればstateの凝集度が低いということです。
これはたとえば、次のような問題が起こります。
- formの項目を追加/削除するたびにhandleSubmit, handleResetの変更が必要だが、変更漏れでもlinterやcompilerでエラーにならず、気づきにくい
-
isConfirmed
(チェックボックスの値)はフォームの値ではないが、区別がしづらい
フォームの入力値を1つの変数で扱うことができれば、この問題は回避できます。
useReducerを使う
上記のデメリットを改善できる解決策の1つが、useReducerです。
そもそも reducer とは?
ここではreduxの詳細な説明は行いませんが、簡単に説明しておきます!
reducerはreduxにおけるデータ更新関数で、次のように定義されています。
(prevState, action) => newState;
- 更新前のstate(prevState)
- どう更新するかを示すobject(action)
を引数にとり
- 更新後のstate(newState)
を返す純粋関数です。
なぜ useReducer が有効なのか?
reduxが由来であるということを述べましたが、そもそもreduxとはどういうものだったでしょうか。
端的に言うと、ツリー構造を持つグローバル変数(state)で単方向データフローを保って状態管理する仕組みです。
つまり、useReducerは
{
"a": 1,
"b": {
"b1": 2,
"b2": 3
},
"c": 4
}
のような、複雑な構造をもつstateの管理にはうってつけの手段なんです!
useReducerを利用したリファクタリング
方針
公式ドキュメントstate ロジックをリデューサに抽出するをもとに作成
- stateを定義し直す
- actionを抽出する
- stateをどのように変更するか、そのユースケース
- reducerを作成する
- stateのsetをactionのdispatchに置き換える
1. stateの定義
フォームのstateとして以下のように定義します。
type DeliveryForm = {
zipCode: string;
prefecture: string;
city: string;
address: string;
building: string;
wrapping: keyof typeof wrappingType;
};
// 初期値
const initialState: DeliveryForm = {
zipCode: "",
prefecture: "",
city: "",
address: "",
building: "",
wrapping: "none",
};
これでフォームの値を取り回すためのstateが一塊になり、DeliveryForm
という型がつけられます。
2. actionの抽出
おさらいになりますが、actionはstateをどのように更新するかを表すオブジェクトです。
今回は、Flux Standard Action(FSA) に基づき、actionを次のように定義します。
const ActionType = {
reset: "deliveryForm/reset",
updated: "deliveryForm/updated",
} as const;
type ValueOf<T> = T[keyof T];
type Action = {
type: ValueOf<typeof ActionType>;
payload?: Partial<DeliveryForm>;
};
- reset
- stateを初期値に戻す
- updated
- stateの値を更新する
-
payload
には次のように更新したい値を渡す
{
type: ActionType.updated,
payload: { zipCode: '1234567' },
}
actionはsetterではなく、イベントでモデリングする!
これはRedux 公式スタイルガイド に書かれているルールの1つでもあります。
actionの定義として、以下のように項目値ごとのsetterとして定義したくなるかもしれません。
const ActionType = {
setZipCode: 'setZipCode',
setPrefecture: 'setPrefecture',
// ... 省略
これでも動くコードはできるのですが、actionがstateの内容を知っていることが前提となるため、stateの項目値の増減とともに、actionを変更する必要があります。
これでは、stateの内容によらず動くロジックを実現できません。
一方、以下のようにイベントベースで定義すると、actionがstateの中身を知る必要がないため、stateの項目値を増減させても、actionに手を入れることなく実装できます。
const ActionType = {
reset: "deliveryForm/reset",
updated: "deliveryForm/updated",
} as const;
要は、action と reducer を疎結合にする ということです。
3. reducerの作成
reducerはstateを更新するための関数でした。以下のように定義します。
const reducer = (state: DeliveryForm, action: Action): DeliveryForm => {
switch (action.type) {
case ActionType.reset: // 初期値を返す
return initialState;
case ActionType.updated: // payload で更新する
return {
...state,
...action.payload,
};
default: {
const _: never = action.type; // default には何も入らないようにする
return state;
}
}
};
そして、reduxの実装ではよく使われていたのですが、action creatorという、 actionを作成するためだけの関数を定義しておきます。
actionの生成は、この関数を呼び出すことで行います。
const reset = (): Action => ({
type: ActionType.reset,
});
const updated = (payload: Partial<DeliveryForm>): Action => ({
type: ActionType.updated,
payload,
});
4. setStateをdispatchに変更する
最後に、useState
を useReducer
に変更し、setState
を dispatch(actionCreator())
に置き換えてあげます。
const UseReducerForm: FC = () => {
const [formState, dispatch] = useReducer(reducer, initialState);
const [isConfirmed, setIsConfirmed] = useState<boolean>(false);
const handleSubmit = (e: SyntheticEvent) => {
e.preventDefault();
console.log(formState);
};
const handleReset = (e: SyntheticEvent) => {
e.stopPropagation();
setIsConfirmed(false);
dispatch(reset());
};
return (
<Box p={4} w="md" borderWidth="1px" borderRadius="lg" boxShadow="base">
<form onSubmit={handleSubmit}>
<FormLabel htmlFor="zipcode" mt={4}>
郵便番号
</FormLabel>
<Input
size="md"
maxLength={7}
value={formState.zipCode}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
dispatch(updated({ zipCode: e.target.value }));
}}
/>
...以下省略
注目してほしいのは、 handleSubmit
、handleReset
の中身です。
stateを列挙していた部分がなくなり、スッキリしていることがわかるかと思います。
ここまでのコードはこちら↓
Redux Toolkit でコード量削減
お気づきかと思いますが、actionやreducerの作成のため、なかなかのコード量を書く必要があります。
が、Redux Toolkitを使うとコード量を減らすことができます。
Redux Toolkit(RTK) とは?
Reduxのコード量を削減できるRedux公式ライブラリです。
Redux専用ではありますが、useReducerとロジックとしては同じなので、ここで使うこともできます!
RTKによるリファクタリング
RTKには、sliceという概念があります。これは、
- action
- reducer
- action creator
の3つをまとめたもので、createSlice
という関数を用いて以下のように生成できます。
export const deliveryFormSlice = createSlice({
name: "deliveryForm",
initialState,
reducers: {
reset: () => initialState,
updated: (state, action: PayloadAction<Partial<DeliveryForm>>) => {
return { ...state, ...action.payload };
},
},
});
なんとこれだけで、action、reducer、action creatorが、deliveryFormSlice
にオブジェクトとして渡されてくるんです。便利ですね。
そして、コンポーネントは以下のようになります。
const { reset, updated } = deliveryFormSlice.actions;
const ReduxToolKitForm: FC = () => {
const [formState, dispatch] = useReducer(
deliveryFormSlice.reducer,
initialState,
);
const [isConfirmed, setIsConfirmed] = useState<boolean>(false);
const handleSubmit = (e: SyntheticEvent) => {
e.preventDefault();
console.log(formState);
};
const handleReset = (e: SyntheticEvent) => {
e.stopPropagation();
setIsConfirmed(false);
dispatch(reset());
};
const handleFormChange = (payload: Partial<DeliveryForm>) => {
dispatch(updated(payload));
};
return (
<Box p={4} w="md" borderWidth="1px" borderRadius="lg" boxShadow="base">
<form onSubmit={handleSubmit}>
<FormLabel htmlFor="zipcode" mt={4}>
郵便番号
</FormLabel>
<Input
size="md"
maxLength={7}
value={formState.zipCode}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
handleFormChange({
zipCode: e.target.value,
});
}}
/>
// ...以下省略
それぞれ個別に定義していたaction creatorやreducerを、deliveryFormSlice
で作成したものに置き換えているだけです。
最終的なコードはこちら↓
まとめ
- useStateをたくさん使いすぎると管理が大変になる
- useReducerは構造が複雑な(ツリー構造を持つ)stateの管理に優れている
- actionとreducerは、イベントベースでモデリングすることで、stateと疎結合なロジックにできる
- Redux Tool Kitを使えば、コード量が多い問題を回避できる
今回はわかりやすくフォームを例にとって、useReducerの使い所を考えました。
もちろん、フォーム以外のユースケースはたくさんあると思います。
そんなに頻繁に使うものではないと思っていますが、複数のプロパティを持つstateを管理したい場合、状況に応じてuseReducerの利用を考えてもいいかもしれません。
補足:useStateでもできるよね?
実はuseStateでも似たようなことができちゃいます。
[state, setState] = useState<T>(initialState);
と定義したとき、
// prevState: 更新前のstate
// newState: 更新後のstate
setState((prevState) => newState);
とかけるので、
const [formState, setFormState] = useState<DeliveryForm>(initialState);
// 省略
return (
<Box p={4} w="md" borderWidth="1px" borderRadius="lg" boxShadow="base">
<form onSubmit={handleSubmit}>
<FormLabel htmlFor="zipcode" mt={4}>
郵便番号
</FormLabel>
<Input
value={formState.zipCode}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setFormState((prevState) => ({
...prevState,
zipCode: e.target.value,
}));
}}
/>
のように実装すればuseStateでもstateを一塊にして管理できちゃうんですね…
ただ個人的には、useReducerのメリットの1つとして、コンポーネントからstate管理のロジックを切り離せることが大きいと考えており、同じことをやるならuseReducer使うのがいいのでは?と思っています。
いずれにしても、その場に応じた設計をしていく必要があります。複雑なことをやる前に、もしかしたらstateの定義の仕方や、設計から見直してもいいかもしれません。
参考文献
-
複雑なフォームを実装する場合、Formik や React Hook Form などのフォームヘルパーを利用するのが最適だと思います ↩︎
Discussion