はじめに
最近、React19が安定版となりました。
そこで、今回は、React19で頻繁に使用されることになるuseActionState
とserver actions
を利用したform
の実装について解説していこうと思います。
今回作成したソースは以下にあります。
useActionStateとは
まず、useActionState
についてですが、このフックはフォームアクションの結果に基づいてstate
を更新するためのフックです。
以下のReactの記事を参考に見ていきましょう。
(実装に移りたい方は、次の項の『Conformの導入』に進んでください)
まず、React18以前では、どのようにformの状態を更新していたのかという部分から解説します。
これを追うことで、useActionState
が頻繁に使われることになると言われている理由がわかるからです。
React18以前では以下のようにformの値やpending状態もすべてuseState
で管理していました。
これは管理する状態が増えるため、非常に面倒だったと思います。
function UpdateName({}) {
const [name, setName] = useState("");
const [error, setError] = useState(null);
const [isPending, setIsPending] = useState(false);
const handleSubmit = async () => {
setIsPending(true);
const error = await updateName(name);
setIsPending(false);
if (error) {
setError(error);
return;
}
redirect("/path");
};
return (
<div>
<input value={name} onChange={(event) => setName(event.target.value)} />
<button onClick={handleSubmit} disabled={isPending}>
Update
</button>
{error && <p>{error}</p>}
</div>
);
}
ただ、React19では、useTransition
がコールバックに非同期関数をサポートするようになったため、以下のようにすることで、pendingの管理をuseTransition
がよしなに行ってくれます。
(Next.jsではReact18でもこの機能はありましたね!)
function UpdateName({}) {
const [name, setName] = useState("");
const [error, setError] = useState(null);
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(async () => {
const error = await updateName(name);
if (error) {
setError(error);
return;
}
redirect("/path");
})
};
return (
<div>
<input value={name} onChange={(event) => setName(event.target.value)} />
<button onClick={handleSubmit} disabled={isPending}>
Update
</button>
{error && <p>{error}</p>}
</div>
);
}
useState
の一元管理よりスッキリしました。
ただ、これでもよいのですが、server actionかつReact19時代にはuseActionState
を使うことが一般的になります。
以下のようになります。
useActionState
は以下の値を含む配列を返します。
useActionState
の返却値ですが、最初の値にstateが入ります。
このstateには初回レンダー時には初期値(useActionState
の第2引数の値。ここではnull)が入りますが、action後はactionにより更新されたstateが入ります。つまり、以下の例の場合は、errorがあれば、errorが入るし、なければnullが入るということになります。
2つ目の値は、実際に実行するaction関数が入ります。
つまり、useActionState
の第1引数に指定した任意の関数が入ります。
最後にisPending
フラグですが、これはuseTransition
のisPendingと同様で、action実行時にtrueとなり、よしなに管理されるbool値になります。
function ChangeName({ name, setName }) {
const [error, submitAction, isPending] = useActionState(
async (previousState, formData) => {
const error = await updateName(formData.get("name"));
if (error) {
return error;
}
redirect("/path");
return null;
},
null,
);
return (
<form action={submitAction}>
<input type="text" name="name" />
<button type="submit" disabled={isPending}>Update</button>
{error && <p>{error}</p>}
</form>
);
}
確かに記述がスッキリしましたね!
ただ、どうして、formのactionを使用しないといけないのか、まだ釈然としない人もいると思いますので、その点についても解説します。
action属性について
結論ですが、action属性を使用すると、JSがオフの状態でもformの属性を正しく送信できるためです。
また、開発者向けには、action属性を使用することでformDataにアクセスできるという利点があります。つまり、react-hook-form
やuseState
のように自力でformの値を管理する必要がなくなるという利点があります。
まとめると、アクセシビリティの観点でプログレッシブエンハンスメント・ファーストであると言え、かつ、formDataにアクセスできるという大きな2つの利点があります。
(プログレッシブ・エンハンスメントについては次項の『Conformの導入』についてざっくりと解説しています)
Conformの導入
今回、server actionsでのformの実装を行うため、server actionsに対応したライブラリであるConform
を導入します。
その前に、Conformについて、少し概要を解説します。
Conformとは
Conform は、Web 標準に基づいて HTML フォームを段階的に強化し、 Remix や Next.js のようなサーバーフレームワークを完全にサポートする、型安全なフォームバリデーションライブラリです。
上記は公式ドキュメントの記載ですが、上記にあるようにNext.jsのserver actionsに対応したフォームバリデーションライブラリのようです。
ちなみに、Reactのフォームバリデーションライブラリでスタンダードなreact-hook-form
はserver actionsはまだ検証段階のようです。
話は戻り、Conformの特徴ですが、型安全なことはもちろん、プログレッシブエンハンスメント・ファーストであることが挙げられます。
プログレッシブエンハンスメントについては、以下に詳しく記載されておりますが、ざっくり概要のみお伝えすると、「前提として、すべての人がWebページのコンテンツにアクセスできることを重視し、弱い環境下(JSがオフな状態のブラウザ環境下など)のユーザーにもコンテンツを提供しつつ、強い環境下(JSなどが使用できるブラウザ環境下など)にいるユーザーには、より強力な機能を提供する」という思想のことをいいます。
それでは、Conformと関連するライブラリを導入していきましょう!
ライブラリ導入
以下のコマンドを実行して、ライブラリの導入を行います。
bun add conform-to/react @conform-to/zod zod
フォーム実装
続いて、さっそくフォームの実装をします。
今回ですが、ユーザー情報として以下を登録するためのSignUp用のフォームUIを作成します。
理由は、Conformによる、ラジオボタンやチェックボックスの実装も行っていきたいと考えたからです。
また、コンポーネントライブラリのJustd
も使っていきます。
コンポーネントライブラリとConformを使用した実装は以下を参考にしましたが、公式ドキュメントの記載と少し乖離があったので、おそらく本記事が2024年12月現在において、最新かつ安定な情報なのではと思います。
それでは、前置きが少し長くなりましたが、実装に移ります!
zodスキーマ定義
まず、UIの作成にあたり、先んじてformのzodスキーマの定義を記述します。
以下のように定義します。
import { z } from 'zod'
export const signUpSchema = z.object({
email: z
.string({ required_error: 'Email is required' })
.email({ message: 'Email is invalid' })
.max(100),
password: z
.string({ required_error: 'Password is required' })
.min(8, { message: 'Password is too short' })
.max(100)
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z\d]{8,}$/,
'password needs including alphabet and number',
),
age: z
.number({ required_error: 'Age is required' })
.nonnegative({ message: 'Age is more than 0' }),
gender: z
.enum(['male', 'female', 'other'], {
required_error: 'Gender is required',
message: 'Gender is invalid',
})
.default('other'),
isAgree: z
.enum(['on', 'off'], {
required_error: 'Agreement is required',
message: 'Please check',
})
.refine((val) => val === 'on', {
message: 'Please check',
}),
})
export type SignUpSchemaType = z.infer<typeof signUpSchema>
UI作成
続いて、UIの作成に入ります。
以下、SignUp用のformを含んだコンポーネントになります。
(具体的な記述の説明は順次行います)
'use client'
import '@/utils/zod-error-map-utils'
import {
Button,
Card,
Checkbox,
Form,
Loader,
Radio,
RadioGroup,
TextField,
} from '@/components/ui'
import { signUp } from '@/features/auth/actions/sign-up'
import { signUpSchema } from '@/types/sign-up-schema'
import {
getCollectionProps,
getFormProps,
getInputProps,
useForm,
} from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { useActionState } from 'react'
export const SignUpCard = () => {
const [lastResult, action, isPending] = useActionState(signUp, null)
const [form, fields] = useForm({
constraint: getZodConstraint(signUpSchema),
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: signUpSchema })
},
})
return (
<Card className="max-w-md mx-auto">
<Card.Header>
<Card.Title>Sign Up</Card.Title>
<Card.Description>Create your account to get started.</Card.Description>
</Card.Header>
<Form {...getFormProps(form)} action={action}>
<Card.Content className="space-y-4">
<div>
<TextField
{...getInputProps(fields.email, { type: 'email' })}
placeholder="Enter your email"
isDisabled={isPending}
label="Email"
errorMessage={''}
/>
<ErrorMessage
errorId={fields.email.errorId}
errors={fields.email.errors}
/>
</div>
<div>
<TextField
{...getInputProps(fields.password, { type: 'password' })}
label="Password"
isRevealable={true}
placeholder="Enter your password"
isDisabled={isPending}
errorMessage={''}
/>
<ErrorMessage
errorId={fields.password.errorId}
errors={fields.password.errors}
/>
</div>
<div>
<TextField
{...getInputProps(fields.age, { type: 'number' })}
label="Age"
placeholder="Enter your age"
isDisabled={isPending}
errorMessage={''}
/>
<ErrorMessage
errorId={fields.age.errorId}
errors={fields.age.errors}
/>
</div>
<div>
<RadioGroup
label="Gender"
name={fields.gender.name}
value={fields.gender.value}
onChange={(value) => {
form.update({
name: fields.gender.name,
value,
})
}}
>
{getCollectionProps(fields.gender, {
type: 'radio',
options: ['male', 'female', 'other'],
}).map((props) => {
const { key, ...rest } = props
return (
<Radio
key={key}
{...rest}
value={props.value}
isDisabled={isPending}
>
{props.value}
</Radio>
)
})}
</RadioGroup>
<ErrorMessage
errorId={fields.gender.errorId}
errors={fields.gender.errors}
/>
</div>
<div>
{getCollectionProps(fields.isAgree, {
type: 'checkbox',
options: ['on'],
}).map((props) => {
const { key, ...rest } = props
return (
<Checkbox
key={key}
{...rest}
name={fields.isAgree.name}
isSelected={props.value === fields.isAgree.value}
onChange={(checked) => {
form.update({
name: fields.isAgree.name,
value: checked ? 'on' : 'off',
})
}}
value={props.value}
isDisabled={isPending}
>
I agree to the terms and conditions
{props.value}
</Checkbox>
)
})}
<ErrorMessage
errorId={fields.isAgree.errorId}
errors={fields.isAgree.errors}
/>
</div>
</Card.Content>
<Card.Footer>
<Button type="submit" className="w-full" isDisabled={isPending}>
Sign Up
{isPending && <Loader className="ml-2" variant="spin" />}
</Button>
</Card.Footer>
</Form>
</Card>
)
}
type ErrorMessageProps = {
errorId: string
errors?: string[]
}
const ErrorMessage = ({ errorId, errors }: ErrorMessageProps) => {
return (
<div id={errorId} className="mt-1 text-sm text-red-500">
{errors}
</div>
)
}
ここから、詳細な解説を行います。
ConformとuseActionState
まず以下、ドキュメントに従い、useActionStateとConformを統合します。
const [lastResult, action, isPending] = useActionState(signUp, null)
const [form, fields] = useForm({
constraint: getZodConstraint(signUpSchema),
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: signUpSchema })
},
defaultValue: {
email: '',
password: '',
age: 0,
gender: 'other',
isAgree: 'off',
},
})
useActionState
ですが、stateの初期値はnullとします。
そして、実際に使用するactionですが、server actionsである、signUp
関数を指定します。
そして、useForm
ですが、これがConformのフックスになります。
引数には様々なオプションを渡すことができ、私の実装にないものだとshouldValidate
などがあります。
これはバリデーションのタイミングを決めることができるオプションです。
そして、今回、私が指定したconstraint
ですが、これはzodのスキーマに応じてConformがHTMLのバリデーション属性を自動的に付与してくれるオプションになります。
どういうことか分かりづらいと思うので、devtoolを使って比較してみましょう。
以下はconstraint
なしのものです。
特にHTMLのバリデーション属性が付与されていないことがわかります。
以下が、constraint
オプション有りの場合です。
HTMLのバリデーション属性まで付与されていることがわかると思います。
次にlastResult
ですが、これはserver actionsの結果が返却されます。
そしてonValidate
でクライアント側でのバリデーションをすることを指示し、parseWithZod
で実際のformData
(入力値)とzodのスキーマ定義を照合します。
defaultValue
はformの初期値です。
返却値ですが、form
には以下のようにformのメタデータが入ってきます。
const form: {
errorId: string;
errors: string[] | undefined;
valid: boolean;
dirty: boolean;
value: {
email?: string | undefined;
password?: string | undefined;
age?: string | undefined;
isAgree?: string | undefined;
gender?: string | undefined;
} | undefined;
key: string | undefined;
descriptionId: string;
name: FieldName<{
email: string;
password: string;
age: number;
isAgree: "on" | "off";
gender?: "male" | "female" | "other" | undefined;
}, {
email: string;
password: string;
age: number;
isAgree: "on" | "off";
gender?: "male" | "female" | "other" | undefined;
}, string[]>;
initialValue: {
email?: string | undefined;
password?: string | undefined;
age?: string | undefined;
isAgree?: string | undefined;
gender?: string | undefined;
} | undefined;
// さらに定義が続く
fields
には指定したzodスキーマの定義に応じて以下のような各フィールドのメタデータが入ってきます。
const fields: Required<{
email: FieldMetadata<string, {
email: string;
password: string;
age: number;
isAgree: "on" | "off";
gender?: "male" | "female" | "other" | undefined;
}, string[]>;
password: FieldMetadata<...>;
age: FieldMetadata<...>;
isAgree: FieldMetadata<...>;
gender?: FieldMetadata<...> | undefined;
}>
これを以下のようにConformのgetFormProps()
やgetInputProps()
などに渡すことでプログレッシブ・エンハンスメントなformを作ることができます。
<Form {...getFormProps(form)} action={action}>
<TextField
{...getInputProps(fields.email, { type: 'email' })}
placeholder="Enter your email"
isDisabled={isPending}
label="Email"
errorMessage={''}
/>
</Form>
ちなみに、getFormProps
の型は以下のように定義されており、aria-invalid
・aria-describedby
なども付与されることがわかるとおもいます。
(デフォルトではどちらもtrueだそうです)
export declare function getFormProps<Schema extends Record<string, any>, FormError>(metadata: FormMetadata<Schema, FormError>, options?: FormControlOptions): {
'aria-invalid'?: boolean | undefined;
'aria-describedby'?: string | undefined;
id: import("@conform-to/dom").FormId<Schema, FormError>;
onSubmit: (event: import("react").FormEvent<HTMLFormElement>) => void;
noValidate: boolean;
};
JSX部分の解説
目立つ部分はRadioボタンとCheckboxの部分と思います。
それぞれ解説していきます。
(email・password・ageの部分は解説を省略します)
Radio Group
まず、RadioGroupの部分です。
<div>
<RadioGroup
label="Gender"
name={fields.gender.name}
value={fields.gender.value}
onChange={(value) => {
form.update({
name: fields.gender.name,
value,
})
}}
>
{getCollectionProps(fields.gender, {
type: 'radio',
options: ['male', 'female', 'other'],
}).map((props) => {
const { key, ...rest } = props
return (
<Radio key={key} {...rest} value={props.value} isDisabled={isPending}>
{props.value}
</Radio>
)
})}
</RadioGroup>
<ErrorMessage errorId={fields.gender.errorId} errors={fields.gender.errors} />
</div>
こちらについては、getCollectionProps
を使用します。
key
のみ抽出しているのは、スプレッド演算子で展開してしまうと、keyの重複でエラーとなるためです。
また、RadioGroup
にonChange
が必要なようで、上記のような記述で当該フィールドのname属性と新しい値を指定し、どのフィールドをどの値で更新するのかということを記載します。
以上がRadioGroupの解説になります。
Checkbox
次にチェックボックスですが、こちらもRadioGroup
とほとんど同様の記述で実現できます。
違いは、getCollectionProps
のtype
属性くらいです。
<div>
{getCollectionProps(fields.isAgree, {
type: 'checkbox',
options: ['on'],
}).map((props) => {
const { key, ...rest } = props
return (
<Checkbox
key={key}
{...rest}
name={fields.isAgree.name}
isSelected={props.value === fields.isAgree.value}
onChange={(checked) => {
form.update({
name: fields.isAgree.name,
value: checked ? 'on' : 'off',
})
}}
value={props.value}
isDisabled={isPending}
>
I agree to the terms and conditions
{props.value}
</Checkbox>
)
})}
<ErrorMessage
errorId={fields.isAgree.errorId}
errors={fields.isAgree.errors}
/>
</div>
以上で、クライアント側の実装は完了です。
クライアント側のバリデーションエラー時のUIは以下のようになります。
Server Actions
続いて、server actionsの処理についてです。
useActionState
で指定したsignUp
関数の内容は以下のようになっています。
const [lastResult, action, isPending] = useActionState(signUp, null)
'use server'
import { signUpSchema } from '@/types/sign-up-schema'
import { parseWithZod } from '@conform-to/zod'
import { redirect } from 'next/navigation'
export const signUp = async (prevState: unknown, formData: FormData) => {
// formDataをzodのスキーマと照合
const submission = parseWithZod(formData, { schema: signUpSchema })
console.log('🚀 ~ signUp ~ submission:', submission)
if (submission.status !== 'success') {
return submission.reply()
}
redirect('/')
}
やっていることはシンプルで、server側でもバリデーションを行い、バリデーションが通れば、ホーム画面にredirectをかけ、そうでない場合(バリデーションエラーの場合)は、reply()
ということで、エラー結果を格納したオブジェクトを返却します(ステータスやらerrors・errorIdなど実際のエラーの内容などです)。
このreply()
の結果はuseActionState
のlastResult
に格納されてくるというのが一連の流れです。
サーバー側のバリデーションだけではダメなのか?
結論ダメとは言いませんが、あまり良くはないでしょう。
理由は、クライアント側でバリデーションチェックができれば、クライアントで処理を完結できるので、サーバー側のリソースを逼迫するという心配がなくなるからです。
なので、どちらもやっておくに越したことはないということです。
動作確認
それでは、実際に動作確認に入ります。
isPendingによる制御が効いているか確認するため、ネットワークのプリセットを3Gにして確認してみましょう!
おわりに
いかがでしたでしょうか?
個人的にはaction属性指定したserver actionsはバリデーション周辺の実装が面倒だったりしたので、Conformはかなり良かったと思っております。
React19が安定版になったので、今後はserver actions ☓ Conform ☓ useActionState
がスタンダードで、要件に応じてreact-hook-form
を使用して、インタラクティブなことをすることになりそうと思いました。
実際、react-hook-form
のhandleSubmit
をformのonSubmit
に渡し、useTransition
とserver actionを使えば、処理としては同じことができ、プラスアルファでactionの結果に応じてtoaster
の表示・楽観的UI更新などもできたりします。
ただ、アクセシビリティが重要視されてきているため、react-hook-form
は出現頻度が減りそうではあるかなという感じです。
おまけ
最後におまけです。
formのaction属性とserver actionsでのformでは、基本redirectさせることが多いと思いますが、要件によってはフォームリセットが必要な場合もあると思います。
そのようなときには、以下の記事のように、server actionsで受け取れるstate
にpayload
を持たせて、JSXでdefaultValue
にuseActionState
のstate
からpayload
を取得することでフォームのリセットが可能なようです。
"use server";
type ActionState = {
message: string;
payload?: FormData;
};
export const createPost = async (
_actionState: ActionState,
formData: FormData
) => {
const data = {
name: formData.get("name"),
content: formData.get("content"),
};
if (!data.name || !data.content) {
return {
message: "Please fill in all fields",
payload: formData,
};
}
// TODO: create post in database
return { message: "Post created" };
};
const PostCreateForm = () => {
const [actionState, action] = useActionState(createPost, {
message: "",
});
return (
<form action={action}>
<label htmlFor="name">Name:</label>
<input
name="name"
id="name"
defaultValue={(actionState.payload?.get("name") || "") as string}
/>
<label htmlFor="content">Content:</label>
<textarea
name="content"
id="content"
defaultValue={(actionState.payload?.get("content") || "") as string}
/>
<button type="submit">Send</button>
{actionState.message}
</form>
);
};
参考文献