🍩

Redux-Toolkitで「A non-serializable value was detected」エラーが出たときの対処方法

2022/01/17に公開

1. 事件発生

勉強でGithub issueを取得するAPIとredux-toolkitを使ってissue検索サイトを作ってました。

fetchIssueList.ts
export const fetchIssueList = createAsyncThunk<Issue[], InputValues, ThunkAPI>(
  'issue/fetchIssueList', 
  async (getIssueListParam, { rejectWithValue }) => {
  try {
    const { data } = await getIssueList(getIssueListParam);
    return formatIssueList(data);
  } catch (error) {
    return rejectWithValue(error);
  }
});
index.ts
useEffect(() => {
  dispatch(fetchIssueList({ owner, repository, page: currenPageNumber }));
}, [owner, repository, currenPageNumber, dispatch]);

そして検索結果が0件のとき(=404エラーが返ってきたとき)、事件は起こったのです。

エラー
A non-serializable value was detected in an action, in the path: `payload`. Value: Error: Request failed with status code 404
    at createError (createError.js:16:1)
    at settle (settle.js:17:1)
    at XMLHttpRequest.onloadend (xhr.js:66:1) 
Take a look at the logic that dispatched this action:  {type: 'issue/fetchIssueList/rejected', payload: Error: Request failed with status code 404
    at createError (http://localhost:3000/static/js/bund…, meta: {…}, error: {…}}

「action の payload に non-serializable value が探知された」というエラーですが最初どういう意味かよくわからず、Redux Style Guideを探ってみました。

Avoid putting non-serializable values such as Promises, Symbols, Maps/Sets, functions, or class instances into the Redux store state or dispatched actions. This ensures that capabilities such as debugging via the Redux DevTools will work as expected. It also ensures that the UI will update as expected.

store の state、dispatch する action に non-serializable values を入れることを避けてくださいと書いてます。

でもそんなもん入れてねぇぞ( ಠ ಠ )?? と悔しい気持ちになって原因を調べました。

2. 原因分析

このエラーはfetchIssueListfulfilledのときは起きずrejectedのときだけ起きたのでまずその2つの action をコンソールで出力してみました。


fulfilled action


rejected action

たしかにpayload値の色から違いますね。rejectedaction.payloadも出力してみます。


action.payload

正直ここで大体わかった気にもなりますが一応createErrorの中身を見てみます。

なるほど。 new Errorで生成したインスタンスが payload に入ってたからそういうエラーが出たという流れです。

3. 解決方法

3-1. serializableCheckを外す

探した1つ目解決案はserializableCheck自体を外すことです。redux-toolkit公式サイトを参考にして書いてみました。

store.ts
const store = configureStore({
  reducer: rootReducer,
+  middleware: (getDefaultMiddleware) =>
+    getDefaultMiddleware({
+      serializableCheck: false,
+    }),
})

でもこれだとすべての state と action に対してserializableCheckを外すことになるので、本当に必要な部分だけ外したい気持ちがあります。ということでスコープを絞ります。

やり方はredux公式サイトに載ってます。

store.ts
configureStore({
  //...
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        // Ignore these action types
        ignoredActions: ['your/action/type'],
        // Ignore these field paths in all actions
        ignoredActionPaths: ['meta.arg', 'payload.timestamp'],
        // Ignore these paths in the state
        ignoredPaths: ['items.dates'],
      },
    }),
})

action type を指定する方法、action のパスを指定する方法、state のパスを指定する方法の3つがあります。

私の場合 state は絡んでないので以下の2つが使えます。

store.ts
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
+        ignoredActions: [
+          'issue/fetchIssueList/rejected', // この action に対しては serializableCheck しない
+        ]
      },
    }),
store.ts
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
+        ignoredActionPaths: ['payload'] // action.payload に対しては serializableCheck しない
        ]
      },
    }),

これを設定しておくことで解決できます。

ですが、この方法は採用されませんでした。理由は次で話します。

3-2. Error を dispatch しない

そもそもの話ですが、Error 本体を dispatch しなければいいということに(今更)気づきました。

createAsyncThunkのエラー処理で使われてるrejectWithValueの型を確認してみましょう。

// rejectValue を unknown に定義してたので value が unknown になってる
rejectWithValue: (value: unknown) => RejectWithValue<unknown, unknown>

これだけだと情報が足りないのでnode_modules/@reduxjs/toolkitにジャンプしてRejectWithValueも確認します。

createAsyncThunk.ts
class RejectWithValue<Payload, RejectedMeta> {
  /*
  type-only property to distinguish between RejectWithValue and FulfillWithMeta
  does not exist at runtime
  */
  private readonly _type!: 'RejectWithValue'
  constructor(
    public readonly payload: Payload, // Payload は rejectWithValue の第一引数の型
    public readonly meta: RejectedMeta
  ) {}
}

色々ありますが、要はRejectWithValueに第一引数として渡したやつが rejected action のpayloadに入るということでしょう。

そして私は(何も考えずに)rejectWithValue(error)と書いてたので、しっかり action に non-serializable value を突っ込んでたということです! そんなもん入れてねぇぞと思ってたのが恥ずかしくなった

ここまでわかったら解決は簡単です。

まず payload にエラーメッセージ(string)だけ入れたいのでrejectValueの型を修正します。

ThunkAPI.ts
export type ThunkAPI = {
  dispatch: AppDispatch;
-  rejectValue: unknown;
+  rejectValue: string;
  state: RootState;
};

+) ThunkAPI の型が何なのか分からない方はこの記事をご参考ください。
https://zenn.dev/luvmini511/articles/c9cdb77a145f4d

次はエラーをカスタマイズします。catch 文のエラーの型はunknownになってるので、そこからstringを取り出して返す関数を作成します。

customizeError.ts
export const customizeError = (error: unknown): string => {
  if (error instanceof Error) return error.message;
  return '未知のエラーが発生しました';
};

そしてrejectWithValueに入れれば完成!

fetchIssueList.ts
export const fetchIssueList = createAsyncThunk<Issue[], InputValues, ThunkAPI>(
  'issue/fetchIssueList', 
  async (getIssueListParam, { rejectWithValue }) => {
  try {
    const { data } = await getIssueList(getIssueListParam);
    return formatIssueList(data);
  } catch (error) {
    return rejectWithValue(customizeError(error)); // エラーメッセージ(string)を payload に入れる
  }
});

これでstore.tsの設定触らずにエラー解決できました。何も考えずにコード書くのやめろっていう話ですね^_^...

4.終わり

このエラーをググってみたら action に timestamp を入れた場合がよく出てきて、解決案で3-1. serializableCheckを外すで紹介した方法が挙げられてました。

「Do Not Put Non-Serializable Values in State or Actions」にも action が reducer に至る前に thunk みたいなミドルウェアに渡されるときは例外だと載ってますので、そういうとき使えるんじゃないかと思います。

Exception: you may put non-serializable values in actions if the action will be intercepted and stopped by a middleware before it reaches the reducers. Middleware such as redux-thunk and redux-promise are examples of this.

でも JSON.stringify()JSON.parse() で値を整形したら解決できそうな気がしたので、今度そういう場面に出くわしたら試してみます。


+)余談
2021年頑張って書いた記事、思ったよりたくさんの方々が読んでくださってビックリしました!!! 1年間なんとサポートバッジ3! いいね1,318! ページビュー81,957!!!


本当にありがとうございます(´⌣`ʃƪ) 今年も頑張りますー!!!

GitHubで編集を提案

Discussion