React/Reduxと<canvas>要素を組み合わせて使う

# 概要

reduxの`store`の内容を使って`<canvas>`要素を描画したいと思ったのですが、普通にやってもうまくいかなかったので解決法をメモしておきます。問題の原因は`<canvas>`の内容をいじる場合はDOMを直接操作する必要があるので、根本的にreactと相性が悪いことです。とりあえず適当に検索していたら出てきた[reactとd3.jsを組み合わせる例](https://stackoverflow.com/questions/21903604/is-there-any-proper-way-to-integrate-d3-js-graphics-into-facebook-react-applicat)の真似をしてみたらうまくいったのですが、もっといい方法がありそうな気がするのでわかる方はぜひコメントください。

# 結論

`<canvas>`の描画は初回のみreactから行い、その後はreactから直接は触らないようにする。そのために、`<canvas>`要素を含む`React.Component`において、

- `<canvas>`要素に`ref`を設定し、
- `componentDidMount()`で`context`の設定と初回の描画を、
- `shouldComponentUpdate()`で`ref`から直接DOMを操作して描画内容の更新を行い、`false`を返す。

# デモ

簡単な例として、フォームに入力したテキストを`<canvas>`に画像として描画したい場合を考えます。

![redux-canvas.gif](https://qiita-image-store.s3.amazonaws.com/0/159260/8bd8ee01-6aa4-7f07-ce40-feca4d0356ed.gif)

## コード

コードはgithub (https://github.com/hoture/redux-canvas-example) にあります。以下、簡単に説明します。

まず、以下のように`index.js`で`reducer`と`store`を定義しておきます。`state`の構造は非常に単純で、フォームに入力されているテキスト（＝`<canvas>`に描画したいテキスト）の内容のみを管理します。

```index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { createStore } from 'redux'
import { Form, Canvas } from './components'

const initialState = {text: ''}
const reducer = (state = initialState, action) => {
    switch (action.type) {
        case 'CHANGE_TEXT':
            return {
                ...state,
                text: action.text
            }
        default:
            return state
    }
}

const store = createStore(reducer)

const render = () => {
    ReactDOM.render(
        <div>
            <Form store={store}/>
            <Canvas store={store}/>
        </div>,
        document.getElementById('root')
    )
}

store.subscribe(() => render())
render()
```

以下、`render()`内部で使っている`<Form />`と`<Canvas />`を定義していきます。まず`<Form />`については普通に書けば良いです。

```components.js
import React, { Component } from 'react'

export const Form = ({ store }) => {
  const { text } = store.getState()
  return (
    <div>
      <input 
        value={text} 
        onChange={e => store.dispatch({
          type: 'CHANGE_TEXT',
          text: e.target.value
        })}
      />
    </div>
  )
}
```

普通です。`<input>`の`value`を`store`からもらってきて表示し、変更が加えられるたびに`dispatch`します。

次に、問題になる`<canvas>`要素を含む`<Canvas />`コンポーネントです。

```components.jsに追記
const canvasWidth = 300
const canvasHeight = 50

export class Canvas extends Component {
  componentDidMount() {
    this.ctx = this.refs.canvas.getContext('2d')
    this.ctx.textAlign = 'center'
    this.ctx.font = '24px sans-serif'

　　　　　　　　const { text } = this.props.store.getState()
　　　　　　　　this.ctx.fillText(text, canvasWidth / 2, canvasHeight / 2)
  }
  shouldComponentUpdate() {
    const { text } = this.props.store.getState()

    this.ctx.clearRect(0, 0, canvasWidth, canvasHeight)
    this.ctx.fillText(text, canvasWidth / 2, canvasHeight / 2)

    return false
  }

  render() {
    return (
      <div>
        <canvas ref="canvas" width={canvasWidth} height={canvasHeight} />
      </div>
    )
  }
}
```

こんな感じで、`componentDidMount()`の内部で基本的な設定と描画を行い、`shouldComponentUpdate()`の内部で`store`の状態を`<canvas>`に反映させることでうまく動きました。このやり方だとreduxの`store`が更新されるたびに`shouldComponentUpdate()`が呼ばれその中で直接DOMを操作し、その後`false`を返すのでreactはそれ以上`<canvas>`を変更しないということが可能になります。