TypeScriptで Redux + React チュートリアル

TypeScript + Redux + Reactでミニマムなアプリを作っていきましょう。 題材は例の如く、 Todoアプリ です。 Create React App を使い、Reactアプリの土台を作っていきます。

もしReduxが初めての場合、 React + Reduxアプリ の最初の設計を整えるのはちょっと 労力が必要 ですが、メンテナンスしていく中で壊れにくいアプリを作れるなどそれに見合うだけの価値はあると思います。私も一番始めに、Reduxに触れた時には、表面上の複雑さに困惑しましたが、とりあえず自分で作ったコードを改変して、色々試してみるのが一番早いのかな、と思っています。

では、始めましょう。

Node.js 8系以上と yarnを使いますので、持っていない場合は必要なツールを揃えます。 また、現時点で、TypeScriptを書くための個人的なおすすめのエディタはVisual Studio Codeです。無料で入手できますので、エディタに拘りが無い場合には使ってみても良いと思います。

このチュートリアルは下記の環境で動作させています。

* OS: Mac OS X 10.13.4
* Node.js: 8.11.1
* Yarn: 1.5.1

また、今回作ったミニマムなサンプルは下記からダウンロードできます。

もくじ

🍇Create React AppsでReactアプリの雛形をつくる

下記のコマンドをTerminalで叩いて、TypeScriptでのReactアプリの雛形を作ります。

# TypeScriptで最小限なReactアプリを作成
npx create-react-app react-app --scripts-version=react-scripts-ts
create react apps

ディレクトリの中に移動して、コマンドを入力します。

cd react-app

# Reactアプリをejectする
yarn run eject

Yes or Noと聞かれるので、 Yを入力します。 そして、次に下記コマンドを入力します。

yarn start

これでReactアプリが起動しますので、念のため、動いているところを見ておきましょう。

react starter

🥗必要ライブラリのインストール

# Reduxなど必要なライブラリーを入れる。
yarn add  redux react-redux redux-devtools-extension typescript-fsa typescript-fsa-reducers
yarn add -D @types/redux @types/react-redux 

少しだけ、ライブラリの説明をします。

  • redux: Reactのメジャーなステートマネージャーライブラリです。
  • redux-devtools-extension: ReduxのStateの状態やアクションの履歴をGUIでわかりやすく把握することのできるブラウザ拡張機能のためのライブラリです。
  • typescript-fsa, typescript-fsa-reducers : TypeScript用に型を指定してアクションを発行したり、そのアクションをReducer側で受け取ったりできます。

まず全体像を把握しておきます。 他のところで解説されている図式と若干違うかもしれませんが、こちらの方が解りやすいと思い、下記のように図式化してます。ちょっと歪んでいる汚い図ですが、ご寛恕ください。

react redux architecture

ではこれから、最小限のReduxアプリを作っていきましょう。まずは追加の機能しか持たない Todoアプリ です。 これから、srcディレクトリ 内部の下記赤線を引いたファイル群を作成もしくは編集していきます。

redux files

最初に Action の作成から取り掛かっていきましょう。

🚂Actionをつくる

Actionをつくりましょう。作成するのは全体図の中でのこの部分です。

redux actions

ではファイル src/actions.ts を作成します。

/* src/actions.ts を作成 */

import actionCreatorFactory from 'typescript-fsa';
const actionCreator = actionCreatorFactory();

/* actionCreator()で型指定しながらActionをつくる。 string型のpayload (データ) を伴ってこのActionが発行されるよう定義する。 */
export const addTodo = actionCreator<string>(
  'ADD_TODO',
);

ActionDispatcher (あとから出てきます) を経由して呼び出される、ただのJavaScriptオブジェクトです。下記のような形式を通常は持ちます。

{
    type: 'ADD_LANGUAGE',
    payload: 'Java'
}

Action の主流の形式に関しては Flux Standard Actions が詳しいので、興味のある方は覗いてみてください。 src/actions.ts ではライブラリの typescript-fsa から提供される actionCreator() という関数を使って、下記の Action を作っています。 actionCreator は手軽に Action を定義するための関数です。

* typeとして "ADD_TODO" を持ちます。
* string型の Payload ( Reducerに渡されるデータ) を持ちます。

Dispatcher を経由して例えば、上の Action を呼び出すことで ActionReducer に向かって飛んでいきます。 Reducer はその Action を受け取って、アプリ全体の Store を更新するというしくみです。

redux action

とりあえずは Action をひとつ作りました。これで Action は完了です。 次はこの Action が飛んでいく先、すなわち Reducer を作りましょう。

🚟Reducerをつくる

Reducer を作っていきましょう。 Reducer の役割は飛んできた Action を受け取って ReduxStore (データ) のことを更新することです。

作成するのは全体図の中でのこの部分です。

redux reducer

Reducerとして、下記のファイルをつくります。

/* src/reducer.ts を作成 */

import { reducerWithInitialState } from 'typescript-fsa-reducers';

/* すべてのActionをインポート */
import * as actions from './action';

/* tasks[] 配列に格納するオブジェクトの型を定義する */
interface ITask {
  id: number;
  text: string;
  done: boolean;
}

/* Storeの型を定義する。 */
export interface ITodoState {
  tasks: ITask[];
}

/* 初期状態のStoreのデータを定義する */
export const initialReduceTodoState: ITodoState = {
  tasks: [
    {
        done: false,
        id: 1,
        text: 'initial task',
    }],
};

let idCounter: number = 1;

/* Taskを作成する。ITaskという指定された型を返す。 */
const buildTask = (text: string): ITask => ({
  done: false,
  id: ++idCounter,
  text,
})

/* 
    addTodoというActionを待ち受けるとともに初期状態のStoreをセットする。
    addTodoが飛んできた場合には、新しいTaskをStoreに格納して、Storeを更新する。
 */
export default reducerWithInitialState(initialReduceTodoState)
  .case(actions.addTodo, (state: ITodoState, payload) => ({
    ...state,
    tasks: state.tasks.concat(
      buildTask(payload)
    )
  }))
  .build();

粗っぽく誤解を恐れずに言えば、関数である Reducer() の実行結果 (戻り値) が Redux Store のデータとなります。 ここでは typescript-fsa-reducers というライブラリを使って、 reducer を定義しています。 typescript-fsa-reducers は型情報を伴ってラクに Reducer を定義するためのライブラリです。

case() という関数で対応する Action を受け取ります。第一引数に 対応するAction 第二引数のコールバックに 現在のStateActionから渡されてきたデータ が入ります。 それらをもとに、新しい State を返すことによって、 Redux Store を更新します。

中身を見ていきましょう。 addTodo という Action を受け取った時に 既存の State が持つ tasks という配列に対して、 buildTask() で生成した新しい task を追加する仕組みになっています。

({
    ...state,
    tasks: state.tasks.concat(
      buildTask(payload)
    )
})

上記の式が実行されると、下記のようなオブジェクトが Reducer から戻り値として返されます。それがReduxの Store となります。

{
    tasks: [
    {
        done: false,
        id: 1,
        text: 'initial task',
    },
    {
        done: false,
        id: 2,
        text: 'Do Rails Tutorial!',
    }        
    ]
}

addTodo という Action は先程、 Action のところで定義したように stringのデータ(payload)を伴って飛んできます ので 飛んできた string型のデータを buildTask() に渡して Task を生成します。このようにしてReduxの Store に新しい Task が追加され、更新されます。 id は新しい Task をつくるたびに自動的に増えていくようにします。


let idCounter: number = 1;

const buildTask = (text: string): ITasks => ({
  done: false,
  id: ++idCounter,
  text,
})

export default reducerWithInitialState(initialReduceTodoState)
  .case(actions.addTodo, (state: ITodoState, payload) => ({
    ...state,
    tasks: state.tasks.concat(
      buildTask(payload)
    )
  }))
  .build();

下記のようなイメージです。

redux reducer description

さらに、この ReducerRedux Store の初期状態のデータも定義してしまいます。

export const initialReduceTodoState: ITodoState = {
  tasks: [
    {
        done: false,
        id: 1,
        text: 'initial task',
    }],
};

export default reducerWithInitialState(initialReduceTodoState)
    ...

initialReduceTodoState で初期状態のデータを定義します。そして、initialReduceTodoStatereducerWithInitialState() に渡すことで、Reduxの Store に初期状態のデータがセットされます。 初期状態の Store が持つデータは下記のようになります。

/* 初期状態の Redux Store */

{
  tasks: [
    {
        done: false,
        id: 1,
        text: 'initial task',
    }],
}

🐥Storeをつくる

上記でつくった ReducerStore と密接に結びついています。

Storeをつくりましょう。作成するのはこの部分です。

redux store

それでは src/store.ts をつくりましょう。

/* src/store.ts をつくる */

import { createStore } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import reducer from './reducer';

const composeEnhancers = composeWithDevTools({});

/* production環境でない場合にはRedux Dev Toolsを有効化する */
export const buildTodoStore = () => (
  process.env.NODE_ENV === 'production' ?
  createStore(
    reducer,
  )
  :
  createStore(
    reducer,
    composeEnhancers()
  )
)

Redux が提供する createStore() という関数で Reduxの Store をつくります。

  createStore(reducer)

createStore() の引数には、初期状態のデータや、Reduxの middleware といった、ライブラリ群をとります。 ここでは先程、定義した Reducer を引数にかませることによって、Reducer が返す初期状態のデータ、すなわち下記が Reduxの Store にセットされます。 Action が飛んできて、 Reducer が発動し、Redux Store が更新されるたびにデータは更新されます。

{
  tasks: [
    {
        done: false,
        id: 1,
        text: 'initial task',
    }],
}

下記のコードで production 環境でない時にはReduxデバッグ用の Redux Dev Tools を適用します。 redux-devtools-extension が提供するcomposeWithDevTools() で初期化をしています。 Google Chromeの拡張機能 (他のブラウザでもアドオンとして提供されています) で Redux アプリの State や発行された Actionの履歴 などを俯瞰できる便利なデバッグツールです。

const composeEnhancers = composeWithDevTools({});

...

  createStore(
    reducer,
    composeEnhancers()
  )

これで Store は完成です。これから、この Store を利用する Reactコンポーネントをつくっていきます。

🐶View (React コンポーネント) をつくる

Redux Store を利用するViewを作成していきます。 作成するのはこの部分です。

redux react view

では src/App.tsx を編集しましょう。 下記のように編集します。

/* src/App.tsxを編集する  */

import * as React from 'react';
import { Provider } from 'react-redux'

import './App.css';
import { buildTodoStore } from './store';
import TodoContainer from './TodoContainer';

/* react-reduxのProviderでラッピングしたコンポーネントをレンダリング */
class App extends React.Component {
  public render() {
    return (
      <Provider store={buildTodoStore()}>
        <TodoContainer/>
      </Provider>
    );
  }
}

export default App;

react-redux というライブラリが提供する Provider でReactコンポーネントをラッピングします。この react-redux というライブラリで ReactReduxをつなぎます。 そして、ProviderPropsbuildTodoStore() することで生成した Store を渡してあげます。 この Provider でラッピングしたReactコンポーネント配下では、 react-redux が提供する connect() 関数が使えるようになります。

この connect() 関数で Redux Store が持つデータをコンポーネントに流し込めるようになります。 Reactコンポーネントは connect() によって流し込まれた Store が持つデータを見ながら 自分の見た目を変えたり、振る舞いを変えたりする流れです。

ここでは TodoContainer というReact コンポーネントを Provider でラッピングしています。

<Provider store={buildTodoStore()}>
    <TodoContainer/>
</Provider>

さて、このラッピングしている TodoContainer をつくりましょう。

🙂connect() でReduxとReactをつなぐ

src/TodoContainer をつくります。

/* src/TodoContainer.tsxをつくる */

import { connect, Dispatch } from 'react-redux';
import { ITodoState } from './reducer';
import TodoComponent from './TodoComponent'

/* 操作を加えることもできるが、今回は何も操作を加えない stateをそのままPropsに渡す */
const mapStateToProps = (state: ITodoState) => state
const mapDispatchToProps = (dispatch: Dispatch<any>) => ({ dispatch });

/* ReduxのStore由来のデータとDispatcherをPropsに格納して、TodoComponentに渡す。 */
export default connect(mapStateToProps, mapDispatchToProps)(TodoComponent);

react-redux から connect() 関数をインポートして TodoComponent に対して、

  • mapStateToProps で Store の持つデータをPropsに格納して、TodoComponent に渡します。
  • mapDispatchToProps で Dispatcher をPropsに格納してTodoComponent に渡します。

上記をまとめて、Redux Storeのデータが下記のPropsとしてTodoComponentに渡ってきて、コンポーネント内部で自由に使えるようになります。

{
    tasks: [{
        done: false,
        id: 1,
        text: 'initial task',
    }],
    dispatch: 'Dispatcher (これを用いて、ReducerにActionを飛ばします)。'
}

イメージにすると下記のようになります。

react connect

✨Reactのレンダリングおよび、Actionの発行

次はconnect()する対象である TodoComponent をつくりましょう。ファイル src/TodoComponent.ts をつくります。

import * as React from 'react';
import { Dispatch } from 'redux';
import { addTodo } from './action';
import { ITodoState } from './reducer';

/* コンポーネントのProps ITodoStateを継承することで、ITodoStateの持つプロパティに加えて、dispatchを持つ */
interface IProps extends ITodoState {
  dispatch: Dispatch<any>;
}

/* コンポーネントが持つ内部State。今回は新しく追加するtodoのテキストを ReduxのStoreとは独立した内部データとして持たせる。 */
interface IState {
  text: string;
}

/* tslint:disable:jsx-no-lambda */
export default class extends React.Component<IProps, IState> {

  constructor(props: IProps) {
    super(props);

    this.state = {
      text: ''
    }
  }

  /* Propsとして渡ってきたDispatcherを経由して、ReducerにActionを投げる */
  public addTodo = () => {
    this.props.dispatch(addTodo(this.state.text))
  }

  /* Propsとして渡ってきた Redux Storeのデータをもとに自身のTodoリストをレンダリングする */
  public renderTodoList = () => (
    this.props.tasks.map((task) => (<li key={task.id.toString()}>{task.text}</li>))
  )

  public render() {
    return(
      <section style={{width: '500px', margin: '0 auto'}}>
        <h1>MY TODO LIST</h1>
        <input 
          type="text"
          value={this.state.text}
          onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
            this.setState({text: e.currentTarget.value})
          }}
        />
        <button onClick={() => { this.addTodo() }}>Add Todo</button>      
        <ul>
          {this.renderTodoList()}
        </ul>
      </section>
    )
  }
}

🎨基本的な設計の完成

ブラウザで確認

これで、Reduxアプリの "最小限の基本的な" 屋台骨の設計はいったん完成です。 あとは、必要に応じて、ActionやReducerを増やしていったり、Reactコンポーネントを増やしていったりします。 一旦、ここまでの結果をブラウザで確認してみましょう。

適当なテキストを入力して、「Add Todo」 ボタンをクリックします。 Dispatcher を経由して ActionReducer に飛んでいき、その結果 Store が更新され、 View にテキストが追加されていくと思います。

Redux Dev Tools

また、Google Chromeにインストールした拡張機能の Redux Dev Tools を開いてみてみましょう。 現在の State はもちろん、 Action が発行された履歴や Action が発行された時点へJumpしてその時点の State を確認できたりします。 Stateをゴリゴリ変更することで、その時点のアプリの状態を自由に再現できたりします。

Redux Dev ToolsでStateを把握
react  debug state
Redux Dev ToolsでActionの履歴を把握
react debug action

ここまでのミニマムなサンプルは下記からダウンロードできます。

1段階目 (Taskの追加機能のみ)

🍵Actionの追加編集 (タスクの削除とタスク完了の更新)

タスクの削除とタスク完了の更新機能のActionを追加していきましょう。

src/action.ts を編集します。新たにdeleteTodoとupdateDoneTodoを追加します。それぞれ number型のpayloadを伴って発行されるようにします。

/* src/action.tsを編集*/


import actionCreatorFactory from 'typescript-fsa';
const actionCreator = actionCreatorFactory();

export const addTodo = actionCreator<string>(
  'ADD_TODO',
);

export const deleteTodo = actionCreator<number>(
  'DELETE_TODO',
);

export const updateDoneTodo = actionCreator<number>(
  'UPDATE_DONE_TODO',
);

🍯Reducerの追加編集

上記で追加したActionの deleteTodoupdateDoneTodo に対応する処理を Reducer に追記しましょう。 src/reducer.ts を下記のように編集します。

/* src/reducer.tsを編集 */

import { reducerWithInitialState } from 'typescript-fsa-reducers';
import * as actions from './action';

export interface ITask {
  id: number;
  text: string;
  done: boolean;
}

export interface ITodoState {
  tasks: ITask[];
}

export const initialReduceTodoState: ITodoState = {
  tasks: [
    {
        done: false,
        id: 1,
        text: 'initial task',
    }],
};

let idCounter: number = 1;

const buildTask = (text: string): ITask => ({
  done: false,
  id: ++idCounter,
  text,
})

const updateDone = (tasks: ITask[], taskId: number): ITask[] => (
  tasks.map((task) => {
    if(task.id === taskId) {
      task.done = true
    }
    return task;
  })
)

export default reducerWithInitialState(initialReduceTodoState)
  .case(actions.addTodo, (state: ITodoState, payload) => ({
    ...state,
    tasks: state.tasks.concat(
      buildTask(payload)
    )
  }))
  .case(actions.deleteTodo, (state: ITodoState, payload) => ({
    ...state,
    tasks: state.tasks.filter((task) => ( task.id !== payload ))
  }))
  .case(actions.updateDoneTodo, (state: ITodoState, payload) => ({
    ...state,
    tasks: updateDone(state.tasks, payload)
  }))
  .build();

追加したのは下記の処理です。 deleteTodoが来た場合 には指定されたidのタスクをtasks配列から取り除いた新しいStateを返します。 updateDoneTodoが来た場合 には、指定されたidのタスクのdoneをtrueに更新したStateを返します。

生のJavaScriptで処理を書いていますが、不慣れな場合には lodash などを使うのもありだと思います。


  const updateDone = (tasks: ITask[], taskId: number): ITask[] => (
    tasks.map((task) => {
      if (task.id === taskId) {
        task.done = true
      }
      return task;
    })
  )

   ...

  .case(actions.deleteTodo, (state: ITodoState, payload) => ({
    ...state,
    tasks: state.tasks.filter((task) => ( task.id !== payload ))
  }))
  .case(actions.updateDoneTodo, (state: ITodoState, payload) => ({
    ...state,
    tasks: updateDone(state.tasks, payload)
  }))

🧀Viewの追加編集

上記で追加したActionを発行できるように、そして画面にStateの状態を反映できるようにVIewを編集しましょう。src/TodoComponent.tsx を編集します。


/* src/TodoComponent.tsxを編集 */

import * as React from 'react';
import { Dispatch } from 'redux';
import { addTodo, deleteTodo, updateDoneTodo } from './action';
import { ITodoState } from './reducer';

interface IProps extends ITodoState {
  dispatch: Dispatch<any>;
}

interface IState {
  text: string;
}

/* tslint:disable:jsx-no-lambda */
export default class extends React.Component<IProps, IState> {

  constructor(props: IProps) {
    super(props);

    this.state = {
      text: ''
    }
  }

  public addTodo = () => {
    this.props.dispatch(addTodo(this.state.text))
  }

  public renderDoneBtn = (taskId: number) => (
    <button
      onClick={() => {
        this.props.dispatch(updateDoneTodo(taskId))
      }}
    >
      DONE
    </button>
  )

  public renderDeleteBtn = (taskId: number) => (
    <button
      onClick={() => {
        this.props.dispatch(deleteTodo(taskId))
      }}
    >
      Delete
    </button>
  )

  public renderDone = (done: boolean) => (
      done ? <span>done!</span> : null
  )

  public renderTodoList = () => (
    this.props.tasks.map((task) => (
      <li key={task.id.toString()}>
        <span>{task.id}</span>
        <span>{task.text}</span>
        {this.renderDeleteBtn(task.id)}
        {this.renderDoneBtn(task.id)}
        {this.renderDone(task.done)}
      </li>)
    )
  )

  public render() {
    return(
      <section style={{width: '500px', margin: '0 auto'}}>
        <h1>MY TODO LIST</h1>
        <input 
          type="text"
          value={this.state.text}
          onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
            this.setState({text: e.currentTarget.value})
          }}
        />
        <button onClick={() => { this.addTodo() }}>Add Todo</button>      
        <ul>
          {this.renderTodoList()}
        </ul>
      </section>
    )
  }
}

ここまで更新をして、一旦画面を見みましょう。「DELETE」をクリックすると追加したタスクが削除され「DONE」をクリックすると追加されたタスクが完了状態になることを確認できると思います。

ここまでのサンプルはこちらからダウンロードできます。

2段階目 (Taskの追加・削除・完了更新機能有)

👍役に立ちそうなリンク集

最後に他の方が書かれた ReduxやReact、Typescriptなどの解りやすい記事などへのリンクを貼っておきます。

TypeScriptを書くのにおすすめのエディタ

Redux

TypeScript FSA

Redux Dev Tools

TypeScript + React

Recompose

今回 は極力外部のライブラリを入れたくなかったため、クラスを使いましたが、 recompose を使うともっと簡潔にコンポーネントにstateを持たせたりすることができます。また、connectする箇所でrecomposeのcompose()を使ってエンハンサーをまとめあげる使い方もできます。

  • このエントリーをはてなブックマークに追加