はじめに
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項目 export class Todo { id: number = Date.now(); constructor(public text: string, public completed: boolean = false) { } } // TODOのリストを管理する export class TodoList { // TODOのハッシュ todos: { [key: number]: Todo } = {}; } // Todo関連のユーテリティ export class 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の識別子 export enum Types { AddTodo } // Actionのインターフェース export interface Action<TPayload> { type: Types; payload: TPayload; } // TODO追加のPayload export class AddTodoPayload { constructor(public text: string) { } } // TODO追加アクション export function 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アプリの処理をディスパッチする export function 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; } } // アプリのステート export interface TodoAppState { todos: Models.TodoList } // Reducerの作成 export const 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 }; } export const ReduxTodoListPage = ReactRedux.connect(selectTodoListPage)(TodoListPage);
最後に、アプリのルートのクラスを作ります。
components.tsx続き
interface TodoAppProps extends React.Props<{}> { } export class 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> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <meta charset="utf-8" /> <title>Hello world</title> </head> <body> <div id="content"></div> <script src="bundle.js"></script> </body> </html>
実行すると、以下のようにリストに追加できるようになっています。
完了機能の追加
ここに、TODOを完了させる機能を追加させます。まず、actions.tsにTODOのcompletedを切り替えるためのactionを定義します。
actions.ts
// Actionの識別子 export enum Types { AddTodo, ToggleTodo } ... // TODO完了状態のトグルのPayload export class ToggleTodoPayload { constructor(public id: number) { } } ... // TODO完了状態のトグルアクション export function 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アプリの処理をディスパッチする export function 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の識別子 export enum Types { AddTodo, ToggleTodo, DeleteTodo } ... // TODO追加のPayload export class DeleteTodoPayload { constructor(public id: number) { } } ... // TODO削除のアクション export function 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アプリの処理をディスパッチする export function 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 }; } export const 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'; // 追加 ... export class 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にコードを上げておきました。