TypeScript
jest
React
redux
enzyme

TodoList簡易版@Typescript+React+Redux

はじめに

スクリーンショット 2017-12-19 1.31.48.png

みたいな感じのTodolist簡易版をTypescript+React+Reduxで作ったので、メモ。 Todolistといっているのは、https://redux.js.org/docs/basics/ExampleTodoList.html のことです。

動機

reduxのtodolistのtutorialに関してはいろんな解説がすでに存在しているが、

  • Typescriptを使っているので、型を可能な限り明記する(proptypesとか使わない)

  • 小さいコンポーネントから作成していく

  • テストを書く(enzyme + jest)

という観点で書かれた記事がないので、改めて自分で書こうと思った。

また、tutorialのcontainer/AddTodo.js については、本来のcontainerに書くべき部分とそうでない部分(component)がごちゃっとしている。これはあんまり教育的でないと思うので、これをcomponentとcontainerに分離した。

上の画像のやつができれば、後は、必要なコンポーネントとsetVisibilityFilterを追加すればtodolist完成版ができる。なので、tutorialの部分でも本質的な部分のみの説明になっていると思う。

環境

package.json
{
  "name": "react_test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "lint": "tslint --format stylish './src/**/*.ts{,x}'",
    "test": "npm run clean && jest",
    "bundle": "webpack && open ./index.html",
    "clean": "rm -r ./dist && mkdir dist",
    "build": "npm run clean && npm run lint && npm run pack",
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@types/enzyme": "^3.1.6",
    "@types/enzyme-adapter-react-16": "^1.0.1",
    "@types/react": "^16.0.30",
    "@types/react-dom": "^16.0.3",
    "@types/react-redux": "^5.0.14",
    "enzyme": "^3.2.0",
    "enzyme-adapter-react-16": "^1.1.0",
    "react": "^16.2.0",
    "react-dom": "^16.2.0",
    "react-redux": "^5.0.6",
    "redux": "^3.7.2"
  },
  "devDependencies": {
    "@types/jest": "^21.1.8",
    "awesome-typescript-loader": "^3.4.1",
    "jest": "^21.2.1",
    "source-map-loader": "^0.2.3",
    "ts-jest": "^21.2.4",
    "tslint": "^5.8.0",
    "tslint-config-airbnb": "^5.4.2",
    "typescript": "^2.6.2"
  },
  "jest": {
    "transform": {
      "^.+\\.tsx?$": "<rootDir>/node_modules/ts-jest/preprocessor.js"
    },
    "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js",
      "json"
    ]
  }
}

です。webpack使用しています。環境構築は、https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html から参考にすれば良いと思う。

ディレクトリ構成は、こんな感じ:

.── index.html
├── package-lock.json
├── package.json
├── src
│   ├── App.tsx
│   ├── actions
│   │   └── index.ts
│   ├── components
│   │   ├── AddTodoButton.test.tsx
│   │   ├── AddTodoButton.tsx
│   │   ├── Todo.test.tsx
│   │   ├── Todo.tsx
│   │   ├── TodoList.test.tsx
│   │   └── TodoList.tsx
│   ├── containers
│   │   ├── AddTodoButton.ts
│   │   └── TodoList.ts
│   ├── index.tsx
│   ├── reducers
│   │   ├── todos.test.ts
│   │   └── todos.ts
│   └── states
│       ├── TodoState.test.ts
│       └── TodoState.ts
├── tsconfig.json
├── tslint.json
└── webpack.config.js

もちろん、webpack.config.jssrc/index.tsxをエントリーポイントと指定している。また、TodoStateはいろんなところから参照するので、statesディレクトリに分離した。

手順

手順としては、redux公式のtutorialのように、いきなりactionから作り始めるのではなく、https://github.com/Microsoft/TypeScript-React-Starter
の方を参考に、componentから作る。

[[stateの作成]]
+ state/TodoState.ts -> 簡易テスト書く

[[componentの作成]]
+ components/(Todo|TodoList|AddTodoButton).tsx => テスト書く

[[connected componentの作成]]
+ actions/index.ts 
+ containers/(TodoList|AddTodoButton).ts

[[Storeを作成して、ReactDOM.renderする]]
+ reducers/todo.ts -> テスト書く 
+ App.tsx, index.tsx

[[ブラウザで動作確認]]
+ `webpack && open ./index.html`

みたいにやっていくと良い。

stateの作成

src/states/TodoState.ts
export interface Todo {
  id: number;
  completed: boolean;
  text: string;
}

export type Todos = Todo[];

let id: number = 0;

function generateTodo(text: string, id: number): Todo {
  return {
    id: id,
    completed: false,
    text: text,
  }
}

export function generateTodos(text: string[]): Todo[] {
  return text.map(t => {
    let res = generateTodo(t, id);
    id++;
    return res;
  })
}

後に定義するアクションによって、Todos型のStateが変化するみたいな認識をしておく。
簡単な関数しか定義していないということでテストは省略。

componentの作成

Todo, TodoList, AddTodoButton componentの作成

Todo.tsxとTodoList.tsxはtutorialとほぼそのまま:

src/components/Todo.tsx
import * as React from "react";

interface TodoProps {
  completed: boolean;
  text: string;
  onClick: () => void;
}

class Todo extends React.Component<TodoProps, {}> {
  render() {
    const { completed, text, onClick } = this.props;
    return (
      <li
        onClick={onClick}
        style={ {textDecoration: completed ? 'line-through' : 'none'}}
      >
        {text}
      </li>
    )
  }
}

export default Todo;
src/components/Todo.test.tsx
import * as React from 'react';
import * as enzyme from 'enzyme';
import Todo from './Todo';

import * as Adapter from "enzyme-adapter-react-16";

enzyme.configure({ adapter: new Adapter() });

it("renders text when completed=true", () => {
  const onDummy = () => {return;};
  const hello = enzyme.shallow(<Todo completed={true} text="hello" onClick={onDummy}/>);

  expect(hello.find("li").text()).toEqual("hello");
  expect(hello.find("li").props().style.textDecoration).toEqual("line-through");
});

it("renders text when completed=false", () => {
  const onDummy = () => {return;};
  const hello = enzyme.shallow(
    <Todo 
      completed={false} 
      text="hello" 
      onClick={onDummy}
    />
  );

  expect(hello.find("li").text()).toEqual("hello");
  expect(hello.find("li").props().style.textDecoration).toEqual("none"); 
});

this.stateはクラス内の内部状態を保持しておくものなので、this.propsを使い、これをreduxで操作する。

おんなじような感じで、TodoList componentも作成:

src/components/TodoList.tsx
import * as React from "react";
import Todo from "./Todo";
import * as State from "../states/TodoState"


export interface TodoListProps {
  todos: State.Todos;
  onTodoClick: (id: number) => void;
}

class TodoList extends React.Component<TodoListProps, {}> {
  render() {
    const {todos, onTodoClick} = this.props;
    return (
      <ul>
        {todos.map(todo => 
          <Todo 
            key={todo.id}
            {...todo}
            onClick={() => onTodoClick(todo.id)}
          />
        )}
      </ul>
    )
  }
}

export default TodoList;

Todoのkeyについては、https://reactjs.org/docs/lists-and-keys.html に書いてあるとおり、siblings内で一意に設定する必要がある。mapをJSX内で用いるときとかは、おまじないのようにつければ良い。
Todoと同じようにtestを書けば良いので、こちらも省略。


さて、一番厄介であろう、AddTodoButton.tsxの部分。ここは、textInputがあり、tutorialでは、refを使っている部分で、割りと詰まってしまう部分だと思う。私は今回refでなく、stateに置き換えて実装した1

src/components/AddTodoButton.tsx
import * as React from "react";

export interface AddTodoButtonProps {
  onSubmit: (s: string) => void;
}

class AddButton extends React.Component<AddTodoButtonProps, {input: string}> {
  constructor(props: AddTodoButtonProps) {
    super(props)
    this.state = {
      input: "",
    }
  }
  onFormSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
    // if not, call @@INIT/redux
    e.preventDefault();
    if(!this.state.input.trim()) {
      return
    }

    this.props.onSubmit(this.state.input);
    this.setState({
      input: "",
    })
  }

  onInputChange = (e: React.FormEvent<HTMLInputElement>): void => {
    this.setState({
      input: e.currentTarget.value,
    })
  }

  render() {
    return (
      <div>
        <form
          onSubmit={this.onFormSubmit}
        >
          <input
            value={this.state.input}
            onChange={this.onInputChange}
          />
          <button type="submit">Add Todo</button>
        </form>
      </div>
    )
  }
}

export default AddButton;

onFormSubmitonInputChangeはmethodでなく、function propertyになっているが、理由はhttps://qiita.com/knknkn1162/items/29d675c8cd26592a95b5 に書いたので見てほしい。

一つだけ注意するとしたら、formへのsubmitは別ページに飛ばすことを目的としているので、renderされて初期化される。(つまり、initialStateが空に戻る) それを防ぐためにonFormSubmitの一番初めにe.preventDefault();と置いて、別ページに飛ばすことを阻害している。

src/components/AddTodoButton.test.tsx
import * as React from 'react';
import * as enzyme from 'enzyme';
import AddTodoButton from './AddTodoButton';

import * as Adapter from "enzyme-adapter-react-16";

enzyme.configure({ adapter: new Adapter() });

it("renders input", () => {
  let res: string;
  const onDummy = (s: string) => {return;};
  const wrapper = enzyme.shallow(<AddTodoButton onSubmit={onDummy}/>);
  wrapper.setState({input: "hello"});
  expect(wrapper.find("button").text()).toEqual("Add Todo");
});

connected componentの作成

connected componentを作成するために、actionを作成する。今回は、箇条書きをクリックしたときに、打ち消し線をswitchさせる(TOGGLE_TODO)アクションと、フォームボタンを入力したら、listが末尾に追加される(ADD_TODO)アクションを定義する:

src/actions/index.ts
let nextTodoId = 0;

export enum TodoActionType {
  ADD_TODO = 'ADD_TODO',
  TOGGLE_TODO = 'TOGGLE_TODO',
}

export interface AddTodoAction {
  type: TodoActionType.ADD_TODO;
  id: number;
  text: string;
}

export interface ToggleTodoAction {
  type: TodoActionType.TOGGLE_TODO;
  id: number;
}

export type TodoAction = AddTodoAction | ToggleTodoAction;

export function addTodo(text: string): AddTodoAction {
  return {
    type: TodoActionType.ADD_TODO,
    id: nextTodoId++,
    text: text,
  }
}

export function toggleTodo(id: number): ToggleTodoAction {
  return {
    type: TodoActionType.TOGGLE_TODO,
    id: id,
  }
}

これを定義すれば、もうconnected componentsが作成できる:

src/containers/TodoList.ts
import { connect, Dispatch } from 'react-redux';
import { toggleTodo, TodoAction } from "../actions";
import TodoList, { TodoListProps } from "../components/TodoList";
import { Todos } from "../states/TodoState"

interface StateFromProps {
  todos: Todos,
}

interface DispatchFromProps {
  onTodoClick: (id: number) => void,
}

function mapStateToProps(state: Todos): StateFromProps {
  return {
    todos: state,
  };
}

function mapDispatchToProps(dispatch: Dispatch<TodoAction>): DispatchFromProps {
  return {
    onTodoClick: (id: number) => {
      dispatch(toggleTodo(id))
    }
  }
}

export default connect<StateFromProps, DispatchFromProps, {}>(
  mapStateToProps,
  mapDispatchToProps
)(TodoList);

かなりしっかり型を明記した。型StateFromProps & DispatchFromPropsTodoListPropsと等しい必要がある。StateFromPropsDispatchFromPropsは、src/component/TodoList.tsxで定義すればDRYになるが、役割的には分離すべきかな、と思うので、container/TodoList.tsxに置いた

おんなじように、connectedなAddTodoButton componentを作成:

src/containers/AddTodoButton.ts
import * as React from 'react';
import { connect, Dispatch } from 'react-redux';
import { addTodo, TodoAction } from '../actions';
import AddButton, { AddTodoButtonProps } from '../components/AddTodoButton';
import { Todo, Todos } from '../states/TodoState';

function mapStateToProps(state: Todos): {} {
  return {}
}

function mapDispatchToProps(dispatch: Dispatch<TodoAction>): AddTodoButtonProps {
  return {
    onSubmit: (s: string) => {
      dispatch(addTodo(s));
    }
  };
}

export default connect<{}, AddTodoButtonProps, {}>(
  mapStateToProps,
  mapDispatchToProps
)(AddButton);

もう、ここらへんは似たような感じ。connectの引数のmapStateToPropsは直接(state:Todos) => ({})と書いてしまっても問題ないです。

これに関しては、テストを書くというより、型が合っているかの確認をする感じになると思う。componentに関しては、すでにテスト済みなので、こちらはテスト不要なのでは?という感触を持っている2

Storeを作成して、ReactDOM.renderする

さて、renderするには、Storeにコールバック関数(s: State, a: Action) => Stateそのものを渡す必要があるわけだけど、多分何言っているかよくわからないと思うので、最初に、src/index.tsxを見たほうが早いかもしれない:

src/App.tsx
import * as React from 'react';
// containerディレクトリ内のファイルをロードしているしていることに注意。
import TodoList from "./containers/TodoList";
import AddTodoButton from "./containers/AddTodoButton";

function app(): JSX.Element {
  return (
    <div>
      <AddTodoButton />
      <TodoList />
    </div>
  )
}

export default app;
src/index.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider, Store } from 'react-redux'
import { createStore } from 'redux';
import todos from './reducers/todos';
import { Todos } from './states/TodoState'

import App from './App';

// todos: (s: TodoState, a: Action) => TodoState
let store: Store<Todos> = createStore(todos, []);

// @@redux/INIT
ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>,
    document.getElementById('root'),
);

App.tsxはJSXを定義しているだけなので、本質的でない(ただし、Appそのものも()=> Element型であることには留意するべきかも。)。index.tsxを見よう。

index.tsxのcreateStoreの第一引数todosはコールバックであることに注意する。createStoreの第二引数はTodoStateの初期値。

これを踏まえた上で、todosコールバック(reducerと言います)の実装を見よう:

src/reducers/todos.ts
import { Todos } from "../states/TodoState";
import { TodoActionType, AddTodoAction, TodoAction } from "../actions";

function todos(state: Todos, action: TodoAction): Todos {
  switch(action.type) {
   case TodoActionType.ADD_TODO:
    return [
      ...state,
      {
        id: action.id,
        text: action.text,
        completed: false,
      }
    ];
  case TodoActionType.TOGGLE_TODO:
    return state.map(todo => 
      (todo.id == action.id) ? {...todo, completed: !todo.completed} : todo
    );

  default: 
    return state;
 }
}

export default todos;

来るアクションに応じて、Stateが変化しているよね!

テストも書くよ。

src/reducers/todos.test.tsx
import * as React from 'react';
import * as enzyme from 'enzyme';
import todos from './todos';
import {Todo as TodoState, generateTodos } from '../states/TodoState';
import { TodoActionType } from '../actions'

import * as Adapter from "enzyme-adapter-react-16";

enzyme.configure({ adapter: new Adapter() });

it("renders text when completed=true", () => {
  let state = [
    {id: 0, text: "hello", completed: false},
  ];
  state = todos(state, {type: TodoActionType.ADD_TODO, id: 1, text: "goodbye"});

  expect(state).toEqual([
    {id: 0, text: "hello", completed: false},
    {id: 1, text: "goodbye", completed: false},
  ]);
});

後は、

webpack

と叩けば一番上の画像のformが完成する。


  1. 今回の場合は、refからstateに書き直せる(https://qiita.com/knknkn1162/items/29d675c8cd26592a95b5 を参照)のでstateを用いた。また、refを使うと、enzymeでのtestの情報があまりなく、そこで煮詰まってしまった。stateにすれば、testのとき、wrapper.setState({input: "hello"});みたいな感じで書きやすい。 

  2. https://redux.js.org/docs/recipes/WritingTests.html#connected-components の部分でもテスト自体書かれていないし、componentでしっかりテストして下さいということだと思っている。