LoginSignup
2
2

More than 5 years have passed since last update.

Reduxのサンプル async (非同期通信)を調べてみた。

Posted at

Reduxのexampleにある async というサンプルについて調べてみた。

留意点

  • 初心者の覚書です。
  • 自分の環境で動くように参考にしたコードを適当に修正している。
  • Windows10 64bit , PowerShellなどで動かしている。
  • 見栄えを若干よくする為にbootstrap4を利用している。
  • 解説はアバウトな言い回しで厳密ではない。誤解を含む可能性あり。
  • stateの表示などでconsole.logを多用している。

実行画面

変則的だが、最初に実行画面で、selectボックスの選択やボタンのクリックなどに応じて、
stateの状態がどのように変化するか確認。

tokyo(デフォルト), osaka, kyoto の3つから選択できるが、最初はstatesByWordにtokyoのデータだけが入っている。osakaやkyotoなどを選択すると、そのたびに、statesByWordにそれらのデータが追加されていくのがわかる。
3つとも選択されたあとは、selectボックスを変更してもstatesByWordのそれらのデータは(アプリ内部では)更新されない。

ここで、osakaを選択したまま 再読込み ボタンを押すと,
INVALIDATA_Wordアクションのところで、osakaのdidInvalidateがtrueにかわり、
REQUEST_POSTSアクションのあと、osakaのisfetchingがtrue, didInvalidateがfalseになっている。
RECEIVE_POSTSのあとは、isfetching, didInvalidateはどちらもfalseになり、itemsに新しいデータが格納されている。

リデューサー/ アクション

リデューサは selectedWord(初期値tokyo) postsByWord(初期値{})からなる。あとで、combineReducersで一括りにする。
postsByWordオブジェクトは tokyoやosakaなどのプロパティに posts関数で

{isFetching:xxx,
 items:xxx,
 lastUpdated:xxx,
 didInvalidate:xxx
}

のようなオブジェクトを格納。 didInvalidateは後で追加する。 次がリデューサ。

reducers\index.js
import { combineReducers } from 'redux'
import { SELECT_WORD, REQUEST_POSTS, RECEIVE_POSTS } from '../actions'


const selectedWord = (state = 'tokyo', action) => {
    switch (action.type) {
        case SELECT_WORD:
            return action.word
        default:
            return state
    }
}
// posts の初期値
const init_posts = {
    isFetching: false,
    items: []
}


const posts = (state = init_posts, action) => {
    switch (action.type) {
        case REQUEST_POSTS:
            // didInvalidateは強制的に更新したいときだけ使う。
            // いまは必要ない。
            return {
                ...state,
                isFetching: true
            }
        // itemsにはjsonデータから引っ張ってき配列を入れる。
        case RECEIVE_POSTS:
            return {
                ...state,
                isFetching: false,
                items: action.posts,
                lastUpdated: action.receivedAt
            }
         default:
            return state
    }
}

const postsByWord = (state = {}, action) => {
    switch (action.type) {
        case RECEIVE_POSTS:
        case REQUEST_POSTS:
            return {
                ...state,
                [action.word]: posts(state[action.word], action)
            }

        default:
            return state;
    }
}

const rootReducer = combineReducers({
    postsByWord,
    selectedWord
})

export default rootReducer

アクションは次。middlewareを使っているので、dispatchの引数のplain objectタイプではなく、asyncタイプのアクションを利用しているところがあるのに注意。

actions\index.js
export const REQUEST_POSTS = 'REQUEST_POSTS'
export const RECEIVE_POSTS = 'RECEIVE_POSTS'
export const SELECT_WORD = 'SELECT_WORD'

export const selectWord = word => ({
    type: SELECT_WORD,
    word
})
// isFetching: trueにする
export const requestPosts = word => ({
    type: REQUEST_POSTS,
    word
})
//stateに取得したデータを格納し、isFetchingをfalseにする。
export const receivePosts = (word, json) => ({
  type: RECEIVE_POSTS,
  word,
  posts: json.data.children.map(child => child.data),
  receivedAt: Date.now()
})


// dはdispatch
const fetchPosts = (word) => d => {
    d(requestPosts(word))
    fetch(`https://www.reddit.com/r/${word}.json`)
        .then(res => res.json())
        .then(json => d(receivePosts(word, json)))
}


const shouldFetchPosts = (state, word) => {
    const posts = state.postsByWord[word]
// 対応するwordに対するデータ取得前はpostsは空っぽ undefined
    if (!posts) {
        return true
    }
    // ロード中はいじらない
    if (posts.isFetching) {
        return false
    }
    return false
}
// dはdispatch g はgetState
export const fetchPostsIfNeeded = word => (d, g) => {
    if (shouldFetchPosts(g(),word)) {
        //d すなわち dispatchの引数は plain object アクションではなく
        // middleware依存の Async アクションということらしい。
        d(fetchPosts(word));
    }
}

コンポーネント

components\Posts.js
import React from 'react'
import PropTypes from 'prop-types'

// postsはオブジェクトの配列で、
// 例えば、tokyo関係のスレッド30件弱程度
const Posts = ({posts})=>(
    <ul>
    {posts.map((post,i)=>
    <li key={i}>{post.title}</li>)}
    </ul>
)


Posts.propTypes = {
    posts: PropTypes.array.isRequired
  }

  export default Posts
components\Picker.js
import React from 'react'
import PropTypes from 'prop-types'

// value は stateの selectedWord
const Picker = ({ value, onChange, options }) => (
  <div>
    <h1>{value}</h1>
    <select className="form-control" onChange={e => onChange(e.target.value)}
            value={value}>
      {options.map(option =>
        <option value={option} key={option}>
          {option}
        </option>)
      }
    </select>
  </div>
)

Picker.propTypes = {
  options: PropTypes.arrayOf(
    PropTypes.string.isRequired
  ).isRequired,
  value: PropTypes.string.isRequired,
  onChange: PropTypes.func.isRequired
}

export default Picker

containers\App.js
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { selectWord, fetchPostsIfNeeded } from '../actions'
import Picker from '../components/Picker'
import Posts from '../components/Posts'

class App extends Component {
    static propTypes = {
        selectedWord: PropTypes.string.isRequired,
        posts: PropTypes.array.isRequired,
        isFetching: PropTypes.bool.isRequired,
        lastUpdated: PropTypes.number,
        dispatch: PropTypes.func.isRequired
    }
    //最初のページ読み込み時に一回だけ実行される
    componentDidMount() {
        const { dispatch, selectedWord } = this.props
        dispatch(fetchPostsIfNeeded(selectedWord))
    }
    //
    // この関数の内部で this.props とすると古いプロップスになる
    //引数で渡したパラメータnに実行時、新しいプロップスが渡される。 
    // componentWillReceiveProps(n) {
    //   console.log("⚡componentWillReceiveProps");
    //   if (n.selectedWord !== this.props.selectedWord) {
    //     const { dispatch, selectedWord } = n
    //     dispatch(fetchPostsIfNeeded(selectedWord))
    //   }
    // }


    // 上記のcomponentWillReceivePropsは
    // 非推奨になっているようで、代わりにこっちを使う
    //引数で渡したパラメータpに実行時、古いプロップスが渡される。
    // dispatchの引数は plain object アクションではなく
    // middleware依存の Async アクションということらしい。
    componentDidUpdate(p) {
        if (p.selectedWord !== this.props.selectedWord) {
            const { dispatch, selectedWord } = this.props
            dispatch(fetchPostsIfNeeded(selectedWord))
        }
    }

    handleChange = nextWord => {
        this.props.dispatch(selectWord(nextWord))
    }

    render() {
        const { selectedWord, posts, isFetching, lastUpdated } = this.props
        const isEmpty = posts.length === 0
        return (
            <div>
                <Picker value={selectedWord}
                    onChange={this.handleChange}
                    options={['tokyo', 'osaka', 'kyoto']} />
                <p>
                    {lastUpdated &&
                        <span>
                            最終更新日{new Date(lastUpdated).toLocaleTimeString()}.
              {' '}
                        </span>
                    }
                </p>
                {isEmpty
                    ? (isFetching ? <h2>ロード中...</h2> : <h2>空.</h2>)
                    : <div style={{ opacity: isFetching ? 0.5 : 1 }}>
                        <Posts posts={posts} />
                    </div>
                }
            </div>
        )
    }
}

const mapStateToProps = state => {

    // まずは大きな塊から取り出し
    const { selectedWord, postsByWord } = state
    // ES6の新しい文法で混乱した
    // postsByWord[selectedWord]は
    // {
    //   didInvalidate: false​​​,
    //   isFetching: false​​​,
    //   items: Array(26) [ {…}, {…}, {…}, … ]​​​,
    //   lastUpdated: 1529304245998
    // }
    // のようなオブジェクト
    // 下記のように、items: posts と記述すると
    //なんと、postsがプロパティ名になり、Array(26) [ {…}, {…}, {…}, … ]​​​が対応する値になる。

    const {
        isFetching,
        lastUpdated,
        items: posts
    } =
        postsByWord[selectedWord] ||
        {
            isFetching: true,
            items: []
        }
    //上記のようにstateのpostsByWordからデータをとりだし、下記のようにプロップスをつくる。
    return {
        selectedWord,
        posts,
        isFetching,
        lastUpdated
    }
}

export default connect(mapStateToProps)(App)

エントリーポイント

index.js
import React from 'react'
import { render } from 'react-dom'
import { createStore, applyMiddleware } from 'redux'
import { Provider } from 'react-redux'
import thunk from 'redux-thunk'
import reducer from './reducers'
import App from './containers/App'

const store = createStore(
  reducer,
  applyMiddleware(thunk)
)
store.subscribe(()=>{
  console.log(store.getState());
})

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

DOMのコンテナのhtml

index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Redux async サンプル</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB" crossorigin="anonymous">
    <style type="text/css">
html{
  font-size: 12px;
}
    </style>
  <body >
      <!-- classでBootsrapに関する設定 -->
    <div class="mx-auto w-75 mt-3"  id="root"></div>

    <script src="dist/index.js"></script>
  </body>
</html>

ソースコード 01

再読込みの機能を追加

ここまでは、selectボックスで一回選択したあとは、そのwordについては更新されない。そこで強制的に
更新する機能を追加する。
そこで、postsByWordの中で各wordに結び付けられているオブジェクトにdidInvalidateフラグを追加。
ここをfalseにしておいて、selectボックスでの選択変更だけでは更新されないようにする。
更新ボタンを押した場合だけ、didInvalidateフラグをtrueにして、最読み込みを可能にする。

リデューサを修正。 INVALIDATE_WORDへの対応。 didInvalidateフラグ導入。

reducers\index.js
import { combineReducers } from 'redux'
import { SELECT_WORD, REQUEST_POSTS, RECEIVE_POSTS, INVALIDATE_WORD } from '../actions'


const selectedWord = (state = 'tokyo', action) => {
    switch (action.type) {
        case SELECT_WORD:
            return action.word
        default:
            return state
    }
}
// posts の初期値
const init_posts = {
    isFetching: false,
    didInvalidate: false,
    items: []
}


const posts = (state = init_posts, action) => {
    switch (action.type) {
        case REQUEST_POSTS:
            // didInvalidateは強制的に更新したいときだけ使う。
            // いまは必要ない。
            return {
                ...state,
                isFetching: true,
                didInvalidate: false
            }
        // itemsにはjsonデータから引っ張ってき配列を入れる。
        case RECEIVE_POSTS:
            return {
                ...state,
                isFetching: false,
                items: action.posts,
                lastUpdated: action.receivedAt,
                didInvalidate: false
            }
        // didInvalidate をfalse → true にするだけ。
        case INVALIDATE_WORD:
            return {
                ...state,
                didInvalidate: true
            }
        default:
            return state
    }
}

const postsByWord = (state = {}, action) => {
    switch (action.type) {
        case RECEIVE_POSTS:
        case REQUEST_POSTS:
        case INVALIDATE_WORD:
            return {
                ...state,
                [action.word]: posts(state[action.word], action)
            }

        default:
            return state;
    }
}

const rootReducer = combineReducers({
    postsByWord,
    selectedWord
})

export default rootReducer

アクションの修正。invalidateWord関数追加。shouldFetchPosts 関数の挙動にinvalidateWordフラグの真偽を反映。

actions\index.js
export const REQUEST_POSTS = 'REQUEST_POSTS'
export const RECEIVE_POSTS = 'RECEIVE_POSTS'
export const SELECT_WORD = 'SELECT_WORD'
export const INVALIDATE_WORD = 'INVALIDATE_WORD'

export const selectWord = word => ({
    type: SELECT_WORD,
    word
})
// isFetching: trueにする
export const requestPosts = word => ({
    type: REQUEST_POSTS,
    word
})
//stateに取得したデータを格納し、isFetchingをfalseにする。
export const receivePosts = (word, json) => ({
  type: RECEIVE_POSTS,
  word,
  posts: json.data.children.map(child => child.data),
  receivedAt: Date.now()
})

export const invalidateWord = word => ({
    type: INVALIDATE_WORD,
    word
  })
// dはdispatch
const fetchPosts = (word) => d => {
    d(requestPosts(word))
    fetch(`https://www.reddit.com/r/${word}.json`)
        .then(res => res.json())
        .then(json => d(receivePosts(word, json)))
}


const shouldFetchPosts = (state, word) => {
    const posts = state.postsByWord[word]
// 対応するwordに対するデータ取得前はpostsは空っぽ undefined
    if (!posts) {
        return true
    }
    // ロード中はいじらない
    if (posts.isFetching) {
        return false
    }
    return posts.didInvalidate
}
// dはdispatch g はgetState
export const fetchPostsIfNeeded = word => (d, g) => {
    if (shouldFetchPosts(g(),word)) {
        //d すなわち dispatchの引数は plain object アクションではなく
        // middleware依存の Async アクションということらしい。
        d(fetchPosts(word));
    }
}

Appコンポーネント修正。再読込みボタンを追加。それに紐付いたクリックイベントハンドラのhandleRefreshClick関数を追加。mapStateToPropsも修正。

注意点をひとつ。オリジナルのサンプルではcomponentWillReceiveProps関数が利用されていたが、どこかで非推奨になっているとの記事を見つけたので、componentDidUpdate関数に変更。元の関数とは微妙に仕様がちがうのでコメントのところ参照。

componentDidMount関数は初回の読み込み時のみ。

containers\App.js
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { selectWord, fetchPostsIfNeeded, invalidateWord } from '../actions'
import Picker from '../components/Picker'
import Posts from '../components/Posts'

class App extends Component {
    static propTypes = {
        selectedWord: PropTypes.string.isRequired,
        posts: PropTypes.array.isRequired,
        isFetching: PropTypes.bool.isRequired,
        lastUpdated: PropTypes.number,
        dispatch: PropTypes.func.isRequired
    }
    //最初のページ読み込み時に一回だけ実行される
    componentDidMount() {
        const { dispatch, selectedWord } = this.props
        dispatch(fetchPostsIfNeeded(selectedWord))
    }
    //
    // この関数の内部で this.props とすると古いプロップスになる
    //引数で渡したパラメータnに実行時、新しいプロップスが渡される。 
    // componentWillReceiveProps(n) {
    //   console.log("⚡componentWillReceiveProps");
    //   if (n.selectedWord !== this.props.selectedWord) {
    //     const { dispatch, selectedWord } = n
    //     dispatch(fetchPostsIfNeeded(selectedWord))
    //   }
    // }


    // 上記のcomponentWillReceivePropsは
    // 非推奨になっているようで、代わりにこっちを使う
    //引数で渡したパラメータpに実行時、古いプロップスが渡される。
    // dispatchの引数は plain object アクションではなく
    // middleware依存の Async アクションということらしい。
    componentDidUpdate(p) {
        if (p.selectedWord !== this.props.selectedWord) {
            const { dispatch, selectedWord } = this.props
            dispatch(fetchPostsIfNeeded(selectedWord))
        }
    }

    handleChange = nextWord => {
        this.props.dispatch(selectWord(nextWord))
    }

    handleRefreshClick = e => {
        e.preventDefault()

        const { dispatch, selectedWord } = this.props

        dispatch(invalidateWord(selectedWord))
        dispatch(fetchPostsIfNeeded(selectedWord))
    }
    render() {
        const { selectedWord, posts, isFetching, lastUpdated } = this.props
        const isEmpty = posts.length === 0
        return (
            <div>
                <Picker value={selectedWord}
                    onChange={this.handleChange}
                    options={['tokyo', 'osaka', 'kyoto']} />
                <p>
                    {lastUpdated &&
                        <span>
                            最終更新日{new Date(lastUpdated).toLocaleTimeString()}.
              {' '}
                        </span>
                    }
                </p>
                {isEmpty
                    ? (isFetching ? <h2>ロード中...</h2> : <h2>空.</h2>)
                    : <div style={{ opacity: isFetching ? 0.5 : 1 }}>
                        <Posts posts={posts} />
                    </div>
                }
                {!isFetching &&
                    <button className="btn btn-warning mt-2" onClick={this.handleRefreshClick}>
                        再読込み
            </button>
                }
            </div>
        )
    }
}

const mapStateToProps = state => {

    // まずは大きな塊から取り出し
    const { selectedWord, postsByWord } = state
    // ES6の新しい文法で混乱した
    // postsByWord[selectedWord]は
    // {
    //   didInvalidate: false​​​,
    //   isFetching: false​​​,
    //   items: Array(26) [ {…}, {…}, {…}, … ]​​​,
    //   lastUpdated: 1529304245998
    // }
    // のようなオブジェクト
    // 下記のように、items: posts と記述すると
    //なんと、postsがプロパティ名になり、Array(26) [ {…}, {…}, {…}, … ]​​​が対応する値になる。

    const {
        isFetching,
        lastUpdated,
        items: posts
    } =
        postsByWord[selectedWord] ||
        {
            isFetching: true,
            items: []
        }
    //上記のようにstateのpostsByWordからデータをとりだし、下記のようにプロップスをつくる。
    return {
        selectedWord,
        posts,
        isFetching,
        lastUpdated
    }
}

export default connect(mapStateToProps)(App)

ソースコード 02

ついでに、冒頭の動画にあったコンソール出力しまくるソースもアップしておいた。

ソースコード コンソール出力多用

2
2
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
2
2