React Reduxのチュートリアル(Todoアプリ)をTypeScriptで書き換えてみた

JavaScript

2020年2月現在、React ReduxのBasic Tutorialで紹介されているサンプルアプリケーション(Todoアプリ)はJavaScriptで書かれています。

今回、サンプルアプリケーションのTypeScript版を作成したので紹介します。

なお、サンプルアプリケーションの概要を知っている前提で話を進めますので、概要を知らない方は公式ドキュメントとあわせて読んでいただければと思います。

react-reduxは7.1.16、reactは17.0.1、typescriptは3.8.3を利用しています。

Todoアプリの概要

今回作成するTodoアプリでできることは以下の通りです。

  • 「Add Todo」ボタンでTodoを追加できる
  • Todoをクリックするとステータスのトグル操作(completed/incomplete)ができる
  • all/completed/incompleteのステータスでTodoをフィルタリングできる

ディレクトリ構成は以下のようになっています。

.
├── TodoApp.tsx
├── components
│   ├── AddTodo.tsx
│   ├── Todo.tsx
│   ├── TodoList.tsx
│   └── VisibilityFilters.tsx
├── constants.ts
├── index.tsx
├── react-app-env.d.ts
├── redux
│   ├── actionTypes.ts
│   ├── actions.ts
│   ├── reducers
│   │   ├── index.ts
│   │   ├── todos.ts
│   │   └── visibilityFilter.ts
│   ├── selectors.ts
│   ├── store.ts
│   └── types.ts
└── styles.css

アプリケーションのひな型をローカル環境に準備する

create-react-appでreduxを利用する場合は、--templateオプションを利用します。
JavaScriptの場合はredux、TypeScriptの場合はredux-typescriptを指定します。

今回はTypeScriptで記述するのでredux-typescriptを指定します。

### create-react-appを利用してアプリケーションを作成
$ npx create-react-app redux-todo-ts --template redux-typescript
$ cd redux-todo-ts

### アプリケーションで必要になるnpmのインストール
$ yarn add classnames @types/classnames

### srcディレクトリのファイルを削除
$ cd redux-todo-app
$ rm -rf src/*

なお、create-react-appではRedux ToolKitのReduxが利用されています。
Redux ToolkitとはReduxをより簡潔に記述するためのツールです。Reduxの公式ドキュメントでも紹介されてます。1

モックの作成

TodoアプリのモックはCodeSandboxで公開されています。
今回は上記のJavaScriptのソースコードをコピーし、徐々にTypeScript化するというアプローチをとります。
TypeScriptの拡張子は.ts(コンポーネントを扱うファイルの拡張子は.tsx)となるので注意してください。

なお、モックをTypeScript化した最終的なソースコードはnishina555/react-redux-todoapp-mock-tsで公開しています。

本記事では一部ソースコードを省略している箇所もあるため、上記のリポジトリもあわせて参考にしてください。

定数ファイルにconst assertionを導入する

オブジェクトのプロパティをRead Onlyにできるconst assertionという機能がTypeScript 3.4から導入されているので利用してみましょう。
const assertionas constを末尾につけるだけで設定が完了します。

src/constants

export const VISIBILITY_FILTERS = {
  ALL: "all",
  COMPLETED: "completed",
  INCOMPLETE: "incomplete"
} as const;
// {
//   readonly ALL: "all";
//   readonly COMPLETED: "completed";
//   readonly INCOMPLETE: "incomplete";
// }

const assertionの利用した場合としない場合の比較は以下の通りです。

// const assertionを利用した場合
const x = { a: 1 } as const;
x.a = 3 // error

// const assertionを利用しない場合
const x = { a: 1 };
x.a = 3
console.log(x.a) // 3

Stateの型定義をする

アプリケーションのStateはTodoリストの内容を管理するtodosと、Todoをフィルタリングする際に利用するフィルターの状態を管理するvisibilityfilterで構成されています。

具体的には以下のような値がStateに保存されています。

{
  todos: {
    allIds: [
      1,
      2
    ],
    byIds: {
      '1': {
        content: 'do something',
        completed: false
      },
      '2': {
        content: 'go somewhere',
        completed: false
      }
    }
  },
  visibilityFilter: 'all'
}

上記のStateの型定義をします。

まず、todosの型定義です。
byIdskeyにTodoのidvalueに当該Todoの情報がセットされる連想配列です。
連想配列の型は{[key: インデックスの型]: 値の型}で定義します。

todosの型は以下の通りです。

export type TodoState = {
  allIds: Array<number>;
  byIds: { [key: string]: TodoItemState };
};

export type TodoItemState = {
  content: string;
  completed: boolean;
};

次に、visibilityfilterの型定義です。
visibilityfilterには"all", "completed", "incomplete"いずれかの値がセットされるunion型です。

定数オブジェクトを利用してunion型の定義をする場合、以下のようになります。

type VisibilityFilterTypes = typeof VISIBILITY_FILTERS[keyof typeof VISIBILITY_FILTERS]; 
// "all" | "completed" | "incomplete"

最終的なソースコードは以下の通りです。
今回は src/redux/types.tsというファイルを新しく作り、そこにStateの型定義をしました。

src/redux/types.ts

import { VISIBILITY_FILTERS } from "../constants";

// visibilityFilter
export type VisibilityFilterTypes = typeof VISIBILITY_FILTERS[keyof typeof VISIBILITY_FILTERS];

// todos
export type TodoState = {
  allIds: Array<number>;
  byIds: { [key: string]: TodoItemState };
};
export type TodoItemState = {
  content: string;
  completed: boolean;
};

// state
export type State = {
  visibilityFilter: VisibilityFilterTypes;
  todos: TodoState;
};

補足: 定数オブジェクトを利用した型定義の方法について

JavaScriptで書かれていたサンプルアプリケーションでは以下のように定数の定義がされていました。

export const VISIBILITY_FILTERS = {
  ALL: "all",
  COMPLETED: "completed",
  INCOMPLETE: "incomplete"
} as const;

今回は以下のようにしてunion型の定義をしました。

const VISIBILITY_FILTERS = {
  ALL: "all",
  COMPLETED: "completed",
  INCOMPLETE: "incomplete",
} as const;

type VisibilityFilterTypes = typeof VISIBILITY_FILTERS[keyof typeof VISIBILITY_FILTERS]; // "all" | "completed" | "incomplete"

// const filter: VisibilityFilterTypes = VISIBILITY_FILTERS.ALL
// console.log(filter)
// => "all"

type VisibilityFilterTypes = typeof VISIBILITY_FILTERS[keyof typeof VISIBILITY_FILTERS];の部分を分解すると以下のようになります。

type TypeOfVisibilityFilters = typeof VISIBILITY_FILTERS
// {
//   readonly ALL: "all";
//   readonly COMPLETED: "completed";
//   readonly INCOMPLETE: "incomplete";
// }

type Key = keyof TypeOfVisibilityFilters
// "ALL" | "COMPLETED" | "INCOMPLETE"

type VisibilityFilterTypes = TypeOfVisibilityFilters[Key]
// "all" | "completed" | "incomplete"

// よって以下のようになる
type VisibilityFilterTypes = typeof VISIBILITY_FILTERS[keyof typeof VISIBILITY_FILTERS];
// "all" | "completed" | "incomplete"

なお、TypeScriptではenum型で書くこともできます。

enum VISIBILITY_FILTERS {
  ALL = "all",
  COMPLETED = "completed",
  INCOMPLETE = "incomplete",
}
// const filter: VISIBILITY_FILTERS = VISIBILITY_FILTERS.ALL
// console.log(filter)
// => "all"

emun型ではなくunion型を使ったほうがよいという記事をいくつか見かけたため、今回はunion型で定義しました。

Selectorsの型定義

Stateの型定義ができたので、Stateの情報を抽出するSelectorsも型定義します。

src/redux/selectors.ts

import { State } from "./types";

export type TodoItem = {
  content: string;
  completed: boolean;
  id: number;
};

export const getTodoList = (store: State): Array<number> =>
  store && store.todos ? store.todos.allIds : [];
export const getTodoById = (store: State, id: number): TodoItem => {
  return { ...store.todos.byIds[id], id };
};

export const getTodos = (store: State): Array<TodoItem> =>
  getTodoList(store).map((id) => getTodoById(store, id));

Actionの型定義

定数として利用されているActionTypesをenumにします。

src/redux/actionTypes.ts

export enum ActionTypes {
  ADD_TODO = "ADD_TODO",
  TOGGLE_TODO = "TOGGLE_TODO",
  SET_FILTER = "SET_FILTER",
}

また、Actionごとに型を定義します。
たとえば、Todoを追加するaddTodoは以下のようになります。

src/redux/actions.ts

import { ActionTypes } from "./actionTypes";

let nextTodoId = 0;

type AddTodoAction = {
  type: ActionTypes.ADD_TODO;
  payload: {
    id: number;
    content: string;
  };
};

export const addTodo = (content: string): AddTodoAction => ({
  type: ActionTypes.ADD_TODO,
  payload: {
    id: ++nextTodoId,
    content,
  },
});

各Actionの型をまとめたunion型を作成します。

export type TodoActions = AddTodoAction | ToggleTodoAction | SetFilterAction;

actions.tsの最終的なコードは以下の通りです。

src/redux/actions.ts

import { ActionTypes } from "./actionTypes";
import { VisibilityFilterTypes } from "./types";

let nextTodoId = 0;

type AddTodoAction = {
  type: ActionTypes.ADD_TODO;
  payload: {
    id: number;
    content: string;
  };
};

export const addTodo = (content: string): AddTodoAction => ({
  type: ActionTypes.ADD_TODO,
  payload: {
    id: ++nextTodoId,
    content,
  },
});

type ToggleTodoAction = {
  type: ActionTypes.TOGGLE_TODO;
  payload: {
    id: number;
  };
};

export const toggleTodo = (id: number): ToggleTodoAction => ({
  type: ActionTypes.TOGGLE_TODO,
  payload: { id },
});

type SetFilterAction = {
  type: ActionTypes.SET_FILTER;
  payload: {
    filter: VisibilityFilterTypes;
  };
};

export const setFilter = (filter: VisibilityFilterTypes): SetFilterAction => ({
  type: ActionTypes.SET_FILTER,
  payload: { filter },
});

export type TodoActions = AddTodoAction | ToggleTodoAction | SetFilterAction;

Reducersの型定義

ここまで作成したStateやActionの型をReducerに適用します。

src/redux/reducers/visibilityFilters.ts

import { ActionTypes } from "../actionTypes";
import { VISIBILITY_FILTERS } from "../../constants";
import { VisibilityFilterTypes } from "../types";
import { TodoActions } from "../actions";

const initialState: VisibilityFilterTypes = VISIBILITY_FILTERS.ALL;

const visibilityFilter = (state = initialState, action: TodoActions) => {
  switch (action.type) {
    case ActionTypes.SET_FILTER: {
      return action.payload.filter;
    }
    default: {
      return state;
    }
  }
};

export default visibilityFilter;

src/redux/reducers/todos.ts

import { ActionTypes } from "../actionTypes";
import { TodoActions } from "../actions";
import { TodoState } from "../types";

const initialState: TodoState = {
  allIds: [],
  byIds: {},
};

const todos = (state = initialState, action: TodoActions) => {
  switch (action.type) {
    case ActionTypes.ADD_TODO: {
      const { id, content } = action.payload;
      return {
        ...state,
        allIds: [...state.allIds, id],
        byIds: {
          ...state.byIds,
          [id]: {
            content,
            completed: false,
          },
        },
      };
    }
    case ActionTypes.TOGGLE_TODO: {
      const { id } = action.payload;
      return {
        ...state,
        byIds: {
          ...state.byIds,
          [id]: {
            ...state.byIds[id],
            completed: !state.byIds[id].completed,
          },
        },
      };
    }
    default:
      return state;
  }
}

export default todos

コンポーネントのTypeScript化

コンポーネントで利用するProps, Stateのtypeを定義し、React.Component<Props, State>を継承します。

ReactチュートリアルをTypeScriptで書き換えてみた

2021年2月11日

たとえば、AddTodo.tsxは以下のようになります。

src/components/AddTodo.tsx

import React from "react";

type AddTodoState = {
  input: string;
};

class AddTodo extends React.Component<{}, AddTodoState> {
  constructor(props: {}) {
    super(props);
    this.state = { input: "" };
  }

  updateInput = (input: string) => {
    this.setState({ input });
  };

  handleAddTodo = () => {
    // dispatches actions to add todo
    // sets state back to empty string
  };

  render() {
    return (
      <div>
        <input
          onChange={(e) => this.updateInput(e.target.value)}
          value={this.state.input}
        />
        <button className="add-todo" onClick={this.handleAddTodo}>
          Add Todo
        </button>
      </div>
    );
  }
}

export default AddTodo;

他のコンポーネントについては説明を省略します。

起動の確認

モックのTypeScript化が完了したので動作の確認をします。
yarn startでTodoアプリが表示されればOKです。

Redux DevToolsの導入

開発をする過程でStateの状態を確認する必要があるためRedux DevToolsを導入します。

Redux DevTools Extensionの手順に従ってインストールします。

$ yarn add --dev redux-devtools-extension

src/store.ts

import { createStore } from "redux";
import rootReducer from "./reducers";

export default createStore(
  rootReducer,
  (window as any).__REDUX_DEVTOOLS_EXTENSION__ &&
    (window as any).__REDUX_DEVTOOLS_EXTENSION__()
);

ReactとReduxをつなぎこむ

公式ドキュメントで書かれている内容と同じ修正です。
<Provider store={store}>でコンポーネントを囲うことでReactとReduxの連携をします。

src/index.tsx

import React from "react";
import ReactDOM from "react-dom";

import TodoApp from "./TodoApp";
import { Provider } from 'react-redux'
import store from './redux/store'

const rootElement = document.getElementById("root");
ReactDOM.render(
  <Provider store={store}>
    <TodoApp />
  </Provider>,
  rootElement
);

前回と同じくTodoアプリが表示されればOKです。

コンポーネントとReduxをつなぎこむ

ReactとReduxのつなぎこみが完了したので、各コンポーネントがReduxと連携できるようにします。

コンポーネント内で、ReduxのStoreで管理されているStateを扱う場合や、ReduxのActionを実行(dispatch)したい場合はconnect関数を利用します。

StateとActionの利用有無によってconnect関数の記述方法は異なります。
まとめると以下の通りです。

Stateを利用する Stateを利用しない
Actionを実行しない connect()(Component) connect(mapStateToProps)(Component)
Actionを実行する connect(null,mapDispatchToProps)(Component) connect(mapStateToProps, mapDispatchToProps)(Component)

特にmapDispatchToPropsの部分は{アクションクリエイター名}のように記述をすることで、当該コンポーネント内のpropsオブジェクトとしてActionCreatorを利用できます。

コンポーネントからActionを実行(dispatch)できるようにする

Todoを追加するコンポーネント(AddTodo)で、実際に「Todoを追加する」というActionが実行できるようにします。
addTodoは「Todoを追加する」というActionを返すActionCreatorです。

src/components/AddTodo.tsx

import { connect } from 'react-redux'
import { addTodo } from '../redux/actions'

(略)

export default connect(
  null,
  { addTodo }
)(AddTodo)

{ addTodo }の部分をmapDispatchToPropsに書き換えると以下のようになります。
dispatch関数の型(Dispatch)はreduxからimportします。

import { connect } from 'react-redux'
import { addTodo } from '../redux/actions'
import { Dispatch } from "redux"

const mapDispatchToProps = (dispatch: Dispatch) => {
  return {
    addTodo: (input: string) => dispatch(addTodo(input))
  }
}
export default connect(
  null,
  mapDispatchToProps
)(AddTodo)

addTodoがAddTodoコンポーネントで利用できるようになったので、AddTodoPropsの型に追加し、React.Componentの引数およびconstructorのPropsの型として利用します。
ActionCreator(addTodo)はActionを返すので、戻り値はTodoActionsになります。

import { TodoActions } from '../redux/actions'

type AddTodoProps = {
  addTodo: (input: string) => TodoActions
}

type AddTodoState = {
  input: string;
};

class AddTodo extends React.Component<AddTodoProps, AddTodoState> {
  constructor(props: AddTodoProps) {
    super(props);
    this.state = { input: "" };
  }
  (略)

これで、AddTodoコンポーネントにおいてthis.props.addTodoという形でaddTodoが利用できます。

handleAddTodo = () => {
  this.props.addTodo(this.state.input)
  this.setState({ input: '' })
};

画面のボタンから追加したTodoの内容がRedux DevToolsで確認できればOKです。

もしNo store found. Make sure to follow the instructions.と表示され、Redux DevToolsが見られない場合はRedux DevToolsが正常にインストールできていないので、再度確認してみてください。

AddTodoコンポーネントの最終的なコードは以下の通りです。

src/components/AddTodo.tsx

import React from "react";
import { connect } from 'react-redux'
import { addTodo } from '../redux/actions'
import { TodoActions } from '../redux/actions'

type AddTodoProps = {
  addTodo: (input: string) => TodoActions
}

type AddTodoState = {
  input: string;
};

class AddTodo extends React.Component<AddTodoProps, AddTodoState> {
  constructor(props: AddTodoProps) {
    super(props);
    this.state = { input: "" };
  }

  updateInput = (input: string) => {
    this.setState({ input });
  };

  handleAddTodo = () => {
    this.props.addTodo(this.state.input)
    this.setState({ input: '' })
  };

  render() {
    return (
      <div>
        <input
          onChange={(e) => this.updateInput(e.target.value)}
          value={this.state.input}
        />
        <button className="add-todo" onClick={this.handleAddTodo}>
          Add Todo
        </button>
      </div>
    );
  }
}

export default connect(
  null,
  { addTodo }
)(AddTodo)

コンポーネントでStateを利用できるようにする

現在のStateからtodoオブジェクトを返すgetTodosメソッドを利用して、コンポーネントで表示するTodoを取得できるようにします。

src/components/TodoList.tsx

import { connect } from 'react-redux';
import { getTodos } from "../redux/selectors";
import { State } from "../redux/types"

export default connect((state: State) => ({ todos: getTodos(state) }))(TodoList);

(state: State) => ({ todos: getTodos(state) })の部分をmapStateToPropsに書き換えると以下のようになります。
mapStateToPropsで返された値はTodoListコンポーネントのpropsとして利用されるため返却値の型はTodoListPropsです。

const mapStateToProps = (state: State): TodoListProps => ({
  todos: getTodos(state)
})
export default connect(mapStateToProps)(TodoList);

Propsの型定義とReact.FCの引数の修正をします。

import { TodoItem } from "../redux/selectors";

type TodoListProps = {
  todos: Array<TodoItem>;
}

const TodoList: React.FC<TodoListProps> = ({ todos }) => {

追加したTodoが画面に表示されればOKです。

最終的なソースコードは以下の通りです。

src/components/TodoList.tsx

import React from "react";
import Todo from "./Todo";
import { connect } from "react-redux";
import { getTodos, TodoItem } from "../redux/selectors";
import { State } from "../redux/types"

type TodoListProps = {
  todos: Array<TodoItem>;
}

const TodoList = ({ todos }) => (
  <ul className="todo-list">
    {todos && todos.length
      ? todos.map((todo, index) => {
          return <Todo key={`todo-${todo.id}`} todo={todo} />;
        })
      : "No todos, yay!"}
  </ul>
);

export default connect( (state: State) => ({ todos: getTodos(state) }))(TodoList);

Todoのcompleted/incompleteが切替られるようにする

Todoのステータスをトグル操作するActionCreator(toggleTodo)をコンポーネントで利用できるようにします。

src/components/Todo.tsx

import { connect } from "react-redux";
import { toggleTodo } from "../redux/actions";

export default connect(
  null,
  { toggleTodo }
)(Todo);

Propsの型定義を修正します。

import { TodoActions } from '../redux/actions'

type TodoProps = {
  todo: TodoItemState;
  toggleTodo: (id: number) => TodoActions
};

const Todo: React.FC<TodoProps> = ({ todo, toggleTodo }) => (

onClickでActionCreatorを実行します。

onClick={() => toggleTodo(todo.id) }

Todoをクリックし、ステータスが変更できればOKです。

最終的なソースコードは以下の通りです。

src/components/Todo.tsx

import React from "react";
import cx from "classnames";
import { connect } from "react-redux";
import { toggleTodo } from "../redux/actions";

const Todo = ({ todo, toggleTodo }) => (
  <li
    className="todo-item"
    onClick={() => toggleTodo(todo.id) }
  >
    {todo && todo.completed ? "👌" : "👋"}{" "}
    <span
      className={cx(
        "todo-item__text",
        todo && todo.completed && "todo-item__text--completed"
      )}
    >
      {todo.content}
    </span>
  </li>
);

export default connect(
  null,
  { toggleTodo }
)(Todo);

ステータスに応じてTodoをフィルタリングできるようにする

フィルタリング対象のステータスを切り替えるsetFilterと、現在のフィルター情報を保持するvisibilityFilterをコンポーネントで利用できるようにします。

前項と同じく、connect関数を利用します。

最終的なソースコードは以下の通りです。

src/components/VisibilityFilters.tsx

import React from "react";
import cx from "classnames";
import { VISIBILITY_FILTERS } from "../constants";
import { connect } from "react-redux";
import { setFilter } from "../redux/actions";
import { State, VisibilityFilterTypes } from "../redux/types"
import { TodoActions } from '../redux/actions'

type VisibilityFiltersProps = {
  activeFilter: VisibilityFilterTypes;
  setFilter: (filter: VisibilityFilterTypes) => TodoActions
}

const VisibilityFilters: React.FC<VisibilityFiltersProps> = ({ activeFilter, setFilter }) => {
  return (
    <div className="visibility-filters">
      {(Object.keys(VISIBILITY_FILTERS) as Array<
        keyof typeof VISIBILITY_FILTERS
      >).map((filterKey) => {
        const currentFilter = VISIBILITY_FILTERS[filterKey];
        return (
          <span
            key={`visibility-filter-${currentFilter}`}
            className={cx(
              "filter",
              currentFilter === activeFilter && "filter--active"
            )}
            onClick={() => setFilter(currentFilter)}
          >
            {currentFilter}
          </span>
        );
      })}
    </div>
  );
};

const mapStateToProps = (state: State) => {
  return { activeFilter: state.visibilityFilter };
};

export default connect(
  mapStateToProps,
  { setFilter }
)(VisibilityFilters);

フィルターのリンク(all/completed/incomplete)をクリックし、Redux DevTools上でstateの変化が確認できればOKです。

フィルタリング結果を画面に反映させる

Selectorsに新しいメソッド(getTodosByVisibilityFilter)を追加します。

src/redux/selectors.ts

import { State, VisibilityFilterTypes } from './types';
import { VISIBILITY_FILTERS } from "../constants";

export const getTodosByVisibilityFilter = (store: State, visibilityFilter: VisibilityFilterTypes): TodoItem[] => {
  const allTodos = getTodos(store)
  switch (visibilityFilter) {
    case VISIBILITY_FILTERS.COMPLETED:
      return allTodos.filter(todo => todo.completed)
    case VISIBILITY_FILTERS.INCOMPLETE:
      return allTodos.filter(todo => !todo.completed)
    case VISIBILITY_FILTERS.ALL:
    default:
      return allTodos
  }
}

現在のフィルター情報を保持するvisibilityFilterと、先ほど作成したgetTodosByVisibilityFilterを利用してTodoのフィルタリングを行います。

最終的なコードは以下の通りです。

src/components/TodoList.tsx

import React from "react";
import Todo from "./Todo";
import { connect } from 'react-redux';
import { State } from "../redux/types"
import { getTodosByVisibilityFilter, TodoItem } from "../redux/selectors";

type TodoListProps = {
  todos: Array<TodoItem>;
}

const TodoList: React.FC<TodoListProps> = ({ todos }) => {
  return (
    <ul className="todo-list">
      {todos && todos.length
        ? todos.map((todo: any, index: any) => {
            return <Todo key={`todo-${todo.id}`} todo={todo} />;
          })
        : "No todos, yay!"}
    </ul>
  );
};

const mapStateToProps = (state: State): TodoListProps => {
  const { visibilityFilter } = state
  const todos  = getTodosByVisibilityFilter(state, visibilityFilter)
  return { todos }
}
export default connect(mapStateToProps)(TodoList);

フィルターのリンク(all/completed/incomplete)をクリックし、画面のTodoがフィルタリングされていればOKです。

さいごに

React ReduxのチュートリアルをTypeScript化しました。
全体を通してモックをTypeScript化する部分が一番たいへんかと思います。モックをTypeScript化した最終的なソースコードはnishina555/react-redux-todoapp-mock-tsで公開していますので活用していただければと思います。

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!