ジャバ・ザ・ハットリ
Published on

ReactのRedux非同期処理がサルでも分かる超解説

Authors
  • avatar
    ジャバ・ザ・ハットリ

この記事はかつての私と同じように「Redux を使った非同期処理がいまいち分かんねー」という方に向けて書いた。とりあえずは React の公式サイト、Redux の公式サイトDan 氏の Redux ビデオ解説を観たが、なんかスッキリしない。特に Redux の非同期処理が分からない、という方向けの超シンプル解説。

React は公式サイトのチュートリアルなんかも充実していて丁寧だし分かりやすかった。しかし Redux は違う。特に公式サイトの非同期処理の例が変にややこしい。
こういうことをブログで書くと「アタシは公式サイトの説明を読んでも分からないバカです」と言ってるみたいだから、恥ずかしいしあまり書かれない。ウザいぐらいに「Redux は素晴らしい。シンプル。カンタン」という発言がネット上にあふれている。

しかし私の頭ではパっと分からなかった。私以外でも「これ難しいなー」と思ってる人が居るんじゃないだろうか。仮に今は分かっていてもそこに達するまでにまーまー苦労したとか。オープンイノベーションの世界では「オレは習得するのに苦労したから、後続の人も同じ苦労をしろ」を根絶するべき。
したがって恥を忍んででも「React の Redux 非同期処理がサルでも分かる超解説」を書くことにした。

この解説方法を一言で言うとこうなる。

まず先に Redux を使わないで非同期処理のコードを React で書いて、その後で Redux を加える

これをやることでやっと理解できた。

本記事で最終的にできあがるコードの動くサンプル。
https://simple-example-redux.herokuapp.com/

これは単にサーバーに保存されているコメント群をとってきて表示するだけ。

ソースコード
クローンしてそのまま npm install して npm start とすれば動きます。まだまだ学習中の身でもあるので「こうした方がいい」とかあったらぜひコメントください。

React だけの例

まずは React だけを使った例
これは React の基礎知識があれば把握できるレベルの単純なコード。

import React, { Component } from 'react'
import ReactDOM from 'react-dom'

class CommentList extends Component {
  constructor() {
    super()
    this.state = {
      comments: [
        {
          id: 1,
          comment: 'comment 1',
        },
        {
          id: 2,
          comment: 'comment 2',
        },
        {
          id: 3,
          comment: 'comment 3',
        },
        {
          id: 4,
          comment: 'comment 4',
        },
      ],
      hasError: false,
      isLoading: false,
    }
  }
  render() {
    if (this.state.hasError) {
      return <p>error</p>
    }
    if (this.state.isLoading) {
      return <p>loading . . . </p>
    }
    return (
      <ul>
        {this.state.comments.map((item) => (
          <li key={item.id}>{item.comment}</li>
        ))}
      </ul>
    )
  }
}

ReactDOM.render(<CommentList />, document.getElementById('app'))

実行した結果

ソースコードをクローンした場合はコミットログの aacf3a3 "non-redux example"にして、npm start すれば以下の画面が出る。

image

constructor を見れば分かるように state には配列で comment と boolean 形式で2種類のステータスを入れている。


constructor() {
    super();
    this.state = {
      comments: [
        {
          id: 1,
          comment: 'comment 1'
        },
        {
          id: 2,
     // :  省略

      ],
      hasError: false,
      isLoading: false
    }

isLoading かもしくは hasError を true にすると、それぞれの表示に切り替わる。

API からデータを取ってくる

ソースコードに comments を書き入れるのでは内容が変化しないので、そこを API から JSON 形式で取ってくるように変更する。

import React, { Component } from 'react'
import ReactDOM from 'react-dom'

class CommentList extends Component {
  constructor() {
    super()
    this.state = {
      comments: [],
    }
  }
  fetchData(url) {
    this.setState({ isLoading: true })
    fetch(url)
      .then((response) => {
        if (!response.ok) {
          throw Error(response.statusText)
        }
        this.setState({ isLoading: false })
        return response
      })
      .then((response) => response.json())
      .then((comments) => this.setState({ comments }))
      .catch(() => this.setState({ hasErrored: true }))
  }
  componentDidMount() {
    this.fetchData('https://594ecc215fbb1a00117871a4.mockapi.io/comments')
  }
  render() {
    if (this.state.hasError) {
      return <p>error</p>
    }
    if (this.state.isLoading) {
      return <p>loading . . . </p>
    }
    return (
      <ul>
        {this.state.comments.map((item) => (
          <li key={item.id}>{item.comment}</li>
        ))}
      </ul>
    )
  }
}

ReactDOM.render(<CommentList />, document.getElementById('app'))

つまり以下のコードが mount 時に実行されてコメントを API から取ってくる。

componentDidMount() {
  this.fetchData('https://594ecc215fbb1a00117871a4.mockapi.io/comments');
}

https://594ecc215fbb1a00117871a4.mockapi.io/commentsというのは無料で登録したモックで、アクセスすると5つのコメントを JSON 形式で返してくる。本来ならここは Rails とかのサーバーにしてお好きな JSON を返すようにする。

動かした結果の画面はほぼ同じだが、コメントの中身は API から取ってきてますよ、と。

Redux を入れる

では上記のコードに Redux を入れていく。まずは redux react-redux redux-thunk が必要になるのでそれらをインストールする。

npm install redux react-redux redux-thunk --save

念のため Redux の3原則
Three Principles · Redux

  • Single source of truth(状態管理は1箇所だけ)
  • State is read-only(状態は読み取り専用)
  • Changes are made with pure functions(変更は純粋な関数で行う)

Redux を入れてコードが完成した後のファイル構成


├── package.json
└── src
    ├── actions
    │   └── comments.js
    ├── components
    │   └── CommentList.js
    ├── index.html
    ├── index.js
    ├── reducers
    │   ├── comments.js
    │   └── index.js
    └── store
        └── configureStore.js

State の内容

Redux 無しのコードで明らかになったように State には3つのプロパティが必要。comments、hasError、isLoading でありそれぞれに Redux アクションが必要になる。

src/actions/comments.js

export const getCommentsError = (status) => ({
  type: 'GET_COMMENTS_ERROR',
  hasError: status,
})

export const loadComments = (status) => ({
  type: 'LOAD_COMMENTS',
  isLoading: status,
})

export const fetchCommentsSuccess = (comments) => ({
  type: 'FETCH_COMMENTS_SUCCESS',
  comments,
})

getCommentsError と loadComments は status を引数として type とステータスを返す。
fetchCommentsSuccess はデータの取り出しに成功したらコメントを配列に入れて comments として type と共に返す。

アクションクリエータはアクションを返す。返すと書いているのに Return が無い!となった方はこれは以下のように書いてるのと同じ。以下のコードをアロー関数で書いて return を省略しただけ。

export function getCommentsError(status) {
  return {
    type: 'GET_COMMENTS_ERROR',
    hasError: status,
  }
}

アクションとしては元の Redux 無しにあった fetchData に相当するアクションがもうひとつ必要になる。ここではそれを fetchComments として作成する。

src/actions/comments.js

export const fetchComments = (url) => {
  return (dispatch) => {
    dispatch(loadComments(true))

    fetch(url)
      .then((response) => {
        if (!response.ok) {
          throw Error(response.statusText)
        }
        dispatch(loadComments(false))

        return response
      })
      .then((response) => response.json())
      .then((comments) => dispatch(fetchCommentsSuccess(comments)))
      .catch(() => dispatch(getCommentsError(true)))
  }
}

reducers

reducers は state と action という2つの引数を持つ。reducers の中では switch を使って action.type ごとに処理を分けて、それぞれの action を返す。

reducers/comments.js

export const getCommentsError = (state = false, action) => {
  switch (action.type) {
    case 'GET_COMMENTS_ERROR':
      return action.hasError
    default:
      return state
  }
}

export const loadComments = (state = false, action) => {
  switch (action.type) {
    case 'LOAD_COMMENTS':
      return action.isLoading
    default:
      return state
  }
}

export const comments = (state = [], action) => {
  switch (action.type) {
    case 'FETCH_COMMENTS_SUCCESS':
      return action.comments
    default:
      return state
  }
}

それぞれの reducer を rootReducer でくっつける。
import でそれぞれの reducer をインポートする。後は combineReducers で囲う。
reducer 名をもっとシンプルに getError とか load とかでも良かったんじゃないの?と思うかもしれないが、ここはできるだけ comments という主語を入れた名前の方がいい。
今回の例では全ての reducer は comments に関することだが、これ以降に users、likes、とか色んな reducer を扱うようになった時に混乱しないため。

reducers/index.js

import { combineReducers } from 'redux'
import { getCommentsError, loadComments, comments } from './comments'

export default combineReducers({
  getCommentsError,
  loadComments,
  comments,
})

Store

ここはほぼ全ての Redux の解説にある内容と同じ。こうして Store 作りますよ、と。
store/configureStore.js

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import rootReducer from '../reducers'

const configureStore = (initialState) => {
  return createStore(rootReducer, initialState, applyMiddleware(thunk))
}

export default configureStore

index.js

import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import configureStore from './store/configureStore'
import CommentList from './components/CommentList'

const store = configureStore()

render(
  <Provider store={store}>
    <CommentList />
  </Provider>,
  document.getElementById('app')
)

Components

components/CommentList.js

import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
import { fetchComments } from '../actions/comments'

class CommentList extends Component {
  componentDidMount() {
    this.props.fetchData('https://594ecc215fbb1a00117871a4.mockapi.io/comments')
  }

  render() {
    if (this.props.hasError) {
      return <p> error </p>
    }
    if (this.props.isLoading) {
      return <p> Loading...</p>
    }

    return (
      <ul>
        {this.props.comments.map((item) => (
          <li key={item.id}>{item.comment}</li>
        ))}
      </ul>
    )
  }
}

CommentList.propTypes = {
  fetchData: PropTypes.func.isRequired,
  comments: PropTypes.array.isRequired,
  hasError: PropTypes.bool.isRequired,
  isLoading: PropTypes.bool.isRequired,
}

const mapStateToProps = (state) => ({
  comments: state.comments,
  hasError: state.getCommentsError,
  isLoading: state.loadComments,
})

const mapDispatchToProps = (dispatch) => ({
  fetchData: (url) => dispatch(fetchComments(url)),
})

export default connect(mapStateToProps, mapDispatchToProps)(CommentList)

まず import している connect が component を store につなげる役割をする。
actions からは fetchComments のみを import する。ここで必要なのはこのアクションだけで他のは dispach して呼び出す。

後はもう細かい説明よりコード見た方がいい。

JavaScript 界隈に足を踏み入れる前に以下の本を読んで、結構参考になった。バリバリのフロントエンドのエンジニアには不要だが、これからフロントエンドも知っておこうかな、ぐらいの人にはちょうどいい内容だった。

いまから始めるWebフロントエンド開発
いまから始める Web フロントエンド開発
作者: 松田承一
発売日: 2016/08/25
メディア: Kindle 版

関連記事