APIレスポンスがエラーでもPromise.allSettledが全部成功扱いになる謎

に公開

紹介すること

Promise.allSettled()で同一のPOSTリクエストを複数同時に呼んだ際、POSTレスポンスはエラーを返しているのに、Promise.allSettledは全てfulfilled(成功)になる現象が発生しました。色々試していると、POSTレスポンスがエラーの場合にrejectedになることもあり、何が起きているのかがわからなかったので、調べたことを書いていきます。

デモ

こちらで再現させることができます。viteのプロキシを使用し、何も入力しなければステータスコード400, 1文字でも入力すればステータスコード200を返すPOST: /sampleを擬似的に作っています。

通常このようなフォームを作ることはないと思いますが、2つのフィールドがそれぞれ独立したAPIリクエストを送ります。何も入力していなければ400, 1文字でも入力していたら200が返却されるのが期待されます。

画面上では両方のリクエストが成功すれば全てのリクエストが成功しました、1つでも失敗していたらエラーがありましたと表示します

https://stackblitz.com/edit/vitejs-vite-s3zxftsn?file=src%2FApp.tsx

挙動を整理

単独で実行した場合は期待通りに動くことなどを確認し、発生条件を探るとこのような挙動でした。

入力フィールド1 入力フィールド2 Promise.allSettled 結果 メモ
success success fulfilled, fulfilled 全てのリクエストが成功しました 期待通り
success fail fulfilled, rejected エラーがありました 期待通り
fail fail fulfilled, rejected エラーがありました 最初のリクエストが失敗してるのにfulfilledになっている
fail success fulfilled, fulfilled 全てのリクエストが成功しました 最初のリクエストが失敗しているのにfulfilledになっている

Promise.allSettledが期待通りの値を返さない原因

useSWRMutationの使い方が原因でした。POST: /sampleにリクエストする際、どちらのリクエストでも同じkeyを使っていることが原因でした。

// sample.ts

import useSWRMutation from 'swr/mutation';

const API_PATH = '/api/sample';

export const postSample = async (text: string) => {
  const response = await fetch(API_PATH, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  });
  const data = await response.json();
  if (!response.ok) {
    throw new Error(data.error);
  }
  return data;
};

export const usePostSampleMutation = () => {
  const swrKey = API_PATH;
  const query = useSWRMutation(
    API_PATH,
    async (_key: string, { arg }: { arg: string }) => {
      return postSample(arg);
    }
  );

  return {
    swrKey,
    ...query,
  };
};

// App.tsx
  const { trigger, isMutating } = usePostSampleMutation();

  const results = await Promise.allSettled([
    trigger(field3),
    trigger(field4),
  ]);

同じPromise.allSettledで同じキーへのリクエストを連続して呼ぶと、最初のリクエストがfulfilledになる

同じキーへのリクエストが重複した場合、2回目のリクエストが成功しようが失敗しようが、最初のリクエストが失敗していてもfulfilledになる仕様のようです。

コードを読んでみたものの難しく適切に理解できているのか怪しいのですが、初回リクエストの処理が終わる前に2回目のリクエストが実行され、ここの処理に入り、エラーが投げられるのではなくreturn dataされているのではないかと思われます。

対策

同じkeyを使わないようにする

useSWRMutationを読むと

また、このフックは他の useSWRMutation と状態を共有しません。

とあるように、別々のフックとして宣言してあげれば期待通りに動きました。

// App.tsx
  const { trigger: trigger3, isMutating: isMutating3 } =
    usePostSampleMutation();
  const { trigger: trigger4, isMutating: isMutating4 } =
    usePostSampleMutation();

  const results = await Promise.allSettled([
    trigger3(field3),
    trigger4(field4),
  ]);

ただし、フィールドの数が動的に変わる場合はuseSWRMutation使えないと思います。

useSWRMutationを使わない

直接postSampleを使わずにusePostSampleMutationを使うモチベーションは、送信中の状態表示でしたが、reactのuseTransitionuseActionStateを使うことで解決できます。
使用しているフォームライブラリが送信中の状態を提供してくれている場合はそれを使うのもよいと思います。例えばreact-hook-formにはisSubmittingがあります。

useTransition

useTransitionを使う

  const [isPending, startTransition] = useTransition();
  const handleSave2 = async (e: React.FormEvent) => {
    e.preventDefault();
    
    startTransition(async () => {
      setMessage2('');

      try {
        const results = await Promise.allSettled([
          postSample(field3),
          postSample(field4),
        ]);
        ...
    });
  };

    <form onSubmit={handleSave2} className="form">
      ...

      {message2 && (
        <div
          style={{
            padding: '10px',
            borderRadius: '4px',
            backgroundColor: '#f0f0f0',
            whiteSpace: 'pre-line',
          }}
        >
          {message2}
        </div>
      )}

      <button type="submit" className="button" disabled={isPending}>
        {isPending ? '送信中...' : 'APIを呼び出す'}
      </button>
    </form>

useActionState

formのonSubmitではなくactionとuseActionStateを使う

  const submitAction = async (_prevState: unknown, formData: FormData) => {
    const field3 = formData.get('field3') as string;
    const field4 = formData.get('field4') as string;
    
    try {
      const results = await Promise.allSettled([
        postSample(field3),
        postSample(field4),
      ]);

      ...

  const [state, formAction, isPending] = useActionState(submitAction, {
    message: '',
    isPending: false,
  });

    <form action={formAction} className="form">
      ...
    
      {state.message && (
        <div
          style={{
            padding: '10px',
            borderRadius: '4px',
            backgroundColor: '#f0f0f0',
            whiteSpace: 'pre-line',
          }}
        >
          {state.message}
        </div>
      )}
    
      <button type="submit" className="button" disabled={isPending}>
        {isPending ? '送信中...' : 'APIを呼び出す'}
      </button>
    </form>

なぜ今までこのような現象に出会わなかったのか

POSTリクエストを送る場合はほとんどの箇所でreact-hook-formを使っており、isSubmittingを送信状態の管理に使っていますが、そうではない箇所でも送信状態管理をしたいことがあるため、原則useSWRMutationを使い、必要な場合はisMutatingをいつでも使えるようにしていましたが、問題が起きたことはなかったです。

useSWRMutationのkeyはAPIのURLにしているので、URLにIDが存在するPUTDELETEで同様の事象が発生することはなく、POSTの場合も「1回の送信処理で同じエンドポイントに複数回リクエストを送る」という箇所がなかったため、今までuseSWRMutationのこの仕様に気がつくことはなかったのだと思います。

まとめ

reactの進化により、useTransitionuseActionStateを使えば簡単に送信状態の管理が出来るようになったので、今後はわざわざPOSTリクエストにuseSWRMutationは使う必要はなくなったと思います。技術の進歩で開発が楽になるのは素敵なことですよね!

コミューン株式会社

Discussion