TL;DR
要点だけ知りたい方は、最後の方だけ読めば大丈夫です。
背景:Reduxが全然わからないのでちゃんと勉強した
開発現場にReduxを導入しておきながら、チーム全員が「全然分からない。俺達は雰囲気でReduxをやっている」状態だったので、本腰入れてドキュメント読みました。基本を押さえたら一気に見通しが良くなったので、説明します。色々なサイトやドキュメントは明らかに冗長な説明多いので、極限までエッセンシャルを絞って説明することで、ゼロ知識からでもある程度、理解できるレベルの説明に落とし込むことに挑戦しました。うちの開発チームで知見として残すために作成したものですが、需要がありそうかなと思ったので、公開します。需要がなければすみませんでした。おかしな点があれば、まさかりお待ちしております。
今回は、公式ドキュメントのBasics辺りの話まで。
Reduxの主な登場人物
- Reducer
- State
- Action(+ ActionCreator)
- Store
ReducerとState
Reducer
一般的なreduce
Reduxにおいて、頻出のReducer・Reduceというものがそもそも何かご存知でしょうか。これが何なのかを知っているだけで、理解が早く進むので最初に概念を押さえておきましょう。
reduceとは、辞書で、軽減する、変えるという意味があります。Reduxに置けるreduceの意味は「変える」という意味が適切でしょう。つまり、reducerとは「変化させるもの」、プログラミングで言うところの関数です。もっと厳密に言うと、Reduxにおいては純粋関数(入力と出力の間で何も値を変化させない(入力値不変、外部の値不変、関数内部の値不変)。次のstateは新しい値を戻り値として返すだけ。)である必要があります。
pythonや、javascriptにもreduceという関数が存在します。この関数は、初期値と関数と入力値のリストを与えると、初期値と最初の引数を用いて関数実行します。その戻り値を次の入力値と共に、同じ関数に入力して、新たな戻り値を得ます。引数のリストが尽きるまで実行を繰り返し最終的に得られた結果をreturnします。以下Javascriptの簡単な例です。
//第二引数を指定する場合は、第二引数が最初のstateになる
[1,2,3].reduce( (state,input,index) => state+input*input, 10 )
=> 24
//第二引数を指定しない場合は、入力値リストの最初の値が最初のstateになる
[1,2,3].reduce( (state,input,index) => state+input*input )
=> 14
この一回あたりの関数適用によって行われる値の変化をreduceと呼び、適用される関数のことをreducerと呼びます。ReduxにおけるReducerもこの適応する関数と同じように用いられ、与えられた初期状態からreduceを繰り返すことによって、値を変更させていきます。このreduceによって変更対象となっている値をstateと定義することにします。Reduxにおいても、stateの初期状態もReducerを通して与えることも出来ますが、以上の例では、任意に与えたり、最初の引数をstateの初期値として扱っていると捉えることが出来ます。
またReduxのReducerは必ず冪等性(関数+引数の適用順序と初期値が同じであれば、同じ実行結果になる性質)を保つ必要があります。(普通のreducerでも基本的に、副作用を起こすような処理はアンチパターンです。)
要するに、reduceとは入力値に関数を用いて次の出力を得る事を意味し、Reduxは、このreduceの繰り返しのみでプログラムの実行を行うようにする仕組みです。このメリットについては、後述します。
余談: b=f(x),g(b)=cとしたとき、g(f(x)) = g(b) = c と計算結果が縮約していく様子からreduceという単語が使われるようになったのではないかなと勝手に予想してます。
ReduxにおけるReducer
ReduxにおけるReducerは、関数の中に単一の処理があるわけではなく、以下のように一つのデータに対して実行可能性がある複数の処理を全てまとめたものを条件分岐させる形で一つの関数を書きます。以下、ageというパラメータに対するReducerを例にします。
function age (state, action) {
switch(action.type) {
case "SET_PERSON_AGE": return action.age
case "ADD_PERSON_AGE": return state + 1
dafault:
return state
}
}
また、条件分岐には上記のようにswtich-case文が使用される事が多いようですが、必ずしもswitch-caseによる条件分岐である必要があるわけでもなく、後述のActionのtypeによって、実行されたReducerの処理が特定できれば、どのような実装になっても良いです(Hashによる実装例)。
この条件分岐をみると、条件分岐をしている処理がaction.typeによって、SET_PERSON_AGEが指定された時、ADD_PERSON_AGEが指定された時に別々の処理が走るようになっていますが、これを指定することによって、それぞれが別々の関数のように扱うことが出来るのがわかるでしょうか。
つまり、ReduxにおけるReducerはパターンマッチによる条件分岐によって別々の関数が適用されてデータがreduceされていく処理を模倣しているのです。
Reducerのstate変更の実装スコープをどこまで含めるかは、実装者に一任されています。例えば、以下のような
person: {
name: '',
age: 12
}
というデータ構造に対して、
function person (state,action){
return {
person: {
name: name(state.person.name,action),
age: age(state.person.age,action)
}
}
}
function name(state='', action) {
switch(action.type) {
case "SET_PERSON_NAME": return action.name
default:
return state
}
}
function age (state=0, action) {
switch(action.type) {
case "SET_PERSON_AGE": return action.age
case "ADD_PERSON_AGE": return state + 1
dafault:
return state
}
}
と、ネストされたオブジェクト内のpropertiesまで、深くReducerを作ることも、
function person (state,action){
return {
person: action.person
}
}
以上のようにオブジェクトを値と捉えて、ネストの浅い所でReducerを作るのを辞めることもできるなど、個別に変更する必要性に合わせて粒度を調整することが出来ます。公式ドキュメントではネストが生じなくなるまでデータプロパティまで細かくReducerを実装しています。
Reducerの分割・結合
Reducerはstateを返す単なる関数なので名前は好きにつけることも出来ますが、プロパティとそのプロパティに関与するReducerの名前は同一にすることをおすすめします。
何故かと言うと、以下のようにプロパティと同名のReducerという関係を保ち、そのプロパティが関与する全ての処理を1つのReducerに集約することで、そのプロパティに関わる処理は、同一名のReducerに処理を追加すれば良いという暗黙のルールが出来るため、秩序なくデータの変更処理が加えられることを防ぎ、誰であっても、同じ所に処理をかけばよいことが明確になるからです。
ただし、チームの開発状況によっては、Reducerの処理が膨大に必要になり、1ファイルのコードが巨大になっていくことなどもあるでしょう。その場合、ロジック部分だけを実装したcaseReducerを作成し、任意の粒度でReducerを分割することも出来ます。これをコードに落とすと以下のようになります。
// function name (state='', action) {略}
function ageSetter (state=0, action) {
return action.age
}
function ageIncrement (state=0, action) {
return state + 1
}
function age (state=0, action) {
switch(action.type) {
case "SET_PERSON_AGE": return ageSetter(state, action)
case "ADD_PERSON_AGE": return ageIncrement(state, action)
dafault:
return state
}
}
const person = (state,action) => {
return {
person:{
name,
age:age(state.person.age,action)
}
}
}
/**
* 上記のage(state, action)が、
* function age (state=0, action) {
* switch(action.type) {
* case "SET_PERSON_AGE": return action.age
* case "ADD_PERSON_AGE": return state + 1
* dafault:
* return state
* }
* }
* と等価になります。
*/
また、以上のコードは公式でも言及されているとおり、createReducerを実装するとさらに見やすくなります。
最終的には、アプリケーション内で、定義された全てのReducerをcombineReducersやReducerのネストによって、一つにまとめます。この一つになったReducerをrootReducerと呼び、rootReducerをstore(後述)にセットすることでstoreから、実装された全てのReducerを呼び出すことが出来るようになります。以下は、personのageとnameという名前のReducerを一つにまとめた図です。
このcombineReducersの実行結果は、
(state, action) => {
return {
name: name(state.name, action)
age: age(state.age, action)
}
}
と等価になり、自動でStateのkeyを指定してcombineしたReducerを実行してくれます。(実際はcombineReducerでオブジェクトが返ってるわけではなく、combineしたReducerをkeyの順番毎に実行するReducerが返ってくるという動作をしています。)以下にperson ReducerにReducerを統合した場合の模倣図を示します。
以下の図は、実際にstore.dispatch(後述)を実行した時の動作を示しています。以上の図では、ネスト関係になっているReducerが生じていますが、ActionとStateが入力される経路を辿ってみると、結果的に、以下の二つ目の図のように一つの巨大なReducerを形成しており、大量の条件分岐の中からswitch文によって、実行すべき処理を選択していることと等価になっていることが解るかと思います。
言い換えると、rootReducerはstateの変更に関わる全ての処理を管理しており、rootReducerをセットしたstoreから、store.dispatchというメソッドをActionを指定して実行すると、rootReducerに含まれる全てのReducerは、それぞれのswitch文の中からActionに対応するstateを計算し、次なるstateを統合して返すという動作をしています。(後述)。
このことから、ReduxはStoreがstateをreduceする権限を持ち、処理内容Actionをdispatchで指定することで、指定されたReducerがstateのreduceを行うという流れが見えてきたかと思います。
State
Stateは現在のアプリケーション(スコープによっては、ローカルなStateもあります)の全てのデータの値を保存しているオブジェクトになります。以下簡単な構造例。
let initialState = {
person: {
name: ''
age: 12
}
}
Reduxにおいては、StoreからはReadOnlyのパラメータになっており、新しく生成された値をReducerの戻り値として与えることでのみ変更可能になっています。
そのため、得たいデータが現在のStateに依存する場合は、State自身に変更を加えず、参照型のデータであってもDeepCopyしたデータに変更を加える、あるいは、stateを入力値として新たな値を生成する方法で得たデータを戻り値にする必要が有ります。(詳しくは公式ドキュメントを参照してください)
またReducerの中であっても再代入による変更は禁止されています。これらは、アプリケーション内のデータの変更が関数の実行結果以外で、変更されることによって想定外の動作を引き起こすバグを防ぐための規則です。
Action
Actionとは複数のReducerの中から、stateに適用させたい処理を特定して実行させるためのtypeというキーで指定される識別子と、その処理を行うのに追加で必要な引数が定義された、以下のようなJavascriptプレーンオブジェクトです。以下のオブジェクトがReducerに渡されると、SET_PERSON_NAMEの識別子に一致する処理が実行されます。
const SET_PERSON_NAME = {
type: "SET_PERSON_NAME",
name: "Tkow"
}
Actionがオブジェクトというのは、何か違和感を感じられるかもしれませんが、オブジェクトであることを受け入れてください。Actionはtypeというパラメータで、Reducerから適用したい処理を,その他のkeyでReducerに渡る引数を指定します。
Actionをプレーンオブジェクトにする理由は、logに吐き出しやすいことと、logに吐き出すと、適用されたReducerの処理とそれぞれに与えられた引数が解るからです。「適用されたReducerの処理」と「与えられた引数が解る」ことはとても重要です。
バグが発生した際に、以下のように、何処で、適用したReducerと引数なのかが分かれば、必ずバグの再現性が取れるからです。バグが発生した場所で、渡ってきたAction.typeから、「適用されたReducerの処理」が、その他のkeyから「パラメータとして何が渡されたのか」が解るので、バグが発生した処理を簡単に特定可能になります。
state:{"person":{"name":"Tkow","age":0}}
action:{"type":"@@redux/INIT"}
state:{"person":{"name":"Tkow","age":0}}
action:{"type":"SET_PERSON_NAME","name":"Hogeo"}
state:{"person":{"name":"Hogeo","age":0}}
action:{"type":"SET_PERSON_AGE","age":29}
state:{"person":{"name":"Hogeo","age":29}}
ActionCreator
基本的なActionCreator
Actionは、必ず、Reducerに対して不変の値を渡すというルールが有ります。ただし、これだとstateの値を変更するためのReducerに与えられる引数が常に固定されることを意味しているため、動的なデータをReducerの引数に与えることができません。動的な引数に対応させるために、Actionを生成する関数を作り、その引数によってReducerに引き渡される引数を変更可能にします。この動的な値を持つ、Actionを生成するために用意する関数がActionCreatorです。以下のように引数によって、作成するActionに送る入力値を変更することで、任意のデータによってActionを生成する事ができます。
const setPersonName = (name) => {
return {
type: "SET_PERSON_NAME",
name
}
}
const setTkowAction = setPersonName ("Tkow")
/**
/* {
/* type: "SET_PERSON_NAME",
/* name: "Tkow"
/* }
*/
const setHogeAction = setPersonName ("Hoge")
/**
/* {
/* type: "SET_PERSON_NAME",
/* name: "Hoge"
/* }
*/
//store・dispatchについては後述
store.dispatch(setTkowAction)
console.log(store.getState().name)
// => Tkow
store.dispatch(setHogeAction)
console.log(store.getState().name)
// => Hoge
ActionCreatorはAction.typeを不変にする必要があります。また、同じReducerで使われるActionCreatorは一つのファイルにまとめるようにしたほうが可用性が良くなります。
発展的なActionCreator
ActionCreatorの内部で、生成したActionをすぐにdispatchしてしまうような関数を作っても構いません。このような記法をstoreからdispatchをbindしてActionCreatorで使用できるようにしていることからboundActionCreatorと呼ばれます。
ただし、この記法はstoreオブジェクト内や、storeをbindできるケースでしか記述できないため、一般的には、定義したboundActionCreatorをstoreのスコープで実行できる関数にinjectionして実行するか、storeのプロパティメソッドとして定義する必要があります。
//dispatchが有効なスコープの中で
const boundSetPersonName = (name) => dispatch(setPersonName(name))
boundSetPersonName('Hogeo')
// store.dispatch(setPersonName(name))と等価
boundActionCreatorは配列で渡すことなどによって、PipeLineの処理を上手く表現する事ができます。
// 例えば、Nameを新たにセットしてViewの表示を切り替える処理を記述する
[
boundSetPersonName,
boundChangeViewByName
].forEach ((reduce) => reduce(name))
一般的にboundActionCreatorはユーザー定義された関数とActionCreatorの区別が付きづらくなるため、React-Reduxなどのライブラリによってstoreがbindされる特殊な状況下に限定して使用することをおすすめします。逆に、このような状況下ではAction生成とdispatchのタイミングが確実に一致することが保証されている場合に加え、非同期データの例外的な副作用を扱うことや、イベントハンドリングの関数を付け替え可能にするためにdispatchの連鎖を一つのActionCreatorなどで制御する場合など、積極的な使用を推奨します。(React-Reduxを使用する場合、基本的にはvalueがactionCreatorのオブジェクトをconnectメソッドの第二引数にセットすると、自動でboundCreatorを生成してくれるので、自前で実装することは殆ど無いかと思います。)
Store
StoreはReducerとStateにアクセスできるオブジェクトです。ますはじめに、Storeを生成する際に、Storeには先程出てきたcombineReducersなどのメソッドを用いて「定義した全てのReducer」をまとめたrootReducerをセットします。createStore関数の引数にrootReducerをセットすると、定義された全てのReducerは、StoreのdispatchメソッドとAction.typeの指定によって、自由に呼び出すことが出来るようになります。
Stateの初期値はセットしてもしなくても構いません。
import { createStore } from 'redux'
import sampleApp from './reducers'
const store = createStore(sampleApp)
storeはstateにgetStateメソッドを通していつでもアクセスできます。
console.log(store.getState())
また、dispatchが走った後のコールバックをsubscribeによって設定することが出来ます。また、store.subscribeの戻り値がsubscriberの削除関数になっているため、戻り値を関数として実行するとdispatch後のイベントsubscribeを停止する事ができます。
const unsubscribe = store.subscribe(() =>
console.log(store.getState())
)
Viewの更新や同期などは、このsubscribeイベント、あるいは、他ライブラリのイベントエミッターなどに処理を移譲することで行います。
Storeは常にアプリケーションのエントリポイントから実行されます。つまり、ReduxはStoreにstateの初期状態とReducerがセットされた状態からアプリケーションの実行が開始されます。
StoreのdispatchはActionを引数に取ると、複数のReducerの中からAction.typeにマッチする処理を特定し、実行します。以下、setPersonNameと、setPersonAgeが、それぞれ、SET_PERSON_AGE,SET_PERSON_NAMEのAction.typeを持ったActionを生成するActionCreatorだと仮定します。
import { createStore, combineReducers } from 'redux'
import { person } from './reducers'
import { setPersonName, setPersonAge, addPersonAge } from '
./actions'
const store = createStore(person,{
person : {
name: "Tkow",
age: 0
}
})
const unsubscribe = store.subscribe(() =>
console.log(store.getState())
)
store.dispatch(setPersonName('Hogeo'))
// subcribeによって、stateが表示される
/**
* {
* name: "HOGEO",
* age: 0
* }
*/
store.dispatch(setPersonAge(29))
// subcribeによって、stateが表示される
/**
* {
* name: "HOGEO",
* age: 29
* }
*/
// subscribeを停止する
unsubscribe()
このプログラムは以下のreducers.jsとactions.jsを用いて動作します。
ActionCreatorを実行することによって、アプリケーションの状態遷移に必要なActionを静的あるいは動的に生成し、dispatchメソッドによってstateに適応することで、アプリケーションの処理が実行されていきます。
import { combineReducers } from 'redux'
export const person = (state, action) => {
return { person:
combineReducers({
name,
age
})(state.person,action)
}
}
function name(state='', action) {
switch(action.type) {
case "SET_PERSON_NAME": return action.name
default:
return state
}
}
function age(state=0, action) {
switch(action.type) {
case "SET_PERSON_AGE": return action.age
case "ADD_PERSON_AGE": return state + 1
default:
return state
}
}
export const setPersonName = (name) => {
return {
type: "SET_PERSON_NAME",
name
}
}
export const setPersonAge = (age) => {
return {
type: "SET_PERSON_AGE",
age
}
}
export const addPersonAge = (age) => {
return {
type: "ADD_PERSON_AGE"
}
}
またcombineReducersは、それぞれのReducerにReducer, stateとActionを振り分けるReducerを作成してくれるので、命名規則に従っている限り、state内のプロパティを自動でcombine後のReducerに、受け渡して実行してくれます。
Reduxで実現されていることはなんなのか
State,Store,Reducer,Storeの関係をおおよそ掴んだ所で、図にまとめると、Reduxの動作は以下のようにアプリケーションの状態遷移を関数だけで表現することができます。
つまり、初期のstateと、reducersをstoreにセットし、適用したい処理をActionをdispatchすることによってReducerから選択し、関数適用のみでしか状態が変更されない強固な仕組みを用いることで、プログラムの開始から終了まで関数処理だけで完結できる仕組みを作り出したのです。
関数処理だけで完結できるということは、どういう関数を適用したかと、どういうパラメータを入力したかだけのシステムが表現出来るようになり、アプリケーションの実行順序やエラーの場所を追跡しやすくなるなどの利点が得られます。
また、Reducerによってパラメータに対して、変更処理を記述する事を統一することで、誰でもどこで変更処理が行われているかわかりやすくなり、保守性が大きく高まることに貢献しています。
まとめ
以上のように、Reduxの至上の目的とは、「純粋関数適用以外での値の変更を許さない」ことを強制することによって、「処理の途中で不規則なデータ変更によって生じるバグを未然に防ぎ」、変更に冪等性があることから、「バグが発生した時に、どの順序で何の処理を行ったかという履歴を追跡することで、バグ発生時の状況を必ず再現できること、また、何を行ったことでバグが発生したのかを追跡する」ことなのです。 この点を押さえられると、Reduxの理解が早くなると思います。もしこの記事がReduxが分からなかった方に参考になったら幸いです。