LoginSignup
1
2

More than 3 years have passed since last update.

Redux ExampleのTodo ListをはじめからていねいにをもういちどTypescriptとImmerで

Posted at

概要

immerよいと聞いたので、試してみようと思った。
以前、Redux ExampleのTodo Listをはじめからていねいに(1)
参考にした記事でやろうと思ったが、時間が経っていて古いので改めて環境作成から行った。
ハマった。

環境

バージョン

  • Windows 10 Home
  • Vagrant 2.2.6
  • virtualbox 6.0.14
  • Ubuntu 18.04 LTS (Bionic Beaver)
  • Docker version 19.03.2, build 6a30dfc
  • docker-compose version 1.24.1, build 4667896b

ライブラリのバージョン

{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "immer": "^5.0.0",
    "react": "^16.12.0",
    "react-dom": "^16.12.0",
    "react-redux": "^7.1.3",
    "redux": "^4.0.4"
  },
  "devDependencies": {
    "@types/jest": "^24.0.23",
    "@types/node": "^12.12.14",
    "@types/react": "^16.9.13",
    "@types/react-dom": "^16.9.4",
    "@types/react-redux": "^7.1.5",
    "@types/redux": "^3.6.0",
    "react-scripts": "^3.2.0",
    "typescript": "^3.7.2"
  }
}

ディレクトリ構成(Hello world 時)

app
  - tsconfig.json  # ビルドツールで利用するTypescript設定
  - package.json   # ライブラリを記載・また、起動スクリプトなどを記載
  - yarn.lock      # npmでインストールするライブラリのバージョン固定用
  - src
    - index.tsx  # エントリーポイント
    - components # Reactコンポーネント
      - App.tsx
    - react-app-env.d.ts # TSコンパイラへのファイル間の依存関係の宣言
  - public # 開発サーバのベースとなるフォルダ
    - index.html # 開発サーバのホーム。

  + dist   # ビルドされたファイルの格納先
  - docker # dockerファイルをまとめたフォルダ
    - docker-compose.yml # コンテナ起動時設定ファイル
    - node
      - Dockerfile # コンテナ作成ファイル
  - bin # docker-composeの操作をシェル化
    - start.sh # 開発サーバの起動
    - build.sh # dist内にjsファイルをビルド

ソースのもとについて

以下で構築したフォルダ構成を参考に作成

npm install create-react-app
npx create-react-app my-app --typescript

設定ファイル

ie のサポートはしない予定なので設定ファイルを書換。(*)(**)

tsconfig.json
{
   "compilerOptions": {
-    "target": "es5",
-    "allowJs": true,
-    "skipLibCheck": true,
-    "esModuleInterop": true,
+    "target": "es6",
+    "allowJs": false,
+    "skipLibCheck": false,
+    "esModuleInterop": false,
     "allowSyntheticDefaultImports": true,

docker 設定

docker/docker-compose.yml
# lesson_buildtool_react_tsというコンテナ名で作成
my_react_ts:
  # Dockerfileビルド
  build: ./node
  # ディレクトリを共有する。
  volumes:
    # ビルドするソースファイル
    - ../src:/app/src
    # ビルドファイルの出力先
    - ../dist:/app/build
    # 開発用サーバのホームページに使用するhtml用ディレクトリ
    - ../public:/app/public
    # package.json上書き
    - ../package.json:/app/package.json
    # typescriptの設定ファイル
    - ../tsconfig.json:/app/tsconfig.json
  # ホストのポート8080をコンテナのポート3000にポートフォワーディング
  ports:
    - 8080:3000 # ホスト:コンテナでポート指定
  environment:
    - CHOKIDAR_USEPOLLING=true # デフォルトの設定の場合、vagrantだとファイルの変更を検知できない。ホットリロードのためにpollingが必要。
  # docker-compose run を行ったときにコンテナ上で下のコマンドを行う
  command: [yarn, start]

開発サーバ用 HTML

public/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>React App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>

1. Hello World

create-react-app で作成された状態のソースは余分なものも含まれているので、まずはシンプルにする。
整理前のソース

ソース

src/index.tsx
import React from "react";
import ReactDOM from "react-dom";
import App from "./components/App";

// reactのコンポーネントを#root以下に作成する
ReactDOM.render(<App />, document.getElementById("root"));
src/components/App.tsx
import React from "react";

// React.FC は React.FunctionComponent の短縮形
// @types/reactとlib.dom.d.tsで型が衝突することがあるため、慣習としてReactの型はnamed import({FC} from 'react'みたいなやつ)を避ける
const App: React.FC = () => {
  return <div> Hello World!!! </div>;
};

export default App;

整理後のソース

実行

開発用サーバを起動。

./bin/start.sh

ブラウザでアクセスして確認。

2. actionCreator で発行した action を reducer に渡して store の state を更新する

参考*をもとに作成。
Flux Standard Action という考えかたがあるよう(*)なので、type(必須) と payload(オプション) の組にしてみる。サンプルから少し逸脱。

Acitions

src/actions/index.ts

// TypeScript3.4で導入された const assertion を利用することで各定数がstringではなく、その文字列の型として定義される
const ADD_TODO = "ADD_TODO" as const;

let nextTodoId = 0;

// actionを発行する関数
export const addTodo = (text: string) => {
  // actionはtypeを持つオブジェクト
  // この場合、アクションタイプはADD_TODO
  // データはpayloadとなる。
  return {
    type: ADD_TODO,
    payload: {
      id: nextTodoId++,
      text
    }
  };
};

// TypeScript2.8で導入されたReturnTypeで型をかえす
export type AddTodoAction = ReturnType<typeof addTodo>;

Reducers

immer を使ってみた(*)。
produce 関数で、普通(ミュータブル)に書いたものが、イミュータブルなオブジェクトとして返されることとなる(*)。

src/reducers/index.ts
import produce, { Draft } from "immer";
import { AddTodoAction } from "../actions";

export class TodoState {
  constructor(public id: number, public text: string) {}
}

const todo = produce((draft: Draft<TodoState>, action: AddTodoAction) => {
  switch (action.type) {
    case "ADD_TODO":
      const {
        payload: { id, text }
      } = action;
      draft.id = id;
      draft.text = text;
      return draft;

    default:
      return draft;
  }
});

export default todo;

この状態だと、以下のエラーが出てハマった。

my_react_ts_1  | Failed to compile.
my_react_ts_1  |
my_react_ts_1  | /app/src/index.tsx
my_react_ts_1  | TypeScript error in /app/src/index.tsx(7,27):
my_react_ts_1  | No overload matches this call.
my_react_ts_1  |   Overload 1 of 2, '(reducer: Reducer<unknown, Action<any>>, enhancer?: StoreEnhancer<unknown, unknown> | undefined): Store<unknown, Action<any>>', gave the following error.
my_react_ts_1  |     Argument of type '<Base extends { readonly id: number; readonly text: string; }>(base: Base, action: { type: "ADD_TODO"; payload: { id: number; text: string; }; }) => Base' is not assignable to parameter of type 'Reducer<unknown, Action<any>>'.
my_react_ts_1  |       Types of parameters 'base' and 'state' are incompatible.
my_react_ts_1  |         Type 'unknown' is not assignable to type '{ readonly id: number; readonly text: string; }'.
my_react_ts_1  |   Overload 2 of 2, '(reducer: Reducer<unknown, Action<any>>, preloadedState?: DeepPartial<unknown> | undefined, enhancer?: StoreEnhancer<unknown, {}> |
undefined): Store<...>', gave the following error.
my_react_ts_1  |     Argument of type '<Base extends { readonly id: number; readonly text: string; }>(base: Base, action: { type: "ADD_TODO"; payload: { id: number; text: string; }; }) => Base' is not assignable to parameter of type 'Reducer<unknown, Action<any>>'.  TS2769
my_react_ts_1  |
my_react_ts_1  |      5 | import todo from "./reducers";
my_react_ts_1  |      6 | import { addTodo } from "./actions";
my_react_ts_1  |   >  7 | const store = createStore(todo);

tsconfig の設定を"strict": false,にすると動く。型の問題だけっぽい。
Draft<TodoState> が違っていた模様。以下のようにしたら動いた。

src/reducers/index.ts
import produce from "immer";
import { AddTodoAction } from "../actions";
import { Reducer } from "redux";
export class TodoState {
  constructor(public id: number, public text: string) {}
}

export const initialState = () => new TodoState(0, "");

const todo: Reducer<TodoState, AddTodoAction> = produce((draft, action) => {
  switch (action.type) {
    // actionTypeがADD_TODOのとき、
    // 新しいTodoStateを返す
    case "ADD_TODO":
      // es6の分割代入でpayloadからidとtextを取り出す
      const {
        payload: { id, text }
      } = action;
      draft.id = id;
      draft.text = text;
      return draft;
    // それ以外のときはstateを変化させない。何もしないproduceは、元の状態を返す。
    // default:
    //   return draft;
  }
});

export default todo;
src/index.tsx
import React from "react";
import ReactDOM from "react-dom";
import App from "./components/App";
import { createStore } from "redux";
import todo, { initialState } from "./reducers";
import { addTodo } from "./actions";

// createStoreの引数が1つだと初期値がなくてエラーとなる
const store = createStore(todo, initialState());
store.dispatch(addTodo("Hello World!"));
console.log(store.getState());
ReactDOM.render(<App />, document.getElementById("root"));

この時点のソース

2.5 store, reducer の整理

reducer

  • 名前が分かりにくいので todo から reducer にリファクタリング
  • todoList を作るようの interface を定義
  • todoList を作る用の reducer に変更
  • 今後 reducer は増えていくので、集約が出来るように準備をしておく。
  • store フォルダに格納
src/stores/todos/index.ts
import produce from "immer";
import { AddTodoAction } from "../../actions";
import { Reducer } from "redux";

interface Todo {
  id: number;
  text: string;
}

interface State {
  todos: Todo[];
}

export function initialState(): State {
  return { todos: [] };
}

export const reducer: Reducer<State, AddTodoAction> = produce(
  (draft, action) => {
    switch (action.type) {
      case "ADD_TODO":
        const { payload } = action;
        draft.todos.push(payload);
        return draft;
    }
  }
);
  • combineReducers を使って集約する。
  • 今回は1つだけなのであまり意味はない。
src/store/reducer.ts
import { combineReducers } from "redux";
import * as Todos from "./todos";
export function initialState() {
  return {
    todos: Todos.initialState()
  };
}
export const reducer = combineReducers({ todos: Todos.reducer });

store

src/index.tsx にあった createStore を関数に切り出してフォルダを分ける。

src/store/index.ts
import { createStore, Store } from "redux";
import { initialState, reducer } from "./reducers";
export type StoreState = ReturnType<typeof initialState>;
export type ReduxStoreInstance = Store<StoreState>;

export function initStore(state = initialState()) {
  // createStoreの引数が1つだと初期値がなくてエラーとなる
  return createStore(reducer, state);
}

3. store で保持した state を View で表示する

components

src/components/Todo.tsx
import React from "react";

const Todo: React.FC<{ text: string }> = ({ text }) => {
  return <li>{text}</li>;
};

export default Todo;
src/components/TodoList.tsx
import React from "react";
import Todo from "./Todo";
import { State } from "../store/todos";

const TodoList: React.FC<State> = props => {
  return (
    <ul>
      {props.todos.map(todo => (
        <Todo key={todo.id} {...todo} />
      ))}
    </ul>
  );
};

export default TodoList;

container

src/container/VisibleTodoList.tsx
import { connect } from "react-redux";
import TodoList from "../components/TodoList";
import { StoreState } from "../store";

const mapStateToProps = (store: StoreState) => {
  return { todos: store.todos.todos };
};

const VisibleTodoList = connect(mapStateToProps)(TodoList);

export default VisibleTodoList;

App

src/components/App.tsx
import React from "react";
import VisibleTodoList from "../containers/VisibleTodoList";

const App: React.FC = () => {
  return (
    <div>
      <VisibleTodoList />
    </div>
  );
};

export default App;

index

index.tsx

import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import App from "./components/App";
import { addTodo } from "./actions";
import { initStore } from "./store";

const store = initStore();

store.dispatch(addTodo("Hello World!"));

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

ここまでで、画面にリストが出ることを確認できる。

この時点のソース

いつのまにか以下のエラーがでるようになっていた。。

Reducer "todos" returned undefined during initialization.
If the state passed to the reducer is undefined, you must explicitly return the initial state. The initial state may not be undefined. If you don't want to set a value for this reducer, you can use null instead of undefined.

以下のように reducer を直したらエラーは発生しなくなった。*

src/stores/todos/index.ts
export const reducer: Reducer<State, AddTodoAction> = produce(
-  (draft, action) => {
+  (draft = initialState(), action) => {
    switch (action.type) {
      case "ADD_TODO":
        const { payload } = action;
        draft.todos.push(payload);
        return draft;
      default:
        return draft;
    }
  }
);

4.フォームから todo を追加

containers/AddTodo.tsx
import React from "react";
import { Dispatch } from "redux";
import { connect } from "react-redux";
import { addTodo } from "../actions";

const mapDispatchToProps = (dispatch: Dispatch) => ({
  handleClick: (text: string) => dispatch(addTodo(text))
});

let AddTodo: React.FC<any> = ({ handleClick }) => {
  let input: HTMLInputElement;

  return (
    <div>
      <input
        ref={node => {
          input = node!; // nodeがnullはありえないので、!でnullでないことを示す
        }}
      />
      <button
        onClick={() => {
          handleClick(input.value);
          input.value = "";
        }}
      >
        Add Todo
      </button>
    </div>
  );
};

AddTodo = connect(null, mapDispatchToProps)(AddTodo);

export default AddTodo;
src/comonents/App.tsx
import React from "react";
import VisibleTodoList from "../containers/VisibleTodoList";
+import AddTodo from "../containers/AddTodo";
const App: React.FC = () => {
  return (
    <div>
+      <AddTodo />
      <VisibleTodoList />
    </div>
  );
};

export default App;

この時点のソース

参考

【Typescript×React】tsconfig.json の設定項目を詳しく紹介

tsconfig 日本語訳(3.03)
[TypeScript] create-react-app で始めるだいたいストレスフリーな開発環境の構築 2
Redux 開発で絶対使うべき Redux DevTools Extension 解説
Redux Example の Todo List をはじめからていねいにを Typescript で(1)
Immerjs で Redux 周りをスッキリさせたい
create-react-app で作った雛形のコードが Service Worker で何をしているのか
React + TypeScript の ESLint ルールをカスタマイズしたり、Airbnb のやつを導入するぞ。
トリプルスラッシュ・ディレクティブ
関東最速で React+Redux+TypeScript なアプリの開発環境を作る
redux を typescript で使うならこれを使うしかない。(typescript-fsa がすごい)
typescript-fsa に頼らない React × Redux
TypeScript で Redux の Reducer 部分を型安全かつスッキリ書く
TypeScript 2.4+における Redux Action
Flux Standard Action(FSA)の説明
Redux の createStore()の処理を追う(Middleware 有りの場合)
React ビギナーズガイドを typescript で勉強し直してわかったこと ②【propTypes の必要性について】
React を TypeScript で書く 3: React 編
react-redux-typescript-guide
React を TypeScript で書ける環境で、Redux の Tutorial をしてみる
React (TypeScript): ベストプラクティス
エラー「Reducer returned undefined during initialization」(React/Redux)
Redux をソースコードから理解する その 1

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2