🚀

shadcn/ui の Form に拡張性を持たせる[useFormState, useFormStatus]

2024/03/10に公開
2

執筆のモチベーション

筆者は UI ライブラリとして shadcn/ui を利用することが多いのですが、以下の既にクローズ済みの issueの内容(Form コンポーネントに useFormState と useFormStatus をサポートする)に対して、
https://github.com/shadcn-ui/ui/issues/2103
shadcn/ui のドキュメントの更新を待っていたのですが、なかなか来ないので自力で拡張することにしました。(React v19 へのメジャーアップデートのタイミングでドキュメントの更新がされると予想しますが、どうなるでしょうか 🤔 というか、なんでクローズされたんや...

useFormState と useFormStatus について

これらの hooks は、名前からも推測できるようにフォームの状態を管理するための hooks で、Server Actions と Client Actions を含む Actions 向けの React 標準の hooksです。

特に useFormState を利用することで Progressive Enhancement を維持できることが大きな利点です。これにより、JavaScript が実行されない環境でもフォームを送信することができます。

本記事を通して簡単に使い方を紹介しますが、詳細はドキュメントを参照してください。

https://ja.react.dev/reference/react-dom/hooks/useFormState

https://ja.react.dev/reference/react-dom/hooks/useFormStatus

ドキュメントのサンプルコードを拡張してみる

以下は shadcn/ui の Form コンポーネントのドキュメントのリンクです。

https://ui.shadcn.com/docs/components/form

このサンプルを拡張して、useFormState と useFormStatus を利用してフォームの状態を管理するようにしてみます。

以下に最終的な ProfileForm コンポーネントと Server Actions の全コードを示します。

ProfileForm の全コード
'use client';

import React, { useRef, useTransition } from 'react';
import { actions } from '@/actions/contact';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { useFormState } from 'react-dom';
import { useForm } from 'react-hook-form';
import { type z } from 'zod';

import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { FormErrorMessage } from '@/components/form-error-message';
import { FormSuccess } from '@/components/form-success-message';
import { ActionStatus } from '@/app/validations/enums';
import { formSchema } from '@/app/validations/schemas';

import { Button } from './ui/button';
import { Input } from './ui/input';

function ProfileForm() {
  const [state, formAction] = useFormState(actions, {
    status: ActionStatus.Idle,
    fields: {
      username: '',
    },
  });

  const [isPending, startTransition] = useTransition();

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      ...state.fields,
    },
  });

  const formRef = useRef<HTMLFormElement>(null);

  return (
    <Form {...form}>
      <form
        className='space-y-8'
        ref={formRef}
        action={formAction}
        onSubmit={form.handleSubmit(() => {
          if (formRef.current) {
            const formData = new FormData(formRef.current);
            startTransition(() => {
              formAction(formData);
            });
          }
        })}
      >
        <FormField
          control={form.control}
          name='username'
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder='shadcn' {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type='submit' disabled={isPending}>
          {isPending && (
            <Loader
              size={16}
              color='white'
              className='mr-2 animate-spin'
              aria-hidden='true'
            />
          )}
          Submit
        </Button>
      </form>

      <div className='mt-4 flex justify-end'>
        {/* 成功メッセージ */}
        {state?.status === ActionStatus.Success && (
          <FormSuccess message={state.message} />
        )}
        {/* エラーメッセージ */}
        {state?.status === ActionStatus.Error && (
          <div className='w-fit'>
            <ul className='space-y-2'>
              {state.issues.map((issue, index) => (
                <li key={index}>
                  <FormErrorMessage message={issue} />
                </li>
              ))}
            </ul>
          </div>
        )}
      </div>
    </Form>
  );
}

export default ProfileForm;
Server Actions の全コード
'use server';

export type FormState =
  | {
      status: ActionStatus.Success;
      fields?: Record<string, string>;
      message: string;
    }
  | {
      status: ActionStatus.Error;
      issues: string[];
      fields?: Record<string, string>;
    }
  | {
      status: ActionStatus.Idle;
      fields?: Record<string, string>;
    };

export async function actions(
  _: FormState,
  data: FormData
): Promise<FormState> {
  const formData = Object.fromEntries(data);
  const parsed = formSchema.safeParse(formData);

  await new Promise((resolve) => setTimeout(resolve, 1000));

  if (!parsed.success) {
    const fields: Record<string, string> = {};
    for (const key of Object.keys(formData)) {
      // eslint-disable-next-line @typescript-eslint/no-base-to-string
      fields[key] = formData[key].toString();
    }

    return {
      status: ActionStatus.Error,
      fields,
      issues: parsed.error.issues.map((issue) => issue.message),
    };
  }

  if (parsed.data.username == 'shadcn') {
    return {
      status: ActionStatus.Error,
      fields: parsed.data,
      issues: ['The user name is already in use'],
    };
  }

  return {
    status: ActionStatus.Success,
    fields: {
      username: '',
    },
    message: 'Success!',
  };
}

では、具体的にどのように拡張したのか内部コードを部分的に抜粋して説明します。

ProfileForm のスターターコード
'use client';

import React from 'react';
import { actions } from '@/actions/contact';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { useFormState, useFormStatus } from 'react-dom';
import { useForm } from 'react-hook-form';
import { type z } from 'zod';

import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { ActionStatus } from '@/app/validations/enums';
import { formSchema } from '@/app/validations/schemas';

import { Button } from './ui/button';
import { Input } from './ui/input';

function ProfileForm() {
  const [state, formAction] = useFormState(actions, {
    status: ActionStatus.Idle,
    fields: {
      username: '',
    },
  });

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      ...state.fields,
    },
  });

  return (
    <Form {...form}>
      <form className='space-y-8' action={formAction}>
        <FormField
          control={form.control}
          name='username'
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder='shadcn' {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <SubmitButton />
      </form>
    </Form>
  );
}

export default ProfileForm;

const SubmitButton = () => {
  const { pending } = useFormStatus();

  return (
    <Button type='submit' disabled={pending}>
      {pending && (
        <Loader
          size={16}
          color='white'
          className='mr-2 animate-spin'
          aria-hidden='true'
        />
      )}
      Submit
    </Button>
  );
};
Server Actions のスターターコード
'use server';

export type FormState =
  | {
      status: ActionStatus.Success;
      fields?: Record<string, string>;
      message: string;
    }
  | {
      status: ActionStatus.Error;
      issues: string[];
      fields?: Record<string, string>;
    }
  | {
      status: ActionStatus.Idle;
      fields?: Record<string, string>;
    };

export async function actions(_: FormState, __: FormData): Promise<FormState> {
  await new Promise((resolve) => setTimeout(resolve, 1000));

  return {
    status: ActionStatus.Success,
    message: 'Success!',
  };
}

useFormState の使用箇所

const [state, formAction] = useFormState(actions, {
  status: ActionStatus.Idle,
  fields: {
    username: '',
  },
});

返り値

useFormState は、2 つの要素を含む配列を返します。

要素 説明
state 現在の state。初回レンダー時には引数の initialState と等しく、アクションが呼び出された後はそのアクションが返した値となります。
formAction フォームコンポーネントの action 属性として渡すことができる新しいアクション。

引数

useFormState には以下の引数を渡すことができます。

引数 説明
actions React Actions を指定する。フォームの送信時に呼び出されます。
initialState 初期 state。アクションが呼び出されるまでの間、フォームの state として使用されます。 ここでは status として ActionStatus.Idle、fields として username を指定しています。

Server Actions の実装

shadcn/ui のドキュメントのサンプルコードでは、Actions に関する実装が用意されていないので、本記事では以下のように Server Actions として実装してみます。

actions/profile.ts
"use server"

export async function actions(_: FormState, __: FormData): Promise<FormState> {
  await new Promise((resolve) => setTimeout(resolve, 1000))

  return {
    status: ActionStatus.Success,
    message: "Success!",
  }
}

ここでの FormState は独自に定義でき、以下のように定義しています。

types/actions.ts
export type FormState =
  | {
      status: ActionStatus.Success
      fields?: Record<string, string>
      message: string
    }
  | {
      status: ActionStatus.Error
      issues: string[]
      fields?: Record<string, string>
    }
  | {
      status: ActionStatus.Idle
      fields?: Record<string, string>
    }

useFormStatus の使用箇所

SubmitButton コンポーネント内で useFormStatus を利用しています。

const SubmitButton = () => {
  const { pending } = useFormStatus();

  return (
    <Button type='submit' disabled={pending}>
      {pending && (
        <Loader
          size={16}
          color='white'
          className='mr-2 animate-spin'
          aria-hidden='true'
        />
      )}
      Submit
    </Button>
  );
};

返り値

useFormStatus は以下のプロパティを持つオブジェクトを返します。

プロパティ 説明
pending true の場合、親 <form> で送信中であることを意味します。それ以外の場合は false となります。

ここまでの実装で、フォームの送信中には Submit ボタンが無効化されるようになり、useFormStatus が機能していることが確認できるかと思います。

成功メッセージを表示する

次にフォームの送信後、サーバーからのレスポンスに応じて成功メッセージを表示するように拡張してみます。
具体的には、フォームの送信後に useFormState が返す state が成功時(status が ActionStatus.Success)の場合に、FormSuccess コンポーネントを表示するようにします。

ProfileForm.tsx
    <Form {...form}>
      <form action={formAction} className="space-y-8">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="shadcn" {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <SubmitButton />
      </form>

+     <div className="mt-4 flex justify-end">
+       {/* 成功メッセージ */}
+       {state?.status === ActionStatus.Success && (
+         <FormSuccess message={state.message} />
+       )}
+     </div>
    </Form>

0fc94356667284

クライアントの バリデーションを行う

ここまでの実装で気づかれたかもしれませんが、入力値がない場合 zod のバリデーションが実行されず、サーバーの処理が実行されてしまいます。つまり、クライアントのバリデーションが行われていないため、サーバーに無駄なリクエストが送信されてしまいます。
これは form の onSubmit イベントハンドラでバリデーションを行わずに、action 属性を利用して直接 Server Actions を呼び出しているためです。
そもそも、useFormState はクライアント上で JavaScript が実行される前にフォームの送信を可能にするための hooks であり、クライアントのバリデーションは担いません。

form の onSubmit イベントハンドラでクライアントサイドのバリデーションを行うために、RHF(React Hook Form) の handleSubmit メソッドを利用します。またフォーム要素への参照を取得するために useRef を利用します。
その Ref を利用してクライアントの検証が通った場合にフォームがサーバーに送信されるようにします。

以下の処理では onSubmit イベントハンドラ内部で formRef.current の状態(つまりフォームの状態を取得し、クライアントのバリデーションが通った場合)に イベントオブジェクトからフォームのデータを取得し、そのデータを useFormState が返す formAction に渡しサーバーの処理を実行しています。

ProfileForm.tsx
function ProfileForm() {
  const [state, formAction] = useFormState(actions, {
    status: ActionStatus.Idle,
    fields: {
      username: "",
    },
  })

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      ...state.fields,
    },
  })

+ const formRef = useRef<HTMLFormElement>(null)

  return (
    <Form {...form}>
      <form
        className="space-y-8"
        action={formAction}
+       ref={formRef}
+       onSubmit={form.handleSubmit(() => {
+         if (formRef.current) {
+           const formData = new FormData(formRef.current)
+           formAction(formData)
+         }
+       })}
      >
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="shadcn" {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <SubmitButton />
      </form>

     ...

    </Form>
  )
}

ここまでの実装で、サンプルコード同様に入力値がない場合にクライアントのバリデーションメッセージが表示されるようになりました。

0fc94356667284

サーバーのバリデーションを行う

クライアントの検証後にフォームがサーバーに送信されるようになりました。次にサーバーのバリデーションを行うために、Server Actions の実装を以下のように変更します。想定として、DB に格納されているユーザー名(shadcn とします)と重複する場合にエラーメッセージを返すようにしてみます。

actions/profile.ts
export async function actions(
  prevState: FormState,
  data: FormData
): Promise<FormState> {
+ const formData = Object.fromEntries(data)
+ const username = formData.username

  await new Promise((resolve) => setTimeout(resolve, 1000))

+ if (username == "shadcn") {
+   return {
+     status: ActionStatus.Error,
+     issues: ["The user name is already token"],
+   }
+ }

  return {
    status: ActionStatus.Success,
    message: "Success!",
  }
}

サーバーのバリデーションエラーメッセージの表示

再度、ProfileForm コンポーネントに戻って、サーバーのバリデーションエラーメッセージを表示するように拡張します。

ProfileForm.tsx
      <div className="mt-4 flex justify-end">
        {/* 成功メッセージ */}
        {state?.status === ActionStatus.Success && (
          <FormSuccess message={state.message} />
        )}
        {/* エラーメッセージ */}
+       {state?.status === ActionStatus.Error && (
+         <div className="w-fit">
+           <ul className="space-y-2">
+             {state.issues.map((issue, index) => (
+               <li key={index}>
+                 <FormErrorMessage message={issue} />
+               </li>
+             ))}
+           </ul>
+         </div>
+       )}
      </div>

これにより、サーバーのバリデーションエラーメッセージが表示されるようになりました。

0fc94356667284

JS の実行が無効化された環境でのバリデーション対応

ここまでの実装で、クライアントのバリデーションとサーバーのバリデーションを両方行うことができるようになりました。
さらに useFormState を用い Form を拡張したことにより、 Progressive Enhancement を維持するために、JS の実行が無効化された環境でもフォームを送信することができるようになりました。

JS の実行が無効化された環境の検証方法

JS の実行が無効化された環境での検証方法として、Chrome の DevTools で JavaScript を無効化する方法を以下に示します。

  1. DevTools を開く
  2. Command + Shift + P を押して、コマンドパレットを開く
  3. Disable JavaScript と入力して、Disable JavaScript を選択

ただし現時点の実装では、JS の実行が無効化された環境でフォームを送信すると、(JS の実行が無効化された状況のため)クライアントのバリデーションが行われずサーバーへ処理が移るので、ユーザーネームの重複チェックが行われるだけで zod のバリデーションが行われません。

この問題を解決するために、JS の実行が無効化された環境でも zod の検証が実行されるようにするため、サーバーサイドの処理を拡張します。

actions/profile.ts
export async function actions(
  prevState: FormState,
  data: FormData
): Promise<FormState> {
+ const formData = Object.fromEntries(data)
- const username = formData.username
+ const parsed = formSchema.safeParse(formData)

  await new Promise((resolve) => setTimeout(resolve, 1000))

+ if (!parsed.success) {
+   const fields: Record<string, string> = {}
+   for (const key of Object.keys(formData)) {
+     // eslint-disable-next-line @typescript-eslint/no-base-to-string
+     fields[key] = formData[key].toString()
+   }
+
+   return {
+     status: ActionStatus.Error,
+     fields,
+     issues: parsed.error.issues.map((issue) => issue.message),
+   }
+ }


- if (username == "shadcn") {
+ if (parsed.data.username == "shadcn") {
    return {
      status: ActionStatus.Error,
      fields: parsed.data,
      issues: ["The user name is already token"],
    }
  }

  return {
    status: ActionStatus.Success,
    message: "Success!",
  }
}

以上の実装で、JS の実行が無効化された環境でもフォームの送信時にクライアントのバリデーション同様の zod の検証が実行されるようになりました。

0fc94356667284

ここで気づく:useFormStatus が機能していない

ここまでの実装で、クライアント側のエラーなのか、サーバー側のエラーなのかを区別することができるようになりました。
しかし、JS の実行環境かどうかに関わらず、フォームの送信中に Submit ボタンが無効化されるようにするために利用した useFormStatus が機能していないことに気づきます。
useFormStatus の pending プロパティを追ってみると、常に false が返されていることがわかります。

原因

form の参照として Ref を利用しているため、form の参照が変わると useFormStatus の参照も変わり、useFormStatus が機能していないという問題が発生していると考えられます。

筆者の分析には誤りがありました。
React Hook Form(RHF)の handleSubmit() 関数が React Actions と互換性がないことが原因で、useFormStatus が期待通りに機能していないと考えられます。
詳細については、本記事の Discussions に寄せられたコメントを参照してください。@koichik さん、この場を借りてお礼申し上げます 🙏

またこの問題とは直結するわけではありませんが、冒頭で紹介したuseActionState の取り組みuseFormStatusの使用を避けるという記事を見る限り、現時点で useFomStatus の変わりに startTransition を利用することがよさそうです。

解決策

そのため、useTransition を利用して pending 状態を管理することで解決することができます。

ProfileForm.tsx
function ProfileForm() {
  const [state, formAction] = useFormState(actions, {
    status: ActionStatus.Idle,
    fields: {
      username: "",
    },
  })
+ const [isPending, startTransition] = useTransition()

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      ...state.fields,
    },
  })

  const formRef = useRef<HTMLFormElement>(null)

  return (
    <Form {...form}>
      <form
        className="space-y-8"
        ref={formRef}
        action={formAction}
        onSubmit={form.handleSubmit(() => {
          if (formRef.current) {
            const formData = new FormData(formRef.current)
+           startTransition(() => {
              formAction(formData)
+           })
          }
        })}
      >
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="shadcn" {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
+       <Button type="submit" disabled={isPending}>
+          {isPending && (
+           <Loader
+             size={16}
+             color="white"
+             className="mr-2 animate-spin"
+             aria-hidden="true"
+           />
+         )}
+         Submit
+       </Button>
-       <SubmitButton />
      </form>

    ...
    </Form>
  )
}

まとめ

結果的に useFormStatus は使用しないことになりましたが、useFormState を用いて shadcn/ui の Form を拡張することができました。

拡張したことで以下のような利点が得られます。

  • JS の実行が無効化された環境でもフォームを送信することができる
  • サーバーの結果をクライアントで受け取る状態管理が容易になる
  • クライアント側とサーバー側の両方でフォームデータを検証することができる

以上です!

おまけ:サーバーの結果をトーストで表示する

最後に、サーバーの結果をトーストで表示するように拡張してみます。

記事の冒頭でも確認しましたが、現状の useFormState を利用したコードは以下です。
第一引数には Server Actions を直接渡しています。

const [state, formAction] = useFormState(actions, {
  status: ActionStatus.Idle,
  fields: {
    username: '',
  },
});

こちらのコードを以下のように変更します。
匿名関数を利用して Client Actions の中で Server Actions を呼び出すようにします。

  const [state, formAction] = useFormState(
-   actions,
+   async (prev: FormState, action: FormData) => {
+     const result = await actions(prev, action)
+
+     return result
+   },
    {
      status: ActionStatus.Idle,
      fields: {
        username: "",
      },
    }
  )

この変更により、関数の呼び出し前後で追加の処理を行うことができるようになります。
ここでは、サーバーの結果をトーストで表示するために、以下のように変更します。

  const [state, formAction] = useFormState(
    async (prev: FormState, action: FormData) => {
      const result = await actions(prev, action)

+     if (result.status === ActionStatus.Success) {
+       toast.success(result.message)
+     } else if (result.status === ActionStatus.Error) {
+       result.issues.forEach((issue) => {
+         toast.error(issue)
+       })
+     }

      return result
    },
    {
      status: ActionStatus.Idle,
      fields: {
        username: "",
      },
    }
  )

以上の実装で、サーバーの結果をトーストで表示するようになりました。

0fc94356667284

Discussion

koichikkoichik

執筆お疲れ様です!
「ここで気づく:useFormStatus が機能していない」の以下ですが、、、

form の参照として Ref を利用しているため、form の参照が変わると useFormStatus の参照も変わり、useFormStatus が機能していないという問題が発生していると考えられます。

shadcn/uiの<Form>コンポーネントが何をやってるとか全然知らなくて本記事を眺めただけの推測ですが、原因はそれではなく、React Hook Form(以下RHF)のhandleSubmit()関数を使っているためではないかと思います(RHFのhandleSubmit()はReact Actions未対応)

RHFのhandleSubmit()関数は、内部で(ほぼ)無条件にevent.preventDefault()を呼び出してしまいます
https://github.com/react-hook-form/react-hook-form/blob/202a1b7525173ed8400ee0dfd8d52fa207e5f2ee/src/logic/createFormControl.ts#L1115-L1121

このため、handleSubmit()関数を設定してしまうと<form>要素自体がサブミットされることはありません(react-domが提供する<form>コンポーネントにsubmitイベントが伝わらない)
そのため本記事のようにhandleSubmit()の引数に渡す関数の中でReact Actionを明示的に呼び出すことになってしまいます(Custom Invocation)
その状況でuseFormState()を機能させるため、更にstartTransition()を使う必要もあるというのが今ココですね
ここまでやっても結局<form>はサブミットされておらず、useFormStatus()はそれを呼び出しているコンポーネントの祖先であるところの<form>コンポーネントの状態を反映するため機能していない、というのがこの記事で起きていることだと思います

onSubmitイベントのハンドラを使ったうえでuseFormStatus()を機能させるには、

  • クライアントサイドバリデーションの成功時はevent.preventDefault()を呼び出さない(クライアントサイドバリデーションの失敗時のみevent.preventDefault()を呼び出す)

ようにする必要があります
RHFでもそのような動作をサポートするためのPRがあり、着手はされていたのですが、、、

https://github.com/react-hook-form/react-hook-form/pull/11061

残念ながら停滞してしまってますね、、、

ということで、最近はRemixおよびNext.js App Router(React Actions)に対応したConformというライブラリが注目されているようです
https://github.com/edmundhung/conform

ConformのonSubmitイベントハンドラは前述のように実装されています

https://github.com/edmundhung/conform/blob/1bd3cdff9e6ef2c93794b14605929131c073448b/packages/conform-react/context.tsx#L446-L454

このため、Conformでは以下のドキュメントのようにonSubmit()はクライアントサイドバリデーションを行うだけになっています

https://conform.guide/integration/nextjs

React Actionの実行は<form>要素に任せられるため、明示的に呼び出すCustom Invocationの必要はありません(もちろんstartTransition()の明示的な呼び出しも不要です)
そしてクライアントサイドバリデーションが成功すれば<form>要素がサブミットされるため、useFormStatus()も正しく動作するはずです
(と偉そうに書いてるけど自分ではまだConformを動かしたことはありません😇)

TsuboiTsuboi

コメントありがとうございます!
先ほど、記事に対する修正を施しました。

Conform に関しては以前から認識していましたが、ご提示いただいたような(RHFとの)実装の差異があるんですね...

React Actionの実行は<form>要素に任せられるため、明示的に呼び出すCustom Invocationの必要はありません(もちろんstartTransition()の明示的な呼び出しも不要です)
そしてクライアントサイドバリデーションが成功すれば<form>要素がサブミットされるため、useFormStatus()も正しく動作するはずです

この点については、私も時間を見つけて検証してみようと思います!改めて貴重なご指摘をいただき、ありがとうございました 🙇‍♂️