10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

クラベスAdvent Calendar 2024

Day 12

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

Posted at

はじめに

最近、React19が安定版となりました。
そこで、今回は、React19で頻繁に使用されることになるuseActionStateserver 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-formuseStateのように自力で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と関連するライブラリを導入していきましょう!

ライブラリ導入

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

fish
bun add conform-to/react @conform-to/zod zod

フォーム実装

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

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

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

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

zodスキーマ定義

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

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を含んだコンポーネントになります。
(具体的な記述の説明は順次行います)

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

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

ConformとuseActionState

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

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のフックスになります。

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

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

どういうことか分かりづらいと思うので、devtoolを使って比較してみましょう。
以下はconstraintなしのものです。
特にHTMLのバリデーション属性が付与されていないことがわかります。
Cursor_と_Create_Next_App.png

以下が、constraintオプション有りの場合です。
HTMLのバリデーション属性まで付与されていることがわかると思います。
Cursor_と_Create_Next_App.png

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

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

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

Checkbox

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

<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

Server Actions

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

sign-up-card.tsx
const [lastResult, action, isPending] = useActionState(signUp, null)
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()の結果はuseActionStatelastResultに格納されてくるというのが一連の流れです。

サーバー側のバリデーションだけではダメなのか?

結論ダメとは言いませんが、あまり良くはないでしょう。
理由は、クライアント側でバリデーションチェックができれば、クライアントで処理を完結できるので、サーバー側のリソースを逼迫するという心配がなくなるからです。

なので、どちらもやっておくに越したことはないということです。

動作確認

それでは、実際に動作確認に入ります。
isPendingによる制御が効いているか確認するため、ネットワークのプリセットを3Gにして確認してみましょう!

confirmation

serverログもしっかりでています
Cursor_と_bun_dev___d_n_react19-conform-with-server-action.png

次はJavaScriptを無効にして確認してみます。
image.png

しっかりとバリデーションが効いているのでよさそうです。
image.png

おわりに

いかがでしたでしょうか?
個人的にはaction属性指定したserver actionsはバリデーション周辺の実装が面倒だったりしたので、Conformはかなり良かったと思っております。

React19が安定版になったので、今後はserver actions ☓ Conform ☓ useActionState がスタンダードで、要件に応じてreact-hook-formを使用して、インタラクティブなことをすることになりそうと思いました。

実際、react-hook-formhandleSubmitをformのonSubmitに渡し、useTransitionとserver actionを使えば、処理としては同じことができ、プラスアルファでactionの結果に応じてtoasterの表示・楽観的UI更新などもできたりします。
ただ、アクセシビリティが重要視されてきているため、react-hook-formは出現頻度が減りそうではあるかなという感じです。

おまけ

最後におまけです。
formのaction属性とserver actionsでのformでは、基本redirectさせることが多いと思いますが、要件によってはフォームリセットが必要な場合もあると思います。

そのようなときには、以下の記事のように、server actionsで受け取れるstatepayloadを持たせて、JSXでdefaultValueuseActionStatestateから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>
  );
};

参考文献

10
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?