Conform
Conform
公式ドキュメント Conform Next.js
Conform / Next.js
Conformは、Next.jsやRemixなどのサーバーフレームワークを完全にサポートする、タイプセーフなフォームバリデーションライブラリです。
Next.js の Server Actions に対応
イメージ
Login
Signup
Todo list
リポジトリ
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つの基礎的な使い方でした。