TypeScript でも React Meets Redux

React を使って TODO サンプルを TypeScript で書き直したので、 今度はさらに Redux も使ってみる。

まずは Redux の型定義ファイルをインストール。

tsd query redux --action install --resolve
tsd query react-redux --action install --resolve

特にひっかかることなく、スンナリと実装できた。

/// <reference path="typings/tsd.d.ts" />
import * as React from "react"
import * as ReactDOM from "react-dom"
import { createStore, combineReducers, Dispatch } from "redux"
import { Provider, connect } from "react-redux"
import * as _ from "lodash"

// タスクのインタフェース
interface ITask {
    name?: string;
    completed?: boolean;
    editing?: boolean;
}

// タスクを追加するときに呼び出すコールバックのインタフェース
interface ITaskAddCallback {
    (task: ITask): void;
}

// タスクを保存するときに呼び出すコールバックのインタフェース
interface ITaskSaveCallback {
    (task: ITask, newValue: ITask): void;
}

// タスクを削除するときに呼び出すコールバックのインタフェース
interface ITaskDeleteCallback {
    (task: ITask): void;
}

// アクションの種類を定義
enum TaskActionType {
    Add = 1,
    Save,
    Delete
}

// タスクのアクションを表すインタフェース
interface ITaskAction {
    type: TaskActionType;
    task?: ITask;
    values?: ITask;
}

// タスクを追加するアクションを作成する
function addTask(task: ITask): ITaskAction {
    return {
        type: TaskActionType.Add,
        task: task
    };
}

// タスクを保存するアクションを作成する
function saveTask(task: ITask, newValues: ITask): ITaskAction {
    return {
        type: TaskActionType.Save,
        task: task,
        values: newValues
    };
}

// タスクを削除するアクションを作成する
function deleteTask(task: ITask): ITaskAction {
    return {
        type: TaskActionType.Delete,
        task: task
    };
}

// タスクを操作する Reducer
function tasksReducers(state: ITask[], action: ITaskAction): ITask[] {
    state = state || [
        { name: "foo", completed: false, editing: false },
        { name: "bar", completed: false, editing: false },
        { name: "hoge", completed: false, editing: false },
        { name: "fuga", completed: false, editing: false }
    ];
    switch (action.type) {
        case TaskActionType.Add:
            return state.concat(action.task);
        case TaskActionType.Save:
            return state.map(t => {
                if (t == action.task) {
                    return _.assign({}, t, action.values);
                } else {
                    return t;
                }
            });
        case TaskActionType.Delete:
            return state.filter(t => t != action.task);
        default:
            return state;
    }
}

// タスクを表示するコンポーネントの props のインタフェース
interface ITaskProps {
    task: ITask;
    onTaskSave: ITaskSaveCallback;
    onTaskDelete: ITaskDeleteCallback;
}

// タスクを表示するコンポーネント
class Task extends React.Component<ITaskProps, any> {
    private handleEdit = (e) => {
        this.props.onTaskSave(this.props.task, {
            editing: true
        });
    }

    private handleDelete = (e) => {
        this.props.onTaskDelete(this.props.task);
    }

    private handleChangeCheckbox = (e) => {
        this.props.onTaskSave(this.props.task, {
            completed: !this.props.task.completed
        });
    }

    render(): JSX.Element {
        return (
            <div>
        <input type="checkbox"
            checked={this.props.task.completed}
            onChange={this.handleChangeCheckbox} />
        <span>{this.props.task.name}</span>
        <a href="#" onClick={this.handleEdit}>[edit]</a>
        <a href="#" onClick={this.handleDelete}>[del]</a>
                </div>
        );
    }
}

// タスクを編集するコンポーネントの props のインタフェース
interface IEditTaskFormProps {
    task: ITask;
    onTaskSave: ITaskSaveCallback;
}

// タスクを編集するコンポーネントの state のインタフェース
interface IEditTaskFormState {
    name: string;
}

// タスクをその場編集するためのコンポーネント
class EditTaskForm extends React.Component<IEditTaskFormProps, IEditTaskFormState> {
    constructor(props: IEditTaskFormProps) {
        super(props);
        this.state = { name: "" };
    }

    private handleCancel = (e) => {
        this.props.onTaskSave(this.props.task, {
            editing: false
        });
    }

    private handleSave = (e) => {
        e.preventDefault();

        if (!this.state.name) {
            return;
        }

        this.props.onTaskSave(this.props.task, {
            name: this.state.name,
            editing: false
        });

        this.setState({ name: "" });
    }

    private handleChangeText = (e) => {
        this.setState({ name: e.target.value });
    }

    render(): JSX.Element {
        return (
            <div>
        <input type="text"
            value={this.state.name || this.props.task.name}
            onChange={this.handleChangeText}
            ref="name"/>
        <button onClick={this.handleSave}>save</button>
        <button onClick={this.handleCancel}>cancel</button>
                </div>
        );
    }
}

// タスク一覧を表示するコンポーネントの props のインタフェース
interface ITaskListProps {
    tasks: ITask[];
    onTaskSave: ITaskSaveCallback;
    onTaskDelete: ITaskDeleteCallback;
}

// タスク一覧を表示するためのコンポーネント
class TaskList extends React.Component<ITaskListProps, any> {
    render(): JSX.Element {
        var rows = this.props.tasks.map(t => {
            if (t.editing) {
                return (
                    <EditTaskForm task={t}
                        onTaskSave={this.props.onTaskSave} />
                );
            } else {
                return (
                    <Task task={t}
                        onTaskSave={this.props.onTaskSave}
                        onTaskDelete={this.props.onTaskDelete} />
                );
            }
        });

        return (
            <div>
        {rows}
                </div>
        );
    }
}

// タスク追加フォームの props のインタフェース
interface IAddTaskFormProps {
    onTaskAdd: ITaskAddCallback;
}

// タスク登録フォームを表示するためのコンポーネント
class AddTaskForm extends React.Component<IAddTaskFormProps, any> {
    private handleAdd = (e) => {
        e.preventDefault();

        var newTask = ReactDOM.findDOMNode<HTMLInputElement>(this.refs["name"]).value.trim();
        if (!newTask) {
            return;
        }

        this.props.onTaskAdd({
            name: newTask,
            completed: false,
            editing: false
        });

        ReactDOM.findDOMNode<HTMLInputElement>(this.refs["name"]).value = "";
    }

    render() {
        return (
            <div>
        <input type="text" placeholder="newTask" ref="name"/>
        <button onClick={this.handleAdd}>add</button>
                </div>
        );
    }
}

// ルートコンポーネントの state のインタフェース
interface ITaskAppProps {
    tasks?: ITask[];
    dispatch?: Dispatch;
}

// ルートコンポーネント
// 状態をまとめて管理する
class _TaskApp extends React.Component<ITaskAppProps, any> {
    private handleTaskAdd = (newTask: ITask): void => {
        this.props.dispatch(addTask(newTask));
    }

    private handleTaskDelete = (task: ITask): void => {
        this.props.dispatch(deleteTask(task));
    }

    private handleTaskSave = (task: ITask, newValues: ITask): void => {
        this.props.dispatch(saveTask(task, newValues));
    }

    render(): JSX.Element {
        return (
            <div>
        <AddTaskForm onTaskAdd={this.handleTaskAdd} />
        <TaskList tasks={this.props.tasks}
            onTaskSave={this.handleTaskSave}
            onTaskDelete={this.handleTaskDelete} />
                </div>
        );
    }
}
const TaskApp = connect(state => {
    return {
        tasks: state.tasks
    };
})(_TaskApp);

// ストアに渡す Reducer を作成
const rootReducer = combineReducers({
    tasks: tasksReducers
});

// 唯一のストアを作成
const store = createStore(rootReducer);

ReactDOM.render(
    <Provider store={store}>
        <TaskApp />
    </Provider>,
    document.getElementById("content")
);

コンテナコンポーネントが持っていた状態を Redux のストアに持つようにした。 それにともない、コンテナコンポーネントは状態を操作するのではなく、アクションを呼び出すだけに変更。 Redux の枠組みにのっとって、状態は Reducer が操作している。

あと、アクションの種類に enum を使ってみた。 もし複数の enum で定義する場合は、明示的に開始値を指定して値の範囲を分けておかないと、 複数の Reducer で処理されてしまいそう。 素直に文字列で定義するか、1 つの enum にアクションの種類をすべて詰め込んでいいかもな。