Make it to make it

いろいろ作ってアウトプットするブログ

学びの記録『りあクト!』第11章 Redux でグローバルな状態を扱う

github.com

useSelector, useDispatchを用いたReduxの書き方

ディレクトリ構造

container, presentationalディレクトリで分けて、atomic design風に分ける。

.
├── App.css
├── App.tsx
├── components
│   ├── container
│   │   ├── molecules
│   │   │   └── ColorfulBeads.tsx
│   │   └── organisms
│   │       └── CounterBoard.tsx
│   └── presentational
│       ├── molecules
│       │   ├── ColorfulBeads.css
│       │   └── ColorfulBeads.tsx
│       └── organisms
│           ├── CounterBoard.css
│           └── CounterBoard.tsx
├── index.tsx
└── redux
    ├── actions.ts
    └── reducer.ts

コード

index.tsx

import React from 'react'
import ReactDOM from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'

import { counterReducer, initialState } from './redux/reducer'
import { App } from './App'
import 'semantic-ui-css/semantic.css'

const store = createStore(counterReducer, initialState)

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

App.tsx

import React from 'react'

import { EnhancedCounterBoard as CounterBoard } from './components/container/organisms/CounterBoard'
import { EnhancedColorfulBeads as ColorfulBeads } from './components/container/molecules/ColorfulBeads'

import './App.css'

export const App: React.FC = () => (
  <div className="container">
    <header>
      <h1>Beads counter</h1>
    </header>
    <CounterBoard />
    <ColorfulBeads count={0} />
  </div>
)

App.tsxにcontainerコンポーネントCounterBoard.tsx, ColorfulBeads.tsxを読み込む。

src/components/container/organisms/CounterBoard.tsx

import React from 'react'
import { useSelector, useDispatch } from 'react-redux'

import { ICounterState } from '../../../redux/reducer'
import { decrement, increment, add } from '../../../redux/actions'
import { CounterBoard } from '../../presentational/organisms/CounterBoard'

export const EnhancedCounterBoard: React.FC = () => {
  const count = useSelector<ICounterState, number>((state) => state.count)
  const dispatch = useDispatch()

  return (
    <CounterBoard
      count={count}
      decrement={() => dispatch(decrement())}
      increment={() => dispatch(increment())}
      add={(amount) => dispatch(add(amount))}
    />
  )
}

src/components/container/molecules/ColorfulBeads.tsx

import React from 'react'
import { useSelector } from 'react-redux'

import { ICounterState } from '../../../redux/reducer'
import { ColorfulBeads } from '../../presentational/molecules/ColorfulBeads'

export const EnhancedColorfulBeads: React.FC<{ count: number }> = () => {
  const count = useSelector<ICounterState, number>((state) => state.count)

  return <ColorfulBeads count={count} />
}

containerコンポーネント側でuseSelector, useDispatchを利用してRedux storeとdispatcherとのやり取りをしている。

Redux actions, reducerは以下のようになっている。

src/redux/actions.ts

export enum CounterActionType {
  ADD = 'counter/ADD',
  DECREMENT = 'counter/DECREMENT',
  INCREMENT = 'counter/INCREMENT',
}

export interface ICounterActionDecrement {
  type: CounterActionType.DECREMENT
}

export interface ICounterActionIncrement {
  type: CounterActionType.INCREMENT
}

export interface ICounterActionAdd {
  type: CounterActionType.ADD
  amount: number
}

export const decrement = (): ICounterActionDecrement => ({
  type: CounterActionType.DECREMENT,
})

export const increment = (): ICounterActionIncrement => ({
  type: CounterActionType.INCREMENT,
})

export const add = (amount: number): ICounterActionAdd => ({
  type: CounterActionType.ADD,
  amount,
})

export type CounterActions =
  | ReturnType<typeof decrement>
  | ReturnType<typeof increment>
  | ReturnType<typeof add>

src/redux/reducer.ts

import { Reducer } from 'redux'
import { CounterActions, CounterActionType as Type } from './actions'

export interface ICounterState {
  count: number
}

export const initialState: ICounterState = { count: 0 }

export const counterReducer: Reducer<ICounterState, CounterActions> = (
  state: ICounterState = initialState,
  action: CounterActions,
): ICounterState => {
  switch (action.type) {
    case Type.ADD:
      return {
        ...state,
        count: state.count + action.amount,
      }
    case Type.DECREMENT:
      return {
        ...state,
        count: state.count - 1,
      }
    case Type.INCREMENT:
      return {
        ...state,
        count: state.count + 1,
      }
    default: {
      const _: never = action

      return state
    }
  }
}

presentationalコンポーネント

src/components/presentational/organisms/CounterBoard.tsx

import React from 'react'
import { Card, Statistic, Button } from 'semantic-ui-react'

const BULK_UNIT = 10

interface ICounterBoardProps {
  count: number
  decrement: () => void
  increment: () => void
  add: (amount: number) => void
}

export const CounterBoard: React.FC<ICounterBoardProps> = ({
  count = 0,
  decrement,
  increment,
  add,
}) => (
  <Card>
    <Statistic className="number-board">
      <Statistic.Label>count</Statistic.Label>
      <Statistic.Value>{count}</Statistic.Value>
    </Statistic>
    <Card.Content>
      <div className="ui two buttons">
        <Button color="red" onClick={decrement}>
          -1
        </Button>
        <Button color="green" onClick={increment}>
          +1
        </Button>
      </div>
      <div className="fluid-button">
        <Button fluid color="grey" onClick={() => add(BULK_UNIT)}>
          +{BULK_UNIT}
        </Button>
      </div>
    </Card.Content>
  </Card>
)

src/components/presentational/molecules/ColorfulBeads.tsx

import React from 'react'
import { SemanticCOLORS, Container, Label } from 'semantic-ui-react'

import './ColorfulBeads.css'

// equivalent to https://lodash.com/docs/#range
// see also https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/from
const range = (n: number): number[] =>
  n < 0 ? [] : Array.from(Array(n), (_, i) => i)

const colors: SemanticCOLORS[] = [
  'red',
  'orange',
  'yellow',
  'olive',
  'green',
  'teal',
  'blue',
  'violet',
  'purple',
  'pink',
  'brown',
  'grey',
  'black',
]

export const ColorfulBeads: React.FC<{ count: number }> = ({ count = 0 }) => (
  <Container className="beads-box">
    {range(count).map((n) => (
      <Label circular color={colors[n % colors.length]} key={n} />
    ))}
  </Container>
)

Redux toolkitの導入

Redux toolkitを用いれば、DUCKSパターンを用いたシンプルなRedux構造を考えることができる。

また、Redux toolkit提供メソッドの一つであるcreateSliceを用いれば、

  • initial state
  • reducer
  • action types
  • actions

などをまとめて一つのファイル内に記述できる。

ディレクトリ構造

.
├── App.css
├── App.tsx
├── components
│   ├── container
│   │   ├── molecules
│   │   │   └── ColorfulBeads.tsx
│   │   └── organisms
│   │       └── CounterBoard.tsx
│   └── presentational
│       ├── molecules
│       │   ├── ColorfulBeads.css
│       │   └── ColorfulBeads.tsx
│       └── organisms
│           ├── CounterBoard.css
│           └── CounterBoard.tsx
├── features
│   └── counter.ts
└── index.tsx

コード

src/index.tsx

import React from 'react'
import ReactDOM from 'react-dom'
import { configureStore } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'

import { counterSlice } from './features/counter'
import { App } from './App'
import 'semantic-ui-css/semantic.css'

const store = configureStore({ reducer: counterSlice.reducer })

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

src/App.tsx

import React from 'react'

import { EnhancedColorfulBeads as ColorfulBeads } from './components/container/molecules/ColorfulBeads'
import { EnhancedCounterBoard as CounterBoard } from './components/container/organisms/CounterBoard'

import './App.css'

export const App: React.FC = () => (
  <div className="container">
    <header>
      <h1>Beads counter</h1>
    </header>
    <CounterBoard />
    <ColorfulBeads count={0} />
  </div>
)

src/features/counter.ts

counterに関連するreduxコードがこちらのファイルに全てまとまる。

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

export interface ICounterSlice {
  count: number
}

const initialState: ICounterSlice = { count: 0 }

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    decremented: (state) => ({ ...state, count: state.count - 1 }),
    incremented: (state) => ({ ...state, count: state.count + 1 }),
    added: (state, action: PayloadAction<number>) => ({
      ...state,
      count: state.count + action.payload,
    }),
  },
})

src/components/container/organisms/CounterBoard.tsx

import React from 'react'
import { useDispatch, useSelector } from 'react-redux'

import { ICounterSlice, counterSlice } from '../../../features/counter'
import { CounterBoard } from '../../presentational/organisms/CounterBoard'

export const EnhancedCounterBoard: React.FC = () => {
  const count = useSelector<ICounterSlice, number>((state) => state.count)
  const dispatch = useDispatch()
  const { decremented, incremented, added } = counterSlice.actions

  return (
    <CounterBoard
      count={count}
      decrement={() => dispatch(decremented())}
      increment={() => dispatch(incremented())}
      add={(amount) => dispatch(added(amount))}
    />
  )
}

src/components/container/molecules/ColorfulBeads.tsx

import React from 'react'
import { useSelector } from 'react-redux'

import { ColorfulBeads } from '../../presentational/molecules/ColorfulBeads'
import { ICounterSlice } from '../../../features/counter'

export const EnhancedColorfulBeads: React.FC<{ count: number }> = () => {
  const count = useSelector<ICounterSlice, number>((state) => state.count)

  return <ColorfulBeads count={count} />
}