LoginSignup
26
17

More than 5 years have passed since last update.

React/Reduxのtips

Last updated at Posted at 2017-08-16

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.jsstage-2を指定する必要がある。

webpack.config.js
  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を作成することでも実現できる。

react/react_test/src/app.jsx
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をどのように用意するか、毎回悩まされる。

actionstateを取得

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))のようにstorestore.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を割り当てることができる。
なお、この例では、bindActionCreatorsdispatchTestがコンポーネントにマッピングされていることが前提。

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を以下のように変更する。

react/react_test/src/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の更新内容が追加されていく。

スクリーンショット 2017-11-04 21.37.41.png

メンション機能

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

サンプルのディレクトリ構成

スクリーンショット 2017-08-30 18.39.32.png

defaultMentionStyle.jsdefaultStyle.jsについては、ここから取得する。

なお、メンションで候補として表示されるポップアップのスタイルを変更したい場合は以下のようにsuggestions直下に記載する(本サンプルではgithubにあるものをそのまま利用した)。

defaultStyle.js
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',
      },
    },
  }
})

ファイルの内容

package.json
{
  "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"
  }
}
webpack.config.js
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']
        }
      }
    ]
  }
}
index.html
<!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>
jsx/actions.jsx
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,
  }
}
jsx/reducer.jsx
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

※非同期でのメンション対象となるユーザ情報が必要ない場合、Mentiondataプロパティに直接配列を指定する。

jsx/container.jsx
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)
jsx/app.jsx
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秒後に(初回のみ)メンション対象のユーザ一覧が表示され、さらにタイプすることでユーザが絞り込まれる。

スクリーンショット 2017-08-30 18.38.36.png

無限スクロール

いわゆるレイジースクロールといったところ。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

サンプルのディレクトリ構成

スクリーンショット 2017-08-27 19.04.15.png

ここからmain.cssstylesheet.cssを取得しておく。

最低限cssで、以下の定義を忘れると、スクロールしない・スクロールイベントが発生しないなどの不備が発生するので、注意。

.redux-infinite-scroll {
    overflow: scroll;
}

ファイルの内容

package.json
{
  "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"
  }
}
webpack.config.js
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']
        }
      }
    ]
  }
}
index.html
<!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>
jsx/actions.jsx
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)
  }
}
jsx/reducer.jsx
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
jsx/ReduxInfiniteScrollEx.jsx
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>
    )
  }
}
jsx/container.jsx
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)
jsx/app.jsx
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を実行すると以下のように表示される。上のリストは下方向にスクロールすると下にアイテムが追加されて、下のリストは上方向にスクロールすると上にアイテムが追加されていく(上手にスクリーンショットがとれなかった)。

スクリーンショット 2017-08-27 19.02.49.png

RailsのActionCableとの連携

npmyarnactioncable関連のプラグインを導入することも検討したが、そもそもcable.jsがあり、グローバルで実行されているのであればプラグインが不要。

cable.js
(function() {
  this.App || (this.App = {});
  App.cable = ActionCable.createConsumer();
}).call(this);

上記のように定義されていれば、react側でApp.cableを利用すれば、いいので、下記のようにActionを定義できる。ただし、非同期で処理するようなケースでは、dispatchが必要なので、その点は注意する。
また、bindActionCreatorsChatChannelをコンポーネントにマッピングする必要がある。

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")
      }
    }
  }
)
26
17
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
26
17