39
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

TodoList簡易版@Typescript+React+Redux

Last updated at Posted at 2017-12-19

はじめに

スクリーンショット 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でしっかりテストして下さいということだと思っている。

39
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?