はじめに
Reduxをちょっとやり始めたので、無理やりですが画面遷移(react-router)を含めたTODOアプリサンプルを作ってみたいと思います。 やりながら書くので、最終結果がダメでしたというオチになってるかもしれない点ご了承ください。
プロジェクトの作成
Reactのひな型のプロジェクトを作成します。
追加で以下の項目をnpm install --saveとtsd installでインストールします。
- redux
- react-redux
- object-assign
- react-router
- history
TypeScriptの型定義ファイルをプロジェクトに追加しておきます。
Modelの作成
まず、TODOアプリのデータをデザインします。 データの入れ物としては、TODOの1項目と、それのリストを管理するクラスの2つを用意します。
models.ts
// TODOの1項目exportclass Todo { id: number = Date.now(); constructor(public text: string, public completed: boolean = false) {}}// TODOのリストを管理するexportclass TodoList {// TODOのハッシュ todos: {[key: number]: Todo } = {}; }// Todo関連のユーテリティexportclass TodoUtils {static toList(todos: {[key: number]: Todo }) {var items: Todo[] = []; for (var key in todos) { items.push(todos[key]); }return items.sort((a, b) => {if (a.id > b.id) {return 1; }if (a.id < b.id) {return -1; }return 0; }); }}
TODOを追加する機能の作成
Modelが出来たので、TODOを追加する機能を作っていこうと思います。 まず、最初にアクションから作成していきます。 アクションは、識別用のtypeとデータ部のpayloadを持ったインターフェースとして定義しておきます。
actions.ts
// Actionの識別子exportenum Types { AddTodo }// Actionのインターフェースexportinterface Action<TPayload> { type: Types; payload: TPayload; }// TODO追加のPayloadexportclass AddTodoPayload { constructor(public text: string) {}}// TODO追加アクションexportfunction addTodo(text: string): Action<AddTodoPayload> {return{ type: Types.AddTodo, payload: new AddTodoPayload(text) }; }
次にReducerを作成します。 Reducerは、Todoを追加するaddTodoメソッドと、実際のActionを受け取って処理をディスパッチするtodosを書いてます。 最後に、combineReducersしています。
reducers.ts
import * as Redux from 'redux'; import * as Actions from './actions'; import * as Models from './models'; import assign = require('object-assign'); // TODOを追加するfunction addTodo(state: Models.TodoList, payload: Actions.AddTodoPayload) {var todos = <{[key: number]: Models.Todo }>assign({}, state.todos); var todo = new Models.Todo(payload.text); todos[todo.id] = todo; return<Models.TodoList>assign({}, state, <Models.TodoList>{ todos: todos }); }// TODOアプリの処理をディスパッチするexportfunction todos(state: Models.TodoList = new Models.TodoList(), action: Actions.Action<any>) {switch (action.type) {case Actions.Types.AddTodo: return addTodo(state, <Actions.AddTodoPayload>action.payload); default: return state; }}// アプリのステートexportinterface TodoAppState { todos: Models.TodoList }// Reducerの作成exportconst todoApp = Redux.combineReducers({ todos });
そして、コンポーネントを作成します。 まず、TODOの1項目に対応するTodoComposerとTodoListに対応するTodoListComposerを作成します。
components.tsx
import * as React from 'react'; import * as Redux from 'redux'; import * as Models from './models'; import * as Actions from './actions'; import * as Reducers from './reducers'; import * as ReactRedux from 'react-redux'; import * as ReactRouter from 'react-router'; // TODO 1項目に対応するコンポーネントinterface TodoComposerProps extends React.Props<{}> { todo: Models.Todo; }class TodoComposer extends React.Component<TodoComposerProps, {}> { render() {var todo = this.props.todo; return ( <li> {todo.text}</li> ); }}// TODOのリストinterface TodoListComposerProps extends React.Props<{}> { todos: Models.Todo[]; }class TodoListComposer extends React.Component<TodoListComposerProps, {}> { render() {var todos = this.props.todos.map(x => <TodoComposer key={x.id} todo={x} />); return ( <div> <ul> {todos}</ul> </div> ); }}
続けて、TODOを入力するためのフォームを作成します。
components.tsx続き
// TODOの入力フォームinterface TodoFormComposerProps extends React.Props<{}> { onAddTodo: (text: string) => void; }class TodoFormComposer extends React.Component<TodoFormComposerProps, {}> {private handleSubmit(e: React.SyntheticEvent) { e.preventDefault(); var text = this.refs['text'] as HTMLInputElement; this.props.onAddTodo(text.value); text.value = ''; } render() {return ( <form onSubmit={this.handleSubmit.bind(this)}> <input type='text' ref='text' /> <input type='submit' value='追加' /> </form> ); }}
そして、TODOを入力して表示するためのページを定義しましょう。 このページはreact-reduxを使ってstateと接続します。
components.tsx続き
interface TodoListPageProps extends React.Props<{}> { todoList?: Models.TodoList; dispatch?: Redux.Dispatch; }class TodoListPage extends React.Component<TodoListPageProps, {}> { render() {var{ todoList, dispatch } = this.props; return ( <div> <TodoFormComposer onAddTodo={x => dispatch(Actions.addTodo(x))} /> <hr /> <TodoListComposer todos={Models.TodoUtils.toList(todoList.todos)} /> </div> ); }}function selectTodoListPage(state: Reducers.TodoAppState): TodoListPageProps {return{ todoList: state.todos }; }exportconst ReduxTodoListPage = ReactRedux.connect(selectTodoListPage)(TodoListPage);
最後に、アプリのルートのクラスを作ります。
components.tsx続き
interface TodoAppProps extends React.Props<{}> {}exportclass TodoApp extends React.Component<TodoAppProps, {}> { render() {return ( <div> <h1>TODOアプリ</h1> {this.props.children}</div> ); }}
そして、react-routerのルートの定義とReactDOM.renderをします。
app.tsx
import * as React from 'react'; import * as ReactDOM from 'react-dom'; import{createHashHistory} from 'history'; import{Router, Route, IndexRoute} from 'react-router'; import{TodoApp, ReduxTodoListPage} from './components'; import{Provider} from 'react-redux'; import * as Redux from 'redux'; import * as Reducers from './reducers'; let history = createHashHistory(); var routes = ( <Router history={history}> <Route path='/' component={TodoApp}> <IndexRoute component={ReduxTodoListPage} /> </Route> </Router> ); let store = Redux.createStore(Reducers.todoApp); ReactDOM.render( <Provider store={store}> {routes}</Provider>, document.getElementById('content'));
最後にindex.htmlを作って追加は完成。
index.html
<!DOCTYPE html><html><head><metahttp-equiv="Content-Type"content="text/html; charset=utf-8" /><metaname="viewport"content="width=device-width,initial-scale=1" /><metacharset="utf-8" /><title>Hello world</title></head><body><divid="content"></div><scriptsrc="bundle.js"></script></body></html>
実行すると、以下のようにリストに追加できるようになっています。
完了機能の追加
ここに、TODOを完了させる機能を追加させます。まず、actions.tsにTODOのcompletedを切り替えるためのactionを定義します。
actions.ts
// Actionの識別子exportenum Types { AddTodo, ToggleTodo } ... // TODO完了状態のトグルのPayloadexportclass ToggleTodoPayload { constructor(public id: number) {}} ... // TODO完了状態のトグルアクションexportfunction toggleTodo(id: number): Action<ToggleTodoPayload> {return{ type: Types.ToggleTodo, payload: new ToggleTodoPayload(id) }; }
続けてreducersに、このアクションを受け取る処理を追加します。
reducers.ts(追記)
// 対象のTODOのcompletedを切り替えるfunction toggleTodo(state: Models.TodoList, payload: Actions.ToggleTodoPayload) {var todos = <{[key: number]: Models.Todo }>assign({}, state.todos); var target = <Models.Todo>assign({}, todos[payload.id]); target.completed = !target.completed; todos[payload.id] = target; return<Models.TodoList>assign({}, state, <Models.TodoList>{ todos: todos }); }// TODOアプリの処理をディスパッチするexportfunction todos(state: Models.TodoList = new Models.TodoList(), action: Actions.Action<any>) {switch (action.type) {case Actions.Types.AddTodo: return addTodo(state, <Actions.AddTodoPayload>action.payload); case Actions.Types.ToggleTodo: return toggleTodo(state, <Actions.ToggleTodoPayload>action.payload); default: return state; }}
続けてUI側を対応していきます。TODOリストの1項目のTodoComposerに対してクリック時にonToggleという イベントを発行させるようにします。 そして、completedの状態を見て打消し線を出すようにスタイルを設定します。
components.ts
// TODO 1項目に対応するコンポーネントinterface TodoComposerProps extends React.Props<{}> { todo: Models.Todo; onToggle: (id: number) => void; }class TodoComposer extends React.Component<TodoComposerProps, {}> { render() {var todo = this.props.todo; var style: React.CSSProperties = { textDecoration: todo.completed ? 'line-through' : 'none'}; return ( <li onClick={() => this.props.onToggle(todo.id)}> <span style={style}>{todo.text}</span> </li> ); }}
さらに、これの上位のTodoListComposerで、onToggleイベントを上位に伝搬させます。
components.ts
// TODOのリストinterface TodoListComposerProps extends React.Props<{}> { todos: Models.Todo[]; onToggle: (id: number) => void; }class TodoListComposer extends React.Component<TodoListComposerProps, {}> { render() {var todos = this.props.todos.map(x => <TodoComposer key={x.id} todo={x} onToggle={x => this.props.onToggle(x)}/>); return ( <div> <ul> {todos}</ul> </div> ); }}
このコンポーネントの上位のTodoListPageは、Redux.Dispatchを持ってるのでここでactionを発行します。
components.ts
class TodoListPage extends React.Component<TodoListPageProps, {}> { render() {var{ todoList, dispatch } = this.props; return ( <div> <TodoFormComposer onAddTodo={x => dispatch(Actions.addTodo(x))} /> <hr /> <TodoListComposer todos={Models.TodoUtils.toList(todoList.todos) } onToggle={x => dispatch(Actions.toggleTodo(x))} /> </div> ); }}
実行してみるとクリックするとこで、打消し線が入るようになります。
TODO削除ページの追加
次に、TODOを削除するページを追加したいと思います。 まず、TODOを削除するためのアクションを定義していきます。
actions.ts
// Actionの識別子exportenum Types { AddTodo, ToggleTodo, DeleteTodo } ... // TODO追加のPayloadexportclass DeleteTodoPayload { constructor(public id: number) {}} ... // TODO削除のアクションexportfunction deleteTodo(id: number): Action<DeleteTodoPayload> {return{ type: Types.DeleteTodo, payload: new DeleteTodoPayload(id) }; }
actionが出来たので、それを受けて処理をするreducersを編集します。 単純に指定されたidのTODOを削除しています。
reducers.ts
// 対象のTODOを削除するfunction deleteTodo(state: Models.TodoList, payload: Actions.DeleteTodoPayload) {var todos = <{[key: number]: Models.Todo }>assign({}, state.todos); delete todos[payload.id]; return<Models.TodoList>assign({}, state, <Models.TodoList>{ todos: todos }); }// TODOアプリの処理をディスパッチするexportfunction todos(state: Models.TodoList = new Models.TodoList(), action: Actions.Action<any>) {switch (action.type) {case Actions.Types.AddTodo: return addTodo(state, <Actions.AddTodoPayload>action.payload); case Actions.Types.ToggleTodo: return toggleTodo(state, <Actions.ToggleTodoPayload>action.payload); case Actions.Types.DeleteTodo: return deleteTodo(state, <Actions.DeleteTodoPayload>action.payload); default: return state; }}
そして、画面を定義します。 TODOのリストはTodoListComposerを再利用します。 削除用のUIとしては不親切かもしれませんが、TODOの項目をクリックしたらさくっと消えてもらいます。
components.ts
// TODO削除ページinterface TodoListManagePageProps extends React.Props<{}> { todoList?: Models.TodoList; dispatch?: Redux.Dispatch; }class TodoListManagePage extends React.Component<TodoListManagePageProps, {}> { render() {var{ todoList, dispatch } = this.props; return ( <div> <TodoListComposer todos={Models.TodoUtils.toList(todoList.todos) } onToggle={x => dispatch(Actions.deleteTodo(x)) } /> </div> ); }}function selectTodoListManagePage(state: Reducers.TodoAppState): TodoListManagePageProps {return{ todoList: state.todos }; }exportconst ReduxTodoListManagePage = ReactRedux.connect(selectTodoListManagePage)(TodoListManagePage);
app.tsxのルート定義にReduxTodoListManagePageを追加します。
app.tsx
import * as React from 'react'; import * as ReactDOM from 'react-dom'; import{createHashHistory} from 'history'; import{Router, Route, IndexRoute} from 'react-router'; import{TodoApp, ReduxTodoListPage, ReduxTodoListManagePage} from './components'; import{Provider} from 'react-redux'; import * as Redux from 'redux'; import * as Reducers from './reducers'; let history = createHashHistory(); var routes = ( <Router history={history}> <Route path='/' component={TodoApp}> <IndexRoute component={ReduxTodoListPage} /> <Route path='/manage' component={ReduxTodoListManagePage} /> </Route> </Router> ); let store = Redux.createStore(Reducers.todoApp); ReactDOM.render( <Provider store={store}> {routes}</Provider>, document.getElementById('content'));
そして、components.tsxのTodoAppクラスにページ遷移のためのLinkを追加します。
components.tsx
import * as React from 'react'; import * as Redux from 'redux'; import * as Models from './models'; import * as Actions from './actions'; import * as Reducers from './reducers'; import * as ReactRedux from 'react-redux'; import * as ReactRouter from 'react-router'; import{Link, IndexLink} from 'react-router'; // 追加 ... exportclass TodoApp extends React.Component<TodoAppProps, {}> { render() {return ( <div> <h1>TODOアプリ</h1> <div> <IndexLink to='/' activeStyle={{ backgroundColor: 'pink'}}>TODOリスト</IndexLink> | <Link to='/manage' activeStyle={{ backgroundColor: 'pink'}}>管理</Link> </div> {this.props.children}</div> ); }}
実行すると、ページを切り替えてTODOを削除できるようになってます。
コード
GitHubにコードを上げておきました。