useReducerについて
useReducer
はReactでstate
を扱うために用意されたhooksのひとつです。
reducer
と呼ばれるstate
の更新ロジックを集約した関数を用いてstate
を操作します。これにより、複数の方法・複数の箇所で更新を行うような場合はコード全体の見通しが非常によくなります。
その他にもreducer
として外部の関数に更新処理を書いていることによって、テストやデバックを行いやすいという特徴を持ちます。
useReducer
はreducer
を使って以下のように利用します。
const [state, dispatch] = useReducer(reducer, initializerArg, initializer);
initializerArg
はstate
の初期値の元となる値です。initializer
はinitializerArg
を引数に取り初期値を生成する関数です。initializer
は省略可能で省略した場合はそのままinitializerArg
が初期値になります。第三引数を渡さずに第二引数にstate
の初期値をinitializer(initializerArg)
を渡すようにしない理由はinitializer
を初期レンダリング時でのみ実行させるためです。
例としてstate
の型がnumber
で+1
または、-1
しか更新しないような場合を考えます。useState
を用いた場合は以下のようにstate
から提供されるセッターをもとにincrement
関数とdecrement
関数を用意してstate
の更新を行います。
const [count, setCount] = useState(0);
const increment = () => setCount((count) => count + 1);
const decrement = () => setCount((count) => count + 1);
この方法ではstate
を更新する関数がさまざまな場所で宣言されて散らかったり、セッターをそのまま用いた自由な更新が行われる余地が残されるような問題があります。
これらの問題を解決するのがuseReducer
です。
const countReducer(
count: number,
action: { type: 'increment' | 'decrement' },
): number {
switch (action.type) {
case 'increment': {
return count + 1;
}
case 'decrement': {
return count - 1;
}
}
}
const [count, dispatch] = useReducer(countReducer, 0);
// incrementは`dispatch({ type: 'increment' });`
// decrementは`dispatch({ type: 'decrement' });`
reducer
としてstate
の更新処理を記述した関数でstate
の更新を行うので更新方法の定義が散らかる問題と自由な更新の両者の問題を解決できました。
しかし、useState
よりもuseReducer
の方が優れているわけではありません。先ほどの例では確かに提示した問題点を解決する点では優れて見えますが、コード量は膨れていますし、私には可読性もuseState
の方に軍牌が上ると考えています。
useState
とuseReducer
のコード量や可読性はstate
をどのように扱うかによって優位性は変わります。より複雑で複数の方法で更新されるstate
はuseReduer
の方が有利だったりします。状況に応じて使い分けるようにしましょう。
reducer
関数
useReducer
が提供するstate
の更新方法を示す関数であるreducer
にはいくつかの慣習とポイントがあります。
慣習としては更新方法の指定はtype
を元に行うということです。
const countReducer(
count: number,
action: { type: 'increment' | 'decrement' },
): number {
switch (action.type) {
case 'increment': {
return count + 1;
}
case 'decrement': {
return count - 1;
}
}
}
type
を通して文字列で更新方法を指定して分岐させるように定義することが慣習とされています。
そして、気をつけておくポイントが2つあります。reducer
が純粋であることと操作単位で記述することです。
純粋性はReactを書く上で多くの場所で出てきます。reducer
は同じaction
を渡したら必ず同じ結果を返すような関数にしてください。呼び出しの順番やタイミングなどで結果が変わるような関数は書かないようにしましょう。
そして操作単位で更新方法を指定することです。
type User = {
id: number;
givenName: string;
familyName: string;
mailaddress: string;
};
このように定義された型を更新するreducer
を記述する場合はtype
をset_id
やset_given_name
のように状態を更新するための単位ではなく、init
やupdate_name
のように操作単位にしましょう。これによってstate
を更新する際に必要な複数のstate
の更新処理を呼び出すのではなく、状況に合わせて1つの更新処理を呼び出すだけで済みます。これによって予期せぬバグを防ぐこともできますし、デバックも容易になり可読性も向上します。
// stateのフィルードごとの定義
<button
onClick={() => {
dispatch('set_id', '878fa115-4310-4bf2-b0c8-006da6073332');
dispatch('set_given_name', '');
dispatch('set_family_name', '');
dispatch('set_mailaddress', '');
}}
>
初期化
</button>
// 操作単位の定義
<button
onClick={() => dispatch('init')}
>
初期化
</button>
型
useReducer
の型は以下のように定義されています。
type DispatchWithoutAction = () => void;
type Reducer<S, A> = (prevState: S, action: A) => S;
type ReducerWithoutAction<S> = (prevState: S) => S;
type ReducerState<R extends Reducer<any, any>> = R extends Reducer<infer S, any> ? S : never;
type ReducerAction<R extends Reducer<any, any>> = R extends Reducer<any, infer A> ? A : never;
type ReducerStateWithoutAction<R extends ReducerWithoutAction<any>> =
R extends ReducerWithoutAction<infer S> ? S : never;
function useReducer<R extends ReducerWithoutAction<any>, I>(
reducer: R,
initializerArg: I,
initializer: (arg: I) => ReducerStateWithoutAction<R>
): [ReducerStateWithoutAction<R>, DispatchWithoutAction];
function useReducer<R extends ReducerWithoutAction<any>>(
reducer: R,
initializerArg: ReducerStateWithoutAction<R>,
initializer?: undefined
): [ReducerStateWithoutAction<R>, DispatchWithoutAction];
function useReducer<R extends Reducer<any, any>, I>(
reducer: R,
initializerArg: I,
initializer: (arg: I) => ReducerState<R>
): [ReducerState<R>, Dispatch<ReducerAction<R>>];
function useReducer<R extends Reducer<any, any>>(
reducer: R,
initialState: ReducerState<R>,
initializer?: undefined
): [ReducerState<R>, Dispatch<ReducerAction<R>>];
まとめてみると仰々しいのでひとつずつピックアップして見ていきます。
1個目
type DispatchWithoutAction = () => void;
type ReducerWithoutAction<S> = (prevState: S) => S;
type ReducerStateWithoutAction<R extends ReducerWithoutAction<any>> =
R extends ReducerWithoutAction<infer S> ? S : never;
function useReducer<R extends ReducerWithoutAction<any>, I>(
reducer: R,
initializerArg: I,
initializer: (arg: I) => ReducerStateWithoutAction<R>
): [ReducerStateWithoutAction<R>, DispatchWithoutAction];
これはinitializer
が与えられ、dispatch
に引数を持たせない場合のuseReducer
です。
const [state, dispatch] = useReducer(
(state) => console.log(state),
'hello',
(state) => `${state} world`,
);
reducer
は受け取った引数の型を返り値にも持つ関数です。initializerArg
は適当な値です。initializer
はinitializerArg
に渡した値の型を引数にとって、reducer
の引数や返り値の型を返り値に持つ関数です。
返り値はreducer
の引数や返り値の型と、引数がなしで返り値がvoid
の関数を配列に詰めて返します。
2個目
type Dispatch<A> = (value: A) => void;
type DispatchWithoutAction = () => void;
type ReducerWithoutAction<S> = (prevState: S) => S;
type ReducerStateWithoutAction<R extends ReducerWithoutAction<any>> =
R extends ReducerWithoutAction<infer S> ? S : never;
function useReducer<R extends ReducerWithoutAction<any>>(
reducer: R,
initializerArg: ReducerStateWithoutAction<R>,
initializer?: undefined
): [ReducerStateWithoutAction<R>, DispatchWithoutAction];
これはinitializer
が与えられず、dispatch
に引数を持たせない場合のuseReducer
です。
const [state, dispatch] = useReducer(
(state) => console.log(state),
'hello world',
);
reducer
は受け取った引数の型を返り値にも持つ関数です。initializerArg
はreducer
の引数や返り値の型です。initializer
は渡さないのでundefined
です。
返り値はreducer
の引数や返り値の型と、引数がなしで返り値がvoid
の関数を配列に詰めて返します。
3個目
type Dispatch<A> = (value: A) => void;
type ReducerState<R extends Reducer<any, any>> = R extends Reducer<infer S, any> ? S : never;
type ReducerAction<R extends Reducer<any, any>> = R extends Reducer<any, infer A> ? A : never;
type Reducer<S, A> = (prevState: S, action: A) => S;
function useReducer<R extends Reducer<any, any>, I>(
reducer: R,
initializerArg: I,
initializer: (arg: I) => ReducerState<R>
): [ReducerState<R>, Dispatch<ReducerAction<R>>];
これはinitializer
が与えられ、dispatch
が引数を持つ場合のuseReducer
です。
const [state, dispatch] = useReducer(
(state, actions) => reducer(state, action),
'hello',
(state) => `${state} world`,
);
reducer
は受け取った第一引数の型を返り値にも持ち、state
の更新に関する情報を第二引数にもつ関数です。initializerArg
は適当な値です。initializer
はinitializerArg
に渡した値の型を引数にとって、reducer
の第一引数や返り値の型を返り値に持つ関数です。
返り値はreducer
の第一引数や返り値の型と、引数がreducer
の第二引数の型で返り値がvoid
の関数を配列に詰めて返します。
4個目
type Dispatch<A> = (value: A) => void;
type ReducerState<R extends Reducer<any, any>> = R extends Reducer<infer S, any> ? S : never;
type ReducerAction<R extends Reducer<any, any>> = R extends Reducer<any, infer A> ? A : never;
type Reducer<S, A> = (prevState: S, action: A) => S
function useReducer<R extends Reducer<any, any>>(
reducer: R,
initialState: ReducerState<R>,
initializer?: undefined
): [ReducerState<R>, Dispatch<ReducerAction<R>>];
これはinitializer
が与えられず、dispatch
が引数を持つ場合のuseReducer
です。
const [state, dispatch] = useReducer(
(state, actions) => reducer(state, action),
'hello world',
);
reducer
は受け取った第一引数の型を返り値にも持ち、state
の更新に関する情報を第二引数にもつ関数です。initializerArg
は適当な値です。initializer
はreducer
の第一引数や返り値の型です。
返り値はreducer
の第一引数や返り値の型と、引数がreducer
の第二引数の型で返り値がvoid
の関数を配列に詰めて返します。
useStateからuseReducerを作る
useState
からuseReducer
を作成することで理解を深めていきます。既存の名前と被らないようにuseCustomReducer
と名付けて実装していきます。
型については先ほど見た型を付与すれば良いだけなので端折ります。そのため、型を考えずにJavaScriptで実装していきます。
function useCustomReducer(
reducer,
initializerArg,
initializer,
) {
const [state, setState] = useState(
() => {
if (initializer === undefined) {
return initializerArg;
}
return initializer(initializerArg);
};
);
const dispatch = useCallback((action) => {
setState((state) => reducer(state, action));
}, [setState]);
return [state, dispatch];
}
第3引数のinitializer
はデフォルトで自身の値を返すような関数となるようにしました。指定しないときはinitializeArg
が初期値になるようにするためです。
そしてuseState
を呼び出してベースとなるstate
を生成します。
useReducer
の初期値を生成するために引数を第2引数と第3引数に分離している理由は初期レンダリング以外での計算の省略のためでした。そのためここで呼び出したuseState
の初期値もinitializer(initializerArg)
を直接与えるのではなく、() => initializer(initializerArg)
のように渡すことで初期レンダリング以降での計算を省略するようにしました。
そして、dispatch
ではsetState
内で取得したstate
をreducer
に渡して既存の値を更新するようにしました。これは、useReducer
の更新はスナップショットに対する操作ではなく、レンダリング時にstate
を参照して更新を行うからです。
これでuseReducerと同等の機能を持つuseCustomReducer
(型なし)ができました。