Redux Toolkit で Async Thunk が曲者なので詳しく解説する

こんにちは HRBrainでフロントエンドを書いている鈴木です

この記事はHRBrainAdventCalendar8日目の記事です

Reduxには @reduxjs/toolkit という超メガドデカハチャメチャ便利ライブラリがありますが、 createAsyncThunk で作ったコードでなんやかんやしようとするとかなりハマったのでそちらの紹介です redux-toolkit.js.org

型パラしんどい

createAsyncThunk には3つの型パラメーターが必要ですが、複雑でかなりしんどいです まずは型定義を見てみましょう

function createAsyncThunk<
    Returned,
    ThunkArg = void,
    ThunkApiConfig extends AsyncThunkConfig = {}
>(
    typePrefix: string,
    payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>,
    options?: AsyncThunkOptions<ThunkArg, ThunkApiCongi>
): AsyncThunk<Returned, ThunkArg, ThunkApiConfig>;

パッと見よくわかりませんね 一つ一つ見ていきましょう

結論

function createAsyncThunk<2引数の関数の返り値,2引数の関数の第1引数の型(生成された関数を実行する時に必要な引数),
    Thunkが引き回しているコンテキストの型
>(/* ... */)

以下解説です

Returned

1つ目の型パラメータは Returned となっています この ReturnedAsyncThunkPayloadCreator に渡されているので、こっちを見てみます

type AsyncThunkPayloadCreator<
    Returned,
    ThunkArg = void,
    ThunkApiConfig extends AsyncThunkConfig = {}
> = (arg: ThunkArg, thunkAPI: GetThunkAPI<ThunkApiConfig>) => AsyncThunkPayloadCreatorReturnValue<Returned, ThunkApiConfig>;

AsyncThunkPayloadCreator から渡ってきた Returned は更に AsyncThunkPayloadCreatorReturnValue に渡されています

type AsyncThunkPayloadCreatorReturnValue<
    Returned,
    ThunkApiConfig extends AsyncThunkConfig
> = Promise<Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>> | Returned | RejectWithValue<GetRejectValue<ThunkApiConfig>>;

入ってきた Returned がそのまま Promise<T> の中に渡されています そもそも AsyncThunkPayloadCreatorReturnValueAsyncThunkPayloadCreator の返り値として定義されていましたし、更に AsyncThunkPayloadCreatorcreateAsyncThunk の第2引数の関数だと定義されていたので… どうやら createAsyncThunkの第2引数の関数の返り値 だということがわかりました

ですので、大抵は void とかフェッチしてきたデータを返してあげたりする時の型を入れてあげれば良いでしょう

// 例えばこんなかんじ
const fetch = createAsyncThunk<{ users: User[] }, ?, ?>('fetch', async (arg, thunkAPI) => {
    const res = await axios.get<User[]>('/api/users')
    return res.data
})

ThunkArg

次は ThunkArg ですが、こちらも AsyncThunkPayloadCreator に渡されてたのでもう一度思い出してみます

type AsyncThunkPayloadCreator<
    Returned,
    ThunkArg = void,
    ThunkApiConfig extends AsyncThunkConfig = {}
> = (arg: ThunkArg, thunkAPI: GetThunkAPI<ThunkApiConfig>) => AsyncThunkPayloadCreatorReturnValue<Returned, ThunkApiConfig>;

こちらはこの時点で第1引数の arg で使われています ということは、 createAsyncThunkの第2引数の関数の第1引数 であることがわかりました

ThunkApiConfig

これはRedux ToolkitがThunkのAPIをまとめた型で、中にはおなじみの dispatchgetState などが入っています ThunkApiConfig extends AsyncThunkConfig となっているので、 AsyncThunkConfig を詳しく見てみましょう

type AsyncThunkConfig = {
    state?: unknown;
    dispatch?: Dispatch;
    extra?: unknown;
    rejectValue?: unknown;
}

とんでもなくざっくりとした型となっています これを実際のコードのどこで使うかと言うと、第2引数の関数の第2引数で使います

const hoge = createAsyncThunk('hoge', (arg, thunkAPI) => {})
//                                         ^^^^^^^^^ これ

thunkAPI の名前にある通り redux-thunk で使う関数などが入っています 型がざっくりしているのは、開発プロジェクトによって可変になるからです

このままだと使いづらいし何よりRedux Toolkitの型定義がこいつを export してくれていないので、自分の好きなように型を作ってしまいます

type RootState = { /* ... */ }
type ExtraArg = { /* ... */ }

export type AsyncThunkConfig<T = unknown> = {
    state: RootState
    dispatch: Dispatch
    extra: ExtraArg
    rejectValue: T
}

使い方

全部の型パラメータの正体がわかったので早速使ってみましょう

// 例 カウンターの増幅はこうなりそう
const increment = createAsyncThunk<
    { count: number },
    undefined,
    AsyncThunkConfig
>('increment', (arg, thunkAPI) => {
    return thunkAPI.getState().count + 1
})

// 例 IDからユーザーをフェッチするとこう
const fetchUserById = createAsyncThunk<
    { user: User },
    { id: string },
    AsyncThunkConfig
>('fetchUserById', async (arg, thunkAPI) => {
    const res = await window.fetch(`/api/user/${arg.id}`)
    return JSON.parse(res.json())
})

動きがいまいちわからん

さて、これだけ丹精込めて createAsyncThunk を使って関数を作っても、肝心の作った関数の使い方が全然わからないということはないでしょうか 実は従来の redux-thunk のように使うよりもより便利なAPIがReduxToolkitから提供されていることも併せて理解しておきたいところです

作った関数を実行する

これは特に変なところはないです 普通に実行しましょう

const increment = createAsyncThunk(/* ... */)

const Component = () => {
    const dispatch = ReactRedux.useDispatch()

    const clickIncrement = React.useCallback(() => {
        // 普通にThunk関数として使える
        dispatch(increment())
    }, [dispatch])

    return (/* ... */)
}

非同期のアクションを受け取る

createAsyncThunk で作成したThunk関数には必ず pending fulfilled rejected の3つのActionが一緒に作成されます どれも作成されたThunk関数の中にオブジェクトとして入っており、以下のように参照可能です

const increment = createAsyncThunk(/* ... */)

increment.pending
increment.fulfilled
increment.rejected

また、それぞれが表すイベントはこんな感じです

Action 効果
pending 実行中 Promise.resolve()が呼ばれる前
fulfilled 正常終了 Promise.resolve() が呼ばれた時
rejected 異常終了 Promise.reject() が呼ばれた時

これらを使うと、非同期関数を実行した時の各イベントでReducerを書くことができます

const slice = createSlice({
    name: 'counter',
    reducer: {},
    extraReducer: builder => {
        builder.addCase(increment.pending, (state, action) => { /* ... */ })
        builder.addCase(increment.fulfilled, (state, action) => { /* ... */  })
        builder.addCase(increment.rejected, (state, action) => { /* ... */ })
    }
})

めちゃくちゃ便利ですね フェッチの関数とかであればフェッチ中はローディングのフラグをStateに建てるなんてこともできちゃいます

更に、 fulfilledrejected では action.payload に値を詰めることができます まずは createAsyncThunk の中身を弄ります

const fetch = createAsyncThunk<
    // Returned = fulfilledのPayload
    { users: User[] },
    undefined,
    // AsyncThunkConfig<T> の T = rejectedのPayload
    AsyncThunkConfig<{ errorMessage: string }>
>(
    'fetch',
    (arg, thunkAPI) => {
        let res
        try {
            res = await fetch('/api/users')
        } catch (e) {
            thunkAPI.rejectWithValue({ errorMessage: 'Fetch error' })
            return
        }
        
        return JSON.parse(res.json())
    }
)

fulfilled に値を詰める場合は createAsyncThunkReturned が型になり、第2引数の関数の返り値がそのまま入ります rejected に値を詰める場合は AsyncThunkConfig<T>{ rejectValue: T } がその型になります 値を詰めるのは thunkAPI.rejectWithValue() で行います

そうしたらあとはReducerで受け取るだけです

const slice = createSlice({
    name: 'user',
    reducer: {},
    extraReducer: builder => {
        builder.addCase(increment.fulfilled, (state, action) => {
            // { users: User[] } が入ってくる
            state.list = action.payload.users
        })
        builder.addCase(increment.rejected, (state, action) => { 
            state.status = 'error'
            // { errorMessage: string } が入ってくる
            state.message = action.payload.errorMessage
        })
    }
})

このように非同期のActionを使いこなすことによって 非同期の管理からエラー処理までを簡単に書くことができます

おわりに

ReduxToolkit の AsyncThunk は非常に強力です!この機会に是非使いこなしてみてください!