Reduxのexampleにある async というサンプルについて調べてみた。
留意点
- 初心者の覚書です。
- 自分の環境で動くように参考にしたコードを適当に修正している。
- Windows10 64bit , PowerShellなどで動かしている。
- 見栄えを若干よくする為にbootstrap4を利用している。
- 解説はアバウトな言い回しで厳密ではない。誤解を含む可能性あり。
- stateの表示などでconsole.logを多用している。
実行画面
変則的だが、最初に実行画面で、selectボックスの選択やボタンのクリックなどに応じて、
stateの状態がどのように変化するか確認。
Reduxのサンプル asyncの実行画面 https://t.co/7Ud37CSE3f @YouTubeさんから
— tkarasuma (@tkarasuma) 2018年6月20日
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は後で追加する。 次がリデューサ。
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タイプのアクションを利用しているところがあるのに注意。
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));
}
}
コンポーネント
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
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
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)
エントリーポイント
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
<!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>
再読込みの機能を追加
ここまでは、selectボックスで一回選択したあとは、そのwordについては更新されない。そこで強制的に
更新する機能を追加する。
そこで、postsByWordの中で各wordに結び付けられているオブジェクトにdidInvalidateフラグを追加。
ここをfalseにしておいて、selectボックスでの選択変更だけでは更新されないようにする。
更新ボタンを押した場合だけ、didInvalidateフラグをtrueにして、最読み込みを可能にする。
リデューサを修正。 INVALIDATE_WORDへの対応。 didInvalidateフラグ導入。
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フラグの真偽を反映。
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関数は初回の読み込み時のみ。
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)
ついでに、冒頭の動画にあったコンソール出力しまくるソースもアップしておいた。