👩‍❤️‍👨

TypescriptでもReduxのActionTypeと仲良くしたい!

2023/03/04に公開

序章 「この生意気なっ!」

TypescriptでReduxを書くときはこう思ったことはありませんか?この生意気な訳の分からないAnyActionを分からせたい!と。
俺はあります。

redux-middleware-img

上の図がmiddlewareに組み込まれているAnyActionの一例なんですけど、そのtype定義を見てみると、なんとnextactionについては必ずanyになるようになっていると!Genericsを使った拡張や絞り込みも許してくれない!

redux-middleware-type-img

何と生意気!なので分からせて行きます(適当

第一話 「まずは情報収集だ!」

恐らく大分の人たちはRedux/ToolkitでReduxを書いていると思いますが、Toolkitを使うと、普通createSliceを使って、state, reducer, actionなどをまとめて書くのが一般的(なはず)で、直にCreateActionなどを触るのが結構レアになってくるかと思います。

なので一般的なcounterSliceの例を見て行きましょう(急に真面目

import { createSlice, PayloadAction } from "@reduxjs/toolkit";

export const counterSlice = createSlice({
  name: "counter",
  initialState: {
    value: 0,
  },
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
const counterReducer = counterSlice.reducer;
export default counterReducer;

このような書き方をすると、自然にcounterReducerAnyActionになるように推測されてしまう

普通にreduxを書いていく上ではあまりこのような事を気にならないと思うのですが、でも俺は束縛欲の強い人なので、何とか束縛して行きたいですね(迷言

middlewareを書くときはmatchを使ってifスコープ内に一時的に型を束縛してくれるのですが

import { Middleware } from "@reduxjs/toolkit";
import { incrementByAmount } from "./counterSlice";

const sampleMiddleware: Middleware = (storeApi) => (next) => (action) => {
  if(incrementByAmount.match(action)) {
    // ここで action のタイプが incrementByAmount になる
  }
};

例えスコープ外で何かをやりたい時はanyのままなのでこれは良くありません。sliceを見返してみると、ちゃんとexport const {...} = counterSlice.actionsをしているのなら、ここで何とかActionTypeを抽出したいですね。

第二話 「体は正直なRedux君」

Redux君と健全かつ友好的な交流を深めたところ(ドキュメント読みかつ型定義コード読み)、sliceによって作られたactionはActionCreatorWithPayloadなどの型をしていて、この型の中身がActionTypeのobjectが入っていることに気づきました、いや、Redux君が教えてくれましたよ(ハート

export interface ActionCreatorWithPayload<P, T extends string = string> extends BaseActionCreator<P, T> {
    /**
     * Calling this {@link redux#ActionCreator} with an argument will
     * return a {@link PayloadAction} of type `T` with a payload of `P`
     */
    (payload: P): PayloadAction<P, T>;
}

これで話が簡単になったので、ReturnTypeを使えばこの実際的に生成されたtypeを触ることができます。えちちですね。

// ...前のコードを省略

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export type CounterActionsType = ReturnType<
  typeof increment | typeof decrement | typeof incrementByAmount
>;
const counterReducer = counterSlice.reducer;
export default counterReducer;

おお、やればできるじゃないかRedux君!でもこの書き方だと毎回reducerを追加するたびにここのtypeof xxxも追加しないといけないので、めんどくさい。
Redux君ってなぜこれを自分でやらないの!何でも他人だよりって俺は良くないって思うな!
なのでこれもredux君に押し付けやってもらいましょう。

const counterActions = counterSlice.actions;
export const { increment, decrement, incrementByAmount } = counterActions;
export type CounterActionsType = ReturnType<
  (typeof counterActions)[keyof typeof counterActions]
>;
const counterReducer = counterSlice.reducer;
export default counterReducer;

これで毎回reducer追加するときreduxのジェネリックすは自動的に型を推測してくれるようになりました、よかったですね!

そしてこれをsliceことに書いて、最後にstoreなどでまとめてあげるとアプリ全体のActionTypeが出来上がります

// store.ts などのところで

import { type CounterActionsType } from "./counterSlice";

const rootReducer = combineReducers({
  counter: counterReducer,
});

const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) => {
    return getDefaultMiddleware().concat([yourMiddlewareHere]);
  },
});

export type RootState = ReturnType<typeof rootReducer>;
export type AppDispatch = typeof store.dispatch;
export type AppActionsType =
  | CounterActionsType
  | ... // 他の slice の ActionType
  | ...

最後はこれをmiddlewareなどの使いたいところで使うだけですね、たとえば
storeに関する型定義は公式ドキュメントにあるのでここでは割愛します

import { Middleware } from "@reduxjs/toolkit";
import { RootState, AppDispatch } from "./store";

const loggerMiddleware: Middleware<{}, RootState> =
  (storeApi) => (next: AppDispatch) => (action: AppActionsType) => {
    // action がすでに typed されています
  };

第一部 完

レアケースじゃないと使われないかもしれないですが、一応こういうやり方もありますよって感じで書かせていただきました。

余談ですが、reduxのmiddlewareでは、storeApi.dispatch()next()の2つの方法でmiddleware内でdispatchすることができるのですが、具体的な違いはstoreApi.dispatch()はゼロからのdispatchで、再度全てのmiddleware chainを通過しますが、next()は引き続きdispatchするので、すでに通過したmiddlewareは再度トリガーされません。

import { Middleware } from "@reduxjs/toolkit";
import { RootState, AppDispatch } from "./store";
import { increment, decrement } from "./counterSlice";

const loggerMiddleware: Middleware<{}, RootState> =
  (storeApi) => (next: AppDispatch) => (action: AppActionsType) => {
    console.log("catch action": action);
    next(increment())
    storeApi.dispatch(decrement())
    return;
  };

上のmiddlewareを適用して、例えばどこかでdispatch(increment())を呼ぶと、コンソールに

catch action {
  type: "counter/increment";
}
catch action {
  type: "counter/decrement";
}

が出力されるはずです、そしてもし最初のstateがだったら、今のstateは1になるはずです。

以上、解散!

Discussion