84
67

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ざっくり React & Redux with TypeScript

Last updated at Posted at 2019-08-15

はじめに

この記事を書いた動機

3年前に書いた記事に対して未だにちょこちょこと反応が来るものの、自分自身はもうこの方法ではやってないし、1年で大きく動くと言われるフロントエンド界隈で3年以上前の方法で学ばれるのもまずいと思い、知識の整理も込めて比較的新しい書き方で当該記事をリメイクしたい。

方針

  • TypeScript を使う
  • React と Redux と React-Thunk を使う部分は変えない
  • redux-starter-kit を使って楽する

環境構築

npm install

npm init した上で実行。
このへんに関してはあまり何も考えずに入れる。

# webpack系
npm install --save-dev webpack webpack-cli webpack-dev-server clean-webpack-plugin html-webpack-plugin

# React & Redux
npm install --save-dev react react-dom redux react-redux redux-starter-kit

# TypeScript
npm install --save-dev typescript tslint ts-loader @types/react @types/react-dom @types/react-redux

webpackの設定

src/index.tsx をルートとして、ビルド時には build 以下にアセット群を吐き出すような場合は以下のように設定する。
html-webpack-plugin は元となるテンプレートからHTMLを生成してくれる。
clean-webpack-plugin はビルド生成先のディレクトリ配下にある不要なファイルを消してくれる。

webpack.config.js
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: __dirname + '/src/index.tsx',
  plugins: [
    new CleanWebpackPlugin({
      cleanAfterEveryBuildPatterns:['build']
    }),
    new HtmlWebpackPlugin({
      template: 'src/templates/index.html'
    }),
  ],
  output: {
    path: __dirname + '/build',
    filename: '[name].[contenthash].js'
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js']
  },
  module: {
    rules: [
      { test: /\.tsx?$/, loader: 'ts-loader' }
    ]
  }
}

TypeScriptコンパイラの設定

この辺もあんまり考えることなく設定。

tsconfig.json
{
  "compilerOptions": {
    "sourceMap": true,
    "module": "commonjs",
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "experimentalDecorators": true,
    "target": "es5",
    "jsx": "react",
    "lib": [
      "dom",
      "es6"
    ]
  },
  "include": [
    "src"
  ],
  "compileOnSave": false
}

tslint設定

tslint:recommend を使った上で、コードを書いてるときに指摘されて「これはめんどいな…」と思ったものを rules に追加して、無効にしたりカスタマイズしていくと良さそう。

tslint.json
{
  "extends": ["tslint:recommended"],
  "rules": {
    "object-literal-sort-keys": false,
    "ordered-imports": false,
    "no-console": [true, "log"]
  }
}

ビルド&開発用サーバー設定

前は grunt とか使ってたりしたけど、最近はそのへんをすべて webpack に集約させているので、滅茶苦茶複雑なビルドパイプラインとかが必要でない限りは大体 npm scripts で事足りるようになった。
最終的な package.json はこんな感じになる。

package.json
{
  "name": "react-sample",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "clean": "rm -rf ./node_modules package-lock.json && npm i",
    "build": "webpack -p",
    "start": "webpack-dev-server -d --content-base ./public"
  },
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "@types/react": "^16.9.1",
    "@types/react-dom": "^16.8.5",
    "@types/react-redux": "^7.1.1",
    "clean-webpack-plugin": "^3.0.0",
    "html-webpack-plugin": "^3.2.0",
    "react": "^16.9.0",
    "react-dom": "^16.9.0",
    "react-redux": "^7.1.0",
    "redux": "^4.0.4",
    "redux-starter-kit": "^0.6.3",
    "ts-loader": "^6.0.4",
    "tslint": "^5.18.0",
    "typescript": "^3.5.3",
    "webpack": "^4.39.2",
    "webpack-cli": "^3.3.6",
    "webpack-dev-server": "^3.8.0"
  }
}

動作確認

テンプレート追加

src/templates/index.html
<html lang="ja">
<body>
    <div id="root"></div>
</body>
</html>

文字を表示するだけの tsx を追加

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

ReactDOM.render(
  <h1>Hello World!</h1>,
  document.getElementById("root"),
);

ビルドして表示する

下記のコマンドを叩くと webpack-dev-server が起動するので、ブラウザで http://localhost:8080 にアクセスして Hello World! が表示されれば準備完了。
コード差分を検知してホットリローディングされるので、開発する際には立ち上げっぱなしで良い。

npm start
open http://localhost:8080/

ToDoアプリ実装

ディレクトリ構成

いわゆる ducks スタイルというものを採用。
Redux が出始めの頃は actionreducer でディレクトリを分ける感じだったが、最近はまとめて書くやり方のほうが主流っぽい。
React Component のうち、Redux とのやりとりをする containers とそれ以外の components に分けるという構成もあるのだが、小規模な構成のうちはとりあえず全部 components に突っ込んでしまっても良いと思う。
初期状態の src ディレクトリ以下の構成はこういう感じ。

src
├── index.tsx
├── components/
├── modules/
└── templates/

moduleを実装

Redux の action と reducer の部分を実装する。
前半部分に interface を定義する部分が増えたものの、全体的なコード量は以前と比べて減ったのではないだろうか?

前のやり方との違いとして、state を変更する部分で Object.assign などを使わなくても redux-starter-kit 側でいい感じにやってくれているので、直で state の値をいじっても良くなった(内部的にはImmerを使ってるらしい)。

reducers として定義しているものを React 側からは taskModule.actions.addTask() という風にして呼び出す。

また、非同期処理で redux-thunk を使う場合は、こういう感じ(下記コードの saveTasks を参照)で書くと良い。

src/modules/taskModule.ts
import { createSlice, PayloadAction } from "redux-starter-kit";

export interface ITask {
  id: number;
  name: string;
  isExecuted: boolean;
}

interface IEditTask {
  id: number;
  name: string;
}

interface ILoadingState {
  isLoading: boolean;
  isSuccess: boolean;
}

export interface IState {
  addTask: IEditTask;
  editTask: IEditTask;
  tasks: ITask[];
  saveState: ILoadingState;
}

const initialState: IState = {
  addTask: {
    id: 1,
    name: "",
  },
  editTask: {
    id: -1,
    name: "",
  },
  tasks: [],
  saveState: {
    isLoading: false,
    isSuccess: false,
  },
};

const taskModule = createSlice({
  initialState,
  reducers: {
    setAddTaskName: (state: IState, action: PayloadAction<string>) => {
      state.addTask.name = action.payload;
    },
    setEditTaskById: (state: IState, action: PayloadAction<number>) => {
      const task = state.tasks.find((t) => t.id === action.payload);
      if (!task) {
        return;
      }
      state.editTask = task;
    },
    setEditTaskName: (state: IState, action: PayloadAction<string>) => {
      state.editTask.name = action.payload;
    },
    addTask: (state: IState) => {
      if (!state.addTask.name) {
        return;
      }
      state.tasks.push({
        id: state.addTask.id,
        name: state.addTask.name,
        isExecuted: false,
      });
      state.addTask.id = state.addTask.id + 1;
      state.addTask.name = "";
    },
    editTask: (state: IState) => {
      const task = state.tasks.find((t) => t.id === state.editTask.id);
      if (!task) {
        return;
      }
      task.name = state.editTask.name;
      state.editTask.id = -1;
    },
    endTask: (state: IState, action: PayloadAction<number>) => {
      const task = state.tasks.find((t) => t.id === action.payload);
      if (!task) {
        return;
      }
      task.isExecuted = true;
    },
    setSaveState: (state: IState, action: PayloadAction<ILoadingState>) => {
      state.saveState = action.payload;
    },
  },
});

export const { actions: taskActions } = taskModule;
export default taskModule;

export const saveTasks = () => {
  return async (dispatch, getState) => {
    const { task } = getState();
    if (task.saveState.isLoading) {
      return;
    }
    dispatch(taskActions.setSaveState({isLoading: true, isSuccess: false}));
    // dummy
    setTimeout(() => {
      dispatch(taskActions.setSaveState({isLoading: false, isSuccess: true}));
    }, 1000);
  };
};

あと hooks で state を引っ張ってくるときに必要になるので、modules 配下にある state の interface はまとめておく。

src/modules/index.ts
import { IState as TaskState } from "./taskModule";

export interface IRootState {
  task: TaskState;
}

storeを作る

正確には、reducer をまとめた rootReducer と、store を生成するための setupStore() を作る。

src/store.ts
import { combineReducers, configureStore, getDefaultMiddleware } from "redux-starter-kit";
import taskModule from "./modules/taskModule";

const rootReducer = combineReducers({
  task: taskModule.reducer,
});

export const setupStore = () => {
  const middleware = getDefaultMiddleware();
  return configureStore({
    reducer: rootReducer,
    middleware,
  });
};

componentを作る

props経由でリスト表示する component の例。
上記で実装した actions は useDispatch() 経由で呼び出す。

src/components/todo_list.tsx
import React from "react";
import { useDispatch } from "react-redux";

import { ITask } from "../modules/taskModule";
import { taskActions } from "../modules/taskModule";

const TodoList: React.FC<{ list: ITask[], isEndTasks: boolean }> = ({ list, isEndTasks }) => {
  const dispatch = useDispatch();
  const endTask = (task: ITask) => dispatch(taskActions.endTask(task.id));
  const editTask = (task: ITask) => dispatch(taskActions.setEditTaskById(task.id));

  const targetList = list.filter((task) => {
    if (isEndTasks) {
      return task.isExecuted;
    } else {
      return !task.isExecuted;
    }
  });

  if (list.length < 1) {
    return null;
  }

  return (
    <table>
      <tbody>
      <tr>
        <th>ID</th>
        <th>Name</th>
        <th>Actions</th>
      </tr>
      {targetList.map((task) => {
        return (
          <tr key={task.id}>
            <td>{task.id}</td>
            <td>{task.name}</td>
            <td>
              <button onClick={() => editTask(task)} disabled={isEndTasks}>Edit</button>
              <button onClick={() => endTask(task)} disabled={isEndTasks}>End</button>
            </td>
          </tr>
        );
      })}
      </tbody>
    </table>
  );
};

export default TodoList;

index.tsxで各componentとstoreを接続する

本当は form の部分とか諸々を components に移したほうが良いのだが、横着して index に置いている。
setupStore() を呼び出し <Provider store={store}><App /></Provider> とする部分は前とあまり変わってない。

state の取り出し方は hooks によって大きく変わった部分だが、書いてあるように useSelecter() で取り出したい state を選択するだけで取ってこれるのでそんなに難しくないと思う。

src/index.tsx
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { useDispatch, useSelector } from "react-redux";

import { setupStore } from "./store";
import { IRootState } from "./modules";
import TodoList from "./components/todo_list";
import { taskActions, saveTasks } from "./modules/taskModule";

const store = setupStore();

const App: React.FC = () => {
  const dispatch = useDispatch();
  const taskState = useSelector((state: IRootState) => state.task);

  const onChangeTaskName = (e) => dispatch(taskActions.setAddTaskName(e.target.value));
  const onChangeEditTaskName = (e) => dispatch(taskActions.setEditTaskName(e.target.value));
  const onClickAdd = (e) => {
    if (e) {
      e.preventDefault();
    }
    dispatch(taskActions.addTask());
  };
  const onClickEdit = (e) => {
    if (e) {
      e.preventDefault();
    }
    dispatch(taskActions.editTask());
  };
  const onClickSave = () => dispatch(saveTasks());

  return (
    <>
      <h1>Todo</h1>
      <h2>Add Task</h2>
      <form onSubmit={onClickAdd}>
        <input type="text" value={taskState.addTask.name} onChange={onChangeTaskName} />
        <button onClick={onClickAdd}>Add</button>
      </form>
      <h2>Current List</h2>
      <TodoList list={taskState.tasks} isEndTasks={false} />
      {taskState.editTask.id > -1 ? (
        <>
          <h2>Edit Task</h2>
          <form onSubmit={onClickEdit}>
            ({taskState.editTask.id}):
            <input type="text" value={taskState.editTask.name} onChange={onChangeEditTaskName} />
            <button onClick={onClickEdit}>Edit</button>
          </form>
        </>
      ) : null}
      <h2>Executed List</h2>
      <TodoList list={taskState.tasks} isEndTasks={true} />
      <h2>Save</h2>
      <button onClick={onClickSave} disabled={taskState.saveState.isLoading}>
        {taskState.saveState.isLoading ? "Saving..." : "Save"}
      </button>
      <div style={{display: taskState.saveState.isSuccess ? "block" : "none", color: "#FF0000"}}>
        Save Complete!
      </div>
    </>
  );
};

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

最終的なディレクトリ構造

src
├── index.tsx
├── store.ts
├── components/
│   └── todo_list.tsx
├── modules/
│   ├── index.ts
│   └── taskModule.ts
└── templates/
    └── index.html
84
67
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
84
67

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?