#React/Reduxのいろいろなtips
React/Reduxを使っていて実装方法について苦戦して調査して解決したことをtipとしてまとめていく。
##スプレッド演算子(...
)を利用するとエラー
スプレッド演算子を使うとModule build failed: SyntaxError: Unexpected token
といったエラーが発生したので、調査をしたところ、babel-preset-stage-2
が必要であることが判明。
そのため、babel-preset-stage-2
をローカルインストールする。
$ yarn add --dev babel-preset-stage-2
なお、npm
を利用する場合は以下。
$ npm install --save-dev babel-preset-stage-2
webpack.config.js
にstage-2
を指定する必要がある。
module: {
loaders: [
{
test: /\.jsx$/,
exclude: /(node_modules|.git)/,
loader: 'babel-loader',
query: {
presets: ['es2015', 'react', 'stage-2']
}
}
]
}
##classNameに複数class指定する
className
に複数のclass名を指定する場合、以下のように記述。
return (<div className={ `uk-navbar-nav uk-hidden-small`} />)
さらに、下記のような書き方ができるので、汎用性はある。
const navi = "uk-navbar-nav"
const opt = "uk-hidden-small"
return (<div className={ `${navi} ${opt}`} />)
##styleを利用する
styleを利用する場合は、以下のように記述。
return (<div style={{ textAlign: 'center', margin: '10px 0px' }} />)
##jsx
のコメントアウト
複数行の場合は{/* */}
で囲み、1行の場合は{//
をコメントアウトしたい箇所の前に記載し、改行をした上で}
を記載する。
return (
<div className="test">
<div className="test-title">
<i className="test-icon" />
</div>
{/*
<div className="test-body">
<p>test body</p>
</div>
*/}
<div className="test-comment">
{// <p>test comment</p>
}
</div>
</div>
##複数のstoreを利用する
こちらでComponentを使いまわすときにcombineReducers
で2つのAction
を登録するようにしたが、下記のようにcreateStore
で2つstore
を作成することでも実現できる。
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import thunk from 'redux-thunk'
import { createStore, applyMiddleware } from 'redux'
import reducer from './reducer.jsx'
import DemoComponent from './DemoComponent.jsx'
const store1 = createStore(reducer, applyMiddleware(thunk))
const store2 = createStore(reducer, applyMiddleware(thunk))
render (
<Provider store={ store1 }>
<DemoComponent />
</Provider>,
document.querySelector('.react_container1')
)
render (
<Provider store={ store2 }>
<DemoComponent />
</Provider>,
document.querySelector('.react_container2')
)
なお、2つのProvider
で同じstore
を指定した場合、store
は共有される。reducer
をどのように用意するか、毎回悩まされる。
##action
でstate
を取得
action
からstate
で定義したいずれかの値を参照することがある。
その場合、以下の2つの方法がある。
###方法1
非同期処理などでaction
から別のaction
を実行する際、第1引数にdispatch
を受け取る無名関数をリターンする処理をしたが、第2引数にgetState
を受け取るように追加することでstate
を参照することができる。
import { state } from 'state'
const test1 = () => {
return {
type: state.TEST1
}
}
const test2 = () => {
return {
type: state.TEST2
}
}
const dispatchTest = () => {
return (dispatch, getState) => {
if (getState().myState.type === state.TEST1) {
dispatch(test2())
} else {
dispatch(test1())
}
}
}
###方法2
export const store = createStore(reducer, applyMiddleware(thunk))
のようにstore
をstore.jsx
といったように用意し、そのjsxをaction
側でimport
することで下記の例のようにstate
の参照が可能。
import { state } from 'state'
import { store } from 'store'
const dispatchTest = () => {
if (store.getState().myState.type === state.TEST1) {
return {
type: state.TEST2
}
} else {
return {
type: state.TEST1
}
}
}
##コンテナ(コンポーネント)の初期化時にActionを実行する
下記のようにComponent
を継承したコンテナ(コンポーネント)でcomponentDidMount
をオーバーライドし、this.props
からaction
を抽出し、実行することで、実現できる。
class TestContainer extends Component {
componentDidMount() {
const { actions } = this.props
actions.testMethod()
}
render() {
return (
<div>test</div>
)
}
}
##ループ処理で動的な要素を作成
json
で受信したデータからアイコンリストのような要素を動的にループ処理で作成する場合、下記のように実装できる。
import React from 'react'
export const comp = ({ json }) => {
const mu = Object.keys(json).map((key) => {
return (
<div key={ key } >
<img src={ json[key].icon } /><br />
</div>
)
})
return (
<div>
{ mu }
</div>
)
}
##ショートカットキーを導入し、action
を実行
redux-shortcuts
をローカルインストールする。
$ yarn add --dev redux-shortcuts
なお、npm
を利用する場合は以下。
$ npm install --save-dev redux-shortcuts
下記のようにbindShortcuts
でショートカットキーに対してaction
を割り当てることができる。
なお、この例では、bindActionCreators
でdispatchTest
がコンポーネントにマッピングされていることが前提。
import { bindShortcuts } from 'redux-shortcuts'
import { dispatchTest } from './actions'
import { reducer } from './reducer'
const store = createStore(reducer, applyMiddleware(thunk))
bindShortcuts(
[['command+d', 'ctrl+d'], dispatchTest]
)(store.dispatch)
##redux-devtools
を使ったaction
結果(履歴)とstate
変更(履歴)の確認
redux-devtools
を使うと、action
実行結果(履歴)やstate
の更新(履歴)や内容を画面上に表示して確認することができる。
こちらのサンプルを利用する。
###モジュールのインストール
必要なモジュールをインストールする。
$ yarn add --dev redux-devtools
$ yarn add --dev redux-devtools-log-monitor
$ yarn add --dev redux-devtools-dock-monitor
npm
を利用する場合は以下。
$ npm install --save-dev redux-devtools
$ npm install --save-dev redux-devtools-log-monitor
$ npm install --save-dev redux-devtools-dock-monitor
###サンプルの修正
app.jsx
を以下のように変更する。
mport React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import thunk from 'redux-thunk'
import { createStore, applyMiddleware } from 'redux'
import reducer from './reducer.jsx'
import DemoComponent from './DemoComponent.jsx'
import { createDevTools } from 'redux-devtools'
import LogMonitor from 'redux-devtools-log-monitor'
import DockMonitor from 'redux-devtools-dock-monitor'
const DevTools = createDevTools(
<DockMonitor toggleVisibilityKey="ctrl-h" changePositionKey="ctrl-q">
<LogMonitor theme="tomorrow" preserveScrollTop={false} />
</DockMonitor>
)
const store = createStore(reducer, DevTools.instrument(), applyMiddleware(thunk))
render (
<Provider store={ store }>
<div>
<DemoComponent maxLengthA="3" maxLengthB="6" />
<DevTools />
</div>
</Provider>,
document.querySelector('.react_container')
)
###デモ
ビルドして実行すると、下記のように表示される。
CSV
ファイルを読み込ませることで、action
の実行結果や、state
の更新内容が追加されていく。
##メンション機能
SNSで利用機会があるメンション機能。React
ではreact-mentions
が用意されている。結構カスタマイズができて便利のように思えたが、ドキュメント等がないので、コードを見て、検証する必要があった。
ここではメンションの対象となるユーザを非同期で取得する。
###必要なモジュールのインストール
$ yarn init
$ yarn add --dev webpack
$ yarn add --dev react react-dom redux react-redux redux-thunk
$ yarn add --dev babel-loader babel-core babel-preset-es2015 babel-preset-react babel-preset-stage-2
$ yarn add --dev style-loader css-loader
$ yarn add --dev react-mentions
defaultMentionStyle.js
とdefaultStyle.js
については、ここから取得する。
なお、メンションで候補として表示されるポップアップのスタイルを変更したい場合は以下のようにsuggestions
直下に記載する(本サンプルではgithub
にあるものをそのまま利用した)。
export default ({
suggestions: {
display: "inline-block",
position: "unset",
float: "left",
overflow: "scroll",
maxHeight: "300px",
marginLeft: "50%",
list: {
display: "inline-block",
position: "unset",
float: "left",
backgroundColor: 'white',
fontSize: 10,
},
item: {
position: "relative",
'&focused': {
backgroundColor: '#cee4e5',
},
},
}
})
####ファイルの内容
{
"name": "mention",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-2": "^6.24.1",
"react": "^15.6.1",
"react-dom": "^15.6.1",
"react-mentions": "^1.2.0",
"react-redux": "^5.0.6",
"redux": "^3.7.2",
"redux-thunk": "^2.2.0",
"webpack": "^3.5.5"
},
"scripts": {
"prod": "webpack -p",
"dev": "webpack -d"
}
}
var webpack = require('webpack')
module.exports = {
entry: {
'./js/app': './jsx/app.jsx'
},
output: {
path: __dirname,
filename: '[name].bundle.js'
},
module: {
loaders: [
{
test: /\.jsx$/,
exclude: /(node_modules|.git)/,
loader: 'babel-loader',
query: {
presets: ['es2015', 'react', 'stage-2']
}
}
]
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>React mention</title>
</head>
<body>
<div class="react_mention"></div>
<script type="text/javascript" src="js/app.bundle.js"></script>
</body>
</html>
export const TYPE_NONE = "TYPE_NONE"
export const TYPE_MENTION = "TYPE_MENTION"
export const TYPE_UNLOADED = "TYPE_UNLOADED"
export const TYPE_LOADING = "TYPE_LOADING"
export const TYPE_LOADED = "TYPE_LOADED"
const data = [
{
id: 'walter',
display: 'Walter White',
},
{
id: 'jesse',
display: 'Jesse Pinkman',
},
{
id: 'gus',
display: 'Gustavo "Gus" Fring',
},
{
id: 'saul',
display: 'Saul Goodman',
},
{
id: 'hank',
display: 'Hank Schrader',
},
{
id: 'skyler',
display: 'Skyler White',
},
{
id: 'mike',
display: 'Mike Ehrmantraut',
}
]
const loading = () => {
return {
type: TYPE_LOADING
}
}
const users = (callback) => {
callback(data)
return {
type: TYPE_LOADED,
users: data
}
}
export const loadUsers = (callback) => {
return (dispatch, getState) => {
if (getState().demo.status == TYPE_UNLOADED) {
dispatch(loading())
setTimeout(() => {
dispatch(users(callback))
}, 1000)
}
}
}
export const handleMention = (e, v, tp, m) => {
return {
type: TYPE_MENTION,
value: v,
mentions: m,
}
}
import { combineReducers } from 'redux'
import * as actions from './actions.jsx'
export const initialState = {
value: "",
mentions: [],
status: actions.TYPE_UNLOADED,
users: [],
}
const demo = (state = initialState, action) => {
if (action.type === actions.TYPE_LOADING) {
return {
...state,
status: actions.type
}
} else if (action.type === actions.TYPE_LOADED) {
return {
...state,
status: action.type,
users: action.users,
}
} else if (action.type === actions.TYPE_MENTION) {
return {
...state,
value: action.value,
mentions :action.mentions
}
} else {
return state
}
}
const reducer = combineReducers({
demo
})
export default reducer
※非同期でのメンション対象となるユーザ情報が必要ない場合、Mention
のdata
プロパティに直接配列を指定する。
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as myActions from './actions.jsx'
import { MentionsInput, Mention } from 'react-mentions'
import defaultStyle from './defaultStyle.jsx'
import defaultMentionStyle from './defaultMentionStyle.jsx'
class DemoContainer extends Component {
render() {
const { demo, actions } = this.props
let data = null
if (demo.status === myActions.TYPE_UNLOADED) {
data = (search, callback) => {
actions.loadUsers(callback)
}
} else {
data = demo.users
}
return (
<div style={{ width: "300px", marginLeft: "200px" }} >
<MentionsInput
style={ defaultStyle }
value={ demo.value }
onChange={ actions.handleMention }
placeholder={ "Mention people using '@'" }
displayTransform={ (id, display) => `@${display}` } >
<Mention
trigger="@"
data={ data }
renderSuggestion={
(suggestion, search, highlightedDisplay) => (
<div className="user">
{ highlightedDisplay }
</div>
)
}
onAdd = {
(id, display) => {
console.log(display)
}
}
style={ defaultMentionStyle }
/>
</MentionsInput>
</div>
)
}
}
const mapState = (state) => ({
demo: state.demo
})
const mapDispatch = (dispatch) => ({
actions: bindActionCreators(myActions, dispatch)
})
export default connect(mapState, mapDispatch)(DemoContainer)
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import reducer from './reducer.jsx'
import DemoContainer from './container.jsx'
const store = createStore(reducer, applyMiddleware(thunk))
render (
<Provider store={ store }>
<DemoContainer />
</Provider>,
document.querySelector('.react_mention')
)
###ビルド
下記のコマンドでビルドする。
$ yarn run prod
###デモ
index.html
を実行すると以下のように表示される。@
をタイプすると1秒後に(初回のみ)メンション対象のユーザ一覧が表示され、さらにタイプすることでユーザが絞り込まれる。
##無限スクロール
いわゆるレイジースクロールといったところ。Redux
用にredux-infinite-scrollというライブラリがあるが、下方向スクロールには対応しているが、上方向のスクロールには対応していない。そこで拡張して利用する。
非同期を考慮して、あえてsetTimeout
で1秒間waitするようにした。
###必要なモジュールのインストール
$ yarn init
$ yarn add --dev webpack
$ yarn add --dev react react-dom redux react-redux redux-thunk
$ yarn add --dev babel-loader babel-core babel-preset-es2015 babel-preset-react babel-preset-stage-2
$ yarn add --dev style-loader css-loader
$ yarn add --dev redux-infinite-scroll
ここからmain.css
とstylesheet.css
を取得しておく。
最低限css
で、以下の定義を忘れると、スクロールしない・スクロールイベントが発生しないなどの不備が発生するので、注意。
.redux-infinite-scroll {
overflow: scroll;
}
####ファイルの内容
{
"name": "infinite_scroll",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-2": "^6.24.1",
"css-loader": "^0.28.5",
"react": "^15.6.1",
"react-dom": "^15.6.1",
"react-redux": "^5.0.6",
"redux": "^3.7.2",
"redux-infinite-scroll": "^1.0.9",
"redux-thunk": "^2.2.0",
"style-loader": "^0.18.2",
"webpack": "^3.5.5"
},
"scripts": {
"prod": "webpack -p",
"dev": "webpack -d"
}
}
var webpack = require('webpack')
module.exports = {
entry: {
'./js/app': './jsx/app.jsx'
},
output: {
path: __dirname,
filename: '[name].bundle.js'
},
module: {
loaders: [
{
test: /\.css$/,
loaders: ['style-loader', 'css-loader?modules'],
},
{
test: /\.jsx$/,
exclude: /(node_modules|.git)/,
loader: 'babel-loader',
query: {
presets: ['es2015', 'react', 'stage-2']
}
},
{
test: /\.js$/,
exclude: /(node_modules|.git)/,
loader: "babel-loader",
query:{
presets: ['es2015']
}
}
]
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>React infnite scroll test</title>
<link rel="stylesheet" type="text/css" href="css/main.css" media="screen">
<link rel="stylesheet" type="text/css" href="css/stylesheet.css" media="screen">
</head>
<body>
<div class="react_scroll"></div>
<script type="text/javascript" src="js/app.bundle.js"></script>
</body>
</html>
export const PREPARE1 = "PREPARE1"
export const PREPARE2 = "PREPARE2"
export const MESSAGE1 = "MESSAGE1"
export const MESSAGE2 = "MESSAGE2"
const preparemessages = (n) => {
const state = (n == 1) ? PREPARE1 : PREPARE2
return {
type: state
}
}
const createMessages = (n, arr = []) => {
let lst = arr || []
let start = arr.length + 1
for (let i = start; i < start + 20; i++) {
lst.push(i)
}
const state = (n == 1) ? MESSAGE1 : MESSAGE2
return {
type: state,
msg: lst
}
}
export const loadMore = (n) => {
return (dispatch, getState) => {
dispatch(preparemessages(n))
let messages = (n == 1) ? getState().demo.messages1 : getState().demo.messages2
setTimeout(() => {
dispatch(createMessages(n, messages))
}, 1000)
}
}
import { combineReducers } from 'redux'
import * as actions from './actions.jsx'
export const initialAppState = {
messages1: [],
loadingMore1: false,
messages2: [],
loadingMore2: false
}
const demo = (state = initialAppState, action) => {
if (action.type === actions.PREPARE1) {
return {
...state,
loadingMore1: true,
}
} else if (action.type === actions.MESSAGE1) {
return {
...state,
messages1: action.msg,
loadingMore1: false
}
} else if (action.type === actions.PREPARE2) {
return {
...state,
loadingMore2: true,
}
} else if (action.type === actions.MESSAGE2) {
return {
...state,
messages2: action.msg,
loadingMore2: false
}
} else {
return state
}
}
const reducer = combineReducers({
demo
})
export default reducer
import React, { Component } from 'react'
import ReactDOM from 'react-dom';
import ReduxInfiniteScroll from 'redux-infinite-scroll'
export default class ReduxInfiniteScrollEx extends ReduxInfiniteScroll {
constructor(props) {
super(props)
this._pastLength = 0
}
componentDidUpdate () {
const currentLength = this._scrollHeight()
if (currentLength > this._pastLength) {
ReactDOM.findDOMNode(this).scrollTop += (currentLength - this._pastLength)
}
if (currentLength !== this._pastLength) {
this._pastLength = currentLength
}
this.attachScrollListener();
}
_scrollHeight() {
return ReactDOM.findDOMNode(this).scrollHeight
}
_elScrollListener() {
let el = ReactDOM.findDOMNode(this)
if (this.props.horizontal) {
let leftScrollPos = el.scrollLeft
let totalContainerWidth = el.scrollWidth
let containerFixedWidth = el.offsetWidth
let rightScrollPos = leftScrollPos + containerFixedWidth
return (totalContainerWidth - rightScrollPos)
}
return el.scrollTop
}
scrollListener() {
if (this._totalItemsSize() <= 0) return
let bottomPosition = this.props.elementIsScrollable ? this._elScrollListener() : this._windowScrollListener()
if (bottomPosition === 0) {
this.detachScrollListener()
this.props.loadMore()
}
}
render () {
const Holder = this.props.holderType
return (
<Holder className={ this._assignHolderClass() } style={{height: this.props.containerHeight, overflow: 'scroll'}}>
{this.renderLoader()}
{this.props.animateItems ? this._renderWithTransitions() : this._renderOptions()}
</Holder>
)
}
}
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as actions from './actions.jsx'
import ReduxInfiniteScroll from 'redux-infinite-scroll'
import ReduxInfiniteScrollEx from './ReduxInfiniteScrollEx.jsx'
class DemoContainer extends Component {
render() {
const { demo, actions } = this.props
const _renderMessages = (messages) => {
return messages.map((msg) => {
return(
<div className="item" key={msg}>{msg}</div>
)
})
}
return (
<div style={{ width: "300px", marginLeft: "200px" }} >
<ReduxInfiniteScroll loadingMore={ demo.loadingMore1 } containerHeight="300px" loadMore={ () => actions.loadMore(1) } >
{ _renderMessages(demo.messages1) }
</ReduxInfiniteScroll>
<br />
<ReduxInfiniteScrollEx loadingMore={ demo.loadingMore2 } containerHeight="300px" loadMore={ () => actions.loadMore(2) } >
{ _renderMessages(demo.messages2) }
</ReduxInfiniteScrollEx>
</div>
)
}
}
const mapState = (state) => ({
demo: state.demo
})
const mapDispatch = (dispatch) => ({
actions: bindActionCreators(actions, dispatch)
})
export default connect(mapState, mapDispatch)(DemoContainer)
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import reducer from './reducer.jsx'
import DemoContainer from './container.jsx'
const store = createStore(reducer, applyMiddleware(thunk))
render (
<Provider store={ store }>
<DemoContainer />
</Provider>,
document.querySelector('.react_scroll')
)
###ビルド
下記のコマンドでビルドする。
$ yarn run prod
###デモ
index.html
を実行すると以下のように表示される。上のリストは下方向にスクロールすると下にアイテムが追加されて、下のリストは上方向にスクロールすると上にアイテムが追加されていく(上手にスクリーンショットがとれなかった)。
##RailsのActionCableとの連携
npm
かyarn
でactioncable
関連のプラグインを導入することも検討したが、そもそもcable.js
があり、グローバルで実行されているのであればプラグインが不要。
(function() {
this.App || (this.App = {});
App.cable = ActionCable.createConsumer();
}).call(this);
上記のように定義されていれば、react
側でApp.cable
を利用すれば、いいので、下記のようにAction
を定義できる。ただし、非同期で処理するようなケースでは、dispatch
が必要なので、その点は注意する。
また、bindActionCreators
でChatChannel
をコンポーネントにマッピングする必要がある。
export const ChatChannel = App.cable.subscriptions.create({channel: "ChatChannel" },
{
connected: () => {
console.log("connected")
},
disconnected: () => {
console.log("disconnected")
},
received: (data) => {
console.log("received", data)
},
speak: () => {
return (dispatch, getState) => {
const msg = getState().demo.msg
BrandChannel.perform('speak', { msg })
console.log("speak")
}
}
}
)