spice picks

エンジニアをしているspiceが色々書きます

Reduxの公式チュートリアルをきちんと勉強する-前半

僕は雰囲気で Redux を書いています。公式ドキュメントは困った時に読むだけで、きちんと読んだことはありません。 なので、ちゃんとチュートリアルをやってみます。 公式ドキュメントを食わず嫌いしていた方、一緒にやりましょう。

以下、チュートリアルの流れに沿って、「ポイントの引用 → コメント」という形で書いていきます。 文中の引用文、ソースコードは全てチュートリアルに記載されているものです。

Basics

チュートリアルの冒頭には、このように書かれています。

Don't be fooled by all the fancy talk about reducers, middleware, store enhancers—Redux is incredibly simple. If you've ever built a Flux application, you will feel right at home. If you're new to Flux, it's easy too!

おそらくここで言いたいことは、

「reducer」「middleware」「store」などの言葉に惑わされずに根本の考え方を押さえれば、Redux は簡単なんだ。

ってことですね。 Redux に流れる思想を意識してチュートリアルやっていきましょう!

Actions

TODO アプリの作成を例に、Redux について説明が進んでいきます。

Actions are payloads of information that send data from your application to your store. They are the only source of information for the store. You send them to the store using store.dispatch().

action は、アプリから store に情報を渡す payload(データ本体)のこと。store の情報の源泉です。

action の内容に基づいて、store が書き換えられるんですね。 (store を「書き換える」行為は自体別の役割。)

TODO アプリにおいて、タスクを増やす action のソースコードとして

const ADD_TODO = 'ADD_TODO'

{
  type: ADD_TODO,
  text: 'Build my first Redux app'
}

が書かれています。

Actions are plain JavaScript objects. Actions must have a type property that indicates the type of action being performed. Types should typically be defined as string constants. Once your app is large enough, you may want to move them into a separate module.

action は JavaScript のオブジェクトだけど、typeプロパティを必ず持たなけれならず、これがactionの名前になるとのこと。このプロパティ以外のオブジェクトの構造は自由ですが、推奨される書き方がいくつかあるそうです。

TODO アプリでは他にも action が必要です。

タスクを完了する action だったり、

{
  type: TOGGLE_TODO,
  index: 5
}
// indexに渡す値は完了したいタスクの配列のindex

表示するタスクの種類を切り替える action などです。

{
  type: SET_VISIBILITY_FILTER,
  filter: SHOW_COMPLETED
}

タスク完了の action では、タスクのデータ全てを渡すのではなく、index だけを渡すようになっています。 action はなるべく小さくするのが good ideaと言われています。

ActionCreators

Action creators are exactly that—functions that create actions. It's easy to conflate the terms “action” and “action creator”, so do your best to use the proper term.

action creator は action を生成する関数です。 「action」と「action creator」は紛らわしいので、使いやすい言葉で表してね、とのこと。 じゃあこの時では「action 作成器」と言いましょう。

Redux では、使いまわしやすく、テストがしやすいので、 action 生成器は action を返すだけの関数になります。

function addTodo(text) {
  return {
    type: ADD_TODO,
    text
  }
}

従来の Fluxだと、以下のように関数の中で dispatch(action を store に伝達すること)を行っていました。

function addTodoWithDispatch(text) {
  const action = {
    type: ADD_TODO,
    text
  }
  dispatch(action)
}

しかしこれは Redux の書き方ではありません。 なので、関数の呼び出し側で dispatch する必要があります。 上記の addToDo を呼び出す場合、

dispatch(addTodo(text))

となります。 この書き方以外には、action 生成器と dispatch をつなげる別の関数を書く方法もあります。その場合は

const boundAddTodo = text => dispatch(addTodo(text))

boundAddTodo(text)

と書くことができます。

Reducers

Reducers specify how the application's state changes in response to actions sent to the store. Remember that actions only describe what happened, but don't describe how the application's state changes.

reducer は、store に送られた action に基づいて、state をどのように変更するのかを決定します。

混同していけないのが、

  • action =「何が起こったか」を記述するもの
  • reducer = 「どのように state を変更するのか」を記述するもの

ということです。

In Redux, all the application state is stored as a single object. It's a good idea to think of its shape before writing any code. What's the minimal representation of your app's state as an object?

redux において、アプリの state は一つのオブジェクトとして保存されます。なので、コードを書く前に state の形について考えておくのが good idea です。 TODO アプリだと、アプリの state は以下のような形をとります。

{
  visibilityFilter: 'SHOW_ALL',
  todos: [
    {
      text: 'Consider using Redux',
      completed: true
    },
    {
      text: 'Keep all state in a single tree',
      completed: false
    }
  ]
}

Action をハンドリングする

Now that we've decided what our state object looks like, we're ready to write a reducer for it. The reducer is a pure function that takes the previous state and an action, and returns the next state.

reducer は「直前の state の状態と action」を受け取り、「新しい state」を返す関数になります。

(previousState, action) => newState

シンプルでかっこいい。 reducer を単純にしておくのはすごい大事なことで、

Given the same arguments, it should calculate the next state and return it. No surprises. No side effects. No API calls. No mutations. Just a calculation.

と、強く注意されています。 そんな行為が必要になってきたら発展編のチュートリアルを参照するべし。

TODO アプリのための reducer は、以下のように書かれています。

import { VisibilityFilters } from './actions'

const initialState = {
  visibilityFilter: VisibilityFilters.SHOW_ALL,
  todos: []
}

function todoApp(state, action) {
  if (typeof state === 'undefined') {
    return initialState
  }

  // For now, don't handle any actions
  // and just return the state given to us.
  return state
}

state の初期状態を定義・反映するコードが書かれています。 この todoApp 関数を、渡される action 名によって返す state を変更するようにしたコードが以下の通り。

function todoApp(state = initialState, action) {
  switch (action.type) {
    case SET_VISIBILITY_FILTER:
      return Object.assign({}, state, {
        visibilityFilter: action.filter
      })
    default:
      return state
  }
}

受け取った action の type 名で分岐をしています。 object.assignの第一引数が空のオブジェクトになっているのは、渡された state に変更を加えない(mutate しない)ためです。 reducer を書く上で大事なNo mutasions. Just a calculationを守るためにこの書き方になっています。

Handling More Actions

で、これまでに登場した action に対して対応すると、以下の通りになります。

import { ADD_TODO, TOGGLE_TODO, SET_VISIBILITY_FILTER,  VisibilityFilters} from './actions'

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 TOGGLE_TODO:
      return Object.assign({}, state, {
        todos: state.todos.map((todo, index) => {
          if (index === action.index) {
            return Object.assign({}, todo, {
              completed: !todo.completed
            })
          }
          return todo
        })
      })
    default:
      return state
  }
}

reducerでstateを変更するコードが多くありますが、前述したNo mutasions. Just a calculationを守るため、直接stateに値を追加している部分はありません。

Splitting Reducers

TODOアプリに必要なreducerを一つの関数で書くと冗長なコードになるので、分割しています。

  • 「タスクの表示の切り替え」と「タスクの追加や削除」は独立したふるまい(相互に依存していない)こと
  • 自身が管理するstateだけ引数で受け取ること
  • reducerを統一するにはcombineReducer()を使うこと

を抑えた結果、以下のようなコードになっています。

import { combineReducers } from 'redux'
import {
  ADD_TODO,
  TOGGLE_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 TOGGLE_TODO:
      return state.map((todo, index) => {
        if (index === action.index) {
          return Object.assign({}, todo, {
            completed: !todo.completed
          })
        }
        return todo
      })
    default:
      return state
  }
}

const todoApp = combineReducers({
  visibilityFilter,
  todos
})

export default todoApp

combineReducerの挙動については、

All combineReducers() does is generate a function that calls your reducers with the slices of state selected according to their keys, and combines their results into a single object again. It's not magic. And like other reducers, combineReducers() does not create a new object if all of the reducers provided to it do not change state.

と説明されています。 引数で渡されたkey名にしたがって、必要なstateとactionを引数にして関数を呼び出し、一つにまとめているんですね。 この挙動を自分で書こうとすると、

{
  visibilityFilter: visibilityFilter(state.visibilityFilter, action)
}

みたいな形になります。 combineReducerを使うとシンプルに記述ができます。

以上が、受け取ったactionをもとに新たなstateを作成して返すreducerでした。

Store

actionとreducerを作成しましたが、それらを結びつけるのがstoreです。 storeの振る舞いについては、以下の通り。

The Store is the object that brings them together. The store has the following responsibilities: - Holds application state; - Allows access to state via getState(); - Allows state to be updated via dispatch(action); - Registers listeners via subscribe(listener); - Handles unregistering of listeners via the function returned by subscribe(listener).

  • アプリのstateを保持する
  • getState()によるstateへのアクセス
  • diapatch(action)を通したstateの更新
  • subscribe(listner)によるリスナーの登録と、それを返すことによるリスナーの破棄

がstoreが責任を持つ範囲です、と。 また、storeはアプリに一つだけにするのが大事です。

reducerの作成でcombineReducer()を用いていれば、createStore を用いてstoreの作成が楽にできます。

import { createStore } from 'redux'
import todoApp from './reducers'
const store = createStore(todoApp)

一瞬です。

その他storeが持つ機能(dispachなど)については、通常ならアプリのUIと紐付けられて使われますが、UIなしでもstoreがちゃんと動くことは確認ができます。Dispatching Actionsでは、creareStatesubscribedispatchによるreducerの実行の各機能を使用し、storeの動きを確かめています。

Data Flow

Reduxによる単方向のデータフローを実現します。 これは何が嬉しいのか、書いてあります。

This means that all data in an application follows the same lifecycle pattern, making the logic of your app more predictable and easier to understand. It also encourages data normalization, so that you don't end up with multiple, independent copies of the same data that are unaware of one another.

整理すると、

  • 全てのデータが同じライフサイクルを持つ
  • アプリのロジックが予測・理解しやすくなる
  • データの正規化ができるので、同じデータを複数の場所で保つ必要がなくなる

とのこと。 確かに、アプリのいたるところにデータが混在している場合、管理が難しくなりますね。個人開発や小さいアプリならよくても、それが大きくなったら・・・。恐ろしい。

Reduxでのstateのライフサイクルは全て、

  1. You call store.dispatch(action).
  2. The Redux store calls the reducer function you gave it.
  3. The root reducer may combine the output of multiple reducers into a single state tree.
  4. The Redux store saves the complete state tree returned by the root reducer.

に乗っ取ることになります。 

長くなったので、Usage with React以降については別の記事で書くことにします