createAsyncThunk の型パラメータで苦戦した話

9 min read

始め

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

dispatchgetStateは馴染みあるメソッドですし、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({
        errorMessage: 'データ取得に失敗しました',
      });
    }
  },
);

エラー処理のために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({
      errorMessage: 'データ取得に失敗しました',
    });
  }
});

そしてこのまま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({
      errorMessage: 'データ取得に失敗しました',
    });
  }
});

このままgetStateの型を確認するとgetState: () => unknownになってます。では、3番目型パラメータにstateを追加してみましょう。

  {
    rejectValue: string;
    state: TodoState; //追加
  }

こうするとgetStateの型がgetState: () => TodoStateになりました。

なんかわかりそうな気がしませんか?

テンション上がってrejectValuegetStateの型が定義されてるファイルにジャンプしてみました。

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番目型パラメータの各プロパティは、

  • dispatchDの型
  • stateSの型
  • extraEの型
  • rejectValueRejectedValueの型

を定義してるんじゃないかと!思いました!requestIdsignalは型が固定されてるから別途で定義する必要がなく、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つ型パラメータが必要になったのです。

終わり

ほぼ公式サイトを参考して書きました。英語読むのめんどくさいからついついサボってしまいますけど、やはり公式サイトが一番ですね。