🌐

React(クラスコンポーネント) + Redux + TypeScript の構築

12 min read

クラスコンポーネントの備忘録として、CRUD操作を実装したTodoアプリケーションを開発。
主な技術スタックは以下の通り。

  • React(クラスコンポーネント)
  • Redux
  • Redux-Thunk
  • Material-UI
  • Prism(Stoplight)

完成形のプロジェクト

https://github.com/asano-yuki/class-redux-thunk

1. バージョン情報

$ node -v
v14.16.0
$ yarn -v
1.22.10
$ npx create-react-app --version
4.0.3

2. 開発環境の構築

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

3. ファイル構成

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

src
├── components
  ├── EditTodo
	├── index.tsx
  ├── InputTodo
        ├── index.tsx    
  ├── NewTodo
        ├── index.tsx
  ├── TodoList
        ├── index.tsx  
├── redux
   ├── actions
        ├── actionTypes.ts   
        ├── todos.ts     
   ├── reducers
        ├── index.ts   
        ├── todos.ts
   ├── store
        ├── index.ts   
├── App.tsx
├── index.tsx

4. Storeの実装

redux/store/index.ts
import { createStore, applyMiddleware } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import thunk from 'redux-thunk'
import reducers from '../reducers'

export default createStore(
  reducers,
  composeWithDevTools(applyMiddleware(thunk))
)

5. Actionの実装

redux/actions/actionTypes.ts
export default {
  CREATE_TODO : 'CREATE_TODO',
  READ_TODO   : 'READ_TODO',
  UPDATE_TODO : 'UPDATE_TODO',
  DELETE_TODO : 'DELETE_TODO'
} as const
redux/actions/todos.ts
import axios from 'axios'
import { ThunkDispatch } from 'redux-thunk'
import types from './actionTypes'

const SERVICE_PATH = 'http://127.0.0.1:4010'

export type DataType = {
  id         : number,
  title      : string,
  is_deleted : boolean
}

export type CreateTodo = {
  type : typeof types.CREATE_TODO
  data : DataType
}

export type ReadTodos = {
  type : typeof types.READ_TODO
  data : DataType[]
}

export type UpdateTodo = {
  type : typeof types.UPDATE_TODO
  data : { id: DataType['id'], title: DataType['title'] }
}

export type DeleteTodo = {
  type : typeof types.DELETE_TODO
  data : { id: DataType['id'] }
}

export type Actions = CreateTodo | ReadTodos | UpdateTodo | DeleteTodo

export const createTodo = (data: DataType) => async (dispatch: ThunkDispatch<any, any, CreateTodo>) => {
  await axios.post(`${SERVICE_PATH}/todos`, data)
  dispatch({ type: types.CREATE_TODO, data })
}

export const readTodos = () => async (dispatch: ThunkDispatch<any, any, ReadTodos>) => {
  const res = await axios(`${SERVICE_PATH}/todos`)
  dispatch({ type: types.READ_TODO, data: res.data })
}

export const updateTodo = (data: UpdateTodo['data']) => async (dispatch: ThunkDispatch<any, any, UpdateTodo>) => {
  await axios.put(`${SERVICE_PATH}/todos/${data.id}`, data)
  dispatch({ type: types.UPDATE_TODO, data })
}

export const deleteTodo = (data: DeleteTodo['data']) => async (dispatch: ThunkDispatch<any, any, DeleteTodo>) => {
  await axios.delete(`${SERVICE_PATH}/todos/${data.id}`)
  dispatch({ type: types.DELETE_TODO, data })
}

6. Reducerの実装

redux/reducers/index.ts
import { combineReducers } from 'redux'
import todos from './todos'
import { DataType } from '../actions/todos'

export type State = {
  todos : DataType[]
}

export const initialState: State = {
  todos : []
}

export default combineReducers({ todos })
redux/reducers/todos.ts
import types from '../actions/actionTypes'
import { Actions, DataType } from '../actions/todos'
import { State, initialState } from './index'

const reducer = (state: State = initialState, action: Actions) => {
  switch (action.type) {
    case types.CREATE_TODO : {
      const todos = [ ...state.todos, action.data ]
      return { ...state, todos }
    }
    case types.READ_TODO : {
      return { ...state, todos: action.data }
    }
    case types.UPDATE_TODO : {
      const { id, title } = action.data
      const { todos, index } = getTargetTodoInfo(state, id)
      todos[index].title = title
      return { ...state, todos }
    }
    case types.DELETE_TODO : {
      const { id } = action.data
      const { todos, index } = getTargetTodoInfo(state, id)
      todos[index].is_deleted = true
      return { ...state, todos }
    }
    default: {
      return state
    }
  }
}

export default reducer

function getTargetTodoInfo (state: State, id: number) {
  const todos: DataType[] = JSON.parse(JSON.stringify(state.todos))
  const index = todos.findIndex(todo => todo.id === id)
  return { todos, index }
}

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

  • TodoList
    Todoを一覧表示するコンポーネント。
  • InputTodo
    NewTodo、EditTodoの親コンポーネント。
  • NewTodo
    新規にTodoを登録するためのコンポーネント。InputTodoを委譲。
  • EditTodo
    既存のTodoを更新するためのコンポーネント。InputTodoを委譲。

TodoList

components/TodoList/index.tsx
import { Component } from 'react'
import { Link, RouteComponentProps } from 'react-router-dom'
import Table from '@material-ui/core/Table'
import TableHead from '@material-ui/core/TableHead'
import TableBody from '@material-ui/core/TableBody'
import TableRow from '@material-ui/core/TableRow'
import TableCell from '@material-ui/core/TableCell'
import Button from '@material-ui/core/Button'
import IconButton from '@material-ui/core/IconButton'
import EditIcon from '@material-ui/icons/Edit'
import DeleteIcon from '@material-ui/icons/Delete'
import { connect } from 'react-redux'
import { DataType, DeleteTodo, deleteTodo } from '../../redux/actions/todos'
import { State } from '../../redux/reducers'
import './index.css'

type Props = RouteComponentProps & {
  todos      : DataType[]
  deleteTodo : (data: DeleteTodo['data']) => void
}

class TodoList extends Component<Props> {
  render () {
    const { todos } = this.props
    const cn = 'todo-list'
    return (
      <div className={cn}>
        <Table className={`${cn}__table`}>
          <TableHead>
            <TableRow>
              <TableCell className={`${cn}__cell`}>ID</TableCell>
              <TableCell>Title</TableCell>
              <TableCell className={`${cn}__cell`}>Edit</TableCell>
              <TableCell className={`${cn}__cell`}>Delete</TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            {
              todos.map((todo, i) => {
                const { id, title, is_deleted } = todo
                if (is_deleted) return <></>
                return (
                  <TableRow key={i}>
                    <TableCell className={`${cn}__cell`}>{id}</TableCell>
                    <TableCell>{title}</TableCell>
                    <TableCell className={`${cn}__cell`}>
                      <IconButton onClick={() => this.props.history.push(`/edit/${id}`)}>
                        <EditIcon />
                      </IconButton>
                    </TableCell>
                    <TableCell className={`${cn}__cell`}>
                      <IconButton onClick={() => this.props.deleteTodo({ id })}>
                        <DeleteIcon />
                      </IconButton>
                    </TableCell>
                  </TableRow>
                )
              })
            }
          </TableBody>
        </Table>
        <Link to='/new' className={`${cn}__link`}>
          <Button variant='contained' color='primary'>新規作成</Button>
        </Link>
      </div>
    )
  }
}

const mapStateToProps = (state: State) => (state.todos)
const mapDispatchToProps = { deleteTodo }

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

InputTodo

components/InputTodo/index.tsx
import { Component, FormEvent, ChangeEvent } from 'react'
import { Link, RouteComponentProps } from 'react-router-dom'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
import { DataType } from '../../redux/actions/todos'
import './index.css'

type Props = RouteComponentProps & {
  title?  : string
  btnText : string
  submit  : (title: string) => void
  todos   : DataType[]
}

type FormState = {
  title : string
}

class InputTodo extends Component<Props, FormState> {
  constructor (props: Props) {
    super(props)
    this.state = { title: props.title || '' }
    this.handleSubmit = this.handleSubmit.bind(this)
    this.handleChange = this.handleChange.bind(this)
  }

  async handleSubmit (e: FormEvent) {
    e.preventDefault()
    const { submit, history } = this.props
    submit(this.state.title)
    history.push('/')
  }

  handleChange (e: ChangeEvent<HTMLInputElement>) {
    this.setState({ title: e.target.value })
  }

  render () {
    const cn = 'new-todo'
    return (
      <form onSubmit={this.handleSubmit}>
        <TextField
          label='Title'
          value={this.state.title}
          className={`${cn}__title`}
          required
          inputProps={{ maxLength: 30 }}
          onChange={this.handleChange}
        />
        <div className={`${cn}__btns`}>
          <Button type='submit' variant='contained' color='primary'>{this.props.btnText}</Button>
          <Link to='/' className={`${cn}__btn-back`}>
            <Button variant='contained'>戻る</Button>
          </Link>
        </div>
      </form>
    )
  }
}

export default InputTodo

NewTodo

components/NewTodo/index.tsx
import { Component } from 'react'
import { connect } from 'react-redux'
import { RouteComponentProps } from 'react-router-dom'
import { DataType, CreateTodo, createTodo } from '../../redux/actions/todos'
import { State } from '../../redux/reducers'
import InputTodo from '../InputTodo'

type Props = RouteComponentProps & {
  todos      : DataType[] 
  createTodo : (data: CreateTodo['data']) => void
}

class NewTodo extends Component<Props> {
  constructor (props: Props) {
    super(props)
    this.handleSubmit = this.handleSubmit.bind(this)
  }

  handleSubmit (title: string) {
    const props = this.props
    const id = props.todos.length + 1
    props.createTodo({ id, title, is_deleted: false })
  }

  render () {
    return <InputTodo { ...this.props } btnText='登録' submit={this.handleSubmit} />
  }
}

const mapStateToProps = (state: State) => (state.todos)
const mapDispatchToProps = { createTodo }

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(NewTodo)

EditTodo

components/EditTodo/index.tsx
import { Component } from 'react'
import { connect } from 'react-redux'
import { RouteComponentProps } from 'react-router-dom'
import { DataType, UpdateTodo, updateTodo } from '../../redux/actions/todos'
import { State } from '../../redux/reducers'
import InputTodo from '../InputTodo'

type Props = RouteComponentProps & {
  todos      : DataType[] 
  updateTodo : (data: UpdateTodo['data']) => void
}

class EditTodo extends Component<Props> {
  constructor (props: Props) {
    super(props)
    this.handleSubmit = this.handleSubmit.bind(this)
  }

  getId () {
    const param = window.location.pathname.split('/').pop()
    return Number(param)
  }

  handleSubmit (title: string) {
    const props = this.props
    const id = this.getId()
    props.updateTodo({ id, title })
  }

  render () {
    const id = this.getId()
    const todo = this.props.todos.find(todo => todo.id === id)
    if (!todo) {
      this.props.history.push('/')
      return <></>
    }
    return <InputTodo { ...this.props } title={todo.title} btnText='更新' submit={this.handleSubmit} />
  }
}

const mapStateToProps = (state: State) => (state.todos)
const mapDispatchToProps = { updateTodo }

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(EditTodo)

Discussion

ログインするとコメントできます