かずきのBlog@hatena

日本マイクロソフトに勤めています。XAML + C#の組み合わせをメインに、たまにASP.NETやJavaなどの.NET系以外のことも書いています。掲載内容は個人の見解であり、所属する企業を代表するものではありません。

TypeScript JSX + ReactでReduxな画面遷移のある簡単なTODOアプリを作ってみました

はじめに

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>

実行すると、以下のようにリストに追加できるようになっています。

f:id:okazuki:20160109223644p:plain

完了機能の追加

ここに、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>
        );
    }
}

実行してみるとクリックするとこで、打消し線が入るようになります。

f:id:okazuki:20160109225443p:plain

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を削除できるようになってます。

f:id:okazuki:20160109232439p:plain

f:id:okazuki:20160109232446p:plain

コード

GitHubにコードを上げておきました。

TypeScript JSX Redux Todo