ReduxでのReddit APIを使ったチュートリアル


React.js + ReduxでのReddit APIを使ったチュートリアルにトライしたのでそのメモです。

🍮 プロジェクト準備

プロジェクトの準備です。

mkdir reddit_redux && cd reddit_redux
yarn init
# メインのライブラリ
yarn add react react-dom redux react-redux redux-logger redux-thunk isomorphic-fetch babel-polyfill
# 開発用のライブラリ
yarn add --dev webpack babel-core babel-loader babel-preset-es2015 babel-preset-react babel-plugin-transform-class-properties babel-plugin-transform-object-rest-spread

package.jsonにwebpack用の設定を追記します。

"scripts": {
"build": "webpack --debug"
}

.babelrcに設定を追記します。

{
"presets": ["es2015", "react"],
"plugins": ["transform-class-properties", "transform-object-rest-spread"]
}

webpack.config.jsに設定を追記します。

const webpack = require('webpack');
const path = require('path');
process.env.NODE_ENV = 'development';
module.exports = {
entry: {
app: './src/index.js'
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'public/')
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: 'babel-loader',
}
]
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify(process.env.NODE_ENV)
}
})
],
performance: {
maxAssetSize: 1000000,
maxEntrypointSize: 1000000,
hints: 'warning'
}
};

🚜 public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Reddit API - Advance</title>
</head>
<body>
<div id="root"></div>
<script src="app.bundle.js"></script>
</body>
</html>

🏀 src/index.js

import 'babel-polyfill'
import React from 'react'
import { render } from 'react-dom'
import Root from './containers/Root'
render(
<Root />,
document.getElementById('root')
);

🐯 src/actions.js

import fetch from 'isomorphic-fetch'
export const REQUEST_POSTS = 'REQUEST_POSTS';
export const RECEIVE_POSTS = 'RECEIVE_POSTS';
export const SELECT_SUBREDDIT = 'SELECT_SUBREDDIT';
export const INVALIDATE_SUBREDDIT = 'INVALIDATE_SUBREDDIT';
export function selectSubreddit(subreddit) {
return {
type: SELECT_SUBREDDIT,
subreddit
}
}
export function invalidateSubreddit(subreddit) {
return {
type: INVALIDATE_SUBREDDIT,
subreddit
}
}
function requestPosts(subreddit) {
return {
type: REQUEST_POSTS,
subreddit
}
}
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) {
return (dispatch, getState) => {
if (shouldFetchPosts(getState(), subreddit)) {
return dispatch(fetchPosts(subreddit));
}
}
}

🍣 src/reducer.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

🎃 src/configStore.js

import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import { createLogger } from 'redux-logger'
import rootReducer from './reducers'
const loggerMiddleware = createLogger();
export default function configureStore(preloadedState) {
return createStore(
rootReducer,
preloadedState,
applyMiddleware(
thunkMiddleware,
loggerMiddleware
)
)
}

🏈 src/containers/AsyncApp.js

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import {
selectSubreddit,
fetchPostsIfNeeded,
invalidateSubreddit
} from '../actions'
import Picker from '../components/Picker'
import Posts from '../components/Posts'
class AsyncApp extends Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleRefreshClick = this.handleRefreshClick.bind(this);
}
componentDidMount() {
const { dispatch, selectedSubreddit } = this.props;
dispatch(fetchPostsIfNeeded(selectedSubreddit));
}
componentDidUpdate(prevProps) {
if (this.props.selectedSubreddit !== prevProps.selectedSubreddit) {
const { dispatch, selectedSubreddit } = this.props;
dispatch(fetchPostsIfNeeded(selectedSubreddit));
}
}
handleChange(nextSubreddit) {
this.props.dispatch(selectSubreddit(nextSubreddit));
this.props.dispatch(fetchPostsIfNeeded(nextSubreddit));
}
handleRefreshClick(e) {
e.preventDefault();
const { dispatch, selectedSubreddit } = this.props;
dispatch(invalidateSubreddit(selectedSubreddit));
dispatch(fetchPostsIfNeeded(selectedSubreddit));
}
render() {
const { selectedSubreddit, posts, isFetching, lastUpdated } = this.props;
return (
<div>
<Picker
value={selectedSubreddit}
onChange={this.handleChange}
options={['reactjs', 'frontend']}
/>
<p>
{lastUpdated &&
<span>
Last updated at {new Date(lastUpdated).toLocaleTimeString()}.
{' '}
</span>}
{!isFetching &&
<a href="#" onClick={this.handleRefreshClick}>
Refresh
</a>}
</p>
{isFetching && posts.length === 0 && <h2>Loading...</h2>}
{!isFetching && posts.length === 0 && <h2>Empty.</h2>}
{posts.length > 0 &&
<div style={{ opacity: isFetching ? 0.5 : 1 }}>
<Posts posts={posts} />
</div>}
</div>
)
}
}
AsyncApp.propTypes = {
selectedSubreddit: PropTypes.string.isRequired,
posts: PropTypes.array.isRequired,
isFetching: PropTypes.bool.isRequired,
lastUpdated: PropTypes.number,
dispatch: PropTypes.func.isRequired
};
function mapStateToProps(state) {
const { selectedSubreddit, postsBySubreddit } = state;
const {
isFetching,
lastUpdated,
items: posts
} = postsBySubreddit[selectedSubreddit] || {
isFetching: true,
items: []
};
return {
selectedSubreddit,
posts,
isFetching,
lastUpdated
}
}
export default connect(mapStateToProps)(AsyncApp)

🐠 src/containers/Root.js

import React, { Component } from 'react'
import { Provider } from 'react-redux'
import configureStore from '../configureStore'
import AsyncApp from './AsyncApp'
const store = configureStore();
export default class Root extends Component {
render() {
return (
<Provider store={store}>
<AsyncApp />
</Provider>
)
}
}

😎 src/components/Picker.js

import React, { Component } from 'react'
import PropTypes from 'prop-types'
export default class Picker extends Component {
render() {
const { value, onChange, options } = this.props;
return (
<span>
<h1>{value}</h1>
<select onChange={e => onChange(e.target.value)} value={value}>
{options.map(option => (
<option value={option} key={option}>
{option}
</option>
))}
</select>
</span>
)
}
}
Picker.propTypes = {
options: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired
};

😼 src/components/Posts.js

import React, { Component } from 'react'
import PropTypes from 'prop-types'
export default class Posts extends Component {
render() {
return (
<ul>
{this.props.posts.map((post, i) => <li key={i}>{post.title}</li>)}
</ul>
)
}
}
Posts.propTypes = {
posts: PropTypes.array.isRequired
};

😸 実行

yarn run buildapp.bundle.jsが生成されます。その後、open public/index.htmlでブラウザから操作を確認できます。

🗻 Sample code

🎂 参考リンク

📚 おすすめの書籍

🖥 サーバについて

このブログでは「Cloud Garage」さんのDev Assist Program(開発者向けインスタンス無償提供制度)でお借りしたサーバで技術検証しています。 Dev Assist Programは、開発者や開発コミュニティ、スタートアップ企業の方が1GBメモリのインスタンス3台を1年間無料で借りれる心強い制度です!(有償でも1,480円/月と格安)