homehome

rejected type of Redux Thunk

Published

TocMenu目次

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.rejectedPayloadActionを定義したいのですが、どうすればいいでしょうか?

非同期処理の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.payloadrejectedWithValueでFetchError typeを指定し、非同期処理の失敗時にはそのtypeのrejectWithValue()も返しました。そのため、以下のように定義できるはずです。

builder.addCase(
  fetchContent.rejected,
  (state, action: ActionPayload<FetchError>) => {
  //...
});

しかし、TypeScriptはエラーを警告するでしょう。警告ダイアログのオーバーロード、もしくはVSCodeでfetchContent.rejectedactionをマウスオーバーすると、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 that rejected situation:

  1. you reject it with rejectWithValue - in this case, payload is set.
  2. 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 so payload is optional.

Why is typescript saying that the action payload is possibly undefined in the extraReducer in a slice?

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がうまく働くはずです。