【React 19】ConformとuseActionStateが変えるフォームの新常識

# はじめに
最近、React19が安定版となりました。
そこで、今回は、React19で頻繁に使用されることになる`useActionState`と`server actions`を利用した`form`の実装について解説していこうと思います。

今回作成したソースは以下にあります。

https://github.com/sc30gsw/react19-conform-with-server-action

# useActionStateとは
まず、`useActionState`についてですが、このフックはフォームアクションの結果に基づいて`state`を更新するためのフックです。

以下のReactの記事を参考に見ていきましょう。
（実装に移りたい方は、次の項の『Conformの導入』に進んでください）

https://react.dev/blog/2024/12/05/react-19

まず、React18以前では、どのようにformの状態を更新していたのかという部分から解説します。
これを追うことで、`useActionState`が頻繁に使われることになると言われている理由がわかるからです。

React18以前では以下のようにformの値やpending状態もすべて`useState`で管理していました。
これは管理する状態が増えるため、非常に面倒だったと思います。
```tsx
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でもこの機能はありましたね！）

```tsx
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値になります。
```tsx
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について、少し概要を解説します。

https://conform.guide/

## Conformとは
> Conform は、Web 標準に基づいて HTML フォームを段階的に強化し、 Remix や Next.js のようなサーバーフレームワークを完全にサポートする、型安全なフォームバリデーションライブラリです。

上記は公式ドキュメントの記載ですが、上記にあるようにNext.jsのserver actionsに対応したフォームバリデーションライブラリのようです。

ちなみに、Reactのフォームバリデーションライブラリでスタンダードな`react-hook-form`はserver actionsはまだ検証段階のようです。

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

話は戻り、Conformの特徴ですが、型安全なことはもちろん、**プログレッシブエンハンスメント・ファースト**であることが挙げられます。

プログレッシブエンハンスメントについては、以下に詳しく記載されておりますが、ざっくり概要のみお伝えすると、「前提として、すべての人がWebページのコンテンツにアクセスできることを重視し、弱い環境下（JSがオフな状態のブラウザ環境下など）のユーザーにもコンテンツを提供しつつ、強い環境下（JSなどが使用できるブラウザ環境下など）にいるユーザーには、より強力な機能を提供する」という思想のことをいいます。

https://zenn.dev/cybozu_frontend/articles/think-about-pe

それでは、Conformと関連するライブラリを導入していきましょう！

## ライブラリ導入
以下のコマンドを実行して、ライブラリの導入を行います。

```shell:fish
bun add conform-to/react @conform-to/zod zod
```

# フォーム実装
続いて、さっそくフォームの実装をします。
今回ですが、ユーザー情報として以下を登録するためのSignUp用のフォームUIを作成します。
理由は、Conformによる、ラジオボタンやチェックボックスの実装も行っていきたいと考えたからです。

また、コンポーネントライブラリの`Justd`も使っていきます。

https://getjustd.com/

コンポーネントライブラリとConformを使用した実装は以下を参考にしましたが、公式ドキュメントの記載と少し乖離があったので、おそらく本記事が2024年12月現在において、最新かつ安定な情報なのではと思います。

https://zenn.dev/coji/articles/conform-shadcn-ui-checkbox

それでは、前置きが少し長くなりましたが、実装に移ります！

## zodスキーマ定義
まず、UIの作成にあたり、先んじてformのzodスキーマの定義を記述します。
以下のように定義します。

```ts:sign-up-schema.ts
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を含んだコンポーネントになります。
（具体的な記述の説明は順次行います）

```tsx:page.tsx
'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>
  )
}
```

画面上では以下のように表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/05232511-8974-c534-1ec8-67e85040de1b.png)

ここから、詳細な解説を行います。

### ConformとuseActionState

まず以下、ドキュメントに従い、useActionStateとConformを統合します。

https://ja.conform.guide/integration/nextjs

```tsx:sign-up-card.tsx
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のフックスになります。

https://conform.guide/api/react/useForm

引数には様々なオプションを渡すことができ、私の実装にないものだと`shouldValidate`などがあります。
これはバリデーションのタイミングを決めることができるオプションです。

そして、今回、私が指定した`constraint`ですが、これはzodのスキーマに応じてConformがHTMLのバリデーション属性を自動的に付与してくれるオプションになります。

どういうことか分かりづらいと思うので、devtoolを使って比較してみましょう。
以下は`constraint`なしのものです。
特にHTMLのバリデーション属性が付与されていないことがわかります。
![Cursor_と_Create_Next_App.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/2e9fcf41-c531-c8e7-f3b1-ac7ec9a72243.png)

以下が、`constraint`オプション有りの場合です。
HTMLのバリデーション属性まで付与されていることがわかると思います。
![Cursor_と_Create_Next_App.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/5e4d4774-09ce-c672-4fdb-56455c4581d6.png)

次に`lastResult`ですが、これはserver actionsの結果が返却されます。
そして`onValidate`でクライアント側でのバリデーションをすることを指示し、`parseWithZod`で実際の`formData`（入力値）とzodのスキーマ定義を照合します。
`defaultValue`はformの初期値です。


返却値ですが、`form`には以下のようにformのメタデータが入ってきます。
```ts
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スキーマの定義に応じて以下のような各フィールドのメタデータが入ってきます。
```ts
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を作ることができます。

```tsx
<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だそうです）

https://conform.guide/api/react/getFormProps

```ts
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の部分です。

```tsx
<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の重複でエラーとなるためです。

https://conform.guide/api/react/getCollectionProps

また、`RadioGroup`に`onChange`が必要なようで、上記のような記述で当該フィールドのname属性と新しい値を指定し、どのフィールドをどの値で更新するのかということを記載します。

以上がRadioGroupの解説になります。

#### Checkbox
次にチェックボックスですが、こちらも`RadioGroup`とほとんど同様の記述で実現できます。
違いは、`getCollectionProps`の`type`属性くらいです。

```tsx
<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は以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/c38431f5-1012-83b8-2a3a-c5c64087d772.png)


### Server Actions
続いて、server actionsの処理についてです。
`useActionState`で指定した`signUp`関数の内容は以下のようになっています。

```tsx:sign-up-card.tsx
const [lastResult, action, isPending] = useActionState(signUp, null)
```

```ts:sign-up.ts
'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にして確認してみましょう！

![confirmation](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/2e8bcc9a-4ee1-8876-1e3a-689d4257b44e.gif)

serverログもしっかりでています
![Cursor_と_bun_dev___d_n_react19-conform-with-server-action.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/aeffe199-5041-257d-31ed-59802f2fbb35.png)

次はJavaScriptを無効にして確認してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/d7809001-e0a9-8ece-aa61-80ea80d82723.png)

しっかりとバリデーションが効いているのでよさそうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/698030de-542d-fa8a-9b67-742d1babcfd8.png)

# おわりに
いかがでしたでしょうか？
個人的には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`を取得することでフォームのリセットが可能なようです。

https://www.robinwieruch.de/react-server-action-reset-form/

```ts
"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" };
};
```

```tsx
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>
  );
};
```

# 参考文献
https://react.dev/blog/2024/12/05/react-19

https://ja.react.dev/reference/react/useActionState

https://conform.guide/

https://zenn.dev/akfm/articles/server-actions-with-conform

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

https://zenn.dev/cybozu_frontend/articles/think-about-pe

https://getjustd.com/

https://zenn.dev/coji/articles/conform-shadcn-ui-checkbox

https://www.robinwieruch.de/react-server-action-reset-form/