Redux Documentation の Reducers の超訳です。
レジューサ(reducer)
アクション において 何かが起きた ということを示しましたが、アプリケーションのステートがどのように変化するのかについて明らかにしませんでした。これはレジューサの仕事です。
ステートシェイプの設計
Redux において、すべてのアプリケーションのステートは一つのオブジェクトとして保存されます。何かコードを書きはじめる前にそのシェイプについて考えることは良いことです。一つのオブジェクトでアプリの状態を最小限で表したとするとどうなるでしょう。
TODOアプリでは、二つの異なるものを保存したくなります。
- 現在選択されている表示可能フィルター
- TODOのリスト
UIのステートと同様に、ステートツリー内にいくつかのデータを保存する必要が多々あるかと思いますが、UIのステートとは分けてデータを保存しましょう。
{
visibilityFilter: 'SHOW_ALL',
todos: [{
text: 'Consider using Redux',
completed: true,
}, {
text: 'Keep all state in a single tree',
completed: false
}]
}
リレーションに関するメモ
さらに複雑なアプリでは、お互いを参照しあう別々のエンティティが欲しくなるでしょう。そのような場合には、ネストすることなく、可能な限り正規化してステートを持つことを提案します。エンティティはすべてキーとなるID付きのオブジェクトで保持し、他のエンティティもしくはリストからはIDを利用して参照しましょう。アプリのステートについてデータベースと同じように考えましょう。このアプローチは normalizr のドキュメントに詳細に記述されています。例えば、実際のアプリにおいてはステート内に
todosById: { id -> todo }とtodos: array<id>を持っても良いですが、事例は単純にするようにしています。
アクションを処理する
ステートオブジェクトをどのような形にするか決定したので、レジューサを記述する準備ができました。レジューサとはそれまでのステートとアクションを受け取って、次のステートを返すピュア関数のことです。
(previousState, action) => newState
Array.prototype.reduce(reducer, ?initialValue) に渡すことになるであろう類の関数のためレジューサと呼ばれています。レジューサがピュアであることは非常に重要です。レジューサの中で次のようなことは 決して してはなりません。
- 引数の中身を変更する。
- APIの呼び出しやルーティングの遷移のような副作用があるものの実行。
- 非ピュア関数の呼び出し。例えば、
Date.now()やMath.random()。
上級ウォークスルー においてどういう副作用が起きるか説明する予定です。現時点では、レジューサはピュアでなければならないということだけ覚えておいてください。引数が与えられたならば、次のステートを計算しそれを返さなければならない。サプライズはいらない。副作用を起こすな。APIを呼び出すな。変更するな。ただ計算せよ。
それではレジューサを書きはじめましょう。
初期ステートを定義することからはじめます。Redux は初回 undefined なステートでレジューサを呼び出すでしょう。これはアプリの初期ステートを返す機会です。
import { VisibilityFilters } from './actions';
const initialState = {
visibilityFilter: VisibilityFilters.SHOW_ALL,
todos: []
};
function todoApp(state, action) {
if (typeof state === 'undefined') {
return initialState;
}
// 現段階では、いずれのアクションも操作せずに
// 与えられたステートをただ返します。
return state;
}
さらに簡潔に記述するには ES6 デフォルト引数 を利用します。
function todoApp(state = initialState, action) {
// 現段階では、いずれのアクションも操作せずに
// 与えられたステートをただ返します。
return state;
}
それでは SET_VISIBILITY_FILTER を操作してみましょう。
必要なことはステート内の visibilityFilter を変更することだけです。簡単ですね。
function todoApp(state = initialState, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return Object.assign({}, state, {
visibilityFilter: action.filter
});
default:
return state;
}
}
注意点:
-
stateを変更してはなりません。Object.assign()を利用してコピーします。ただし、Object.assign(state, { visibilityFilter: action.filter })でも間違いです。最初の引数を変更してしまいます。最初の引数に空のオブジェクトを 必ず 渡してください。ES7で提案されている object spread syntax を使って{ ...state, ...newState }と記述することも可能です。 -
defaultの時にはそれまでのstateをそのまま返さなければなりません。 未知のアクションのためにそれまでのstateをそのまま返すことは重要です。
Object.assignに関するメモ
Object.assign()はES6からの仕様で、まだほとんどのブラウザで実装されていません。ポリフィル、Babel plugin 、もしくは_.assign()のような他のライブラリのヘルパー関数のいずれかを利用する必要があります。
switchとボイラープレートに関するメモ
switch文は実際のボイラープレートではありません。Flux のボイラープレートは概念的なものです。更新の生成が必要であるということ、ディスパッチャーを利用してストアに登録する必要があるということ、ストアが一つのオブジェクトである必要があるということ(そして大きなアプリが欲しくなったときに起きる複雑さ)を示しているものです。
ドキュメント内で
switch文を使っているかどうかで多くの人がフレームワークをいまだ選択しているのは嘆かわしいことです。switchが嫌いならば、 “reducing boilerplate” で見れるように、ハンドラーマップを受け付けるカスタムしたcreateReducerを利用することができます。
さらにアクションを処理する
処理すべきアクションがあと二つあります。 ADD_TODO を処理するためにレジューサを拡張しましょう。
function todoApp(state = initialState, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return Object.assign({}, state, {
visibilityFilter: action.filter
});
case ADD_TODO:
return Object.assign({}, state, {
todos: [...state.todos, {
text: action.text,
completed: false
}]
});
default:
return state;
}
}
今までと同様、 state やそのフィールドを直接書き換えてはいけないので、代わりに新しいオブジェクトを返しましょう。
新しい todos は元の todos に新しい項目を最後に追加したものになります。
新規のTODOはアクションからのデータを用いて作成されます。
最後に、 COMPLETE_TODO ハンドラーの実装です。
case COMPLETE_TODO:
return Object.assign({}, state, {
todos: [
...state.todos.slice(0, action.index),
Object.assign({}, state.todos[action.index], {
completed: true
}),
...state.todos.slice(action.index + 1)
]
});
変更を起こさずに配列内の特定の項目を更新したいため、配列をその項目前後で slice する必要があります。そのような処理をしばしば記述するのであれば、ディープコピーをサポートする React.addons.update や updeep といったヘルパー、もしくは Immutable のようなライブラリを使用すると良いでしょう。クローンする前に state の中身に決してアサインしてはいけないということを覚えておいてください。
レジューサの分割
以下がここまでのコードですが、冗長です。
function todoApp(state = initialState, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return Object.assign({}, state, {
visibilityFilter: action.filter
});
case ADD_TODO:
return Object.assign({}, state, {
todos: [...state.todos, {
text: action.text,
completed: false
}]
});
case COMPLETE_TODO:
return Object.assign({}, state, {
todos: [
...state.todos.slice(0, action.index),
Object.assign({}, state.todos[action.index], {
completed: true
}),
...state.todos.slice(action.index + 1)
]
});
default:
return state;
}
}
理解しやすくするための方法はあるでしょうか。 todos と visibilityFilter はそれぞれ完全に独立して更新されるように見えます。ステートのフィールドは依存しあうことがしばしばあり、熟慮する必要がありますが、この事例では todos の更新処理を別の関数に簡単に分割することができます。
function todos(state = [], action) {
switch (action.type) {
case ADD_TODO:
return [...state, {
text: action.text,
completed: false
}];
case COMPLETE_TODO:
return [
...state.slice(0, action.index),
Object.assign({}, state[action.index], {
completed: true
}),
...state.slice(action.index + 1)
];
default:
return state;
}
}
function todoApp(state = initialState, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return Object.assign({}, state, {
visibilityFilter: action.filter
});
case ADD_TODO:
case COMPLETE_TODO:
return Object.assign({}, state, {
todos: todos(state.todos, action)
});
default:
return state;
}
}
todos は相変わらず state を受けとりますが、配列であることに注意してください。todoApp は管理に必要なステートの一部を渡すだけで、 todos はその一部だけを更新します。これは レジューサコンポジションと呼ばれ、 Redux アプリを開発する上での基本的なパターンです。
レジューサコンポジションをさらに進めてみましょう。visibilityFilter だけを管理するレジューサも分けられるでしょうか。できますね。
function visibilityFilter(state = SHOW_ALL, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return action.filter;
default:
return state;
}
}
ステートの一部を管理するレジューサを呼び出し、一つのオブジェクトにそれらをまとめる関数としてメインのレジューサを書き換えることができました。その結果初期ステートを考える必要もなくなりました。最初に undefined が渡されたときに子のレジューサがそれぞれの初期ステートを返すには十分です。
function todos(state = [], action) {
switch (action.type) {
case ADD_TODO:
return [...state, {
text: action.text,
completed: false
}];
case COMPLETE_TODO:
return [
...state.slice(0, action.index),
Object.assign({}, state[action.index], {
completed: true
}),
...state.slice(action.index + 1)
];
default:
return state;
}
}
function visibilityFilter(state = SHOW_ALL, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return action.filter;
default:
return state;
}
}
function todoApp(state = {}, action) {
return {
visibilityFilter: visibilityFilter(state.visibilityFilter, action),
todos: todos(state.todos, action)
};
}
これらのレジューサはそれぞれグローバルなステートの一部を管理していることに注意してください。 state 引数の中身はレジューサごとに異なり、自身が管理するステートの一部に対応します。
これでいい感じになりました!アプリが大きくなってきたら、別のファイルにレジューサを分割し完全に独立させ異なるデータドメインを管理することもできます。
最後に、 Redux は combineReducers() と呼ばれるユーティリティを提供していて、今までの todoApp 内で行っていることと同じことができます。その助けを借りることで、 todoApp を以下のように書き換えることができます。
import { combineReducers } from 'redux';
const todoApp = combineReducers({
visibilityFilter,
todos
});
export default todoApp;
これは以下とまったく同じことです。
export default function todoApp(state = {}, action) {
return {
visibilityFilter: visibilityFilter(state.visibilityFilter, action),
todos: todos(state.todos, action)
};
}
キーと名前が異なる関数を渡すこともできます。以下の二つの方法はまったく同じ内容です。
const reducer = combineReducers({
a: doSomethingWithA,
b: processB,
c: c
});
function reducer(state, action) {
return {
a: doSomethingWithA(state.a, action),
b: processB(state.b, action),
c: c(state.c, action)
};
}
combineReducers() は、 キーにしたがってステートの一部を切り取って 対応するレジューサを呼び出し、各レジューサの結果を再度一つのオブジェクトにする関数を生成するということだけを行います。
ES6 に精通したユーザー向けのメモ
combineReducersはオブジェクトを受け取ることを期待しているので、最上位のレジューサを別のファイルにすべて置くことができ、レジューサ関数をそれぞれexportした上で、import * as reducersを使うことで、それらのレジューサを名称がキーになっている一つのオブジェクトとして受け取ることができます。
import { combineReducers } from 'redux'; import * as reducers from './reducers'; const todoApp = combineReducers(reducers);
import *はまだ新しい構文なので、混乱を避けるためにこれ以上利用しませんが、コミュニティで用いられている例では見かけることがあるかもしれません。
ソースコード
reducers.js
import { combineReducers } from 'redux';
import { ADD_TODO, COMPLETE_TODO, SET_VISIBILITY_FILTER, VisibilityFilters } from './actions';
const { SHOW_ALL } = VisibilityFilters;
function visibilityFilter(state = SHOW_ALL, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return action.filter;
default:
return state;
}
}
function todos(state = [], action) {
switch (action.type) {
case ADD_TODO:
return [...state, {
text: action.text,
completed: false
}];
case COMPLETE_TODO:
return [
...state.slice(0, action.index),
Object.assign({}, state[action.index], {
completed: true
}),
...state.slice(action.index + 1)
];
default:
return state;
}
}
const todoApp = combineReducers({
visibilityFilter,
todos
});
export default todoApp;
次の節では
次は、ステートを保持し、アクションをディスパッチしたときにレジューサの呼び出しを引き受ける Redux の ストア がどのように作られるのかについて進めましょう。