📋
【Nextjs】フォーム送信後にrouter.pushしつつ多重送信を防止したい
前提
- Nextjs 14
- react-hook-form
発生した問題
- お問い合わせフォームに同じ内容が複数回送信されてる
- フロントエンドで送信中にボタンをdisabledにする処理がうまく動作していないっぽい
- どうやらフォーム送信後に
router.push
が実行完了してもページが切り替わるまでラグがあって、その間に送信ボタンのdisabledが解除されて多重送信されている様子-
router.push
の挙動に関する調査:- Nextjs 14のApp Routeにおいては、
router.push
はページ遷移先がレンダリングされるまで呼び出し元で待機する仕様な上に、非同期実行が不可! - Nextjs 13以前のPage Routeにおいては、
await router.push('/')
のように非同期実行できたが、Nextjs 14においてはオミットされてしまった模様
- Nextjs 14のApp Routeにおいては、
- 結果、「送信完了〜完了ページのレンダリングが終わるまで」の時間だけ、送信ボタンが連打できてしまうように...
-
const router = useRouter()
/**
* 送信処理
*/
const onSubmit = useCallback(async (formValues) => {
// API実行の非同期処理が完了するまでsubmittingはtrueになる
await postCreateContactApi(formValues)
/**
* 送信後に完了画面に遷移させたいが、push関数は同期実行される。
* つまり完了ページの描画が終わるまでのラグの間、isSubmittingがfalseになってしまう!
* なのでユーザーが送信ボタンを連打していた場合、多重送信が発生しかねないコードになっている
*/
router.push('/contact/complete')
}, [router])
const form = useForm({
defaultValues,
mode: 'onBlur',
resolver: zodResolver(contactFormSchema),
})
const {
// isSubmittingは「onSubmitが実行中ならtrueになる」というreact-hook-formのprops
// 参考:https://react-hook-form.com/docs/useform/formstate
formState: { isSubmitting },
handleSubmit,
} = formMethods
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* 適当な入力フォーム */}
{/* ボタン */}
<div>
{/* 送信ボタン */}
<CustomButton
elementType={'button'}
isDisabled={isSubmitting}
loading={isSubmitting}
type={'submit'}
>
送信
</Button>
{/* 戻るボタン */}
<CustomButton
elementType={'a'}
href={'/top'}
isDisabled={isSubmitting}
>
戻る
</SquareButton>
</div>
</form>
)
結論
router.prefetch
で完了画面を前もってレンダリングしておく
対応1: - 「submitした時点でレンダリングが完了しとけばラグもなくなるよね!」というアプローチ
- 送信完了→
router.push
実行→既にレンダリング完了しているので即遷移する- とはいえ一瞬でも
isSubmitting
がfalse
になる可能性があるので、根本的な解決には至っていない(もちろんUX的にはやったほうがいい)
- とはいえ一瞬でも
useEffect(() => {
router.prefetch('/contact/complete')
}, [router])
isSubmitSuccessful
で送信ボタンを制御する
対応2: - 要は「送信中もしくは送信成功後であれば、送信ボタンは操作させない」という状態であればいい
-
react-hook-formの
isSubmitSuccessful
を使えば実現可能だった - ちゃんと公式docは読もうねという話であった
-
react-hook-formの
const {
// isSubmitSuccessful「onSubmitが成功したらtrueになる」というreact-hook-formのprops
// 参考:https://react-hook-form.com/docs/useform/formstate
formState: { isSubmitting, isSubmitSuccessful },
handleSubmit,
} = formMethods
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* 適当な入力フォーム */}
{/* ボタン */}
<div>
{/* 送信ボタン */}
<CustomButton
elementType={'button'}
// 送信中&送信成功後であれば操作不可にしておき、連打対策を実現
isDisabled={isSubmitting || isSubmitSuccessful}
loading={isSubmitting}
type={'submit'}
>
送信
</Button>
{/* 戻るボタン */}
<CustomButton
elementType={'a'}
href={'/top'}
isDisabled={isSubmitting}
>
戻る
</SquareButton>
</div>
</form>
)
useRouter
を拡張し、router.push
を非同期で呼び出せるようにする
別解: - 参考元:https://stackoverflow.com/questions/76253446/await-navigation-with-router-from-next-navigation
- 「Nextjs 14でも
await router.push(...)
で呼べるようにカスタマイズしたらええやん」というアプローチ -
startTransition
の使い方についてはこの辺を読むといいかも
- 「Nextjs 14でも
import { useRouter } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
export const useRouterAsync = () => {
const [isLoadingRouter, setIsLoadingRouter] = useState(true);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleRoute = async (path: string) => {
// Note: utilize startTransition because router.push no longer returns a promise.
startTransition(() => {
router.push(path);
});
};
useEffect(() => {
if (isPending) {
return setIsLoadingRouter(true);
}
setIsLoadingRouter(false);
}, [isPending]);
return { handleRoute, isLoadingRouter };
};
const { handleRoute } = useRouterAsync()
const onSubmit = useCallback(async (formValues) => {
await postCreateContactApi(formValues)
await handleRoute('/contact/complete')
}, [router])
Discussion