Edited at

Electron & React & Redux & TypeScript アプリ作成ワークショップ 4日目


概要

前回はごく簡単な React-Redux を使ったアプリを作りました。

今回から、数回に渡ってより実践的なアプリを作ってみましょう。

最終的には、ファイルから非同期でデータの入出力を行うアプリにしますが、その前に

長くなるので、Component は次回にして、その他のモジュールを作成していきます。


作成するアプリの仕様

ToDoを管理するアプリを作成します。画面仕様はこんな感じです。

この画面のイベントとしては、下記があります。


  • タスク一覧の表示

  • タスクの追加

  • タスクの完了・未完了の切り替え

  • タスクの削除


プロジェクトの準備

空のディレクトリを作成し、前回作成したチュートリアルのファイルをコピーします。

dist と node_modules は対象外とします。

新しいディレクトリで VSCode を開いて、書きを行います。


  • package.json を開いて、プロジェクトの名前を変更します。

  • ターミナルでnpm installを実行して、ライブラリをインストールします。

ts ディレクトリ以下の下記ファイルを削除します。ディレクトリは残しておいてください。

ts


├─actions
│ UserEvents.ts // 削除

├─components
│ TextBox.tsx // 削除
│ UserForm.tsx // 削除

├─reducers
│ UserReducer.ts // 削除

└─states
IUser.ts // 削除


各ファイルの関係

各モジュールの関連は下記のようになります。


子 State 定義の作成

Redux で管理するデータの定義を作成します。

TODO ですので、idと、タスクの名前、期限、完了したかどうかのフラグを持ちましょう。

タスクは複数管理されるので、その配列を保持するインターフェイスも定義しておきます。

また、ステートの新規作成するときの関数と、その一覧のオブジェクトの初期値も定義しておきます。

ITask.ts を作成します。

ts/states/ITask.ts

import { v4 as UUID } from 'uuid';

/**
* タスク
*/

export interface ITask {
/** 完了フラグ */
complete: boolean;
/** 期限 */
deadline: Date;
/** タスクを一意に判断するID (UUID) */
id: string;
/** タスクの名前 */
taskName: string;
}
/**
* タスクのリスト
*/

export interface ITaskList {
/** タスクの一覧 */
tasks: ITask[];
}
/**
* タスクのリストの初期値
*/

export const initTaskList: ITaskList = {
tasks: [],
};
/**
* タスクを作成する
* @param taskName タスク名
* @param deadline 期限
*/

export const createTask = (taskName: string, deadline: Date): ITask => {
return {
complete: false,
deadline,
id: UUID(),
taskName,
};
};


State の作成

Reducer を合成、ステートとなるインターフェースの定義、ステートの作成をします。

ts/Store.ts

import { combineReducers, createStore } from 'redux';

import { ITaskList } from './states/ITask';

/**
* store のデータ型を定義する。(親state)
*
* プロパティには、管理する child_state を指定する
*/

export interface IState {
taskList: ITaskList;
// state が増えたら足していく
}

// 複数の reducer を束ねる
const combinedReducer = combineReducers<IState>({
// reducer が増えたら足していく
});

// グローバルオブジェクトとして、store を作成する。
const store = createStore(combinedReducer);
export default store;

combineReducers がエラーになっていますが、新しい Reducer を作成したときに修正することにします。


Action の作成

画面のイベントをアクションとして作成していきます。

今回 Action Creator は、別ファイルに作成します。理由は後ほど非同期アクションについての説明の中で書きます。

ts/action/TaskActions.ts ファイルを作成し、画面のイベントごとにアクション・タイプとアクションを定義します。

ts/action/TaskActions.ts

import { Action } from 'redux';

import { v4 as UUID } from 'uuid';
import { ITask } from '../states/ITask';

/**
* タスクの一覧を表示するアクションタイプ
*/

export const SHOW_TASKS = UUID();
/**
* タスクの一覧を表示するアクション
*/

export interface IShowTaskAction extends Action {
tasks: ITask[];
}
/**
* タスクを追加するアクションタイプ
*/

export const ADD_TASK = UUID();
/**
* タスクを追加するアクション
*/

export interface IAddTaskAction extends Action {
deadline: Date;
taskName: string;
}
/**
* タスク完了のアクションタイプ
*/

export const TOGGLE_COMPLETE_TASK = UUID();

/**
* タスク完了のアクション
*/

export interface IToggleCompleteAction extends Action {
taskId: string;
}

/**
* タスク削除のアクションタイプ
*/

export const DELETE_TASK = UUID();

/**
* タスク削除のアクション
*/

export interface IDeleteAction extends Action {
taskId: string;
}

ts/actions/TaskActionCreators.ts ファイルを作成し、アクション・クリエイターを作成します。

ts/actions/TaskActionCreators.ts

import Moment from 'moment';

import { ITask } from '../states/ITask';
import {
ADD_TASK,
DELETE_TASK,
IAddTaskAction,
IDeleteAction,
IShowTaskAction,
IToggleCompleteAction,
SHOW_TASKS,
TOGGLE_COMPLETE_TASK,
} from './TaskActions';

/**
* タスクの表示アクションを作成する
* @param tasks 表示するタスクのリスト
*/

export const createShowTasksAction = (tasks: ITask[]): IShowTaskAction => {
// 確認のため、ダミーデータをハードコーディングする
const dummyTasks: ITask[] = [
{
complete: false,
deadline: Moment().add(1, 'day').toDate(),
id: '0',
taskName: 'task01',
},
{
complete: true,
deadline: Moment().add(1, 'day').toDate(),
id: '1',
taskName: 'task02',
},
{
complete: false,
deadline: Moment().add(-1, 'day').toDate(),
id: '2',
taskName: 'task03',
},
{
complete: true,
deadline: Moment().add(-1, 'day').toDate(),
id: '3',
taskName: 'task04',
},
];
return {
// tasks, // 本来はこっち
tasks: dummyTasks,
type: SHOW_TASKS,
};
};
/**
* 新しいタスクを作成するアクションを作成する
* @param taskName 新しいタスクの名前
* @param deadline 新しいタクスの期限
*/

export const createAddTaskAction = (taskName: string, deadline: Date): IAddTaskAction => {
return {
deadline,
taskName,
type: ADD_TASK,
};
};
/**
* タスクの完了状態を切り替える
* @param taskId 完了状態を切り替える対象のタスクのID
*/

export const createToggleCompleteAction = (taskId: string): IToggleCompleteAction => {
return {
taskId,
type: TOGGLE_COMPLETE_TASK,
};
};
/**
* タスクを削除するアクションを作成する
* @param taskId 削除するタスクのID
*/

export const createDeleteTaskAction = (taskId: string): IDeleteAction => {
return {
taskId,
type: DELETE_TASK,
};
};


Reducer の作成

作成したアクション毎の Reducer の処理を書いていきます。

前回は、アクション・タイプ を switch で分岐して処理を書いていましたが、アクションが多くなると、Reducer の関数が長くなりすぎて、コードも見にくいですし、メンテナンスしづらくなります。

そこで、事前にアクション・タイプとそれ毎の処理を登録し、アクション・タイプを渡すと、その処理を実行するクラスを作っておきましょう。

このクラスは、新しいディレクトリ utils を作成して、その中に作成します。

ts/utils/ActionToReducerMapper.ts

import clone from 'clone';

import { Action } from 'redux';

type WorkOfAction<S, A extends Action = any> = (state: S, action: A) => void;

/**
* action に対する reducer の処理を管理する
*/

class ActionToReducerMapper<S> {
/** アクション・タイプと処理の定義を保持する。 */
private works: {[actionKey: string]: WorkOfAction<S>} = {};
/** アクション・タイプと処理の定義を追加する。 */
public addWork = <A extends Action>(actionType: string, func: WorkOfAction<S, A>) => {
this.works[actionType] = func;
}
/** 処理を実行する。
* 該当するアクション・タイプがあった場合、state をクローンして指定の処理を行い、返す。
* 該当するアクション・タイプが無い場合、何も処理を行わず、state もクローンせずにそのまま返す。
*/

public execute = (state: S, action: Action) => {
let newState = state;
const process = this.works[action.type];
if (!!process) {
newState = clone(state);
process(newState, action);
}
return newState;
}
}

const createActionToReducerMapper = <S>() => {
return new ActionToReducerMapper<S>();
};

export default createActionToReducerMapper;

addWork で アクションタイプと、その処理を追加しておき、 execute のときに works に、引数で渡された action の type と一致するものがあれば、それの state をクローンして処理を実行します。

そうでない場合は、state をクローンせずにそのまま返します。

上で作ったクラスを利用して、Reducer を書きます。

ts/reducers/TaskReducer.ts

import Clone from 'clone';

import Redux from 'redux';

import * as Action from '../actions/TaskActions';
import { createTask, initTaskList, ITaskList } from '../states/ITask';
import createA2RMapper from '../utils/ActionToReducerMapper';

const a2RMapper = createA2RMapper<ITaskList>();

/** タスク一覧を表示する */
a2RMapper.addWork<Action.IShowTaskAction>(
Action.SHOW_TASKS,
(state, action) => {
state.tasks = Clone(action.tasks);
},
);

/** タスクを追加する */
a2RMapper.addWork<Action.IAddTaskAction>(
Action.ADD_TASK,
(state, action) => {
state.tasks.push(createTask(action.taskName, action.deadline));
},
);

/** タスクを完了/未完了を切り替える */
a2RMapper.addWork<Action.IToggleCompleteAction>(
Action.TOGGLE_COMPLETE_TASK,
(state, action) => {
const {tasks} = state;
// 上記は下記と同じ意味
// const tasks = state.tasks
const target = tasks.find((it) => it.id === action.taskId);
if (!target) { return; }
target.complete = !target.complete;
},
);

/** タスクを削除する */
a2RMapper.addWork<Action.IDeleteAction>(
Action.DELETE_TASK,
(state, action) => {
const {tasks} = state;
const target = tasks.find((it) => it.id === action.taskId);
if (!target) { return; }
// 指定したID以外のオブジェクトを抽出し、それを新しいリストとする
state.tasks = tasks.filter((it) => it.id !== action.taskId);
},
);
/** Reducer 本体 */
export const TaskReducer: Redux.Reducer<ITaskList> = (state = initTaskList, action) => {
return a2RMapper.execute(state, action);
};

このように、switch を利用せずに書くことができました。


処理ごとにコードがブロック化され、見通しの良いソースになったと思います。

Reducer を実装したので、 Store を修正します。

ts/Store.ts

import { combineReducers, createStore } from 'redux';

import { TaskReducer } from './reducers/TaskReducer';
import { ITaskList } from './states/ITask';

/**
* store のデータ型を定義する。(親state)
*
* プロパティには、管理する child_state を指定する
*/

export interface IState {
taskList: ITaskList;
// state が増えたら足していく
}

// 複数の reducer を束ねる
const combinedReducer = combineReducers<IState>({
taskList: TaskReducer, // 追加
// reducer が増えたら足していく
});

// グローバルオブジェクトとして、store を作成する。
const store = createStore(combinedReducer);

export default store;


次回

次回は、 CSS in JavaScript の解説と、Component を作っていきます。