createAsyncThunk の型パラメータで苦戦した話
始め
createSliceの記事で selector について書きたいとつぶやいてましたが、その前に違う試練を乗り越えて別ネタができました笑。
最初はcreateAsyncThunk
をこんな感じで使ってました。問題なかったです。
export const fetchTodos = createAsyncThunk<Array<Todo>>('todo/fetchTodos', async () => {
const response = await TodoServices.fetchTodos();
return response.data;
});
後で try-catch 文でエラー処理しよう!と思って直した瞬間、
export const fetchTodos = createAsyncThunk<Array<Todo>>('todo/fetchTodos', async () => {
try {
const response = await TodoServices.fetchTodos();
return response.data;
} catch (error) {
console.error(error);
}
});
事件は起きたのです。
Argument of type '() => Promise<Todo[] | undefined>' is not assignable to parameter of type 'AsyncThunkPayloadCreator<Todo[], void, {}>'.
Type 'Promise<Todo[] | undefined>' is not assignable to type 'AsyncThunkPayloadCreatorReturnValue<Todo[], {}>'.
Type 'Promise<Todo[] | undefined>' is not assignable to type 'Promise<Todo[] | RejectWithValue<unknown>>'.
Type 'Todo[] | undefined' is not assignable to type 'Todo[] | RejectWithValue<unknown>'.
Type 'undefined' is not assignable to type 'Todo[] | RejectWithValue<unknown>'.
訳のわからない長文のエラーでめちゃくちゃ怒られました。調べたら型パラメータを入れないといけないようでした。
「いやいや、try-catch 文入れる前は怒ってなかったじゃん」とか「型ってどこの型だよ」とか、さんざん悩んだので、私と同じ苦しみを味わってる方々のためにまとめます。
1. createAsyncThunk の引数
まずは落ち着いてcreateAsyncThunk
の引数から把握することにしました。
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
const fetchUserById = createAsyncThunk(
// type(第1引数)
'users/fetchByIdStatus',
// payloadCreator(第2引数)
async (userId, thunkAPI) => {
const response = await userAPI.fetchById(userId)
return response.data
}
// Options(第3引数、任意)
)
1-1. type
'users/fetchByIdStatus'
type
は追加の action type 生成に使われる文字列です。非同期処理には pending、fulfilled、rejected の3つの状態がいつも存在しますので、それに対する action type を生成するということです。
上記の例だと type がusers/fetchByIdStatus
なので、こういう action たちが生成されます。
- pending:
'users/requestStatus/pending'
- fulfilled:
'users/requestStatus/fulfilled'
- rejected:
'users/requestStatus/rejected'
1-2. payloadCreator
async (userId, thunkAPI) => {
const response = await userAPI.fetchById(userId)
return response.data
}
payloadCreator
はコールバック関数で、非同期処理の結果含む Promise を返します。名前通りpayload
を生成する関数ということですね。
このpayloadCreator
関数も2つの引数を受け取ります。
arg
第1引数である1つの値です。action が dispatch されるときに渡される引数を含みます。上記の例だとuserId
がこの arg に当てはまります。
thunkAPI
以下の key を含むオブジェクトです。
dispatch
getState
extra
requestId
signal
rejectWithValue
dispatch
、getState
は馴染みあるメソッドですし、rejectWithValue
はエラー処理するとき使う関数です。(各 key に対するの説明は公式サイトにも載ってます。)
最初はよくわかりませんでしたが、「payloadCreator
の内でロジックを計算するために thunk middleware が提供するメソッドたちのオブジェクト」みたいなイメージではないかなと思いました。
1-3. Options
Options
はオプショナルなフィルドを持つオブジェクトです。任意ですし、今回扱わないので割愛させていただきます。気になる方は公式サイトをご参考ください。
2. createAsyncThunk の型パラメータ
createAsyncThunk
は引数以外に型パラメータも3つ受け取ります。
const fetchUserById = createAsyncThunk<
// payloadCreator の返り値の型
MyData,
// payloadCreator の第1引数(arg)の型
number,
{
// payloadCreator の第2引数(thunkApi)のための型
state: State
extra: {
jwt: string
}
}
>('users/fetchById', async (userId, thunkApi) => {
const response = await fetch(`https://reqres.in/api/users/${userId}`, {
headers: {
Authorization: `Bearer ${thunkApi.extra.jwt}`,
},
})
return (await response.json()) as MyData
})
全部コールバック関数payloadCreator
に関わる型ですね。
ここで戦いが始まりました。
第2型パラメータでpayloadCreator
の第1引数(arg)の型を定義してますが、私のコードではpayloadCreator
が引数を持たない関数だったのです。
また、payloadCreator
の返り値の型と第1引数(arg)の型は分かりますけど、「最後の第2引数(thunkApi)のための型ってなんだ?どこの方を定義してるんだ?まじでわからん…」状態が続いてました。
この2点で2日ほど悩んでました…
3. 苦戦した部分
3-1. payloadCreator に引数がいらない
最初にお見せした通りこれた私が書いてたコードですが、
export const fetchTodos = createAsyncThunk<Array<Todo>>('todo/fetchTodos', async () => {
const response = await TodoServices.fetchTodos();
return response.data;
});
payloadCreator
が引数を受け取らないです。
どうしよう…?と思って本当にめっちゃ調べて、結果的に以下のような方法で解決しました。
export const fetchTodos = createAsyncThunk<Array<Todo>, undefined, {}>(
'todo/fetchTodos',
// 第1引数を`_`にする
async (_, { rejectWithValue }) => {
try {
const response = await TodoServices.fetchTodos();
return response.data;
} catch (error) {
return rejectWithValue('データ取得に失敗しました');
}
},
);
エラー処理のためにpayloadCreator
の第2引数(thunkAPI)から引っ張ってきたrejectWithValue
は使うけど、第1引数は使わないので_
で処理しました。_
で書いたら「こいつは無視する、使わない」という意味らしいですね(初めて知った)。使わないので、型もundefined
に指定しました。
3-2. 第3型パラメータの謎
次は3番目型パラメータの謎です。
とりあえず探ってみたら、3番目型パラメータ自体は以下の4つのプロパティを含むオブジェクトだということはわかりました。
dispatch
state
extra
rejectValue
thunkAPI
と似てますね。ちなみにこのプロパティたちは全部オプショナルです。多分プロジェクトごとにルールが全然違うからカスタムできるように全部オプショナルにしたんじゃないかなと思います。
大事なのは、このオブジェクトがどこの型を定義しているのかです。ここからはもう実験です。
まずは3番目型パラメータにrejectValue
の型を(適当に)指定してみました。
export const fetchTodos = createAsyncThunk<
Array<Todo>,
undefined,
{
rejectValue: string;
}
>('todo/fetchTodos', async (_, { rejectWithValue }) => {
try {
const response = await TodoServices.fetchTodos();
return response.data;
} catch (error) {
return rejectWithValue('データ取得に失敗しました');
}
});
そしてこのままrejectWithValue
の型を確認します。
rejectWithValue: (value: string) => RejectWithValue<string>
ちゃんとrejectValue
で定義したstring
が入ってます。
今度はpayloadCreator
の第2引数にgetState
も追加してみました。
export const fetchTodos = createAsyncThunk<
Array<Todo>,
undefined,
{
rejectValue: string;
}
>('todo/fetchTodos', async (_, { rejectWithValue, getState }) => { //追加
try {
const response = await TodoServices.fetchTodos();
return response.data;
} catch (error) {
return rejectWithValue('データ取得に失敗しました');
}
});
このままgetState
の型を確認するとgetState: () => unknown
になってます。では、3番目型パラメータにstate
を追加してみましょう。
{
rejectValue: string;
state: TodoState; //追加
}
こうするとgetState
の型がgetState: () => TodoState
になりました。
なんかわかりそうな気がしませんか?
テンション上がってrejectValue
とgetState
の型が定義されてるファイルにジャンプしてみました。
declare type BaseThunkAPI<S, E, D extends Dispatch = Dispatch, RejectedValue = undefined> = {
dispatch: D;
getState: () => S;
extra: E;
requestId: string;
signal: AbortSignal;
rejectWithValue(value: RejectedValue): RejectWithValue<RejectedValue>;
};
なるほどなるほど。つまり、3番目型パラメータの各プロパティは、
-
dispatch
→D
の型 -
state
→S
の型 -
extra
→E
の型 -
rejectValue
→RejectedValue
の型
を定義してるんじゃないかと!思いました!requestId
とsignal
は型が固定されてるから別途で定義する必要がなく、3番目型パラメータからも抜けたのでしょう!
4. try-catch 文と型パラメータの関係
それでは最後の謎です。なぜ try-catch 文を入れる前には型パラメータなしでも平気だったのに、try-catch 文を入れたら急に型パラメータが必要になったのか? それを解説します。
try-catch 文ではエラー処理をしなければなりませんね。
redux-toolkit は async 関数内でエラーが発生したとき、 rejected action を dispatch します。そしてこの rejected action をカスタムするためには、直接エラーをキャッチし、thunkAPI.rejectWithValue
関数を使って新しい値を返す必要があります。
async (userData, { rejectWithValue }) => {
const { id, ...fields } = userData
try {
const response = await userAPI.updateById(id, fields)
return response.data.user
} catch (err) {
return rejectWithValue(err.response.data)
}
}
こんな感じでthunkAPI
が提供するrejectWithValue
関数でエラーを処理すると、引数(err.response.data
)をaction.payload
として使えるようになります。
つまり、thunkAPI
のメソッドを使う必要が発生するということですね。そしてここで型パラメータも必要になります。なぜなら、この子たちは型が推論できないからです。
また、Typescript は直接定義した型と推論された型をミックスすることができません。つまりthunkApi
の型を直接定義することになったら、それにつられてpayloadCreator
の返り値と第1引数の型まで直接定義しなければならなくなります。
そういうわけで、thunkAPI
を使うためにトータル3つ型パラメータが必要になったのです。
終わり
ほぼ公式サイトを参考して書きました。英語読むのめんどくさいからついついサボってしまいますけど、やはり公式サイトが一番ですね。
Discussion