こうこく
作 ▸

React hooks + TypeScriptでリデューサを使う

コンテキスト+リデューサでReduxみたいなことばかりしてたら、コンテキストとリデューサ単体の使い方を忘れたので復讐する。今回はリデューサ。

react 17.0.2react-dom 17.0.9
もくじ

説明

リデューサは大きくて複雑な state を扱いたいときに使うやつ。値同士が連動しているというか、あっちに値をセットするたびにこっちのフラグが変化するとかそういう場合に便利。もともとReactと併せてよく使われてたReduxと同じ。

フック API リファレンス – React

使い方だけならそんな難しいことは無いと思うのだが、個人的にはTypeScriptでキレイに書こうと思うと毎回書き方が安定しない。なのでこの記事では、メモも兼ねて筆者が最近やってる書き方をサンプルコードにしておく。

サンプル

サンプルの内容をまとめると以下。この画面の描画に必要な情報をリデューサで管理してみる。

  1. 画面表示時に非同期でユーザ情報を取得する。取得中は画面に「読み込み中」と表示する。
  2. ユーザ情報を入力フォームで変更できる。
  3. リセットボタンを押すと、入力フォームの内容が変更前の状態に戻る。
  4. 保存ボタンを押すと、入力フォームの内容を保存する。
  5. 保存ボタンはユーザ情報が変更されている場合のみ押すことができる。

See the Pen using-reducer-with-react-hooks-and-typescript by napoporitataso (@napoporitataso) on CodePen.

以下、作り方。

準備

以下コマンドで必要なパッケージをインストール。

npm install react react-dom typescript webpack webpack-cli ts-loader @types/react @types/react-dom
npm install -D webpack-dev-server

この記事を書いた時の各パッケージのバージョンはこんな感じ。

package.json (抜粋)
{
  "dependencies": {
    "@types/react": "^17.0.14",
    "@types/react-dom": "^17.0.9",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "ts-loader": "^9.2.3",
    "typescript": "^4.3.5",
    "webpack": "^5.46.0",
    "webpack-cli": "^4.7.2"
  },
  "devDependencies": {
    "webpack-dev-server": "^3.11.2"
  }
}

ほか、各種設定ファイルはこんな感じ。適当。

tsconfig.json
{
  "include": ["src"],
  "compilerOptions": {
    "module": "amd",
    "target": "es5",
    "outDir": "dist",
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "noImplicitAny": true,
    "noImplicitThis": true,
    "strictNullChecks": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "downlevelIteration": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "jsx": "react"
  }
}
webpack.config.js
const path = require('path');

module.exports = {
  mode: 'production',  // ※サンプルページのビルド用に production にしてあるけどローカルなら development でいい
  entry: './src/index.tsx',
  output: {
    path: path.join(__dirname, 'public'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.(tsx?|jsx?)$/,
        include: [path.resolve(__dirname, 'src')],
        use: {
          loader: 'ts-loader',
          options: {
            transpileOnly: true,
            configFile: 'tsconfig.json',
          },
        },
      },
    ],
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx'],
  },
  devServer: {
    contentBase: path.join(__dirname, 'public'),
    host: 'localhost',
    port: 3000,
    open: true,
    watchContentBase: true,
  },
};

ソースコード

ディレクトリ構成

サンプルコードのディレクトリ構成は以下の通り。

public/
  +-- index.html

src/
  +-- components/
  |    +-- App.tsx
  |
  +-- stores/
  |    +-- UserStore.tsx
  |
  +-- index.tsx

ストア

リデューサはstateとそれを操作するための手続きのかたまりなので、本当はそれを扱うコンポーネントと同じファイルに書くのがいいのではと思うのだが、リデューサのコードが肥大化することを考えると別ファイルにするのが良いと思う。

Reduxではそのかたまりをストアと呼んでいたので、ここでは stores/UserStore.tsx というファイルに分離する。

また、アクションごとに型を定義して、アクション自体はそのUnion型 (判別可能なUnion型) で扱うようにする。

src/stores/UserStore.tsx
import React, { useReducer } from 'react';

/** Stateの型 */
type UserStoreState = {
  loading: boolean;
  modified: boolean;
  user: UserData;
  orgUser: UserData;
};
type UserData = {
  id: string;
  name: string;
};

/** Actionの型 */
type RequestAction = {
  type: 'request';
};
type RequestSuccessAction = {
  type: 'requestSuccess';
  user: UserData;
};
type EditAction = {
  type: 'edit';
  user: UserData;
};
type ResetAction = {
  type: 'reset';
};
type SaveAction = {
  type: 'save';
};
type UserStoreAction = RequestAction | RequestSuccessAction | EditAction | ResetAction | SaveAction;

/** ストアの初期値 */
const initialState: UserStoreState = {
  loading: false,    // API実行中であればtrue (「読み込み中」を表示するか否か)
  modified: false,   // ユーザ情報が変更済みであればtrue (リセットボタンを押せるか否か)
  user: {            // 現在のユーザ情報 (テキストボックスに表示されてるもの)
    id: '',
    name: ''
  },
  orgUser: {         // 変更前のユーザ情報 (テキストボックスの左に表示されてるもの)
    id: '',
    name: ''
  },
};

/**
 * リデューサ
 * @param state
 * @param action
 * @returns
 */
const reducer = (state: UserStoreState, action: UserStoreAction): UserStoreState => {
  switch (action.type) {
    // ユーザ情報リクエスト (読み込み中フラグONにするだけ)
    case 'request':
      return {
        ...state,
        loading: true,
      };
    // ユーザ情報取得成功 (読み込み中フラグOFFにしてユーザ情報をセット)
    case 'requestSuccess':
      return {
        ...state,
        loading: false,
        user: { ...action.user },
        orgUser: { ...action.user },
      };
    // ユーザ情報を編集 (新しいユーザ情報をセット、元の値と変更されていれば変更済みフラグON)
    case 'edit':
      return {
        ...state,
        modified: state.orgUser.id !== action.user.id || state.orgUser.name !== action.user.name,
        user: {
          ...action.user,
        },
      };
    // ユーザ情報のリセット (現在のユーザ情報を変更前のユーザ情報で上書き、変更済みフラグOFF)
    case 'reset':
      return {
        ...state,
        modified: false,
        user: {
          ...state.orgUser,
        },
      };
    // ユーザ情報の保存 (現在のユーザ情報で変更前のユーザ情報を上書き、変更済みフラグOFF)
    case 'save':
      return {
        ...state,
        modified: false,
        orgUser: {
          ...state.user,
        },
      };
    default:
      console.error('Invalid action ->', action);
      throw new Error(`Invalid action.`);
  }
};

/**
 * このストアを使うためのフック
 * @returns
 */
export const useUserStore = (): [UserStoreState, React.Dispatch<UserStoreAction>] => {
  const [state, dispatch] = useReducer(reducer, initialState);
  return [state, dispatch];
};

それ以外のコード

何の変哲もない index.htmlindex.tsx

public/index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
  <meta name="landscape" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
  <meta name="format-detection" content="telephone=no" />
  <title>サンプル</title>
</head>
<body>
  <div id="app"></div>
  <script src="./bundle.js"></script>
</body>
</html>
src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';

import App from './components/App';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('app')
);

次にリデューサを使う <App> コンポーネント。

上で作った UserStore から useUserStore() をimportして実行すると、statedispatch() を取得できる。state から値を取り出して画面を描画したり、イベントをトリガーに dispatch() を実行して state を更新したりする。

src/components/App.tsx
import React, { useCallback, useEffect } from 'react';
import { useUserStore } from '../stores/UserStore';

const App: React.FC = (): React.ReactElement => {
  const [state, dispatch] = useUserStore();
  const { loading, modified, user, orgUser } = state;

  // マウント時にapiで取ってくるというテイ
  useEffect(() => {
    dispatch({ type: 'request' });
    setTimeout(() => {
      dispatch({
        type: 'requestSuccess',
        user: {
          id: 'kiriukun',
          name: 'キリウ君',
        },
      });
    }, 2000);
  }, []);

  // ユーザ情報を変更した時
  const handleChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>): void => {
      const newUser = { ...user };
      switch (event.target.name) {
        case 'id':
          newUser.id = event.target.value;
          break;
        case 'name':
          newUser.name = event.target.value;
          break;
      }
      dispatch({
        type: 'edit',
        user: newUser,
      });
    },
    [user]
  );

  // リセットボタンを押した時
  const handleClickReset = useCallback(() => dispatch({ type: 'reset' }), []);

  // 保存ボタンを押した時
  const handleClickSave = useCallback(() => dispatch({ type: 'save' }), []);

  if (loading) {
    return <div>読み込み中...</div>;
  }

  return (
    <div>
      <div>
        id: {orgUser.id} <input type="text" name="id" onChange={handleChange} value={user.id} />
      </div>
      <div>
        name: {orgUser.name} <input type="text" name="name" onChange={handleChange} value={user.name} />
      </div>
      <div>
        <button onClick={handleClickReset}>リセット</button>
        <button disabled={!modified} onClick={handleClickSave}>
          保存
        </button>
      </div>
    </div>
  );
};

export default App;

実行方法

上記のサンプルコードを全部用意して以下コマンドを実行すると、ブラウザ上で動作確認できるはず。

npx webpack-cli serve

以上。

この記事に何かあればこちらまで (非公開)