📋

【Nextjs】フォーム送信後にrouter.pushしつつ多重送信を防止したい

2024/10/18に公開

前提

  • 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においてはオミットされてしまった模様
    • 結果、「送信完了〜完了ページのレンダリングが終わるまで」の時間だけ、送信ボタンが連打できてしまうように...
    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>
    )

結論

対応1: router.prefetchで完了画面を前もってレンダリングしておく

  • 「submitした時点でレンダリングが完了しとけばラグもなくなるよね!」というアプローチ
  • 送信完了→router.push実行→既にレンダリング完了しているので即遷移する
    • とはいえ一瞬でもisSubmittingfalseになる可能性があるので、根本的な解決には至っていない(もちろんUX的にはやったほうがいい)
  useEffect(() => {
    router.prefetch('/contact/complete')
  }, [router])

対応2: isSubmitSuccessfulで送信ボタンを制御する

  • 要は「送信中もしくは送信成功後であれば、送信ボタンは操作させない」という状態であればいい
    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を非同期で呼び出せるようにする

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