Help us understand the problem. What is going on with this article?

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

More than 3 years have passed since last update.

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

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

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

この解説方法を一言で言うとこうなる。
まず先にReduxを使わないで非同期処理のコードをReactで書いて、その後でReduxを加える
これをやることでやっと理解できた。

本記事で最終的にできあがるコードの動くサンプル

これは単にサーバーに保存されているコメント群をとってきて表示するだけ。
(mockapi.ioが動いてない場合はerror表示を出します。)

ソースコード

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

Reactだけの例

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

index.js
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すれば以下の画面が出る。
20170626055310.png

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形式で取ってくるように変更する。

index.js
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

  • 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して呼び出す。

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

ウェブ系エンジニアの皆様へ

「ほとんどのエンジニアには解けるが、下位10%のダメなエンジニアにだけ解けないパズル?」なるものをシリーズ化してパズル1から8まで作成した。もしご興味あれば解いてみてください。
http://tango-ruby.hatenablog.com/entry/2015/11/30/122814

jabba
ベルリンのスタートアップで働くソフトウェアエンジニア(イボ痔持ち)
https://www.jabba.cloud/
admin-guild
「Webサービスの運営に必要なあらゆる知見」を共有できる場として作られた、運営者のためのコミュニティです。
https://admin-guild.slack.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away