はじめに
今回 React 19 で導入されたuseActionState
を使ってフォーム機能を実装しました。
バリデーションライブラリはyup
です。
useActionState
に関しては、個人開発などで使ってみて何となく分かったという感触でした。
しかし、フォーム実装においては使ったことがなく、また(筆者が期待するような)具体的なサンプル例も少なかったので自分で作ってみようと思った次第です。
あと、おまけ?として、Next.js 15 にアップデートして SSG ビルドしたところ、ビルドエラーが出たためその対応も書いていきます。
まずは、フォーム実装についてです。
useActionState × yup によるフォーム実装
以前、React-Hook-Form
×yup
のフォームを実装していて、「useActionState
でReact-Hook-Form
が不要になる?みたいな」話題に触れたことをきっかけに、今回useActionState
× yup
に置き換えた感じです。
以前のReact-Hook-Form
×yup
のフォームはこちらに。
- サンプル例について
- バリデーションエラー発生時
該当項目にテキストが表示されるとともに、Sandbox
下部にあるConsole
欄で詳細を確認できます - 処理成功(送信成功)時
エラー発生時と同じくConsole
欄で詳細を確認でき、フォームUI下部に入力内容を記載したアコーディオンが出てきます
- バリデーションエラー発生時
React-Hook-Form
とuseActionState
どっちがいい?
筆者はReact-Hook-Form
を軽く使った程度の者ですが以下のような所感を得ました。
-
React-Hook-Form
を採用する動機
非制御処理(再レンダリング無し) でシンプルにパパっとフォーム実装がしたい -
useActionState
を採用する動機
外部ライブラリへの依存を減らして React 純正でフォーム実装したいケースや、
今後の React で主流になっていくであろうサーバーアクションなどサーバー側での処理実装に慣れておきたいケース
補足として、useActionState
は非制御処理のReact-Hook-Form
とは違うので、送信アクション後にバリデーションを実施する場合は前回入力した内容を保持しません。
※ただし、この辺りは実装次第で如何ともなるような気がしています。
今回は軽い検証だったのであんまり踏み込んでおらず申し訳ないです。
上記のように、使用する状況や環境によって採用する動機が変わってくるかと思います。
あと、どちらにせよZod
やyup
などバリデーションライブラリは使ったほうが楽だと思います。
具体的な実装例
フォーム
"use client";
import { memo, useActionState, useRef } from "react";
import contactStyle from "./style/contactpage.module.css";
import { RegFormSchema } from "./schamas/regFormSchema";
import { useClientAsyncSubmitAction } from "./hooks/useClientAsyncSubmitAction";
export type useActionStateType = {
isSubmitting: boolean;
errorMessage?: string;
errors?: Partial<RegFormSchema>;
result?: Partial<RegFormSchema>;
};
function ContactMain() {
const formRef = useRef<HTMLFormElement | null>(null);
// useActionState の 初期 state を定義
const initialState: useActionStateType = {
isSubmitting: false,
};
// useActionState に設定する非同期関数
const { clientAsyncSubmitAction } = useClientAsyncSubmitAction();
const [state, formAction, isPending] = useActionState(
clientAsyncSubmitAction,
initialState
);
console.log(state);
const onReset: () => void = () => {
if (formRef.current !== null) {
formRef.current.reset();
}
};
return (
<main className={contactStyle.contactWrapper}>
<section>
<h2 className={contactStyle.subsHeadLine02}>
お問合せフォーム<span>Contact form</span>
</h2>
<form ref={formRef} action={formAction} id="mail_form">
<dl>
<div>
<dt>メールアドレス</dt>
<dd>
<input
type="text"
id="mail"
/* FormDataで値を取得するためには、入力フィールドにname属性が必要 */
name="mail"
/>
<p className={contactStyle.errorNotice}>{state.errors?.mail}</p>
</dd>
</div>
<div>
<dt>氏名</dt>
<dd>
<input
type="text"
id="name"
name="name"
/>
<p className={contactStyle.errorNotice}>{state.errors?.name}</p>
</dd>
</div>
<div>
<dt>会社名</dt>
<dd>
<input
type="text"
id="companyName"
name="companyName"
/>
<p className={contactStyle.errorNotice}>
{state.errors?.companyName}
</p>
</dd>
</div>
<div>
<dt>郵便番号</dt>
<dd>
<input
type="text"
id="addressNumber"
name="addressNumber"
/>
<p className={contactStyle.errorNotice}>
{state.errors?.addressNumber}
</p>
</dd>
<dt>住所</dt>
<dd>
<input
type="text"
id="address"
name="address"
/>
<p className={contactStyle.errorNotice}>
{state.errors?.address}
</p>
</dd>
</div>
<div>
<dt>電話番号</dt>
<dd>
<input
type="tel"
id="tel"
name="tel"
/>
<p className={contactStyle.errorNotice}>{state.errors?.tel}</p>
</dd>
</div>
<div>
<dt>お問い合わせ内容</dt>
<dd>
<textarea
id="content"
name="content"
/>
<p className={contactStyle.errorNotice}>
{state.errors?.content}
</p>
</dd>
</div>
<div>
<dt>入力内容のリセット</dt>
<dd>
<label className={contactStyle.resetLabel}>
<input type="button" onClick={onReset} />
入力内容をすべてリセットする
</label>
</dd>
</div>
<div>
<dt>送信確認</dt>
<dd>
<input
type="checkbox"
id="isAgree"
name="isAgree"
/>
<span className={contactStyle.errorNotice}>
{state.errors?.isAgree}
</span>
</dd>
</div>
</dl>
<div id={contactStyle.form_submit}>
<input
disabled={isPending}
type="submit"
id={contactStyle.form_submit_button}
value="送信する"
/>
</div>
{state.result && (
<details style={{ marginTop: "5em" }}>
<summary>{state.errorMessage}</summary>
<ul>
{state.result.mail && (
<li>メールアドレス:{state.result.mail}</li>
)}
{state.result.name && <li>氏名:{state.result.name}</li>}
{state.result.companyName && (
<li>会社名:{state.result.companyName}</li>
)}
{(state.result.addressNumber || state.result.address) && (
<li>
郵便番号:{state.result.addressNumber} / 住所:
{state.result.address}
</li>
)}
{state.result.tel && <li>電話番号:{state.result.tel}</li>}
{state.result.content && (
<li>お問い合わせ内容:{state.result.content}</li>
)}
</ul>
</details>
)}
</form>
</section>
</main>
);
}
export default memo(ContactMain);
ほとんどのフックと同様に、useActionState はクライアントコード内でしか呼び出せないことに注意してください。
上記引用にあるように'use client';
ディレクティブが必要になります。
あと、これはHTML
とJavaScript
に関する内容ですが、FormData
で値を取得するためには各入力項目にname
属性が必須となります。
先のコードにおいて、useActionState
に関する具体的な部分は以下になります。
// useActionState に設定する非同期関数
const { clientAsyncSubmitAction } = useClientAsyncSubmitAction();
// state: useActionStateによって処理されるステート
// formAction: アクション関数、
// isPending: 処理中・終了をチェックする真偽値
const [state, formAction, isPending] = useActionState(
clientAsyncSubmitAction, // 非同期処理
initialState // ステートの初期値
);
...
..
.
// form の action に useActionState のアクション関数をセット
<form ref={formRef} action={formAction} id="mail_form">
useActionState
については、以下の公式ドキュメントや、より詳細に書いて下さっている記事があるので本記事ではこの程度で留めておきます。
スキーマ
import * as yup from "yup";
import type { InferType } from "yup";
export const regFormSchema = yup.object({
mail: yup.string().required("メールアドレスを入力してください"),
name: yup.string().required("お名前を入力してください"),
companyName: yup.string(),
addressNumber: yup
.string()
.max(7)
.matches(/\d{7}/, "7 桁の数字で入力してください"),
address: yup.string().required("住所は必須項目です"),
tel: yup
.string()
.matches(
/^0[0-9]{1}-[0-9]{4}-[0-9]{4}$|0[789]0-[0-9]{4}-[0-9]{4}$/,
"xxx-xxxx-xxxx または xx-xxxx-xxxx の形式で入力してください"
), // OR(|)が正しく機能するようにするには不要なスペースを入れない
content: yup.string().required("問い合わせ内容は必須項目です"),
isAgree: yup.boolean().oneOf([true], "チェックを入れて下さい").required(),
});
export type RegFormSchema = InferType<typeof regFormSchema>;
特に普通。
サーバーアクション
"use server";
export const serverAsyncSubmitAction = async (formData: FormData) => {
const mail = formData.get("mail");
const name = formData.get("name");
const companyName = formData.get("companyName");
const addressNumber = formData.get("addressNumber");
const address = formData.get("address");
const tel = formData.get("tel");
const content = formData.get("content");
const isAgree = formData.get("isAgree");
if (mail && name && content && isAgree) {
return {
success: true,
mail: mail,
name: name,
companyName: companyName ?? "",
addressNumber: addressNumber ?? "",
address: address ?? "",
tel: tel ?? "",
content: content,
isAgree: isAgree,
};
} else {
return {
success: false,
errorMessage: "server-action validation error occurred.",
};
}
};
公式情報にあるように、use server
ディレクティブによってサーバーアクションを明示しています。
そしてサーバー側での処理なのでasync
が付いています。
特徴としてはuseActionState
によってformData
から直感的にフォーム項目の内容を扱えるようになったことだと思います。
const mail = formData.get("mail");
サーバー側での処理なので、ここで厳密なバリデーションを実施することで安全性が高まりますし、管理や保守もしやすくなりそうです。
クライアントバリデーションのカスタムフック
import { useActionStateType } from "../ContactMain";
import { serverAsyncSubmitAction } from "../server-action/asyncSubmitActions";
import { useValidateForm } from "./useValidateForm";
export const useClientAsyncSubmitAction = () => {
const { validateForm } = useValidateForm();
// クライアント側でのバリデーション
const clientAsyncSubmitAction = async (
prevState: useActionStateType,
formData: FormData
) => {
const validationResult = await validateForm(formData);
// false が返ってきている場合(=クライアントバリデーションが失敗した場合)
if (!validationResult.isValid) {
return {
...prevState,
errorMessage: "フォームの入力に誤りがあります。",
errors: validationResult.errors ?? {},
};
}
// バリデーション後にサーバーアクションを実行
try {
const result = await serverAsyncSubmitAction(formData);
if (result.success) {
// 成功処理: 実用時にはリダイレクトや必要ハンドリング処理を実装
return {
isSubmitting: true,
errorMessage: "処理成功 | エラーはありません",
errors: undefined,
result: {
mail: result.mail?.toString(),
name: result.name?.toString(),
companyName: result.companyName?.toString(),
addressNumber: result.addressNumber?.toString(),
address: result.address?.toString(),
tel: result.tel?.toString(),
content: result.content?.toString(),
isAgree: result.isAgree?.toString() === "on",
},
};
} else {
return {
...prevState,
errorMessage: result.errorMessage ?? "エラーが発生しました。",
};
}
} catch (error: unknown) {
return {
...prevState,
errorMessage: "サーバー送信中にエラーが発生しました。",
};
}
};
return { clientAsyncSubmitAction };
};
ここではクライアント側のバリデーションを行って、その処理結果を返しています。
具体的なバリデーション処理は以下のvalidateForm
カスタムフックで行っています。
const validationResult = await validateForm(formData);
- validateForm
yup
スキーマを使用してフォーム入力をバリデーションする関数
import * as yup from "yup";
import { regFormSchema, RegFormSchema } from "../schamas/regFormSchema";
export const useValidateForm = () => {
const validateForm: (formData: FormData) => Promise<{
errors?: Partial<RegFormSchema>;
isValid: boolean;
}> = async (formData: FormData) => {
try {
const formValues = {
mail: formData.get("mail")?.toString() ?? "",
name: formData.get("name")?.toString() ?? "",
companyName: formData.get("companyName")?.toString() ?? "",
addressNumber: formData.get("addressNumber")?.toString() ?? "",
address: formData.get("address")?.toString() ?? "",
tel: formData.get("tel")?.toString() ?? "",
content: formData.get("content")?.toString() ?? "",
isAgree: formData.get("isAgree") === "on",
};
await regFormSchema.validate(formValues, { abortEarly: false });
// バリデーションに問題ない場合は true を返す
return { isValid: true };
} catch (err: unknown) {
// yup 準拠のエラーハンドリング処理エリア
if (err instanceof yup.ValidationError) {
let validationErrors: Partial<RegFormSchema> = {};
for (const error of err.inner) {
if (error.path) {
validationErrors = {
// 既存設定を取得
...validationErrors,
// フォーム項目名プロパティに
//(当該フォーム項目に準拠する yup の)バリデーションテキストを格納
[error.path]: error.message
};
}
}
return {
isValid: false,
errors: validationErrors,
};
}
// 汎用なエラーハンドリング処理エリア
if (err instanceof Error) {
console.error(`client validation error occurred. | ${err}`);
}
// どの条件にもヒットしなかった場合
return {
isValid: false,
errors: undefined,
};
}
};
return { validateForm };
};
try-catch
文でyup
のバリデーション処理結果に応じた処理分岐を行っています。
catch
の中では、こちらも型ガードで条件分岐してyup 準拠のエラーハンドリング処理エリア
や汎用なエラーハンドリング処理エリア
、どこにも該当しなかった場合など用意しています。
先にも述べましたが、
個人的には、外部ライブラリへの依存を減らして React 純正でフォーム実装したいケースや、
今後の React で主流になっていくであろうサーバーアクションなどサーバー側での処理実装に慣れておきたいケース、という場合にuseActionState
を使ってみても良いかもと感じました。
シンプルさで言うと(個人的には)React-Hook-Form
の方がとっつきやすかったです。
Next.js 15 でのSSGビルドにおける注意事項
Next.js 15 にアップデートして SSG ビルドしたところビルドエラーが出ました。
Mac m2
環境でのみ発生した事案になります。
筆者の個人PC(Windows 11
)では問題なくビルドできました。
node
やnpm
は双方とも以下のバージョンです。
- types/node@22.14.0
- npm@11.2.0
具体的には以下となります。
- 画像処理(最適化)?に関するエラー
- サイトマップに関するエラー
- サーバーアクションに関するエラー
画像処理(最適化)?に関するエラー
いつも通りconfig
ファイルにレンダリングモデル SSG を設定してから、npm run build
すると画像処理(最適化)?に関するエラーが発生しました。
ビルドエラーに記載されたリンクへ飛ぶと、sharp
というライブラリのインストール説明ページでした。
インストールしたところ当該エラーは発生しなくなりました。
原因は、以下の公式ドキュメントにあるように Next が画像最適化処理で使用しているsharp
が、何らかの理由で(インストール)使用できなかったためだと思われます。
サイトマップに関するエラー
sitemap.ts
にexport const dynamic = "force-static"
を記述して、レンダリングモデルが SSG であることを明記する必要がありました。
- 表示されたエラーログ
Collecting page data
..Error: export const dynamic = "force-static"/export const revalidate not configured on route "/sitemap.xml" with "output: export".
See more info here: https://nextjs.org/docs/advanced-features/static-html-export
サーバーアクションに関するエラー
当然ですが、サーバーアクションは SSG に対応していません。
サーバーアクションを使っているディレクトリに_
を前置してルーティングを無効(Next側にページでないことを通知)にするか、当該ディレクトリを削除してからビルドする必要がありました。
さいごに
ここまで読んでいただき、ありがとうございました。
筆者の知識・スキル不足から誤った記載などもあるかもしれません。
その際はご教示いただけますと嬉しく思います!
ビルドエラーに関しては発生すると驚きと失望が強い(気がする)ので、当記事が誰かの役に立てますと幸いです。