React + ReduxToolkit + TypeScript の構築

6 min read読了の目安(約6200字

ReduxToolkitの使い方を焦点に、簡易なTodoアプリケーションを開発。
アプリケーションのデザインはMaterial-UIを使用。

完成形のプロジェクト

https://github.com/asano-yuki/todo-react-reduxtoolkit

1. バージョン情報

$ node -v
v12.15.0
$ yarn -v
1.22.4
$ npx create-react-app --version
3.4.1

2. 開発環境の構築

npx create-react-app [プロジェクト名] --template typescript

3. ファイル構成

srcディレクトリ配下で、必要な部分のみ抜粋。

src
├── components
  ├── Form
        ├── index.tsx  
  ├── Table
        ├── index.tsx    
├── features
    ├── taskSlice
├── store
    ├── index.ts
├── App.tsx

4. Storeの実装

store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import taskReducer, { Tasks } from '../features/taskSlice'

export interface Store {
  tasks: Tasks
}

export default configureStore<Store>({
  reducer: {
    tasks: taskReducer,
  },
})

5. taskSliceでActionとDispatcherを実装

ReduxToolkitの大きな恩恵は以下の2点

  • Action の自動生成
  • Immutable Update はReduxTookitの内部処理で解決

※TypeScriptの型付けは PayloadAction の使い方が肝となる。
※非同期処理は別関数(addTaskAsync)にして、標準で実装されている redux-thunk で呼び出す。

features/taskSlice.ts
import { createSlice, nanoid, Dispatch, PayloadAction } from '@reduxjs/toolkit'

export interface Task {
  id: string
  title: string
  status: string
  deleted: boolean
}

export type Tasks = Task[]

export const taskSlice = createSlice({
  name: 'task',
  initialState: [
    {
      id: nanoid(),
      title: 'タスク名',
      status: '未対応',
      deleted: false  
    }    
  ],
  reducers: {
    addTask: {
      reducer: (state: Tasks, action: PayloadAction<Task>) => {
        state.push(action.payload)
      },
      prepare: (title: Task['title'], status: Task['status']) => {
        return {
          payload: {
            id: nanoid(),
            title,
            status,
            deleted: false,
          },
        }
      },
    },
    deleteTask: (state: Tasks, action: PayloadAction<{ id: Task['id'] }>) => {
      const id = action.payload.id
      const index = state.findIndex((data) => data.id === id)
      state[index].deleted = true
    },
  },
})

export const { addTask, deleteTask } = taskSlice.actions

export const addTaskAsync = (
  title: Task['title'], 
  status: Task['status']
) => (
  dispatch: Dispatch<PayloadAction<Task>>
) => {
  setTimeout(() => {
    dispatch(addTask(title, status))
  }, 2000)
}

export default taskSlice.reducer

6. UIコンポーネントと連携

addTask・addTaskAsync アクションの発行

components/Form/index.tsx
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'
import TextField from '@material-ui/core/TextField'
import MenuItem from '@material-ui/core/MenuItem'
import Button from '@material-ui/core/Button'
import { addTask, addTaskAsync } from 'features/taskSlice'

const Form: React.FC = () => {
  const [state, setState] = useState({
    title: '',
    status: '未対応',
  })

  const dispatch = useDispatch()

  return (
    <form noValidate autoComplete="off">
      <TextField
        label="Todo"
        value={state.title}
        style={{ width: 200, marginRight: 30 }}
        onChange={(e) => {
          const title = e?.target.value || ''
          setState({ ...state, title })
        }}
      />
      <TextField
        select
        label="ステータス"
        value={state.status}
        style={{ width: 100, marginRight: 30 }}
        onChange={(e) => {
          const status = e?.target.value || ''
          setState({ ...state, status })
        }}
      >
        {['未対応', '処理中', '完了'].map((item, i) => (
          <MenuItem key={i} value={item}>
            {item}
          </MenuItem>
        ))}
      </TextField>
      <Button
        variant="contained"
        color="primary"
        style={{ marginRight: 15 }}
        onClick={() => dispatch(addTask(state.title, state.status))}
      >
        同期登録
      </Button>
      <Button
        variant="contained"
        color="primary"
        onClick={() => dispatch(addTaskAsync(state.title, state.status))}
      >
        非同期登録
      </Button>
    </form>
  )
}

export default Form

deleteTask アクションの発行

components/Table/index.tsx
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import Paper from '@material-ui/core/Paper'
import MaterialTable from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableHead from '@material-ui/core/TableHead'
import TableRow from '@material-ui/core/TableRow'
import IconButton from '@material-ui/core/IconButton'
import DeleteIcon from '@material-ui/icons/Delete'
import { Tasks } from 'features/taskSlice'
import { Store } from 'store'
import { deleteTask } from 'features/taskSlice'

const Table: React.FC = () => {
  const tasks = useSelector<Store, Tasks>((state) =>
    state.tasks.filter((task) => !task.deleted)
  )

  const dispatch = useDispatch()

  return (
    <Paper>
      <TableContainer>
        <MaterialTable>
          <TableHead>
            <TableRow>
              <TableCell width={50}></TableCell>
              <TableCell width={50} align="left">
                No.
              </TableCell>
              <TableCell width={120} align="left">
                ステータス
              </TableCell>
              <TableCell align="left">Todo</TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            {tasks.map((task, i) => (
              <TableRow key={task.id}>
                <TableCell>
                  <IconButton
                    onClick={() => {
                      dispatch(deleteTask({ id: task.id }))
                    }}
                  >
                    <DeleteIcon fontSize="small" />
                  </IconButton>
                </TableCell>
                <TableCell align="left">{i + 1}</TableCell>
                <TableCell align="left">{task.status}</TableCell>
                <TableCell align="left">{task.title}</TableCell>
              </TableRow>
            ))}
          </TableBody>
        </MaterialTable>
      </TableContainer>
    </Paper>
  )
}

export default Table