はじめに
みたいな感じの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の部分でも本質的な部分のみの説明になっていると思う。
環境
{
"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.js
でsrc/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の作成
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とほぼそのまま:
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;
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も作成:
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:
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;
onFormSubmit
やonInputChange
はmethodでなく、function propertyになっているが、理由はhttps://qiita.com/knknkn1162/items/29d675c8cd26592a95b5 に書いたので見てほしい。
一つだけ注意するとしたら、formへのsubmitは別ページに飛ばすことを目的としているので、renderされて初期化される。(つまり、initialStateが空に戻る) それを防ぐためにonFormSubmit
の一番初めにe.preventDefault();
と置いて、別ページに飛ばす
ことを阻害している。
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
)アクションを定義する:
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が作成できる:
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 & DispatchFromProps
はTodoListProps
と等しい必要がある。StateFromProps
とDispatchFromProps
は、src/component/TodoList.tsx
で定義すればDRYになるが、役割的には分離すべきかな、と思うので、container/TodoList.tsx
に置いた
おんなじように、connectedなAddTodoButton componentを作成:
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
を見たほうが早いかもしれない:
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;
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と言います)の実装を見よう:
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が変化しているよね!
テストも書くよ。
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が完成する。
-
今回の場合は、refからstateに書き直せる(https://qiita.com/knknkn1162/items/29d675c8cd26592a95b5 を参照)のでstateを用いた。また、refを使うと、enzymeでのtestの情報があまりなく、そこで煮詰まってしまった。stateにすれば、testのとき、
wrapper.setState({input: "hello"});
みたいな感じで書きやすい。 ↩ -
https://redux.js.org/docs/recipes/WritingTests.html#connected-components の部分でもテスト自体書かれていないし、componentでしっかりテストして下さいということだと思っている。 ↩