普段は Go でサーバサイドのコードを書いているが、TypeScript+React+Redux を勉強する機会があった。

その際、巷のチュートリアルやサンプルコードは型で縛っているものが少なく、理解にかなり苦労したので自分なりの Todo アプリケーションを作るチュートリアルを書いておく。

このチュートリアルは以下のことを意識して書いた。

  • 引数や返り値は型で縛る
    • データフローの理解を重視する
  • 外部ライブラリは以下の 3 つしか import しない
    • react
    • redux
    • react-redux
  • 標準の設計になるべく則る

また、この記事では以下のことについては深く言及はしない。

  • nodeのツールのエコシステム
  • TypeScript の記法
  • JSX の記法
  • React+Redux の概念

プロジェクトの準備 Link to heading

まずはプロジェクトを準備。

node と yarn は入っている前提ですすめる。

Facebook が公式で用意しているアプリケーション作成ツールを使用してプロジェクトテンプレートを作成。

npx create-react-app todo-app --scripts-version=react-scripts-ts

次にプロジェクトのディレクトリに移動して以下のツールをインストール。

cd todo-app
yarn add redux react-redux
yarn add -D @types/redux @types/react-redux

@types がついているパッケージは、/以下のパッケージの型情報が記述されているパッケージ。開発時しか使用しないので-Dオプションを付けてインストールする。

以下のコマンドでローカルサーバが立ち上がり、ブラウザに画面が表示されれば問題ない。

yarn start

チュートリアルのコードは作成されたプロジェクト内部の src ディレクトリに記述する。

Component の実装 Link to heading

何はともあれ何かが表示されてないとモチベーションが上がらないのでブラウザに見た目を表示させる。

以下のコードを TodoComponent.tsx として src 以下に記述する。JSX 記法が含まれるファイルは tsx 拡張子にする。

以下のコードは React と TypeScript の要素しか出てきていない。

// TodoComponent.tsx
import * as React from 'react';

interface IProps {
  todos: string[];
  onClickAddButton: (todo: string) => void;
}

interface IState {
  text: string;
}

/* tslint:disable:jsx-no-lambda */
export default class extends React.Component<IProps, IState> {
  constructor(props: IProps) {
    super(props);

    // Componentのstateの初期化
    this.state = {
      text: ''
    };
  }

  public render() {
    const { todos } = this.props;
    const { text } = this.state;
    return (
      <div style={{ width: '500px', margin: '0 auto' }}>
        <h1>TODO LIST</h1>
        <input type="text" value={text} onChange={this.onTextChange} />
        <button onClick={this.onClickAddButton}>Add Todo</button>
        <ul>
          {todos.map((todo, i) => (
            <li key={i}>{todo}</li>
          ))}
        </ul>
      </div>
    );
  }

  // テキストが更新されたときのイベント関数
  private onTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // text inputに文字が入力されるたびに入力内容をinputに反映させている
    this.setState({ text: e.currentTarget.value });
  };

  // ボタンがクリックされたときのイベント関数
  private onClickAddButton = () => {
    const { onClickAddButton } = this.props;
    const { text } = this.state;
    onClickAddButton(text);
  };
}

作成した TodoComponent を表示させるために、元からあった App.tsx を以下のように書き換える。

// App.tsx
import * as React from 'react';

import TodoComponent from './TodoComponent';

/* tslint:disable:no-console */
/* tslint:disable:jsx-no-lambda */
class App extends React.Component {
  public render() {
    return (
      <div>
        <TodoComponent
          todos={['foo', 'bar']}
          onClickAddButton={(todo: string): void => {
            console.log(todo);
          }}
        />
      </div>
    );
  }
}

export default App;

画面を確認するとフォームとAdd Todoボタン、fooとbarがリストで表示されているはず。この状態でフォームに文字を入力した状態で Add TODO ボタンをクリックすると console に入力したテキストが表示される。まだリストに追加はされない。

ここで定義した TodoComponent は"Presentational Component"と呼ばれるもので、見た目を表現するもの。 この Component は Redux やその他ライブラリとは完全に切り離されたものになる。

TodoComponent に渡している IProps は React における props を明示的に型として定義したもの。React における props は親の Component から渡されてくる情報のこと。型を定義しておくことでこの Component にはtodosという string のリストと、string を受け取って void を返す関数の入った props が渡ってくることが明確になる。

IState は React における state を明示的に型として定義したものになる。React における state はこの Component の状態(主に UI)を表す。 型を定義することで、この Component はtextという文字列を状態として持つことを明示する。

onTextChangeやonClickAddButtonのようなコールバック関数はクラスのメンバとして定義する。こうすることで描画のたびに関数が生成されるのを防ぐ。

は補足として、 render() に含まれているリストの処理でkeyにリストのindexを使っているのはあまりいい方法ではない。 Reactではリストの描画の際、key属性を見てその要素が追加されたのか削除されたのかを判別している。そのため、keyはそのリストで一意になる値を使うほうが良い。しかし、そこまでカバーしようとするとTodoオブジェクトを定義する必要があり実装の範囲が広がるため、今回はそこまでしていない。

          {todos.map((todo, i) => (
            <li key={i}>{todo}</li>
          ))}

React のみでTodoアプリケーションを作る場合 Link to heading

Redux はアプリケーションの全体で使う状態( Component の状態ではなく)を外出しすることで、アプリケーションの状態とUIを分離するためのフレームワーク。

今回は Redux のチュートリアルのため、todos は外から渡される props に配置したが、この Component の状態( state )とすることで、React のみで完結させることができる。

以下のように書き換えることで React のみで Todo リストを作成できる。

// TodoComponent.tsx
import * as React from 'react';

interface IProps {
  onClickAddButton: (todo: string) => void;
}

interface IState {
  text: string;
  todos: string[];
}

/* tslint:disable:jsx-no-lambda */
export default class extends React.Component<IProps, IState> {
  constructor(props: IProps) {
    super(props);

    // Componentのstateの初期化
    this.state = {
      text: '',
      todos: []
    };
  }

  public render() {
    const { todos, text } = this.state;
    return (
      <div style={{ width: '500px', margin: '0 auto' }}>
        <h1>TODO LIST</h1>
        <input type="text" value={text} onChange={this.onTextChange} />
        <button onClick={this.onClickAddButton}>Add Todo</button>
        <ul>
          {todos.map((todo, i) => (
            <li key={i}>{todo}</li>
          ))}
        </ul>
      </div>
    );
  }

  // テキストが更新されたときのイベント関数
  private onTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // text inputに文字が入力されるたびに入力内容をinputに反映させている
    this.setState({ text: e.currentTarget.value });
  };

  // ボタンがクリックされたときのイベント関数
  private onClickAddButton = () => {
    const { text, todos } = this.state;
    this.setState({
      todos: todos.concat([text])
    });
  };
}

IPropstodosがなくなったので App.tsx は以下のように書き換える。

// App.tsx
import * as React from 'react';

import TodoComponent from './TodoComponent';

/* tslint:disable:no-console */
/* tslint:disable:jsx-no-lambda */
class App extends React.Component {
  public render() {
    return (
      <div>
        <TodoComponent
          onClickAddButton={(todo: string): void => {
            console.log(todo);
          }}
        />
      </div>
    );
  }
}

export default App;

コンポーネントがネストしておらず、データフローが複雑ではないならこれでこれでも十分ではある。

このあたりのことは Redux の作者でもある、Dan Abramov 氏がYou Might Not Need Reduxという記事を書いているので一読しておくと良いかもしれない。

とは言うものの、この記事は React+Redux のチュートリアル記事なので、Redux を使う方向で進める。

Store の State の定義 Link to heading

次に Store の State を定義する。

// store.ts

// Storeが持つTodoにの状態を定義
export interface ITodoState {
  todos: string[];
}

// 全てのStateを集約したStateを定義
export interface IRootState {
  todoState: ITodoState;
}

Store とは Redux における役割の1つ。公式ドキュメントにあるとおり、そのアプリケーションにおける全ての状態(State)を持つ。この State は後述する Action->Reducer 経由でしか更新されない。

ちなみに上記のコードにはまだ Store は定義されておらず、Store が持つ State しか定義されいない。また、少し冗長だが、いくつか State が出てくることを想定して、IRootState を用意している。これぐらいのアプリケーションなら ITodoState だけでも十分ではある。

少し厄介だが、ここにおける “State” は先程出てきた “Component の state” とは全くの別物です。Redux における State はアプリケーション全体の状態を表し、React の state はその Component の状態を表している。

Action の追加 Link to heading

次に"Add Todo"ボタンを押したときに発火される Action を定義する。

// action.ts
import { Action } from 'redux'; // reduxで定義されているAction interfaceだけimport

// reduxのActionとして判別するための識別子をenumとして定義
export enum TodoActionType {
  ADD_TODO = 'ADD_TODO'
}

// Todoを追加するActionとして、Actionを継承したinterfaceを定義
// 追加するPayloadの情報も同時に持つ
export interface IAddTodoAction extends Action {
  type: TodoActionType.ADD_TODO;
  payload: {
    todo: string;
  };
}

// Actionを表現したinterfaceを一つの型として取り扱うためにTodoAction型を定義
export type TodoAction = IAddTodoAction;

// 定義したActionのinterfaceを作成するCreatorのinterfaceを定義
export interface ITodoActionCreator {
  addTodoAction(todo: string): IAddTodoAction;
}

// 定義したCreatorの実装を定義(exportをつけてないので外からは見えない)
class TodoActionCreator implements ITodoActionCreator {
  public addTodoAction = (todo: string): IAddTodoAction => {
    return {
      payload: {
        todo
      },
      type: TodoActionType.ADD_TODO
    };
  };
}

// Creatorのインスタンスを作成
export const todoActionCreator: ITodoActionCreator = new TodoActionCreator();

少し冗長だが書いてあることはシンプルで、Action の型の定義とそれを作成する Creator を作成している。

redux パッケージの Action の interface 定義には type しか定義されていないが、 Action と一緒にデータを渡したいためAction のコーディング規約に則ってpayloadを付与している。このコーディング規約自体は非公式だが、これに則っているライブラリも多いためこのチュートリアルではそれに従う。

以下の記述は TodoAction 型を作成するためのもの。今後 Action の interface が増えたときに|記述を使って TodoAction と一致する型を増やすことができる。

export type TodoAction = IAddTodoAction;
// TodoActionはITodoActionかIDeleteTodoActionのどちらかを受け取れる
export type TodoAction = IAddTodoAction | IDeleteTodoAction;

Reducer の追加 Link to heading

Action が発火したあと、その Action に応じて Store の State を更新する Reducer を作成する。

// reducer.ts
import { combineReducers, Reducer } from 'redux';

import { IAddTodoAction, TodoAction, TodoActionType } from './action';
import { IRootState, ITodoState } from './store';

// ITodoStateの初期データを作成
const initTodoState: ITodoState = {
  todos: []
};

// Todoで発生するactionに対応してReduxのstateを返すReducerを作成
const todoReducer: Reducer<ITodoState> = (
  state: ITodoState = initTodoState,
  action: TodoAction
): ITodoState => {
  // 関数の引数として渡されてきたactionのtypeを見てReduxのstateを返す
  switch (action.type) {
    case TodoActionType.ADD_TODO:
      // ADD_TODOの場合はactionのpayloadに新しいtodoが詰められているので
      // それを取り出してtodosに追加して新しいstateとして返す
      const addTodoAction: IAddTodoAction = action;
      return {
        ...state,
        todos: state.todos.concat([addTodoAction.payload.todo])
      };
    default:
      return state;
  }
};

// 全てを集約したReducerを作成
const reducer: Reducer<IRootState> = combineReducers({
  todoState: todoReducer
});

export default reducer;

action.ts に定義した TodoAction を受け取り、store.ts に定義した ITodoState 型を指定した Reducer を返す todoReducer を作成している。

todoReducer では TodoActionTypeADD_TODO だったときに、Actionの payloadtodostate.todos に新しく追加している。

ここで気をつけなくてはいけないのは、Reducer が返す State は必ず新しいオブジェクトにするという点。

React+Redux では Reducer が返す State と現在の State を比較して差分がある時に UI を更新する。そのため Reducer のなかで State を直接操作してしまうと Reducer の返り値との差分がなくなり、正常に UI が更新されなくなってしまう。

今回の場合だと「 Action の payload.todostate.todos に追加する」という点から state.todos.push(payload.todo) としてしまうと UI が正常に更新されないため気をつける。

Reducer を使って Store の作成 Link to heading

reducer.ts に定義したreducerを使用して、store.ts にstoreを以下のように追記して定義する。

// store.ts
import { Action, createStore, Store } from 'redux';

import reducer from './reducer';

// Storeが持つTodoにの状態を定義
export interface ITodoState {
  todos: string[];
}

// 全てのStateを集約したStateを定義
export interface IRootState {
  todoState: ITodoState;
}

// importしたreducerを渡してstoreを作成
const store: Store<ITodoState, Action> = createStore(reducer);

export default store;

上記の定義をすることで、 Action が発火されるとそれに対応した Reducer が起動して Store の State が更新されるようになる。

しかし、まだ Action の発火部分と Store が更新されたときの描画については記述されていないので、それを行う Container というものを作成する。

Container を作成 Link to heading

最後に、最初に定義した Component と Redux の世界をつなぐための Container を作成する。

以下のように TodoContainer.tsx を作成する。

// TodoContainer.tsx
import * as React from 'react';
import { connect } from 'react-redux';
import { Action, Dispatch } from 'redux';

import { todoActionCreator } from './action';
import { IRootState } from './store';
import TodoComponent from './TodoComponent';

// ReduxのStoreをReactのContainerのPropsに変換するinterfaceを定義
interface IStateToProps {
  todos: string[];
}

// ReduxのDispatchをPropsに変換するinterfaceを定義
// メンバにはどのアクションを実行するのかを定義する
interface IDispatchToProps {
  addTodo: (todo: string) => void;
}

// IStateToPropsとIDispatchToPropsの複合型を定義
type IProps = IStateToProps & IDispatchToProps;

// IPropsを受け取るContainerを定義
// Stateは持たないので空の定義を渡す
/* tslint:disable:jsx-no-lambda */
class TodoContainer extends React.Component<IProps, {}> {
  constructor(props: IProps) {
    super(props);
  }

  public render(): JSX.Element {
    // TodoComponentにpropsの値を詰めて返す
    const { todos } = this.props;
    return (
      <TodoComponent todos={todos} onClickAddButton={this.onClickAddButton} />
    );
  }

  // TodoComponentにわたすコールバック関数
  private onClickAddButton = (todo: string): void => {
    const { addTodo } = this.props;
    addTodo(todo);
  };
}

// Storeが更新されたときに送られてくるStateを受け取り、
// IStateToPropsに変換して返す関数を定義
const mapStateToProps = (state: IRootState): IStateToProps => {
  const { todoState } = state;
  return {
    todos: todoState.todos
  };
};

// Dispatchを受け取り、IDispatchToPropsの関数でどのアクションをDispatchするのかを定義する
const mapDispatchToProps = (dispatch: Dispatch<Action>): IDispatchToProps => {
  return {
    addTodo: (todo: string) => {
      dispatch(todoActionCreator.addTodoAction(todo));
    }
  };
};

// Storeが更新されたときの挙動が詰められたIStatePropsと、
// Storeを更新するためのメソッドが詰められたIDispatchToPropsをTodoContainerと繋ぎこむ
export default connect<IStateToProps, IDispatchToProps>(
  mapStateToProps,
  mapDispatchToProps
)(TodoContainer);

次に App.tsx を以下のように変更します。

// App.tsx
import * as React from 'react';
import { Provider } from 'react-redux';

import store from './store';
import TodoContainer from './TodoContainer';

class App extends React.Component {
  public render() {
    return (
      <Provider store={store}>
        <TodoContainer />
      </Provider>
    );
  }
}

export default App;

上記まで書くとフォームに Add Todo ボタンを押したときに TODO が追加されるようなる。

TodoContainer は、 TodoComponent が “Presentational Component” と呼ばれるのに対して、 “Container Component” と呼ばれる。

“Container Component” は “Presentational Component” にデータやコールバック関数を与える役割を持つ。また、基本的には UI を持たない。通常 Component が描画をするための render() メソッドは “Presentational Component” の組み立てと、それらにデータとコールバックを与えるのが役割になる。

Containerで で一番理解しづらいのは以下の部分になる。

// Storeが更新されたときに送られてくるStateを受け取り、
// IStateToPropsに変換して返す関数を定義
const mapStateToProps = (state: IRootState): IStateToProps => {
  const { todoState } = state;
  return {
    todos: todoState.todos
  };
};

// Dispatchを受け取り、IDispatchToPropsの関数でどのアクションをDispatchするのかを定義する
const mapDispatchToProps = (dispatch: Dispatch<Action>): IDispatchToProps => {
  return {
    addTodo: (todo: string) => {
      dispatch(todoActionCreator.addTodoAction(todo));
    }
  };
};

// Storeが更新されたときの挙動が詰められたIStatePropsと、
// Storeを更新するためのメソッドが詰められたIDispatchToPropsをTodoContainerと繋ぎこむ
export default connect<IStateToProps, IDispatchToProps>(
  mapStateToProps,
  mapDispatchToProps
)(TodoContainer);

この部分を説明するにあたって、Store の機能を説明する必要がある。

Storeは以下の2つの機能を持っていいる。

  • State が更新されたときに更新された状態を通知する機能 (subscribe)
  • Store に Action を伝えて状態を更新する機能 (dispatch)

これらは Redux の概念のため React とは特に関係のない。その関係のない Redux の Store と、描画のための React の世界をつなげるために、 react-reduxconnect 関数を使う。

connect の1つ目の引数には、「Store の subscribe が発火したときに、更新された State をどのように props に割り当てるのか」という記述がされた mapStateToProps を渡している。 2つ目の引数には「 Action を伝える dispatch を実行するメソッドをどのように props に割り当てるのか」という記述がされた mapDispatchToProps を渡している。

connect の返り値には Redux の Store の挙動をどのように React に割り当てるのか、という情報を持った関数が返ってくるため、それに TodoContainer を渡すことで React と Redux の世界をつなげている。

開発にあたって Link to heading

今回のチュートリアルでは使わなかったが、ReactのDeveloper ToolsReduxのDeveloper Toolsが用意されている。Componentの構造やStoreの状態をChromeのDeveloper Toolsでできるようになる。

それらを使いながら、このチュートリアルにDeleteやDoneの処理を追加すると理解が更に深まるかもしれない。