🌟

useActionStateを使ったフォーム実装

2024/05/07に公開

React v19で追加されるuseActionStateについて、MantineUIのuse-formConformを使って試す

https://react.dev/blog/2024/04/25/react-19#new-hook-useactionstate

useActionStateとは

useActionStateReactDOM.useFormStateを置き換える形で追加されたReactフックです。
詳細については以下PRを参照してください。
https://github.com/facebook/react/pull/28491

このフックはフォームアクションの結果に基づいてstateを更新するためのもので、以下のように書かれます。

const [error, submitAction, isPending] = useActionState(
  async (previousState, newName) => {
    const error = await updateName(newName);
    if (error) {
      // You can return any result of the action.
      // Here, we return only the error.
      return error;
    }

    // handle success
    return null;
  },
  null,
);

詳細はドキュメントを参照してください。

https://ja.react.dev/reference/react/useActionState#noun-labs-1201738-(2)

実装

formAction
action.ts
"use server";

import { parseWithZod } from "@conform-to/zod";
import { redirect } from "next/navigation";
import { formSchema } from "~/app/schema";

export async function formAction(prevState: unknown, formData: FormData) {
  await new Promise((resolve) => setTimeout(resolve, 3000));

  const submission = parseWithZod(formData, { schema: formSchema });

  if (submission.status !== "success") {
    return submission.reply();
  }

  redirect("/success");
}
formSchema
schema.ts
import { z } from "zod";

export const formSchema = z.object({
  email: z
    .string({ required_error: "メールアドレスは必須です" })
    .email("メールアドレスを正しく入力してください"),
  message: z
    .string({ required_error: "メッセージは必須です" })
    .min(4, "メッセージが短すぎます")
    .max(20, "メッセージが長すぎます"),
});

Conform

conform-form.tsx
"use client";

import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import { Button, TextInput } from "@mantine/core";
import { useActionState } from "react";
import { formAction } from "~/app/actions";
import { formSchema } from "~/app/schema";

export function ConformForm() {
  const [lastResult, action, isPending] = useActionState(formAction, undefined);

  const [form, fields] = useForm({
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: formSchema });
    },
    shouldValidate: "onBlur",
  });

  return (
    <form {...getFormProps(form)} action={action}>
      <TextInput
        required
        withAsterisk
        label="Email"
        placeholder="your@email.com"
        {...getInputProps(fields.email, { type: "email" })}
        error={fields.email.errors}
      />
      <TextInput
        required
        withAsterisk
        label="Message"
        placeholder="message"
        {...getInputProps(fields.message, { type: "text" })}
        error={fields.message.errors}
      />
      <Button loading={isPending} type="submit">
        ログイン
      </Button>
    </form>
  );
}

公式ドキュメントやGitHubのリポジトリにサンプルがありますので詳細はこちらを参照してください。
https://ja.conform.guide/integration/nextjs

https://github.com/edmundhung/conform/tree/main/examples/nextjs

MantineUI

  • form.onSubmitだとevent?.preventDefault()によってactionが実行されないため、クライアント側はevent.currentTargetを使ってバリデーションを行う

https://github.com/mantinedev/mantine/blob/bd73170bec1fb56211943de411aaf67ceec2f6d1/packages/%40mantine/form/src/use-form.ts#L188-L192

  • validateInputOnChangevalidateInputOnBlurを設定して入力時に検証、form.isValid()でボタンを非活性にしておくことでonSubmitの判定が要らなくなる?
    • mode: "uncontrolled"だと動きが安定していなさそうだったためmode: 'controlled'にした方がよさそう
mantine-form.tsx
"use client";

import { Button, Text, TextInput } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { useActionState } from "react";
import { formAction } from "~/app/actions";
import { formSchema } from "~/app/schema";

export function MantineForm() {
  const [state, action, isPending] = useActionState(formAction, undefined);

  const form = useForm({
    mode: "uncontrolled",
    initialValues: {
      email: "",
      message: "",
    },

    validate: zodResolver(formSchema),
    // validateInputOnChange: true,
    validateInputOnBlur: true,
  });

  return (
    <form
      action={action}
      onSubmit={(e) => {
        try {
          const data = new FormData(e.currentTarget);
          const formData = Object.fromEntries(data);
          const validatedField = formSchema.safeParse(formData);
          if (!validatedField.success) {
            throw new Error("invalidated");
          }
        } catch (error) {
          e.preventDefault();
        }
      }}
    >
      <TextInput
        required
        withAsterisk
        label="Email"
        placeholder="your@email.com"
        name="email"
        type="email"
        key={form.key("email")}
        {...form.getInputProps("email")}
      />
      <TextInput
        required
        withAsterisk
        label="Message"
        placeholder="message"
        name="message"
        type="text"
        key={form.key("message")}
        {...form.getInputProps("message")}
      />
      <Button loading={isPending} type="submit">
        送信
      </Button>
    </form>
  );
}

まとめ

試してみた感じだとMantineUIのuse-formをそのまま使うのは難しそうで、Conformはそのままでも正しく動いていそう。

今回試したものは以下のGitHubリポジトリで公開しています。
https://github.com/hiropi1224/form-demo

参考

https://speakerdeck.com/takefumiyoshii/progressive-enhancement-with-server-action

https://zenn.dev/tsuboi/articles/0fc94356667284

Discussion