BETA

React + TypeScriptでTODOアプリ (with React Hooks)

投稿日:2020-01-04
最終更新:2020-01-04

概要

React + TypeScriptでTODOアプリを作る。
ベースはこれ
https://redux.js.org/basics/example/
だが、Reduxは使わない。
またHooksを使いfunctional componentで書く。
TODOアプリはあらゆるチュートリアルや記事で作られ、もう飽和状態かもしれない。しかしTS化されていなかったり、class componentで書かれていたりと、条件を絞るとまだ書く余地もあるのではないかと言うことで書く。

コード

// based on https://redux.js.org/basics/example/  
import * as React from 'react'  
import * as ReactDOM from 'react-dom'  
import shortid from 'shortid'  

enum VisibilityFilters {  
  SHOW_ALL,  
  SHOW_COMPLETED,  
  SHOW_ACTIVE,  
}  

interface Todo {  
  id: string  
  text: string  
  completed: boolean  
}  

interface State {  
  todos: Todo[]  
  filter: VisibilityFilters  
}  

const initialState: State = {  
  todos: [],  
  filter: VisibilityFilters.SHOW_ALL,  
}  

const addTodo = (text: string) =>  
  ({  
    type: 'ADD_TODO',  
    id: shortid.generate(),  
    text,  
  } as const)  

const setVisibilityFilter = (filter: VisibilityFilters) =>  
  ({  
    type: 'SET_VISIBILITY_FILTER',  
    filter,  
  } as const)  

const toggleTodo = (id: string) =>  
  ({  
    type: 'TOGGLE_TODO',  
    id,  
  } as const)  

type Action =  
  | ReturnType<typeof addTodo>  
  | ReturnType<typeof setVisibilityFilter>  
  | ReturnType<typeof toggleTodo>  

const reducer = (state: State = initialState, action: Action) => {  
  switch (action.type) {  
    case 'ADD_TODO':  
      return {  
        ...state,  
        todos: [  
          ...state.todos,  
          {  
            id: action.id,  
            text: action.text,  
            completed: false,  
          },  
        ],  
      }  
    case 'TOGGLE_TODO':  
      return {  
        ...state,  
        todos: state.todos.map(todo =>  
          todo.id === action.id  
            ? {  
                ...todo,  
                completed: !todo.completed,  
              }  
            : todo,  
        ),  
      }  
    case 'SET_VISIBILITY_FILTER':  
      return {  
        ...state,  
        filter: action.filter,  
      }  
    default:  
      {  
        // eslint-disable-next-line @typescript-eslint/no-unused-vars  
        const _: never = action  
      }  

      return state  
  }  
}  

const AppContext = React.createContext<{  
  state: State  
  dispatch: (action: Action) => void  
}>({  
  state: initialState,  
  // eslint-disable-next-line @typescript-eslint/no-empty-function  
  dispatch: () => {},  
})  

const AddTodo = () => {  
  const { dispatch } = React.useContext(AppContext)  
  const [input, setInput] = React.useState('')  

  return (  
    <form  
      onSubmit={e => {  
        e.preventDefault()  
        if (!input.trim()) {  
          return  
        }  
        dispatch(addTodo(input))  
        setInput('')  
      }}  
    >  
      <input  
        type="text"  
        name="todo"  
        value={input}  
        onChange={e => {  
          setInput(e.target.value || '')  
        }}  
      />  
      <button type="submit">Add Todo</button>  
    </form>  
  )  
}  

const getVisibleTodos = (todos: Todo[], filter: VisibilityFilters) => {  
  switch (filter) {  
    case VisibilityFilters.SHOW_ALL:  
      return todos  
    case VisibilityFilters.SHOW_COMPLETED:  
      return todos.filter(t => t.completed)  
    case VisibilityFilters.SHOW_ACTIVE:  
      return todos.filter(t => !t.completed)  
    default:  
      throw new Error('Unknown filter: ' + filter)  
  }  
}  

const VisibleTodoList = () => {  
  const { state, dispatch } = React.useContext(AppContext)  

  return (  
    <ul>  
      {getVisibleTodos(state.todos, state.filter).map(todo => (  
        <li  
          key={todo.id}  
          onClick={() => dispatch(toggleTodo(todo.id))}  
          style={{  
            textDecoration: todo.completed ? 'line-through' : 'none',  
          }}  
        >  
          {todo.text}  
        </li>  
      ))}  
    </ul>  
  )  
}  

interface FilterLinkProps {  
  filter: VisibilityFilters  
}  

const FilterLink: React.FC<FilterLinkProps> = ({ filter, children }) => {  
  const { state, dispatch } = React.useContext(AppContext)  

  return (  
    <button  
      onClick={() => dispatch(setVisibilityFilter(filter))}  
      disabled={filter === state.filter}  
      style={{  
        marginLeft: '4px',  
      }}  
    >  
      {children}  
    </button>  
  )  
}  

const Footer = () => (  
  <>  
    <span>Show: </span>  
    <FilterLink filter={VisibilityFilters.SHOW_ALL}>All</FilterLink>  
    <FilterLink filter={VisibilityFilters.SHOW_ACTIVE}>Active</FilterLink>  
    <FilterLink filter={VisibilityFilters.SHOW_COMPLETED}>Completed</FilterLink>  
  </>  
)  

const App = () => {  
  const [state, dispatch] = React.useReducer(reducer, initialState)  

  return (  
    <AppContext.Provider value={{ state, dispatch }}>  
      <AddTodo />  
      <VisibleTodoList />  
      <Footer />  
    </AppContext.Provider>  
  )  
}  

ReactDOM.render(<App />, document.getElementById('root'))  

解説

基本は元になったサンプルと一緒。ただしcontainer/componentで分けられていたのを簡単のためまとめてしまった(小規模アプリだし)。
Reduxを使用していないため、useContext + useReducerで代用している。Contextでstateとdispatchを持ち、各componentでuseContextでそれを引っ張ってくる。
TypeScriptなので、とりあえずReact.createContext()とかReact.createContext(null)でContextを作成することができない。そうすると初期値をどうすれば良いのかという問題がある。今回はstateをinitialStateに、dispatchをempty functionにすることでしのいだが、型が複雑なものを持たせたいときに困ることがある。自分はPartialで誤魔化すくらいしか解決策を知らない…

おわりに

あけましておめでとうございます。

技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
駆け出しエンジニアからエキスパートまで全ての方々のアウトプットを歓迎しております!
or 外部アカウントで 登録 / ログイン する
クランチについてもっと詳しく

この記事が掲載されているブログ

@Catminusminusの技術ブログ Frontend/Machine Learning/C++など、興味のあるものについて、小ネタや調査中のこと、備忘録を書いたりします

よく一緒に読まれる記事

0件のコメント

ブログ開設 or ログイン してコメントを送ってみよう