Help us understand the problem. What is going on with this article?

React + Reduxでカウントアップタイマーを作ってみる

More than 1 year has passed since last update.

はじめに

この記事はHamee Advent Calendar 2016の8日目の記事です。

最近、Reactが熱いですね!
今回は、前から気になっていたReactと相性が良いと言われるfluxフレームワークのReduxを使って、カウントアップタイマーを作ってみようと思います。

t-yng/countup-timer

デモ

20161207231006.png

開発環境の構築

ビルドツールとしてwebpackを使っていきます。
またES6とReactのJSXは、webpackのbabel-loaderでコンパイルします。

$ npm i --save-dev webpack babel-core babel-loader babel-preset-es2015 babel-preset-react

webpackの設定をwebpack.config.jsに記述していきます。

webpack.config.js
module.exports = {
  entry: './js/main.js',
  output: {
    path: 'dist',
    filename: 'bundle.js'
  },
  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        query: {
          presets: ['es2015', 'react']
        }
      }
    ]
  }
}

続いて、react, reduxのインストールです。

$ npm i --save react react-dom redux react-redux

インストールしたライブラリの概要は以下の通りです。

react, react-dom: Reactのライブラリ
redux: Fluxフレームワーク
react-redux: ReactとReduxを連携させるためのライブラリ

packge.jsonのタスクコマンドにwebpackのbuildとwatchを追加します。

package.json
  (省略),
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack",
    "watch": "webpack --watch"
  },
  (省略)

ディレクトリ構成

.
├── README.md
├── css
│   └── style.css
├── dist
│   └── bundle.js
├── index.html
├── js
│   ├── action.js
│   ├── components
│   │   ├── App.js
│   │   ├── Timer.js
│   │   └── timer-button.js
│   ├── container.js
│   ├── main.js
│   ├── reducer.js
│   └── timer-model.js
├── node_modules
├── package.json
└── webpack.config.js

画面モックを作る

最初にHTMLとcssだけで画面のモックを作っていきます。

style.css
body {
  margin: 0;
  background-color: black;
}

.center-container {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

.inline-block {
  display: inline-block;
}

.text-center {
  text-align: center;
}

.timer-number,
.timer-semicolon {
  color: white;
  font-size: 15vw;
}

.timer-button-container {
  width: 50vw;
  display: flex;
  justify-content: space-around;
}

.timer-button {
  font-size: 50px;
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <div class="center-container" style="margin-top: 100px;">
    <div>
      <span class="timer-number" role="hour">00</span>
      <span class="timer-semicolon">:</span>
      <span class="timer-number" role="minute">00</span>
      <span class="timer-semicolon">:</span>
      <span class="timer-number" role="second">00</span>
    </div>
    <div class="timer-button-container">
      <button class="timer-button" type="button" name="button" >START</button>
      <button class="timer-button" type="button" name="button" >STOP</button>
      <button class="timer-button" type="button" name="button" >RESET</button>
    </div>
  </div>
</body>
</html>

Reactだけを使って実装

画面が完成したので、実装に入っていきます。
いきなりReact+Reduxでは難しいので、最初はReactだけで実装を行いそれをベースにReduxを導入していきたいと思います。

Reactを使う際に重要になってくるのがコンポーネントをどのように分割するかだと思います。
今回は時間表示、タイマーボタンを部品コンポーネントとして切り出し、全体部分をアプリケーションコンポーネントとします。

20161207231207.png

main.js
import {render} from 'react-dom'
import {App} from './components/App.js'

render(
  <App />,
  document.getElementById('timer-container')
)
components/app.js
import React from 'react'
import {Timer} from './timer.js'
import {TimerButton} from './timer-button.js'

export class App extends React.Component {

  render () {
    return (
      <div className='center-container' style={{marginTop: '100px'}} >
        <Timer hours={'00'} minutes={'00'} seconds={'00'} ref='timer' />
        <div className='timer-button-container'>
          <TimerButton text={'START'} handleClick={() => this.refs.timer.start()} />
          <TimerButton text={'STOP'} handleClick={() => this.refs.timer.stop()} />
          <TimerButton text={'RESET'} handleClick={() => this.refs.timer.reset()} />
        </div>
      </div>
    )
  }
}
components/timer.js
import React from 'react'

export class Timer extends React.Component {
  constructor () {
    super()
    this.state = {hours: '00', minutes: '00', seconds: '00', time: 0}
  }

  start () {
    this.intervalTimer = setInterval(() => this.update(), 1000)
  }

  stop () {
    clearInterval(this.intervalTimer)
  }

  reset () {
    this.setState({
      hours: '00',
      minutes: '00',
      seconds: '00',
      time: 0
    })
  }

  update () {
    const time = this.state.time + 1
    const hours = this.toHours(time)
    const minutes = this.toMinutes(time)
    const seconds = this.toSeconds(time)

    this.setState({
      hours: this.toText(hours),
      minutes: this.toText(minutes),
      seconds: this.toText(seconds),
      time: time
    })
  }

  toHours (time) {
    return parseInt(time / 60 / 60)
  }

  toMinutes (time) {
    return parseInt(time / 60 % 60)
  }

  toSeconds (time) {
    return time % 60
  }

  toText (time) {
    return ('00' + time).slice(-2)
  }

  render () {
    return (
      <div>
        <span className='timer-number' role='hour'>{this.state.hours}</span>
        <span className='timer-semicolon'>:</span>
        <span className='timer-number' role='minute'>{this.state.minutes}</span>
        <span className='timer-semicolon'>:</span>
        <span className='timer-number' role='second'>{this.state.seconds}</span>
      </div>
    )
  }
}
components/timer-button.js
import React from 'react'

export class TimerButton extends React.Component {
  render () {
    return (
      <button className='timer-button' type='button' name='button' onClick={() => this.props.handleClick()}>{this.props.text}</button>
    )
  }
}

Reactだけで実装した場合、Timerコンポーネントに状態管理の処理が記述されているため、少し複雑になってしまいました。
理想としては、コンポーネントはViewの記述のみにしたいです。

Redux + React で実装

それでは、今回の目的であるReduxの導入をしていきたいと思います。

Reduxの概念に関しては、次の記事が大変参考になりました。
Redux入門【ダイジェスト版】10分で理解するReduxの基礎

Reduxで登場する要素について簡単に説明します。

  • state
    • アプリケーションの状態
    • Reduxでは一つのアプリケーションに対して一つのstateオブジェクトを持つ
  • sotre
    • アプリケーション全体の状態を管理
  • reducer
    • 状態遷移を処理する役割を持つ
    • 状態とアクションを受け取り、新たな状態を返す
  • dispatcher
    • reducerに対して、ユーザーアクションを通知
  • action
    • ボタンクリック等のアクションを表す
    • typeプロパティとアクションに応じたデータを持つオブジェクト
  • actionCreator
    • Actionを生成するための関数
  • container
    • Reduxと連携するトップレイヤーのコンポーネント

reducerの実装

reducerではactionに応じた、タイマーの状態遷移の処理を記述しています。

reducer.js
import * as timerModel from './timer-model.js'

/**
 * Reduxのreducer タイマーの時間の状態遷移を処理する
 * @param state Reduxのstoreで管理されている状態
 * @param action アクションオブジェクト
 * @return actionに応じて変化させた新しい状態
 */
export function timer (state = timerModel.initialState(), action) {
  console.log(state)
  switch (action.type) {
    case 'START_TIMER':
      return timerModel.start(state, action.intervalID)
    case 'STOP_TIMER':
      return timerModel.stop(state)
    case 'UPDATE_TIMER':
      return timerModel.update(state)
    case 'RESET_TIMER':
      return timerModel.reset(state)
    default:
      return state
  }
}

ここで登場している、timer-model.jsはTimerコンポーネントから、タイマーの状態遷移ロジックを抽出した関数群になります。

Reduxでは状態遷移の関数は参照透過である必要があります。
そのため、Object.assign()では新しい空オブジェクトに対して、既存の値をコピーすることで、新たな状態を生成しています。

timer-model.js
/**
 * タイマーの状態を開始状態に変更する
 * @param state タイマーの状態
 * @param {number} intervalID setInterval()で得られたID
 * @return タイマーの開始状態
 */
export function start (state, intervalID) {
  return Object.assign({}, state, {
    started: true,
    intervalID: intervalID
  })
}

/**
 * タイマーの状態を停止状態に変更する
 * @param state タイマーの状態
 * @return タイマーの停止状態
 */
export function stop (state) {
  clearInterval(state.intervalID)

  return Object.assign({}, state, {
    started: false,
    intervalID: -1
  })
}

/**
 * タイマーの時間を1秒進める
 * @param state タイマーの時間の状態
 * @return 時間を1秒進めた新しい状態
 */
export function update (state) {
  const time = state.time + 1
  const hours = toHours(time)
  const minutes = toMinutes(time)
  const seconds = toSeconds(time)

  return Object.assign({}, state, {
    hours: toText(hours),
    minutes: toText(minutes),
    seconds: toText(seconds),
    time: time
  })
}

/**
 * タイマーの時間をリセットする
 * @return タイマーの初期状態
 */
export function reset (state) {
  return Object.assign({}, initialState(), {
    started: state.started,
    intervalID: state.intervalID
  })
}

/**
 * タイマーの初期状態
 */
export function initialState () {
  return {
    hours: '00',
    minutes: '00',
    seconds: '00',
    time: 0,
    started: false,
    intervalID: -1
  }
}

function toHours (time) {
  return parseInt(time / 60 / 60)
}

function toMinutes (time) {
  return parseInt(time / 60 % 60)
}

function toSeconds (time) {
  return time % 60
}

function toText (time) {
  return ('00' + time).slice(-2)
}

actionCreatorの実装

actionオブジェクトを返す、関数を定義していきます。
startTimerAction()では停止処理を実行するために、setInterval()で取得したIDを受け取り、storeに渡しています。

action.js
export function startTimerAction (intervalID) {
  return {type: 'START_TIMER', intervalID: intervalID}
}

export function stopTimerAction () {
  return {type: 'STOP_TIMER'}
}

export function updateTimerAction () {
  return {type: 'UPDATE_TIMER'}
}

export function resetTimerAction () {
  return {type: 'RESET_TIMER'}
}

containerの実装

ReactとReduxの連携部分の実装をして、Redux側の実装は完了です。

mapStateToProprs(state)関数ではReduxのStoreで管理しているstateをReactのpropsに紐付けています。
今回は簡単なアプリケーションなので、そのまま返していますが、複雑になってきた場合はここで加工して、必要な状態をReact側に渡すことができます。

mapDispatchToProps(dispatch)関数にてReduxのdispatch処理をpropsに紐付けることで、Reactのコンポーネントがクリック等のイベントを受け取った際にdispatch処理を実行させることが出来るようになります。

connect()関数の詳細な仕様に関しては、githubのリポジトリにAPIドキュメントがあるのでそちらを参考にしてください。

[https://github.com/reactjs/react-redux/blob/master/docs/api.md#inject-todos-of-a-specific-user-depending-on-props-and-inject-propsuserid-into-the-action:title]

container.js
import {connect} from 'react-redux'

import {App} from './components/app.js'
import * as action from './action.js'

/**
 * reduxで管理しているstateをreact側に渡す
 * @param state reduxのstoreで管理されている状態
 * @return react側で使う状態 this.prorpsで参照
 */
function mapStateToProps (state) {
  return state
}

/**
 * reactで受け取るユーザーアクションとreduxのアクションを連携させる
 * @param dispatch reduxのreducerにアクションを渡す関数
 */
function mapDispatchToProps (dispatch, props) {
  return {
    startTimer: () => {
      const intervalID = setInterval(() => dispatch(action.updateTimerAction()), 1000)
      dispatch(action.startTimerAction(intervalID))
    },
    stopTimer: () => dispatch(action.stopTimerAction()),
    resetTimer: () => dispatch(action.resetTimerAction())
  }
}

function mergeProps (stateProps, dispatchProps, ownProps) {
  return Object.assign({}, stateProps, dispatchProps, ownProps, {
    // タイマーが止まっていたら、カウントアップを開始する
    startTimer: () => {
      if (!stateProps.started) dispatchProps.startTimer()
    }
  })
}

// 上記で定義した関数を使って、ReduxとReactのコンポーネントを繋げる
export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(App)

timer-model.jsでdispatch()関数を呼ぶ方法が思いつかなかったので、ここでsetInterval()関数にてタイマーの開始処理をしています。
理想としては、ここではactionをdispatchするだけにしたかったです。

React側のコンポーネントを修正

Reduxの導入が完了したので、それに合わせてReactのコンポーネントを修正します。
Redux側が状態管理をしてくれるため、Reactのコンポーネントは純粋に状態を受け取るだけで良くなりました。

components/app.js
import React from 'react'
import {Timer} from './timer.js'
import {TimerButton} from './timer-button.js'

export class App extends React.Component {

  render () {
    return (
      <div className='center-container' style={{marginTop: '100px'}}>
        <Timer hours={this.props.hours} minutes={this.props.minutes} seconds={this.props.seconds} ref='timer' />
        <div className='timer-button-container'>
          <TimerButton text={'START'} handleClick={this.props.startTimer} />
          <TimerButton text={'STOP'} handleClick={this.props.stopTimer} />
          <TimerButton text={'RESET'} handleClick={this.props.resetTimer} />
        </div>
      </div>
    )
  }
}
components/timer.js
import React from 'react'

export class Timer extends React.Component {
  render () {
    return (
      <div>
        <span className='timer-number' role='hour'>{this.props.hours}</span>
        <span className='timer-semicolon'>:</span>
        <span className='timer-number' role='minute'>{this.props.minutes}</span>
        <span className='timer-semicolon'>:</span>
        <span className='timer-number' role='second'>{this.props.seconds}</span>
      </div>
    )
  }
}
components/timer-button.js
import React from 'react'

export class TimerButton extends React.Component {
  render () {
    return (
      <button className='timer-button' type='button' name='button' onClick={this.props.handleClick}>{this.props.text}</button>
    )
  }
}

main処理を実装

最後にmainの処理を実装します。
const store = createStore(timer)にて、Reduxのstoreを生成します。

最後の<Provider store={store}>...</Proivder>にて、Reduxのstoreで管理している状態を子コンポーネントである<App />に対して状態を渡しています。
ここの記述はreact-reduxにて決められているため、必ずこのように書く必要があります。

main.js
import React from 'react'
import {Provider} from 'react-redux'
import {render} from 'react-dom'
import {createStore} from 'redux'

import App from './container.js'
import {timer} from './reducer.js'

const store = createStore(timer)

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

さいごに

アプリケーションを作ってみての感想として、React, Redux 単体の場合は分かりやすかったのですが、ReactとReduxを連携させようとすると、難しくなるなと感じました。
ただ、状態管理の処理をReduxが担ってくれるので、Reactのコンポーネントからビジネスロジックが無くなり、純粋なViewに徹することが出来るようになるのは、シンプルになって扱いやすいなと思いました。
Reactを使っていて、状態管理に悩んでいる人はRedux(Fluxフレームワーク)を導入する価値は十分にあると思います。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away