ReactをTypeScriptで書く4: Redux編

メモです。
そして前回からの続きです。

参考
redux/UsageWithTypescript.md at master · reduxjs/redux · GitHub
React + Redux + TypeScriptの最小構成 - Qiita
typescript-fsaに頼らないReact × Redux - ログミーTech

追記: 2019/07/16
若干修正しました。

作るもの

テキストを入力してカードを追加するだけのページを作成する。
ただの劣化ToDo…。

構成はこうなった。

src/

  • store.ts(store)

src/components/

  • card.tsx(カード)
  • cardList.tsx(カードリスト)
  • inputArea.tsx(入力送信フォーム)

src/containers/

  • cardlist.tsx(カードリスト)
  • inputArea.tsx(入力送信フォーム)

src/modules/

  • cardList.tsx(カードリストのstateとaction, reducer)

react typescript04 01

Action, ActionCreator,Reducerは"src/modules/cardList.tsx"にまとめる。

今回は解説のため、各containerにはstateかactionのどちらか片方を渡す。

環境

  • redux 4.0.1
  • react-redux 6.0.0
  • @types/react-redux 6.0.12

導入

reduxとreact-reduxの導入

yarn add redux react-redux

@types/Reduxの導入

DefinitelyTypedの型定義ファイルをインストールする。

yarn add -D @types/react-redux

型ファイルの作成

今回は、使いまわせる型がいくつかある。

  • CardState...カードに持たせる情報
  • CardListState...CardStateが入る配列

これらの型を「カードのstate」「カードリストのstate」にして、src/types.tsxにまとめた。

src/types.tsx
// Cardの型
export type CardState = {
  id: number;
  text: string;
}

// CardListの型
export type CardListState = {
  cards: CardState[];
}

Presentational Componentの構築

前回で覚えたことを使って書いていく。
"card.tsx"と、"cardList.tsx"、"inputArea.tsx"の3つを作成。

src/components/card.tsx
import * as React from 'react';
import { CardState } from '../types';

// React.FCの型引数にCardStateを使用
const Card: React.FC<CardState> = props => {
  return (
    <article className="card">
      <header className="card-header">{props.id}</header>
      <div className="card-box">
        <h2 className="card__text">{props.text}</h2>
      </div>
    </article>
  );
};

export default Card;
src/components/cardList.tsx
import * as React from 'react';
import { CardState, CardListState } from '../types';
import Card from './card';

// CardListのtypeは、types.tsxからimportする
type CardListProps = CardListState;

// React.FCに、CardListPropsを型引数として指定
const CardList: React.FC<CardListProps> = props => {
  return (
    <div>
      {/* CardコンポーネントはCardState型を当てはめる */}
      {props.cards.map((card: CardState) => (
        <Card
          key={card.id}
          id={card.id}
          text={card.text}
        />
      ))}
    </div>
  );
}

export default CardList;
src/components/inputArea.tsx
import * as React from 'react';

type InputAreaProps = {
  add: (text: string) => void;
  remove: () => void;
}

const ADD = 'add';
const REMOVE = 'remove';

const InputArea: React.FC<InputAreaProps> = props => {
  const textInput = React.createRef<HTMLInputElement>();

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (e.currentTarget.getAttribute('data-type') === ADD && textInput.current && textInput.current.value) {
      props.add(textInput.current.value);
      textInput.current.value = '';
    }
    if (e.currentTarget.getAttribute('data-type') === REMOVE) {
      props.remove();
    }
  };

  return (
    <div>
      <input
        type="text"
        placeholder="テキスト"
        ref={textInput}
      />
      <button
        onClick={handleClick}
        data-type={ADD}
      >
        {ADD}
      </button>
      <button
        onClick={handleClick}
        data-type={REMOVE}
      >
        {REMOVE}
      </button>
    </div>
  );
};

export default InputArea;

前回の知識でいける。
"Card"と"CardList"は先ほど作成した型ファイルが使えるので、それをimportして当てはめる。
inputArea.tsxの記述はちょっと無理があった。

Action、Reducer部分の構築

やることはあまり変わっていない。

全体としてはこうなる感じ。

src/modules/cardList.tsx
import { Action } from "redux";
// types.tsxから、CardListState型をimport
import { CardState, CardListState } from "../types";

//////////////////
// Action
//////////////////

// ActionNames
export const ADD = "ADD" as const;
export const REMOVE = "REMOVE" as const;

//////////////////
// ActionCreator
//////////////////

// interfaceでAddActionを定義
interface AddAction extends Action {
  type: typeof ADD;
  payload: CardState;
}

// 引数をstring、戻り値をAddActionで定義
export const add = (text: string): AddAction => ({
  type: ADD,
  payload: {
    id: new Date().getTime(),
    text
  }
});

// interfaceでRemoveActionを定義
interface RemoveAction extends Action {
  type: typeof REMOVE;
}

// 戻り値をRemoveActionで定義
export const remove = (): RemoveAction => ({
  type: REMOVE
});

// Actionの型をまとめた、CardListActions型をエクスポート
// 複数ある場合、union型で繋ぐ
export type CardListActions = AddAction | RemoveAction;

//////////////////
// Reducer
//////////////////

// initialStateには、CardListState型をあてはめる
const initialState: CardListState = {
  cards: [
    {
      id: 0,
      text: "hoge"
    }
  ]
};

// reducerの型
// stateにはCardListState、actionにはCardListActionsを定義
export default function reducer(
  state: CardListState = initialState,
  action: CardListActions
) {
  switch (action.type) {
    case ADD:
      return {
        ...state,
        cards: [...state.cards, action.payload]
      };
    case REMOVE:
      return {
        ...state,
        cards: state.cards.filter(
          (card, index) => index !== state.cards.length - 1
        )
      };
    default:
      const _: never = action; // action漏れの確認
      return state;
  }
}

ActionとActionCreatorの作成

Actionの名前については、Enumで指定する方法がある。

enum ActionNames {
  ADD = 'ADD',
  REMOVE = 'REMOVE',
}

interface ADD extends Action {
  type: ActionNames.ADD;
  payload: {
    id: number;
    text: string;
  };
}

もしくは、export constして文字列リテラル型を入れる。

export const ADD = 'ADD' as const;

interface AddAction extends Action {
  type: typeof ADD;
  payload: {
    id: number;
    text: string;
  };
}

その場合、ADDは変数名となり、型として参照させられない。
typeofを使用することで、"ADD"型を当てはめてくれる。

Enumは識別子の衝突を防げるけど、衝突するような構築にならない。
constの方が出力後のサイズも若干減るし、constでいい気がする。

as const

as constはTypeScript3.4で追加されたconst assertion機能。

const ADD = 'ADD';は'ADD'の文字列リテラルとして推論されるものの、
再代入可能なところに入ると、string型として解釈されてしまう。
そこで型注釈なりアサーションなりで同じ文字列を書かなければならなかった。
const assertionによってこの問題が解決した。

参考
TypeScript - WideningLiteralTypes - - Qiita
TypeScript3.4 の const assertion - Qiita
【redux】as constでaction creatorからActionの型を簡単につくる - Qiita

export const ADD = 'ADD'; // 今まで
export const ADD_ANNOT: 'ADD' = 'ADD'; // 今まで2
export const ADD_AS_ADD = 'ADD' as 'ADD'; // 今まで3
export const ADD_AS_CONST = 'ADD' as const; // これで解決

const check = {
  ADD, // string型
  ADD_ANNOT, // 'ADD'型
  ADD_AS_ADD, // 'ADD'型
  ADD_AS_CONST, // 'ADD'型
};

ActionTypeをまとめる

次に、Actionをまとめた型を作成してexportする。
のちにmapDispatchToPropsActionDispatcherなどで使用する為。

export type CardListActions = AddAction | RemoveAction;

Reducerの作成とnever型

まず、initialStateの型エイリアスを作成する。
型は/src/types.tsxの"CardListState"を用いる。

const initialState: CardListState = {
  cards: [{
    id: 0,
    text: 'hoge',
  }],
};

reducer部分。
defaultの箇所に、never型というのがある。

export default function reducer(state: CardListState = initialState, action: CardListActions) {
  switch (action.type) {
    case ADD:
      return {
        ...state,
        cards: [
          ...state.cards,
          action.payload,
        ]
      }
    case REMOVE:
      return {
        ...state,
        cards: state.cards.filter(
          (card, index) => index !== state.cards.length - 1
        ),
      }
    default:
      const _: never = action; // action漏れの確認      return state;
  }
};

neverは、到達できない値の型になる。
主にthrowする時、if文やswitch文で起こる。
voidとの違いは、「返す値がない」ではなくて「返らない」ところ。

// 常にthrowするアロー関数は返らずにnever型になる
const error = (message: string) => {
  throw new Error(message);
}

// barにはstringかnumberしか入らない
function foo(bar: string | number): boolean {
  // barはstringかもしれない
  if (typeof bar === "string") {
    return true;
  // stringじゃなければnumber
  } else if (typeof bar === "number") {
    return false;
  }
  // stringでもnumberでもないことはあり得ない
  // このbarには到達できない、返らないのでnever型になる
  return bar;
}

このnever型とUnion型を用いて、switch文でActionの漏れを確認することができる。

今、actionはCardListActions型(ADD | REMOVE)になっている。
caseでADDとREMOVEの条件をクリアしているので、actionの型はdefaultに到達不可能、つまりnever型になっている。

そこで、「never型の変数にactionを代入する」という処理を書く。
すると、case漏れでADDかREMOVEの可能性が残っている時、actionはnever型でなくなるので、変数との型不一致でエラーになる。
これで、ADDとREMOVEの処理が網羅できているかどうかをチェックすることができる。

const _: never = action; // actionを網羅 = never型ならエラーは出ない

網羅チェック(Exhaustive Checks) - TypeScript Deep Dive 日本語版

(Unused variablesが有効の場合、こちらで記述されている関数が使用できます)
TypeScript3 + React/ReduxのAction型をactionCreatorから動的定義する - Qiita

storeファイルの構築

createStoreと、Actionをまとめた型と、Stateの型をexportする。

src/store.ts
import { createStore, combineReducers } from 'redux';
import CardList, { CardListActions } from './modules/cardList';

const store = createStore(
  combineReducers({
    CardList,
  })
);

export default store;

// Actionをまとめた、ActionTypes型を作る
export type ActionTypes = CardListActions;
// stateの型を作る
export type AppState = ReturnType<typeof store.getState>;

ActionTypes型の作成

今回まとめる必要がないけれど、dispatcherの型として使うためにまとめておく。

export type ActionTypes = CardListActions;

Stateの型とReturnType型

後にmapStateToPropsで使う、AppState型(stateの型)を指定する。

export type AppState = ReturnType<typeof store.getState>

ReturnTypeという見慣れない型が登場。
これを使うと、型引数に指定した関数の戻り値を型にすることができる。

TypeScript+Reduxで全ステートの型を解決するには - Qiita

Container Componentの構築

react-reduxのconnectを使ったContainer Componentを作成する。

CardList(mapStateToProps)

CardListには、mapStateToPropsを使う。
引数stateの型には、store.tsで作成したAppState型を使う。

src/containers/cardlist.tsx
import CardList from '../components/cardList';
import { connect } from 'react-redux';
import { AppState } from '../store';

// mapStateToPropsの引数にAppState型
const mapStateToProps = (state: AppState) => {
  return ({
    cards: state.CardList.cards
  });
};

export default connect(
  mapStateToProps
)(CardList);

Container: inputArea

inputAreaでは、mapDispatchToPropsを使う。
引数dispatchには、Dispatch型を使い、store.tsで作成したActionTypes型をジェネリクスに指定する。

src/containers/inputArea.tsx
import InputArea from '../components/inputArea';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { add, remove } from '../modules/cardList';
import { ActionTypes } from '../store';

// mapDispatchToPropsには、Dispatch型を指定
// 型引数には、CardListで指定したActionTypesを指定
const mapDispatchToProps = (dispatch: Dispatch<ActionTypes>) => {
  return ({
    add: (text: string) => dispatch(add(text)),
    remove: () => dispatch(remove()),
  });
};

export default connect(
  null,
  mapDispatchToProps
)(InputArea);

終わり。

まとめ

  • Action名は、Enumじゃなくてconstでいい
  • Dispatchの型指定に使うので、Actionの型を型エイリアスでまとめておく
  • never型でReducerのcase漏れを防げる
  • mapStateToPropsにはStateの型、mapDispatchToPropsにはDispatch
  • ReturnType型は、型引数に指定した関数の戻り値の型を返す

middleware編に続くかもしれませんが次回に続きます。