nextjs14 でconform を動かしてみる。

# Conform

Conform

https://ja.conform.guide/


## 公式ドキュメント Conform Next.js

Conform / Next.js

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



Conformは、Next.jsやRemixなどのサーバーフレームワークを完全にサポートする、タイプセーフなフォームバリデーションライブラリです。
Next.js の Server Actions に対応

# イメージ

## Login

![conform_Login.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44761/f79c61a9-2b83-2bac-58d1-125752dbc9b3.png)



## Signup

![conform_Signup.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44761/71d039f4-0d8f-0661-0842-f802bc2a2222.png)



## Todo list

![conform_Todo list.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44761/c6a01e11-e9c6-2e27-b779-46567b83e75c.png)



# リポジトリ

edmundhung/conform: A type-safe form validation library utilizing web fundamentals to progressively enhance HTML Forms with full support for server frameworks like Remix and Next.js.

https://github.com/edmundhung/conform

👆このGitHubリポジトリは、
conformの公式サンプル Next.js版

👇このGitHubリポジトリは、
conformのサンプルを見やすく整理したもの。

masakinihirota/nextjs14_conform

https://github.com/masakinihirota/nextjs14_conform



# ハンズオン

このハンズオンには整理したリポジトリのコードを使用しています。

## nextjs 14 でconformを動かす。

```terminal
> pnpm dlx create-next-app nextjs14
√ Would you like to use TypeScript? ... No / [Yes]
√ Would you like to use ESLint? ... No / [Yes]
√ Would you like to use Tailwind CSS? ... No / [Yes]
√ Would you like to use `src/` directory? ... No / [Yes]
√ Would you like to use App Router? (recommended) ... No / [Yes]
√ Would you like to customize the default import alias (@/*)? ... No / [Yes]
√ What import alias would you like configured? ... @/*

```

```terminal
pnpm install zod
pnpm install --save @conform-to/react @conform-to/zod

```

## appフォルダ下 Layout.tsx

```nextjs14\src\app\layout.tsx
import type { Metadata } from "next";
import Link from 'next/link';

export const metadata: Metadata = {
  title: 'NextJS - Conform Example',
  description: 'This is a NextJS project with Conform'
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  // console.log('RootLayout:children', children);

  return (
    <html lang="ja">
      <body>
        <main>
          {/* レイアウトにリンクを作る。 */}
          <ul className="flex ">
            <li>
              <Link href="login">Login</Link>
            </li>
            <li>
              <Link href="signup">Signup</Link>
            </li>
            <li>
              <Link href="todos">Todo list</Link>
            </li>
          </ul>
          <hr />
          {children}
        </main>
      </body>
    </html>
  );
}

```

## appフォルダ下 Page.tsx

```nextjs14\src\app\page.tsx
export default function Index({
  searchParams
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  const value = searchParams['value'];

  if (!value) {
    return null;
  }

  return (
    <div>
      Submitted the following value:
      <pre>{JSON.stringify(JSON.parse(value.toString()), null, 2)}</pre>
    </div>
  );
}

```

## 他のappフォルダ下はルーティングのみ

```nextjs14\src\app\login\page.tsx
import { LoginForm } from '@/components/form';

export default function Login() {
  return <LoginForm />;
}

```

```nextjs14\src\app\signup\page.tsx
import { SignupForm } from '@/components/form';

export default function Signup() {
  return <SignupForm />;
}

```

```nextjs14\src\app\todos\page.tsx
import { TodoForm } from '@/components/form';

export default function Todos() {
  return <TodoForm />;
}

```

## 機能はcomponents下に配置

```nextjs14\src\components\form.tsx
'use client';

import {
  useForm,
  getFormProps,
  getInputProps,
  getFieldsetProps
} from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { useFormState, useFormStatus } from 'react-dom';
import { createTodos, login, signup } from './actions';
import { todosSchema, loginSchema, createSignupSchema } from './schema';

function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  const { pending } = useFormStatus();

  return <button {...props} disabled={pending || props.disabled} />;
}

export function TodoForm() {
  const [lastResult, action] = useFormState(createTodos, undefined);
  const [form, fields] = useForm({
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: todosSchema });
    },
    shouldValidate: 'onBlur'
  });
  const tasks = fields.tasks.getFieldList();

  return (
    <form action={action} {...getFormProps(form)}>
      <div>
        <label>Title</label>
        <input
          className={!fields.title.valid ? 'error' : ''}
          {...getInputProps(fields.title, { type: 'text' })}
          key={fields.title.key}
        />
        <div>{fields.title.errors}</div>
      </div>
      <hr />
      <div className="form-error">{fields.tasks.errors}</div>
      {tasks.map((task, index) => {
        const taskFields = task.getFieldset();

        return (
          <fieldset key={task.key} {...getFieldsetProps(task)}>
            <div>
              <label>Task #{index + 1}</label>
              <input
                className={!taskFields.content.valid ? 'error' : ''}
                {...getInputProps(taskFields.content, { type: 'text' })}
                key={taskFields.content.key}
              />
              <div>{taskFields.content.errors}</div>
            </div>
            <div>
              <label>
                <span>Completed</span>
                <input
                  className={!taskFields.completed.valid ? 'error' : ''}
                  {...getInputProps(taskFields.completed, {
                    type: 'checkbox'
                  })}
                  key={taskFields.completed.key}
                />
              </label>
            </div>
            <Button
              {...form.remove.getButtonProps({
                name: fields.tasks.name,
                index
              })}
            >
              Delete
            </Button>
            <Button
              {...form.reorder.getButtonProps({
                name: fields.tasks.name,
                from: index,
                to: 0
              })}
            >
              Move to top
            </Button>
            <Button
              {...form.update.getButtonProps({
                name: task.name,
                value: { content: '' }
              })}
            >
              Clear
            </Button>
          </fieldset>
        );
      })}
      <Button {...form.insert.getButtonProps({ name: fields.tasks.name })}>
        Add task
      </Button>
      <hr />
      <Button>Save</Button>
    </form>
  );
}

export function LoginForm() {
  const [lastResult, action] = useFormState(login, undefined);
  const [form, fields] = useForm({
    // Sync the result of last submission
    lastResult,

    // Reuse the validation logic on the client
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: loginSchema });
    },

    // Validate the form on blur event triggered
    shouldValidate: 'onBlur'
  });

  return (
    <form action={action} {...getFormProps(form)}>
      <div>
        <label>Email</label>
        <input
          className={!fields.email.valid ? 'error' : ''}
          {...getInputProps(fields.email, { type: 'text' })}
          key={fields.email.key}
        />
        <div>{fields.email.errors}</div>
      </div>
      <div>
        <label>Password</label>
        <input
          className={!fields.password.valid ? 'error' : ''}
          {...getInputProps(fields.password, { type: 'password' })}
          key={fields.password.key}
        />
        <div>{fields.password.errors}</div>
      </div>
      <label>
        <div>
          <span>Remember me</span>
          <input {...getInputProps(fields.remember, { type: 'checkbox' })} />
        </div>
      </label>
      <hr />
      <Button>Login</Button>
    </form>
  );
}

export function SignupForm() {
  const [lastResult, action] = useFormState(signup, undefined);
  const [form, fields] = useForm({
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, {
        // Create the schema without any constraint defined
        schema: (control) => createSignupSchema(control)
      });
    },
    shouldValidate: 'onBlur',
    shouldRevalidate: 'onInput'
  });

  return (
    <form action={action} {...getFormProps(form)}>
      <label>
        <div>Username</div>
        <input
          className={!fields.username.valid ? 'error' : ''}
          {...getInputProps(fields.username, { type: 'text' })}
          key={fields.username.key}
        />
        <div>{fields.username.errors}</div>
      </label>
      <label>
        <div>Password</div>
        <input
          className={!fields.password.valid ? 'error' : ''}
          {...getInputProps(fields.password, { type: 'password' })}
          key={fields.password.key}
        />
        <div>{fields.password.errors}</div>
      </label>
      <label>
        <div>Confirm Password</div>
        <input
          className={!fields.confirmPassword.valid ? 'error' : ''}
          {...getInputProps(fields.confirmPassword, { type: 'password' })}
          key={fields.confirmPassword.key}
        />
        <div>{fields.confirmPassword.errors}</div>
      </label>
      <hr />
      <Button>Signup</Button>
    </form>
  );
}

```



```nextjs14\src\components\actions.ts
'use server';

import { parseWithZod } from '@conform-to/zod';
import { redirect } from 'next/navigation';
import { loginSchema, todosSchema, createSignupSchema } from './schema';

export async function login(prevState: unknown, formData: FormData) {
  const submission = parseWithZod(formData, {
    schema: loginSchema
  });

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

  redirect(`/?value=${JSON.stringify(submission.value)}`);
}

export async function createTodos(prevState: unknown, formData: FormData) {
  const submission = parseWithZod(formData, {
    schema: todosSchema
  });

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

  redirect(`/?value=${JSON.stringify(submission.value)}`);
}

export async function signup(prevState: unknown, formData: FormData) {
  const submission = await parseWithZod(formData, {
    schema: (control) =>
      // create a zod schema base on the control
      createSignupSchema(control, {
        isUsernameUnique(username) {
          return new Promise((resolve) => {
            setTimeout(() => {
              resolve(username !== 'admin');
            }, Math.random() * 300);
          });
        }
      }),
    async: true
  });

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

  redirect(`/?value=${JSON.stringify(submission.value)}`);
}

```



```nextjs14\src\components\schema.ts
import { conformZodMessage } from '@conform-to/zod';
import { z } from 'zod';

import type { Intent } from '@conform-to/react';

export const taskSchema = z.object({
  content: z.string(),
  completed: z.boolean().optional()
});

export const todosSchema = z.object({
  title: z.string(),
  tasks: z.array(taskSchema).nonempty()
});

export const loginSchema = z.object({
  email: z.string().email(),
  password: z.string(),
  remember: z.boolean().optional()
});

export function createSignupSchema(
  intent: Intent | null,
  options?: {
    // isUsernameUnique is only defined on the server
    isUsernameUnique: (username: string) => Promise<boolean>;
  }
) {
  return z
    .object({
      username: z
        .string({ required_error: 'Username is required' })
        .regex(
          /^[a-zA-Z0-9]+$/,
          'Invalid username: only letters or numbers are allowed'
        )
        // Pipe the schema so it runs only if the username is valid
        .pipe(
          z.string().superRefine((username, ctx) => {
            const isValidatingUsername =
              intent === null ||
              (intent.type === 'validate' &&
                intent.payload.name === 'username');

            if (!isValidatingUsername) {
              ctx.addIssue({
                code: 'custom',
                message: conformZodMessage.VALIDATION_SKIPPED
              });
              return;
            }

            if (typeof options?.isUsernameUnique !== 'function') {
              ctx.addIssue({
                code: 'custom',
                message: conformZodMessage.VALIDATION_UNDEFINED,
                fatal: true
              });
              return;
            }

            return options.isUsernameUnique(username).then((isUnique) => {
              if (!isUnique) {
                ctx.addIssue({
                  code: 'custom',
                  message: 'Username is already used'
                });
              }
            });
          })
        )
    })
    .and(
      z
        .object({
          password: z.string({ required_error: 'Password is required' }),
          confirmPassword: z.string({
            required_error: 'Confirm password is required'
          })
        })
        .refine((data) => data.password === data.confirmPassword, {
          message: 'Password does not match',
          path: ['confirmPassword']
        })
    );
}

```



## TailwindCSSの設定

```nextjs14\src\styles\globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

```

※余分な装飾を削除

ハンズオンはこれで終了です。

----------------------------------------

この公式リポジトリは3つの基礎的な使い方でした。