0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

nextjs14 でconform を動かしてみる。

Last updated at Posted at 2024-06-02

Conform

Conform

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

Conform / Next.js

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

イメージ

Login

conform_Login.PNG

Signup

conform_Signup.PNG

Todo list

conform_Todo list.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.

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

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

masakinihirota/nextjs14_conform

ハンズオン

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

nextjs 14 でconformを動かす。

> 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? ... @/*

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つの基礎的な使い方でした。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?