React + ReduxToolkit + TypeScript の構築
ReduxToolkitの使い方を焦点に、簡易なTodoアプリケーションを開発。
アプリケーションのデザインはMaterial-UIを使用。
完成形のプロジェクト
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