TypescriptでもReduxのActionTypeと仲良くしたい!
序章 「この生意気なっ!」
TypescriptでReduxを書くときはこう思ったことはありませんか?この生意気な訳の分からないAnyAction
を分からせたい!と。
俺はあります。
上の図がmiddlewareに組み込まれているAnyAction
の一例なんですけど、そのtype定義を見てみると、なんとnext
とaction
については必ずany
になるようになっていると!Genericsを使った拡張や絞り込みも許してくれない!
何と生意気!なので分からせて行きます(適当
第一話 「まずは情報収集だ!」
恐らく大分の人たちは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;
このような書き方をすると、自然にcounterReducer
はAnyAction
になるように推測されてしまう
普通に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が0
だったら、今のstateは1
になるはずです。
以上、解散!
Discussion