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));
}, [reducer]);
return [state, dispatch];
}
第3引数のinitializerはデフォルトで自身の値を返すような関数となるようにしました。指定しないときはinitializeArgが初期値になるようにするためです。
そしてuseStateを呼び出してベースとなるstateを生成します。
useReducerの初期値を生成するために引数を第2引数と第3引数に分離している理由は初期レンダリング以外での計算の省略のためでした。そのためここで呼び出したuseStateの初期値もinitializer(initializerArg)を直接与えるのではなく、() => initializer(initializerArg)のように渡すことで初期レンダリング以降での計算を省略するようにしました。
そして、dispatchではsetState内で取得したstateをreducerに渡して既存の値を更新するようにしました。これは、useReducerの更新はスナップショットに対する操作ではなく、レンダリング時にstateを参照して更新を行うからです。
これでuseReducerと同等の機能を持つuseCustomReducer(型なし)ができました。