EY-Office ブログ

React用ステート管理2020 〜Recoilを試してみました〜

以前、Redux libraryは今後どうなるの? を書きましたが、今年5月にRecoilというステート管理ライブラリーが現れました。これはFacebookの実験的(Experimental)なプロジェクトでまだ正式なものではありませんが、React用のステート管理に新たなプレイヤーがあらわれた事は確かです。

2020年10月7日更新: MobXのサンプルコードも追加しました。

Recoil

2020年のReact用ステート管理方法

現在、React用のステート管理方法には以下のものがあります

サンプルコードと解説

ここでは、UseState, Redux toolkit, useReducer + Context, Recoil, MobXのサンプルコードと簡単な解説を書きます。

ReactのuseStateのみのサンプル

useStateのみサンプルコードです。アプリはVueに入門してみた(React、Vueどちらを使うべきか) で使ったジャンケン・ゲームをTypeScript化したものです。

このコードは、特別なステート管理ライブラリーは使わすReactの基本機能のみで書いたコードです。

  • Jyanken.tsxの中でuseStateを使い試合結果scoresを確保し、結果表示のScoreBox.tsxにはpropsでステート値を渡しています
  • またジャンケンを行い試合結果に追加するpon関数をJyanken.tsx内に定義し、ジャンケンボタンのJyankenBox.tsxコンポーネントにpropsでpon関数を渡しています

このレベルのコードでは問題ありませんが、ステート値やステート変更関数をpropsで渡して行くのは現実的なアプリでは面倒すぎる、どのコンポーネントがステートを参照し、どのコンポーネントが変更するのかが判りにくくなるなどの問題がありReduxに代表されるステート管理ライブラリーが使われるようになりました。

Redux(Reduxt toolkit)のサンプル

それでは、Reduxの新ライブラリーReduxt toolkitのサンプルコードのコードを見ていきましょう。Reduxではステートをstoreで集中管理します、ステートの定義、更新を行うアクションの定義、更新を行うreducerなどを定義します。最初に登場したReduxはこれらを行うたくさんのコードを書く必要がありましたが、Reduxt toolkitはアクションとReducerを同時に定義できるcreateSlice等の導入でコードはシンプルに書けるようになりました。 scoreSlice.tsがこの部分になります。

  • Jyanken.tsxはuseStateのみサンプルコードと比べるとJyankenBox、ScoreBoxを配置するだけのシンプルなコンポーネントになりました
  • JyankenBox.tsxでは、propsで受け取っていたジャンケン実行関数を、useDispatch()でReduxからを取得したponアクションを呼び出しています
  • ScoreBox.tsxでは、propsで受け取っていたscoresを、useSelector()でReduxから取得しています
  • index.jsxにはstoreの作成やReduxのProviderを定義しています

Reduxを使うと、このようにステート管理を1カ所に集め、必要なコンポーネントのみステート値の取得や、ステート変更操作の呼び出しをシンプルに書けるようになります。

ReactのuseReducerとContextのサンプル

React 16.3で入ったContextを使うことで、任意のコンポーネントからアクセスできる広域な値を持てるようになりました。また16.8で入ったuseReducerホックは、ReduxのReducer機能をuseStateに加えたもので、この2つを組み合わせるとReduxと同じようなコードが書けます。useReducerとContextのサンプルコード

  • scoreReducer.tsにはReducer関数と、ステート用とアクション(dispatch)用の2つのContextを作成しています
  • Jyanken.tsxはuseReducerでstateとdispatchを取得し、ContextのProviderにそれぞれの値を渡しています
  • JyankenBox.tsx、ScoreBox.tsxはRedux版とほぼ同じです

Redux版に、かなり近いコードになっていると思います。あえてReduxとの違い指摘するとReducer関数では元祖Reduxのようにstateを破壊的な変更をしてはいけません。しかしRedux toolkitではデフォルトでImmerが入っているのでシンプルな破壊的な変更コードが書けます。(もちろんuseImmerReducerホックを使えばこちらでも可能になります)

Recoilのサンプル

さてRecoilですが、代表的なAPIの useRecoilStateはおおざっぱに言うと広域なステート参照・更新APIです、複数のコンポーネントで同じステートを参照・更新できるuseStateです。通常のuseStateは1つのコンポーネントに閉じていますが、useRecoilStateはatomで作成したステートを複数のコンポーネントでuseRecoilStateホックを使い共有できます。

  • Recoilの原理、APIの詳細はホームページビデオを見ると良くわかります
  • Recoidはステート管理の基本機能のみです。Reducer(Redux)的なものは含まれていませんが、上位に追加するのは容易です
  • 不要な再レンダリングを避ける高速なステート管理APIです
  • ステートの更新を非同期で更新でき、その為のAPIが用意されています

Recoilのサンプルコード使ってみました、recoilのステート管理とステートの更新機能を組み合わせたホックを作って利用しています。

  • useGlobalScore.tsはRecoilのatom, useRecoilStateを使った広域ステートと、ジャンケン実行しステートを書き換えるpon関数を持つカスタム・ホックです
  • Jyanken.tsx、JyankenBox.tsx、ScoreBox.tsxはRedux版とほぼ同じです、ステート値やアクションの取得がuseGlobalScoreカスタム・ホックに替わっただけです
  • index.jsxではRecoilRootというプロバイダーが使われています

Recoilはシンプルで強力なステート管理だと思います。ただしアプリ開発者そのまま使うにはナイーブ過ぎます、これからのサンプルコードや上位ライブラリーの充実などに期待しています。

MobXのサンプル

MobXはシンプルでスケーラブルなステート管理ライブラリーです、オブザーバー・パターン(Observer)を使ってステートの変化をViewに伝えいます。
今回はmobx-react-liteを使ってみました。従来のMobXは、クラスコンポーネントに@observer等のデコレータを使いMobXの設定を行うスタイルでしたが、mobx-react-liteはデコレータを使わず、Hooksを使う関数コンポーネント専用に作られたライブラリーです。

MobXのステートを共有する方法は MobX React integrationにあるように、いくつかの方法がありますが、今回のサンプルではReactのContextを使う事にしました。またMobx-React-Lite for Hooks in a nutshell with code sampleを参考にしました。

  • JyankenContext.tsxがMobX(mobx-react-lite)の核になるモジュールです
    • MobXのObservableオブジェクト(ステートやアクション)が格納さるJyankenContextの作成
    • JyankenProviderはJyankenContextのProviderの定義で、内部にuseLocalObservable APIで、Observableオブジェクトを定義しています
  • JyankenBox.tsxではContextでObservableオブジェクトを取得し、アクションを呼び出しています
  • ScoreBox.tsxはステートの変更で再表示するようにobserver APIでくくっています。またContextでObservableオブジェクトを取得しています

サンプルコード

useState

  • Jyanken.tsx
import React, { useState } from 'react'
import JyankenBox, { Te } from './JyankenBox'
import ScoreBox, { ScoreType, Jjudgment }  from './ScoreBox'

const Jyanken = () => {
  const [scores, setScrores] = useState<ScoreType[]>([])

  const pon = (human: Te) => {
    const computer:Te = Math.floor(Math.random() * 3)
    const judgment:Jjudgment = (computer - human + 3) % 3
    const score = {human: human, computer: computer, judgment: judgment}
    setScrores([score, ...scores])
  }

  return (
    <>
      <h1>じゃんけん ポン!</h1>
      <JyankenBox actionPon={te => pon(te)} />
      <ScoreBox scrores={scores} />
    </>
   )
}

export default Jyanken
  • JyankenBox.tsx
import React from 'react'

export enum Te { Guu = 0, Choki, Paa}

type JyankenBoxProp = {
  actionPon: (human: Te) => void
}
const JyankenBox: React.FC<JyankenBoxProp> = ({actionPon}) => {
  const divStyle: React.CSSProperties = {margin: "0 20px"}
  const buttonStyle: React.CSSProperties = {margin: "0 10px", padding: "3px 10px",
    fontSize: 14}
  return (
    <div style={divStyle}>
      <button onClick={() => actionPon(Te.Guu)} style={buttonStyle}>グー</button>
      <button onClick={() => actionPon(Te.Choki)} style={buttonStyle}>チョキ</button>
      <button onClick={() => actionPon(Te.Paa)} style={buttonStyle}>パー</button>
    </div>
  )
}

export default JyankenBox
  • ScoreBox.tsx
import React from 'react'

export enum Jjudgment { Draw = 0, Win, Lose }
export type ScoreType = {
  human: number,
  computer: number,
  judgment: Jjudgment
}

type ScoreBoxProp = {
  scrores: ScoreType[]
}
const ScoreBox: React.FC<ScoreBoxProp>  = ({scores}) => {
  const teString = ["グー","チョキ", "パー"]
  const judgmentString = ["引き分け","勝ち", "負け"]

  const tableStyle: React.CSSProperties = {marginTop: 20, borderCollapse: "collapse"}
  const thStyle: React.CSSProperties = {border: "solid 1px #888", padding: "3px 15px"}
  const tdStyle: React.CSSProperties = {border: "solid 1px #888", padding: "3px 15px",
    textAlign : "center"}
  return (
    <table style={tableStyle}>
      <thead>
        <tr>
          <th style={thStyle}>あなた</th>
          <th style={thStyle}>コンピュター</th>
          <th style={thStyle}>勝敗</th>
        </tr>
      </thead>
      <tbody>
        {scores.map((score, ix) =>
          <tr key={ix}>
            <td style={tdStyle}>{teString[score.human]}</td>
            <td style={tdStyle}>{teString[score.computer]}</td>
            <td style={tdStyle}>{judgmentString[score.judgment]}</td>
          </tr>
        )}
      </tbody>
    </table>
  )
}

export default ScoreBox
  • index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import Jyanken from './Jyanken'

ReactDOM.render(
  <React.StrictMode>
    <Jyanken />
  </React.StrictMode>,
  document.getElementById('root')
)

Redux toolkit

  • scoreSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

export enum Jjudgment { Draw = 0, Win, Lose }
export enum Te { Guu = 0, Choki, Paa}

export type ScoreType = {
  human: number,
  computer: number,
  judgment: Jjudgment
}

export type PonPayload = Te
export type ScoreState = ScoreType[]

let initialState: ScoreState = []

const scoreSlice = createSlice({
  name: "score",
  initialState,
  reducers: {
    pon(state: ScoreState, action: PayloadAction<PonPayload>) {
      const human = action.payload
      const computer:Te = Math.floor(Math.random() * 3)
      const judgment:Jjudgment = (computer - human + 3) % 3
      const score = {human: human, computer: computer, judgment: judgment}
      state.push(score)
    }
  }
})

export const { pon } = scoreSlice.actions
export default scoreSlice.reducer
  • Jyanken.tsx
import React from 'react'
import JyankenBox from './JyankenBox'
import ScoreBox from './ScoreBox'

const Jyanken:React.FC = () => {
  return (
    <>
      <h1>じゃんけん ポン!</h1>
      <JyankenBox />
      <ScoreBox />
    </>
   )
}

export default Jyanken
  • JyankenBox.tsx
import React from 'react'
import { useDispatch } from 'react-redux'
import { pon, Te } from './scoreSlice'

const JyankenBox: React.FC = () => {
  const divStyle: React.CSSProperties = {margin: "0 20px"}
  const buttonStyle: React.CSSProperties = {margin: "0 10px", padding: "3px 10px",
    fontSize: 14}

  const dispatch = useDispatch()
  return (
    <div style={divStyle}>
      <button onClick={() => dispatch(pon(Te.Guu))} style={buttonStyle}>グー</button>
      <button onClick={() => dispatch(pon(Te.Choki))} style={buttonStyle}>チョキ</button>
      <button onClick={() => dispatch(pon(Te.Paa))} style={buttonStyle}>パー</button>
    </div>
  )
}

export default JyankenBox
  • ScoreBox.tsx
import React from 'react'
import { useSelector } from 'react-redux'
import { ScoreState } from './scoreSlice'

const ScoreBox: React.FC  = () => {
  const teString = ["グー","チョキ", "パー"]
  const judgmentString = ["引き分け","勝ち", "負け"]

  const scores = useSelector((state: ScoreState) => state)
  const tableStyle: React.CSSProperties = {marginTop: 20, borderCollapse: "collapse"}
  const thStyle: React.CSSProperties = {border: "solid 1px #888", padding: "3px 15px"}
  const tdStyle: React.CSSProperties = {border: "solid 1px #888", padding: "3px 15px",
    textAlign : "center"}
  return (
    <table style={tableStyle}>
      <thead>
        <tr>
          <th style={thStyle}>あなた</th>
          <th style={thStyle}>コンピュター</th>
          <th style={thStyle}>勝敗</th>
        </tr>
      </thead>
      <tbody>
        {scores.map((score, ix) =>
          <tr key={ix}>
            <td style={tdStyle}>{teString[score.human]}</td>
            <td style={tdStyle}>{teString[score.computer]}</td>
            <td style={tdStyle}>{judgmentString[score.judgment]}</td>
          </tr>
        )}
      </tbody>
    </table>
  )
}

export default ScoreBox
  • index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from  'react-redux'
import Jyanken from './Jyanken'
import logger from 'redux-logger'
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'
import scoreSlice  from './scoreSlice'

const middlewares = [...getDefaultMiddleware(), logger]
const store = configureStore({
    reducer: scoreSlice,
    middleware: middlewares,
})

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <Jyanken />
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
)

useReducer + Context

  • scoreReducer.ts
import { createContext } from "react"

export enum Jjudgment { Draw = 0, Win, Lose }
export enum Te { Guu = 0, Choki, Paa}

export type ScoreType = {
  human: number,
  computer: number,
  judgment: Jjudgment
}

export type Action =
  { type: 'pon', human: Te }

export type ScoreState = ScoreType[]

export const scoreInitialState: ScoreState = []

export const scoreReducer = (state: ScoreState, action: Action) => {
  switch (action.type) {
    case 'pon':
      const human = action.human
      const computer:Te = Math.floor(Math.random() * 3)
      const judgment:Jjudgment = (computer - human + 3) % 3
      const score = {human: human, computer: computer, judgment: judgment}
      return [...state, score]
    default:
      return state;
  }
}

export const stateContext = createContext(scoreInitialState)
export const dispatchContext = createContext((() => null) as React.Dispatch<Action>)
  • Jyanken.tsx
import React, { useReducer } from 'react'
import JyankenBox from './JyankenBox'
import ScoreBox  from './ScoreBox'
import { scoreReducer, scoreInitialState, dispatchContext, stateContext } from './scoreReducer'

const Jyanken = () => {
  const [state, dispatch] = useReducer(scoreReducer, scoreInitialState)

  return (
    <dispatchContext.Provider value={dispatch}>
      <stateContext.Provider value={state}>
        <h1>じゃんけん ポン!</h1>
        <JyankenBox />
        <ScoreBox />
      </stateContext.Provider>
    </dispatchContext.Provider>
   )
}

export default Jyanken
  • JyankenBox.tsx
import React, { useContext } from 'react'
import { dispatchContext, Te } from './scoreReducer'

const JyankenBox: React.FC = () => {
  const divStyle: React.CSSProperties = {margin: "0 20px"}
  const buttonStyle: React.CSSProperties = {margin: "0 10px", padding: "3px 10px",
    fontSize: 14}

  const dispatch = useContext(dispatchContext)
  return (
    <div style={divStyle}>
      <button onClick={() => dispatch({type: 'pon', human: Te.Guu})}
        style={buttonStyle}>グー</button>
      <button onClick={() => dispatch({type: 'pon', human:Te.Choki})}
        style={buttonStyle}>チョキ</button>
      <button onClick={() => dispatch({type: 'pon', human:Te.Paa})}
        style={buttonStyle}>パー</button>
    </div>
  )
}

export default JyankenBox
  • ScoreBox.tsx
import React, { useContext } from 'react'
import { stateContext } from './scoreReducer'

const ScoreBox: React.FC  = () => {
  const teString = ["グー","チョキ", "パー"]
  const judgmentString = ["引き分け","勝ち", "負け"]

  const tableStyle: React.CSSProperties = {marginTop: 20, borderCollapse: "collapse"}
  const thStyle: React.CSSProperties = {border: "solid 1px #888", padding: "3px 15px"}
  const tdStyle: React.CSSProperties = {border: "solid 1px #888", padding: "3px 15px",
    textAlign : "center"}

  const scores = useContext(stateContext)
  return (
    <table style={tableStyle}>
      <thead>
        <tr>
          <th style={thStyle}>あなた</th>
          <th style={thStyle}>コンピュター</th>
          <th style={thStyle}>勝敗</th>
        </tr>
      </thead>
      <tbody>
        {scores.map((score, ix) =>
          <tr key={ix}>
            <td style={tdStyle}>{teString[score.human]}</td>
            <td style={tdStyle}>{teString[score.computer]}</td>
            <td style={tdStyle}>{judgmentString[score.judgment]}</td>
          </tr>
        )}
      </tbody>
    </table>
  )
}

export default ScoreBox
  • index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import Jyanken from './Jyanken'

ReactDOM.render(
  <React.StrictMode>
    <Jyanken />
  </React.StrictMode>,
  document.getElementById('root')
)

Recoil

  • useGlobalScore.ts
import { atom, useRecoilState } from "recoil";

export enum Jjudgment { Draw = 0, Win, Lose }
export enum Te { Guu = 0, Choki, Paa}

type ScoreType = {
  human: number,
  computer: number,
  judgment: Jjudgment
}
type ScoreState = ScoreType[]
const scoreState = atom<ScoreState>({
  key: 'scoreState',
  default: [],
})

type PonAction =  (human: Te) => void

const useGlobalScore = () => {
  const [scores, setScores] = useRecoilState(scoreState)

  const pon:PonAction = (human) => {
    const computer:Te = Math.floor(Math.random() * 3)
    const judgment:Jjudgment = (computer - human + 3) % 3
    const newScore = {human: human, computer: computer, judgment: judgment}
    setScores([...scores, newScore])
  }
  return {scores, pon}
}

export default useGlobalScore
  • Jyanken.tsx
import React from 'react'
import JyankenBox from './JyankenBox'
import ScoreBox  from './ScoreBox'

const Jyanken = () => {
  return (
    <>
      <h1>じゃんけん ポン!</h1>
      <JyankenBox />
      <ScoreBox />
    </>
   )
}

export default Jyanken
  • JyankenBox.tsx
import React from 'react'
import useGlobalScore, { Te } from './useGlobalScore'

const JyankenBox: React.FC = () => {
  const divStyle: React.CSSProperties = {margin: "0 20px"}
  const buttonStyle: React.CSSProperties = {margin: "0 10px", padding: "3px 10px",
    fontSize: 14}

  const { pon } = useGlobalScore()
  return (
    <div style={divStyle}>
      <button onClick={() => pon(Te.Guu)} style={buttonStyle}>グー</button>
      <button onClick={() => pon(Te.Choki)} style={buttonStyle}>チョキ</button>
      <button onClick={() => pon(Te.Paa)} style={buttonStyle}>パー</button>
    </div>
  )
}

export default JyankenBox
  • ScoreBox.tsx
import React from 'react'
import useGlobalScore from './useGlobalScore'

const ScoreBox: React.FC  = () => {
  const teString = ["グー","チョキ", "パー"]
  const judgmentString = ["引き分け","勝ち", "負け"]

  const tableStyle: React.CSSProperties = {marginTop: 20, borderCollapse: "collapse"}
  const thStyle: React.CSSProperties = {border: "solid 1px #888", padding: "3px 15px"}
  const tdStyle: React.CSSProperties = {border: "solid 1px #888", padding: "3px 15px",
    textAlign : "center"}

  const { scores } = useGlobalScore()
  return (
    <table style={tableStyle}>
      <thead>
        <tr>
          <th style={thStyle}>あなた</th>
          <th style={thStyle}>コンピュター</th>
          <th style={thStyle}>勝敗</th>
        </tr>
      </thead>
      <tbody>
        {scores.map((score, ix) =>
          <tr key={ix}>
            <td style={tdStyle}>{teString[score.human]}</td>
            <td style={tdStyle}>{teString[score.computer]}</td>
            <td style={tdStyle}>{judgmentString[score.judgment]}</td>
          </tr>
        )}
      </tbody>
    </table>
  )
}

export default ScoreBox
  • index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import Jyanken from './Jyanken'
import { RecoilRoot } from 'recoil'

ReactDOM.render(
  <React.StrictMode>
    <RecoilRoot>
      <Jyanken />
    </RecoilRoot>
  </React.StrictMode>,
  document.getElementById('root')
)

MobX

  • JyankenContext.tsx
import React, { createContext } from 'react'
import { useLocalObservable } from 'mobx-react-lite'

export enum Jjudgment { Draw = 0, Win, Lose }
export enum Te { Guu = 0, Choki, Paa}

export type ScoreType = {
  human: number,
  computer: number,
  judgment: Jjudgment
}

export type JyankenType = {
  scores: ScoreType[]
  pon: (human: Te) => void
}

export const JyankenContext = createContext({} as JyankenType)

export const JyankenProvider: React.FC = ({ children }) => {

  const store = useLocalObservable<JyankenType>(() => ({
    scores: [],
    pon(human: Te) {
      const computer:Te = Math.floor(Math.random() * 3)
      const judgment:Jjudgment = (computer - human + 3) % 3
      const score = {human: human, computer: computer, judgment: judgment}
      store.scores = [...store.scores, score]
    }
  }))

  return <JyankenContext.Provider value={store}>{children}</JyankenContext.Provider>
}
  • Jyanken.tsx
import React from 'react'
import JyankenBox from './JyankenBox'
import ScoreBox  from './ScoreBox'
import { JyankenProvider } from './JyankenContext'

const Jyanken = () => {
  return (
    <JyankenProvider>
      <h1>じゃんけん ポン!</h1>
      <JyankenBox />
      <ScoreBox />
    </JyankenProvider>
   )
}

export default Jyanken
  • JyankenBox.tsx
import React, { useContext } from 'react'
import { JyankenContext, Te } from './JyankenContext'

const JyankenBox: React.FC = () => {
  const divStyle: React.CSSProperties = {margin: "0 20px"}
  const buttonStyle: React.CSSProperties = {margin: "0 10px", padding: "3px 10px", fontSize: 14}

  const store = useContext(JyankenContext)
  return (
    <div style={divStyle}>
      <button onClick={() => store.pon(Te.Guu)} style={buttonStyle}>グー</button>
      <button onClick={() => store.pon(Te.Choki)} style={buttonStyle}>チョキ</button>
      <button onClick={() => store.pon(Te.Paa)} style={buttonStyle}>パー</button>
    </div>
  )
}

export default JyankenBox
  • ScoreBox.tsx
import React, { useContext } from 'react'
import { observer } from 'mobx-react-lite'
import { JyankenContext } from './JyankenContext'

const ScoreBox: React.FC = observer(() => {
  const teString = ["グー","チョキ", "パー"]
  const judgmentString = ["引き分け","勝ち", "負け"]

  const tableStyle: React.CSSProperties = {marginTop: 20, borderCollapse: "collapse"}
  const thStyle: React.CSSProperties = {border: "solid 1px #888", padding: "3px 15px"}
  const tdStyle: React.CSSProperties = {border: "solid 1px #888", padding: "3px 15px", textAlign : "center"}

  const scores = useContext(JyankenContext).scores
  return (
    <table style={tableStyle}>
      <thead>
        <tr>
          <th style={thStyle}>あなた</th>
          <th style={thStyle}>コンピュター</th>
          <th style={thStyle}>勝敗</th>
        </tr>
      </thead>
      <tbody>
        {scores.map((score, ix) =>
          <tr key={ix}>
            <td style={tdStyle}>{teString[score.human]}</td>
            <td style={tdStyle}>{teString[score.computer]}</td>
            <td style={tdStyle}>{judgmentString[score.judgment]}</td>
          </tr>
        )}
      </tbody>
    </table>
  )
})

export default ScoreBox
  • index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import Jyanken from './Jyanken'

ReactDOM.render(
  <React.StrictMode>
    <Jyanken />
  </React.StrictMode>,
  document.getElementById('root')
)

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ