APIレスポンスがエラーでもPromise.allSettledが全部成功扱いになる謎
紹介すること
Promise.allSettled()で同一のPOSTリクエストを複数同時に呼んだ際、POSTレスポンスはエラーを返しているのに、Promise.allSettledは全てfulfilled(成功)になる現象が発生しました。色々試していると、POSTレスポンスがエラーの場合にrejectedになることもあり、何が起きているのかがわからなかったので、調べたことを書いていきます。
デモ
こちらで再現させることができます。viteのプロキシを使用し、何も入力しなければステータスコード400, 1文字でも入力すればステータスコード200を返すPOST: /sampleを擬似的に作っています。
通常このようなフォームを作ることはないと思いますが、2つのフィールドがそれぞれ独立したAPIリクエストを送ります。何も入力していなければ400, 1文字でも入力していたら200が返却されるのが期待されます。
画面上では両方のリクエストが成功すれば全てのリクエストが成功しました、1つでも失敗していたらエラーがありましたと表示します
挙動を整理
単独で実行した場合は期待通りに動くことなどを確認し、発生条件を探るとこのような挙動でした。
| 入力フィールド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のuseTransitionやuseActionStateを使うことで解決できます。
使用しているフォームライブラリが送信中の状態を提供してくれている場合はそれを使うのもよいと思います。例えば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が存在するPUTやDELETEで同様の事象が発生することはなく、POSTの場合も「1回の送信処理で同じエンドポイントに複数回リクエストを送る」という箇所がなかったため、今までuseSWRMutationのこの仕様に気がつくことはなかったのだと思います。
まとめ
reactの進化により、useTransitionやuseActionStateを使えば簡単に送信状態の管理が出来るようになったので、今後はわざわざPOSTリクエストにuseSWRMutationは使う必要はなくなったと思います。技術の進歩で開発が楽になるのは素敵なことですよね!
Discussion