以前、Redux libraryは今後どうなるの? を書きましたが、今年5月にRecoilというステート管理ライブラリーが現れました。これはFacebookの実験的(Experimental)なプロジェクトでまだ正式なものではありませんが、React用のステート管理に新たなプレイヤーがあらわれた事は確かです。
2020年10月7日更新: MobXのサンプルコードも追加しました。
2020年のReact用ステート管理方法
現在、React用のステート管理方法には以下のものがあります
- ReactのuseStateのみを使う
- 元祖Redux
- Redux Toolkit
- ReactのuseReducerとContextを使う
- MobX
- Recoil
サンプルコードと解説
ここでは、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')
)