りあクト!を読んでReduxを学んだので内容を自分なりにまとめ。
Reduxとは
Reactはコンポーネントを組み合わせてアプリケーションを作っていくためのライブラリです。コンポーネントには「状態」を持つものがあり、「状態」はそのコンポーネント自身だけではなく親や子のコンポーネントの見た目や振る舞いにも影響を与えます。
Reduxが必要な理由
(以下画像はWhen do I know I’m ready for Redux?から引用しています)
親コンポーネントの状態を子コンポーネントと共有する方法として、親コンポーネントに必要な状態を持たせておいて、子孫のコンポーネントにそれらの状態をPropsとして渡す方法があります。(React公式:https://ja.reactjs.org/docs/state-and-lifecycle.html#the-data-flows-down)
状態を兄弟のコンポーネントで共有したい場合、共通の親コンポーネントに状態を持たせます。親コンポーネントはContainer Componentと呼ばれ、子コンポーネントに状態と、状態を変更させるための関数を渡します。子コンポーネントが状態を変更させると、親コンポーネントを経由して別の子コンポーネントに状態が共有されます。(React公式:https://ja.reactjs.org/docs/lifting-state-up.html)
親子でも兄弟でもないコンポーネントと状態を共有するためには、それぞれのコンポーネントの最も近い祖先をContainer Componentとし、同様に状態を共有します。アプリケーションが大きくなればなるほどこの関係は複雑になっていき、手に負えなくなっていきます。
Reduxは、コンポーネントのツリーの外側に状態を管理する仕組みを用意することでこの問題を解決するためのライブラリです。
Reduxのしくみ
(以下画像はRedux公式から引用しています)
ただコンポーネントのツリーの外側に状態を持たせるだけであれば、言ってしまえばそれはグローバル変数で、あちこちのコンポーネントから好き勝手読み書きさせてしまうとそれこそ手に負えなくなってしまいます。どのコンポーネントがどの状態を見ているのか、どんなタイミングでどのコンポーネントから状態が変更されるかわからなくなる、など…。そこでReduxでは、3つの原則を掲げ、それを実現するための仕組みを用意しています。
Single source of truth
Reduxでは、アプリケーションの状態を管理するstoreにオブジェクトツリーstateとそれを更新するための関数reducerを持ち、それらを使って状態を管理します。そして、storeはアプリケーション内に1つしか存在しないことが保証されています。storeが複数あると、storeどうしのやり取りが発生して取り扱いが複雑になってしまいます。storeが1つしか存在しないことが保証されていれば、storeが複数ある場合と比べて取り扱いは単純になりますし、デバッグがやりやすくなったり、テストしやすくなったりします。
State is read-only
stateは読み込み専用で、直接書き込み(状態を変更)することはできません。Reduxでは、stateの状態を変更することができるのはreducerのみです。前述の通りstoreはアプリケーションにたった1つしかないため、reducerもたった1つしかありません。どのコンポーネントから状態を変更することになってもすべての変更は必ず1つのreducerに集約され、厳密な順序で1つずつ処理されます。そのため、すべての処理は競合しません。また、処理のログを取ることで、デバッグの際に参照したり、処理を再現してテストに利用できたりします。
Changes are made with pure functions
reducerはstate(変更前の状態)とaction(変更内容)を受け取ってstate(変更後の状態)を返す純粋関数です。純粋関数であるということは、内部に状態を持たず、ある入力を受け取れば必ず決まった出力を返す関数であるということです。actionは、stateをどのように変更したいかという内容が示されたオブジェクトです。また、actionはdispatcherがUIからイベントを受け取って生成します。UIがreducerに直接actionを渡すのではなくdispatcherを経由することで、UIからstoreの関心を分離することができています。
Reduxの使い方
コードベースで、実際のReduxの使い方を解説します。
Reduxをコンポーネントに提供する
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { counterReducer, initialState } from 'reducer';
const store = createStore(counterReducer, initialState);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root') as HTMLElement,
);
Provider
上位コンポーネントにProvider
を設置すれば、子孫コンポーネントでreduxの機能をHooks APIを使って利用できる。
Provider
には初期化したstoreをPropsとして渡す。
storeの初期化
createStore
を使ってstoreを初期化する。
createStore
には定義したreducerとstoreの初期値を渡す。counterReducer(定義したreducer)とinitialState(storeの初期値)をreducer.ts
からimportする。
reducerを定義する
import { Reducer } from 'redux';
import { CounterAction, CounterActionType as Type } from 'actions';
export type CounterState = {
count: number;
};
export const initialState: CounterState = { count: 0 };
export const counterReducer: Reducer<CounterState, CounterAction> = (
state: CounterState = initialState,
action: CounterAction,
): CounterState => {
switch (action.type) {
case Type.ADD:
return {
...state,
count: state.count + (action.amount || 0),
};
case Type.DECREMENT:
return {
...state,
count: state.count - 1,
};
case Type.INCREMENT:
return {
...state,
count: state.count + 1,
};
default: {
const _: never = action.type;
return state;
}
}
};
initialState(storeの初期値)、counterReducer(定義したreducer)とその返り値の型をexportする。
reducerの内容
例に上げているcounterReducerは
-
state(更新前の状態)とaction(更新処理に必要な情報)を受け取る
- stateを受け取らなかった場合initialStateを初期値として設定
- それぞれの型はReducer型のジェネリクスで設定
- CounterState型のstate(更新後の状態)を返す
-
action.type
によってstateの更新処理をswitchするType.ADD
と同じだった場合、任意の値を加算Type.DECREMENT
と同じだった場合、1減算Type.INCREMENT
と同じだった場合、1加算- どれでもなかった場合stateをそのまま返す(counterに
CounterAction
型アノテーションをつけているため、case文を書き漏らさない限りdefaultには落ちない。case文を書き漏らした場合、never型の変数に代入しようとするため、実際はreturnに行き着く前にコンパイルエラーになる。case文の漏れを未然にチェックするための手法。)
という内容。更新前の状態と更新に必要な情報を受け取って更新後の状態を返す関数。
更新処理に必要な情報(CounterAction
型で表現されている)と更新処理の種類(CounterActionType)はactions.ts
からimportする。(「種類」のTypeと「型」のTypeがごっちゃになってわかりづらくて嫌…)
actionsを用意する
export const CounterActionType = {
ADD: 'ADD',
DECREMENT: 'DECREMENT',
INCREMENT: 'INCREMENT',
} as const;
type ValueOf<T> = T[keyof T];
export type CounterAction = {
type: ValueOf<typeof CounterActionType>;
amount?: number;
};
export const add = (amount: number): CounterAction => ({
type: CounterActionType.ADD,
amount,
});
export const decrement = (): CounterAction => ({
type: CounterActionType.DECREMENT,
});
export const increment = (): CounterAction => ({
type: CounterActionType.INCREMENT,
});
CounterActionType
reducerに渡すための「更新処理の種類」を表したオブジェクトです。as const
をつけることで、CounterActionTypeを定数にする(CounterActionType.ADD = xxx
がエラーになる)ことができます(constアサーション)。これをすると、CounterActionType
の型が{ ADD: string; DECREMENT: string; INCREMENT: string; }
ではなく{ readonly ADD: "ADD"; readonly DECREMENT: "DECREMENT"; readonly INCREMENT: "INCREMENT"; }
になり、タイポによるバグ発生のリスクを減らすことができます。
CounterAction
まず、reducerに渡したいactionとは何かというと、プレーンなオブジェクトです。例えばcounterStateのcountに1を加算したいときは{type: 'INCREMENT'}
、counterStateのcountに5(任意の値amountとする)を加算したいときは{type: 'ADD', amount: 5}
をreducerに渡す、といった具合です。そのプレーンなオブジェクトの型を表現しているのがこのCounterActionです。
type
ValueOf<T>
という型の関数?のようなものを用意して、オブジェクトの値から型を抽出してユニオン型を作っています。これによって、typeの型は"ADD" | "DECREMENT" | "INCREMENT"
になります。これの詳しい解説は「りあクト!TypeScriptで始めるつらくないReact開発」第4章、第5節内の「型表現に使われる演算子」にありますので是非ご購入になって読んでください(りあクト!は最高に丁寧でわかりやすい技術書です)。
amount
actionがaddだった場合、amountプロパティが必要です。increment, decrementだった場合必要ないため、?
をつけて省略可能にしています。
add, decrement, increment
これらは、Action Creatorと呼ばれる関数です(「Reduxのしくみ」で説明したdispatcherです)。コンポーネントからstateの値を変更するときは、reducerに直接{type: 'ADD', amount: 5}
のようなオブジェクトを渡すのではなく、Action Creatorを呼んでその返り値として得たオブジェクトをactionとしてreducerに渡します。
コンポーネントからstateの値を取得/変更する
import React, { FC } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { add, decrement, increment } from 'actions';
import { CounterState } from 'reducer';
import ChildComponent from 'components/ChildComponent';
const Counter: FC = () => {
const count = useSelector<CounterState, number>((state) => state.count);
const dispatch = useDispatch();
return (
<ChildComponent
count={count}
add={(amount: number) => dispatch(add(amount))}
decrement={() => dispatch(decrement())}
increment={() => dispatch(increment())}
/>
);
};
useSelector
とuseDispatch
はreduxが提供するHooks APIです。useSelector
とuseDispatch
を使って取得したcountとAction Creatorを子コンポーネントに渡します。
useSelector
useSelector
を使ってstateからcountを取り出します。引数としてstateからcountを抽出する関数(state) => state.count
を渡します。また、ジェネリクスの第1引数にはstate全体の型、第2引数には取り出したいstateの型を渡します。
useDispatch
useDispatch
にactionを渡してstateを更新するための関数を取得しています。dispatch({ type: 'INCREMENT' })
などすればINCREMENT
に紐づくactionが実行されますが、バグを防ぐため、直接actionを渡すのではなく用意したAction Creator関数の返り値を使うようにします。
まとめ
りあクト!はベテランエンジニアと新米エンジニアの対話形式で最新のReact/TypeScriptを学べる最高の技術書で、この記事の500倍広く深くわかりやすいので、ぜひ購入してください。