rejected type of Redux Thunk
Redux Thunkはストアと対話する非同期処理を作成できるミドルウェアです。アクションオブジェクトの代わりに関数を返すアクションクリエイターを作成することができ、APIリクエストの作成や複数のアクションをディスパッチするのに使用されます。
Redux Toolkitを使用する場合、APIへのフェッチはRTK Queryを使用した方がシンプルになりますが、多々使用する場面はあります。
この記事では、Thunkで非同期処理を行う際のaction typeを探求していきます。Thunkの基礎知識がある人向けです。また、すべてのコードを記述すると長くなるため、storeやhooksについては割合しています。
Thunk
APIからデータを取得して、その結果状況に応じて状態値を変化させる非同期処理を考えます。以下のようなスライスファイルを作成したとします。
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
// fetch内のオブジェクトタイプ
export interface Post {
userId: number;
id: number;
title: string;
body: string;
}
// fetchオブジェクトのタイプ
interface FetchState {
contents: Post[];
isLoading: boolean;
error: null | string;
}
// 初期状態値
const initialState: FetchState = {
contents: [],
isLoading: false,
error: null,
};
export const fetchContent = createAsyncThunk(
"content/fetchContent",
async () => {
try {
const res = await fetch("https://jsonplaceholder.typicode.com/post");
const data: Post[] = await res.json();
return data;
} catch (error: any) {
return error;
}
}
);
export const contentSlice = createSlice({
name: "content",
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchContent.pending, (state) => {
state.isLoading = true;
});
builder.addCase(
fetchContent.fulfilled,
(state, action: PayloadAction<Post[]>) => {
state.isLoading = false;
state.contents = action.payload;
}
);
builder.addCase(fetchContent.rejected, (state, action) => {
state.isLoading = false;
state.eror = action.payload.message;
});
},
});
export default contentSlice.reducer;
createSlice
ではbuilder callbackを使い、Thunk内の非同期処理の状態に応じて処理を分岐しています。fetchContent.fulfilled
のactionではPayloadAction
でpayloadのtypeを指定しています。これはAPIから取得した投稿データを表すTupleです。
UIは本題と異なるため解説は割合しますが、マウント時に非同期処理をdispatchし、取得した結果をレンダリングすることを想定しています。
import { useEffect } from "react";
import { useAppSelector, useAppDispatch } from "../../app/hooks";
import { fetchContent } from "./contentSlice";
export function Content() {
const dispatch = useAppDispatch();
const contents = useAppSelector((state) => state.content.contents);
const isLoading = useAppSelector((state) => state.content.isLoading);
const error = useAppSelector((state) => state.content.error);
useEffect(() => {
dispatch(fetchContent());
}, [dispatch]);
if (isLoading) {
return <p>loading...</p>;
}
if (error) {
return <p>{error}</p>;
}
return (
<div>
{contents.map((content) => (
<div key={content.id}>
<p>{content.title}</p>
</div>
))}
</div>
);
}
rejected type
fulfilled同様にfetchContent.rejected
もPayloadAction
を定義したいのですが、どうすればいいでしょうか?
非同期処理のrejectedのaction.payload
typeを指定するには、createAsyncThunk
のジェネリクスの指定が必要です。具体的には、ThunkAPIのrejectValue
にrejected時のエラーtypeを指定し、非同期処理失敗時にrejectWithValue
でそのtypeの値をreturnします。
rejectValue
のtypeは汎用的なErrorを指定するのも問題ありませんが、以降ではFetchError
という独自typeを定義しています。コードを更新しましょう。
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import { AppDispatch, RootState } from "../../app/store";
//...
// 独自エラータイプ
interface FetchError {
message: string;
}
export const fetchContent = createAsyncThunk<
Post[],
void,
{
state: RootState;
dispatch: AppDispatch;
rejectValue: FetchError;
}
>("content/fetchContent", async (_, { rejectWithValue }) => {
try {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!res.ok) {
return rejectWithValue({ message: "fetch error" });
}
const data: Post[] = await res.json();
return data;
} catch (error: any) {
if (error instanceof Error) {
return rejectWithValue({ message: error.message });
} else {
throw error;
}
}
});
export const contentSlice = createSlice({
name: "content",
initialState,
reducers: {},
extraReducers(builder) {
//...
builder.addCase(fetchContent.rejected, (state, action) => {
state.isLoading = false;
// ※payloadがundefinedの可能性があるため対処が必要
state.error = action.payload?.message ?? "予期せぬエラー";
});
},
});
export default contentSlice.reducer;
rejectedWithValue
を返している場所は2箇所あります。catch
で補足している方の実体はErrorオブジェクトです。必ずしもこのように記述しなければいけないというわけではなく、上記はあくまで例です。
話を戻し、fetchContent.rejected
のactionにもPayloadAction
を定義しましょう。action.payload
はrejectedWithValue
でFetchError typeを指定し、非同期処理の失敗時にはそのtypeのrejectWithValue()
も返しました。そのため、以下のように定義できるはずです。
builder.addCase(
fetchContent.rejected,
(state, action: ActionPayload<FetchError>) => {
//...
});
しかし、TypeScriptはエラーを警告するでしょう。警告ダイアログのオーバーロード、もしくはVSCodeでfetchContent.rejected
のaction
をマウスオーバーすると、payloadはFetchError | undefined
となっているのが確認できます。
(parameter) action: PayloadAction<FetchError | undefined, string, {
arg: void;
requestId: string;
requestStatus: "rejected";
aborted: boolean;
condition: boolean;
} & ({
rejectedWithValue: true;
} | ({
...;
} & {})), SerializedError>
FetchError
を指定したのにFetchError | undefined
というUnionになっているのはなぜでしょうか?
以下のようなコメントを見つけました。
The thing is: While you know for sure that if there is a payload, it is of type
LoginError
, there are still two scenarios you can come to thatrejected
situation:
- you reject it with
rejectWithValue
- in this case, payload is set.- an error is thrown - in this case, payload is not set. This might happen if you re-trow an error, or an error occurs in your
catch
block, or outside of it. As we cannot know for sure if that situation can occur in your code, we have to assume it can - and sopayload
is optional.
rejectedWithValue
をreturnするとpayloadが設定されますが、エラーがスローされた場合はpayloadが設定されないため、undefinedの可能性があるというわけです。仮にcatch
で補足しようが、その内外でエラーが発生する可能性はあり、コードからはその状況が把握できないのです。
そして、エラーがスローされaction.payload
がundefinedの場合は、SerializedError typeのaction.error
が設定されます。このtypeはReact Queryでも定義されています。
export interface SerializedError {
name?: string
message?: string
stack?: string
code?: string
}
undefinedの理由がわかったので、ActionPayload
を以下のようにしてみましょう。
builder.addCase(
fetchContent.rejected,
(state, action: PayloadAction<FetchError | undefined>) => {
state.isLoading = false;
state.error = action.payload?.message ?? "予期せぬエラー";
}
);
これはエラーにはなりません。payloadのtypeを明記できています。
action.error
ここで再び思い出しましょう。action.payload
がundefinedのときは、action.error
が設定されます。この値をrejectedのコールバック内で扱いたい場合があります。
しかし、action.error
をコードに入れてみると警告が表示されます。
builder.addCase(
fetchContent.rejected,
(state, action: PayloadAction<FetchError | undefined>) => {
state.isLoading = false;
if (action.payload) {
state.error = action.payload?.message ?? "予期せぬエラー";
} else {
// Error!!
state.error = action.error.message ?? "予期せぬエラー";
}
}
);
action.error
はSerializedErrorのため、これをPayloadActionのUnionに加えればいいのでは?と考えるかもしれません。しかし、これは間違いです。仮に記述しても、action.error
に警告が表示されます。
import type { PayloadAction, SerializedError } from "@reduxjs/toolkit";
//...
builder.addCase(
fetchContent.rejected,
(state, action: PayloadAction<FetchError | undefined | SerializedError>) => {
state.isLoading = false;
if (action.payload) {
state.error = action.payload.message ?? "予期せぬエラー";
} else if (action.error) {
// ↑ action.errorで警告 ↓
state.error = action.error.message ?? "予期せぬエラー";
}
}
);
これはaction.error
はpayloadではないためです。PayloadAction
はpayload typeを指定するためのもので、action.error
であるSerializeErrorをUnionとして指定することはできません。
- action.payload - FetchError
- action.error - SerializedError
では、どのようにPayloadAction
を記述するかというと、以下のようになります。
action: PayloadAction<FetchError | undefined, string, any, SerializedError>
PayloadActionのジェネリクスは、次のような順番・値になっています。
- アクションのpayload type
- アクションタイプに使われるtype
- アクションのmeta type
- アクションのerror type
ここまで指定していたのは、第1引数のpayload typeのみでした。第2引数はstringが割り当てられており(通常は変更不要)、第3-4引数はオプションです。
meta
はアクションへの追加情報を渡せるのですが、これは使用していないためanyかunknownで問題ないと思います。そして第4引数に非同期処理が失敗した場合に代入されるaction.error
のSerializedErrorを渡します。
ちなみにpayload, meta, errorは、Fluxに準拠しているそうです。
まとめ
最後に、addCase(fetchContent.rejected)
を書き直しましょう。
builder.addCase(
fetchContent.rejected,
(
state,
action: PayloadAction<
FetchError | undefined,
string,
any,
SerializedError
>
) => {
state.isLoading = false;
if (action.payload) {
state.error = action.payload.message;
} else {
state.error = action.error.message ?? "予期せぬエラー";
}
}
);
エラーハンドリングは前述のように2パターン必要です。action.payload
が存在しないときは、action.error
が取得できるためelseに分岐すればよくなり、if内のundefinedの考慮も不要になります。
これですべてのtypeがうまく働くはずです。