3
4

More than 5 years have passed since last update.

訳: Async Actions

Posted at

Redux 公式ドキュメント http://redux.js.org/docs/advanced/AsyncActions.html の訳。

Async Actions(非同期の Action)

basic guide 中では、私達はシンプルな TODO アプリを作り上げました。これは完全に同期的なものでした。action が dispatch されると常に、state は即時更新されました。

このガイド中では、私達は異なったものを、非同期のアプリケーションを作成します。現在の headline を表示し subreddit を選択させる為に Reddit API を用います。非同期という性質をどのようにして Redux のフローに適用するのでしょうか。

Actions

非同期 API をコールする場合、2 つの重要な場面が存在します。API コールを始める瞬間と、応答を受け取った瞬間(もしくはタイムアウト)です。

この 2 つの瞬間ではだいたいアプリケーションの state の変更を必要とします。その為に、reducer によって同期的に処理される通常の action を dispatch する必要があります。およそ、どんな API リクエストにおいても最低 3 種類の action を dispatch したくなるでしょう。

  • リクエスト開始を reducer に通知する action

reducer はこの action を state 中の isFetching フラグをトグルする為に扱うことが出来ます。これによって UI が spinner を表示すべきときがわかります。

  • リクエスト完了を reducer に通知する action

reducer はこの action を、新しいデータを管理下の state にマージし、また isFetching フラグをリセットする為に用いることが出来ます。UI 中では spinner を隠し、受けとったデータを表示します。

  • リクエスト失敗を reducer に通知する action

reducer はこの action を isFetching フラグをリセットする為に用いることが出来ます。加えて、特定の reducer でエラーメッセージを保存することで、UI に表示出来ます。

action に専用の status フィールドを使用するか

{ type: 'FETCH_POSTS' }
{ type: 'FETCH_POSTS', status: 'error', error: 'Oops' }
{ type: 'FETCH_POSTS', status: 'success', response: { ... } }

又は異なる type の action を定義する(Multiple types/Separate types)ことも出来ます。

{ type: 'FETCH_POSTS_REQUEST' }
{ type: 'FETCH_POSTS_FAILURE', error: 'Oops' }
{ type: 'FETCH_POSTS_SUCCESS', response: { ... } }

どちらを選ぶかはあなた次第です。あなたがチームと決定すべき慣例です。Multiple types はミスの可能性を減らすことが出来ますが、しかしこれは action creator や reducer を redux-actions のようなヘルパーライブラリで生成する場合は問題ではありません。

どんな慣例を選ぼうと、アプリケーションを通して一貫性を保てます。
このチュートリアルでは Separate types を使用します。

訳注:convention を慣例と訳していますが、これは「status フィールドを用いるか、type を別に定義するか、のどちらを選ぶか」、意図としてはコーディングルールに近いものがあります。

同期的な Action Creator

example アプリ中で必要な、同期的な action type と action creator を定義しましょう。ユーザーは表示する subreddit を選択出来ます。

action.js

export const SELECT_SUBREDDIT = 'SELECT_SUBREDDIT'

export function selectSubreddit(subreddit) {
  return {
    type: SELECT_SUBREDDIT,
    subreddit
  }

同様に、更新する為の "refresh" ボタンを押すことが出来ます。

export const INVALIDATE_SUBREDDIT = 'INVALIDATE_SUBREDDIT'

export function invalidateSubreddit(subreddit) {
  return {
    type: INVALIDATE_SUBREDDIT,
    subreddit
  }
}

これらはユーザーインタラクションに決定づけられる action です。また他の種類の action として、ネットワークリクエストに決定づけられるものもあります。これらがどのように dispatch されるかはあとで見ることとして、しかし今、これらの action の定義だけはやっておきたいところです。

subreddit の post を fetch したい時には、REQUEST_POSTS action を dispatch します。

export const REQUEST_POSTS = 'REQUEST_POSTS'

function requestPosts(subreddit) {
  return {
    type: REQUEST_POSTS,
    subreddit
  }
}

SELECT_SUBREDDIT や又は INVALIDATE_SUBREDDIT から分けておくことは重要です。これから度々起こり得ることとして、アプリがもっと複雑になっていくにつれ、データの fetch をユーザー action(例えばポピュラーな subreddit をプリフェッチしておいたり、古いデータを更新したり等)とは独立して行いたくなるでしょう。route の変更に応じても fetch したいでしょうし、そうなると、データ fetch を早まって特定の UI イベントと結びつけるのは賢いとは言えません。

最終的に、ネットワークリクエストが届いた場合、RECEIVE_POSTS を dispatch します。

export const RECEIVE_POSTS = 'RECEIVE_POSTS'

function receivePosts(subreddit, json) {
  return {
    type: RECEIVE_POSTS,
    subreddit,
    posts: json.data.children.map(child => child.data),
    receivedAt: Date.now()
  }
}

これが現時点で私達が知るべきことです。これらの action をネットワークリクエストと共に dispatch する為のメカニズムについては後に検討されます。

こういうリファレンスにおける discuss のいい訳が知りたい。

state の構造の設計

丁度 basic tutorial 中でやったように、実装を急ぐ前にアプリケーションの state を設計する必要があります。非同期コードにおいては、より多くの state を処理しますが、よってそのことをよく考えなければなりません。

このパートはよく初学者を混乱させます、何故ならどの情報が非同期アプリケーションの state を表していて、どのように単一のツリーを成しているかが即座にははっきりとしないからです。

最も一般的なユースケース、リストから始めます。ウェブアプリケーションは多くの場合リストを表示します。例えば、post のリストや、友人のリストです。アプリがどんな種類のリストが表示出来るか把握する必要があります。これらは分けて保存しておきたいところで、理由としてはキャッシュしたり必要な場合のみ再度 fetch したり出来る為です。

"Reddit headlines" アプリの state の構造はこのようになるでしょう。

{
  selectedSubreddit: 'frontend',
  postsBySubreddit: {
    frontend: {
      isFetching: true,
      didInvalidate: false,
      items: []
    },
    reactjs: {
      isFetching: false,
      didInvalidate: false,
      lastUpdated: 1439478405547,
      items: [
        {
          id: 42,
          title: 'Confusion about Flux and Relay'
        },
        {
          id: 500,
          title: 'Creating a Simple Application Using React JS and Flux Architecture'
        }
      ]
    }
  }
}

ここでいくつかの重要なことがあります。

  • subreddit の各情報を別々に保存するので、どの subreddit もキャッシュ出来ます。ユーザーが 2 回目にこれらの間を切り替えても、更新は即座に行われ、必要としない限りは再 fetch も不要となります。これら全てのアイテムがメモリ中にあることは心配無用です。数万のアイテムを扱いかつユーザーがタブを閉じることが稀でない限り、クリーンアップの類は必要ありません。

  • 各アイテムリストに対し、スピナーを表示する為に isFetching 、データが失効した場合に toggle 出来る didInvalidate 、最後に fetch されたのがいつか知る為の lastUpdateditems 自身を保存しておきたいでしょう。現実的なアプリでは、ページネーションの状態を保持する fetchedPageCountnextPageUrl も必要かもしれません。

ネストしたエンティティについて

この例中では、受けとったアイテムをページネーションの情報と一緒に保存しています。しかし、このアプローチは相互に参照しあうネストしたエンティティが存在する場合やユーザーにアイテムの編集を許容する場合はうまく動作しないでしょう。ユーザーは fetch した post を編集したいが、その post が state ツリーの複数の場所に重複していることを想像して下さい。これは実装において本当につらみがあるでしょう。

もしネストしたエンティティがある場合、又はユーザーにアイテムの編集を許容する場合は、それがデータベース中にあるように、これらを state 中から分離しておくべきです。ページネーションの情報中では、それらの ID のみを参照するようにしましょう。これで常に最新に保つことが出来ます。real world example はこのアプローチを、(normalizr)[https://github.com/paularmstrong/normalizr] によってネストした API のレスポンスを正規化することと共に見せています。このアプローチで、state はこのようになるでしょう。

{
  selectedSubreddit: 'frontend',
  entities: {
    users: {
      2: {
        id: 2,
        name: 'Andrew'
      }
    },
    posts: {
      42: {
        id: 42,
        title: 'Confusion about Flux and Relay',
        author: 2
      },
      100: {
        id: 100,
        title: 'Creating a Simple Application Using React JS and Flux Architecture',
        author: 2
      }
    }
  },
  postsBySubreddit: {
    frontend: {
      isFetching: true,
      didInvalidate: false,
      items: []
    },
    reactjs: {
      isFetching: false,
      didInvalidate: false,
      lastUpdated: 1439478405547,
      items: [ 42, 100 ]
    }
  }
}

このガイドでは、エンティティの正規化はしませんが、更に動的なアプリケーションの際には検討材料となります。

action のハンドリング

ネットワークリクエストを伴うアクション dispatch の詳細に入る前に、定義した action の reducer を実装します。

Reducer の合成について

ここでは、basics guide の Splitting Reducers セクションに記載されている、 combineReducers() による reducer の合成を理解している想定とします。そうでなければ、まずこれを読んで下さい。

reducers.js

import { combineReducers } from 'redux'
import {
  SELECT_SUBREDDIT,
  INVALIDATE_SUBREDDIT,
  REQUEST_POSTS,
  RECEIVE_POSTS
} from '../actions'

function selectedSubreddit(state = 'reactjs', action) {
  switch (action.type) {
    case SELECT_SUBREDDIT:
      return action.subreddit
    default:
      return state
  }
}

function posts(
  state = {
    isFetching: false,
    didInvalidate: false,
    items: []
  },
  action
) {
  switch (action.type) {
    case INVALIDATE_SUBREDDIT:
      return Object.assign({}, state, {
        didInvalidate: true
      })
    case REQUEST_POSTS:
      return Object.assign({}, state, {
        isFetching: true,
        didInvalidate: false
      })
    case RECEIVE_POSTS:
      return Object.assign({}, state, {
        isFetching: false,
        didInvalidate: false,
        items: action.posts,
        lastUpdated: action.receivedAt
      })
    default:
      return state
  }
}

function postsBySubreddit(state = {}, action) {
  switch (action.type) {
    case INVALIDATE_SUBREDDIT:
    case RECEIVE_POSTS:
    case REQUEST_POSTS:
      return Object.assign({}, state, {
        [action.subreddit]: posts(state[action.subreddit], action)
      })
    default:
      return state
  }
}

const rootReducer = combineReducers({
  postsBySubreddit,
  selectedSubreddit
})

export default rootReducer

このコード中で、興味深い点は 2 点です。

  • ES6 computed property syntax を使用し、完結な方法で state[action.subreddit]Object.assign() で更新しています。この、
return Object.assign({}, state, {
  [action.subreddit]: posts(state[action.subreddit], action)
})

はこれと同等です。

let nextState = {}
nextState[action.subreddit] = posts(state[action.subreddit], action)
return Object.assign({}, state, nextState)
  • 特定の post リストの state を管理する posts(state, action) を抜き出しています。これがまさに reducer の合成です。どのように reducer をより小さな reducer に分割するかが我々の選択であり、この場合、オブジェクト中のアイテムの更新を posts reducer に移譲しています。 real world example では更に発展して、parameterized pagination reducer の reducer factory をどのように作成するかを示しています。

Async Action Creators

最終的に、予め定義しておいた同期的な action creater はどのようにネットワークリクエストと共に用いればいいのでしょうか。Redux でのスタンダードな方法は Redux Thunk Middleware を用いることです。分離された redux-thunk パッケージで使用出来ます。middleware が一般的にどう動作するかは 後に 説明しますが、とりあえず、ひとつたけ重要なことを知る必要があります。この middleware を利用することで、action creator はオブジェクトの代わりに関数を返すことが出来ます。このように、action creator は thunk となります。

action creator が関数を返すとき、その関数は Redux Thunk middleware によって実行されます。この関数は純粋関数である必要は無く、つまり非同期 API コールを含むような副作用を持つことが許容されます。関数はまた(予め定義しておいたような同期的な)action を dispatch することが出来ます。

これらの特別な thunk action creator もまた action.js に定義出来ます。

action.js

import fetch from 'isomorphic-fetch'

export const REQUEST_POSTS = 'REQUEST_POSTS'
function requestPosts(subreddit) {
  return {
    type: REQUEST_POSTS,
    subreddit
  }
}

export const RECEIVE_POSTS = 'RECEIVE_POSTS'
function receivePosts(subreddit, json) {
  return {
    type: RECEIVE_POSTS,
    subreddit,
    posts: json.data.children.map(child => child.data),
    receivedAt: Date.now()
  }
}

// Meet our first thunk action creator!
// Though its insides are different, you would use it just like any other action creator:
// store.dispatch(fetchPosts('reactjs'))

export function fetchPosts(subreddit) {
  // Thunk middleware knows how to handle functions.
  // It passes the dispatch method as an argument to the function,
  // thus making it able to dispatch actions itself.

  return function (dispatch) {
    // First dispatch: the app state is updated to inform
    // that the API call is starting.

    dispatch(requestPosts(subreddit))

    // The function called by the thunk middleware can return a value,
    // that is passed on as the return value of the dispatch method.

    // In this case, we return a promise to wait for.
    // This is not required by thunk middleware, but it is convenient for us.

    return fetch(`https://www.reddit.com/r/${subreddit}.json`)
      .then(
        response => response.json(),
        // Do not use catch, because that will also catch
        // any errors in the dispatch and resulting render,
        // causing an loop of 'Unexpected batch number' errors.
        // https://github.com/facebook/react/issues/6895
        error => console.log('An error occured.', error)
      )
      .then(json =>
        // We can dispatch many times!
        // Here, we update the app state with the results of the API call.

        dispatch(receivePosts(subreddit, json))
      )
  }
}

fetch について

この例中で fetch API を利用しています。これはおよそ一般的な要求において XMLHttpRequest 置き替えの、ネットワークリクエストを作成する新しい API です。殆どのブラウザが未だネイティブサポートしていないので、 isomorphic-fetch ライブラリを使用することを提案します。

// Do this in every file where you use `fetch`
import fetch from 'isomorphic-fetch'

内部的に、クライアントサイドでは whatwg-fetch polyfill を、サーバーサイドでは node-fetch を使用する為、アプリを universal 化する場合でも API コールを変更する必要がありません。

どの fetch polyfill も Promise polyfill が既に存在している想定であることには注意して下さい。Promise polyfill があることを確実にする最も簡単な方法としては、Babel の ES6 polyfill を他の全てのコードが実行される以前のエントリーポイントで有効にすることです。

// Do this once before any other code in your app
import 'babel-polyfill'

どのようにして Redux Thunk middleware を dispatch 機構の中に含めるのでしょうか。Redux から applyMiddleware() を、以下のように使用します。

index.js

import thunkMiddleware from 'redux-thunk'
import { createLogger } from 'redux-logger'
import { createStore, applyMiddleware } from 'redux'
import { selectSubreddit, fetchPosts } from './actions'
import rootReducer from './reducers'

const loggerMiddleware = createLogger()

const store = createStore(
  rootReducer,
  applyMiddleware(
    thunkMiddleware, // lets us dispatch() functions
    loggerMiddleware // neat middleware that logs actions
  )
)

store.dispatch(selectSubreddit('reactjs'))
store
  .dispatch(fetchPosts('reactjs'))
  .then(() => console.log(store.getState()))

thunk のいいところはこれらが互いの result を dispatch 出来ることです。

actions.js

import fetch from 'isomorphic-fetch'

export const REQUEST_POSTS = 'REQUEST_POSTS'
function requestPosts(subreddit) {
  return {
    type: REQUEST_POSTS,
    subreddit
  }
}

export const RECEIVE_POSTS = 'RECEIVE_POSTS'
function receivePosts(subreddit, json) {
  return {
    type: RECEIVE_POSTS,
    subreddit,
    posts: json.data.children.map(child => child.data),
    receivedAt: Date.now()
  }
}

function fetchPosts(subreddit) {
  return dispatch => {
    dispatch(requestPosts(subreddit))
    return fetch(`https://www.reddit.com/r/${subreddit}.json`)
      .then(response => response.json())
      .then(json => dispatch(receivePosts(subreddit, json)))
  }
}

function shouldFetchPosts(state, subreddit) {
  const posts = state.postsBySubreddit[subreddit]
  if (!posts) {
    return true
  } else if (posts.isFetching) {
    return false
  } else {
    return posts.didInvalidate
  }
}

export function fetchPostsIfNeeded(subreddit) {
  // Note that the function also receives getState()
  // which lets you choose what to dispatch next.

  // This is useful for avoiding a network request if
  // a cached value is already available.

  return (dispatch, getState) => {
    if (shouldFetchPosts(getState(), subreddit)) {
      // Dispatch a thunk from thunk!
      return dispatch(fetchPosts(subreddit))
    } else {
      // Let the calling code know there's nothing to wait for.
      return Promise.resolve()
    }
  }
}

これでコードの記載量は殆どかわらないまま、より洗練された非同期コントロールフローを実装出来ます。

index.js

store
  .dispatch(fetchPostsIfNeeded('reactjs'))
  .then(() => console.log(store.getState()))

サーバーサイドレンダリングについて

非同期 action creator は特にサーバーサイドレンダリングで便利です。store を作成、アプリ全体のデータを fetch する非同期 action creator らを dispatch する単一の非同期 action creator を dispatch、そして Promise の return が完了してからのみ render 出来ます。つまり store はレンダリングの前に必要とする state で満たされています。

訳注:係りが難しいので出来れば原文を参照して下さい。

Redux で非同期 action を取り扱う方法は Thunk middleware だけではありません。

middleware の有無によらず、いくつかを試し、気に入ったものを選択し、それに倣うかはあなた次第です。

UI との結合

(UI との結合において)非同期 action の dispatch に同期的な action の dispatch と違いはないので、詳細は論じません。Redux を React component から利用する為の導入として、React での利用 を参照して下さい。この例中の完全なソースコードは http://redux.js.org/docs/advanced/ExampleRedditAPI.html を参照して下さい。

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4