mcpField
C
conform

conform

github.com/tejovanthn開発1164 回インストール4 日前に更新
リポジトリ

conform について

Conformは、Zodによるバリデーションを使い、Remixでアクセシブルかつ段階的に機能するフォームを構築するためのスキルガイドです。型安全な検証を実装する開発者向けで、ファイルアップロードや複数ステップのフォームも扱います。

主な機能

  1. 01JavaScriptなしでも機能する段階的拡張
  2. 02Zodによる型安全なバリデーション
  3. 03アクセシブルなフォーム属性
  4. 04ファイルアップロードとフィールド配列
  5. 05複数ステップとネストされたフォーム

活用シーン

  1. 01Remixで検証付きログインフォームを構築
  2. 02動的なフィールドリストを含むフォームの作成
  3. 03複数ステップのデータ入力を実装
  4. 04ファイルアップロードを検証

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/tejovanthn/rasikalife --skill conform

インストール後、Claude Code で/conformを入力して読み込まれたか確認します。

詳細

Conform Forms

このスキルでは、Zod によるバリデーションを使用して、Remix でアクセシブルかつプログレッシブエンハンスメントに対応したフォームを構築するための Conform v1 以降のパターンを扱います。

基本概念

Conform のメリット:

  • プログレッシブエンハンスメント(JavaScript がなくても動作)
  • Zod との統合による型安全性
  • デフォルトでアクセシブル(ARIA 属性)
  • 制御モードと非制御モード
  • ファイルのアップロード
  • マルチステップフォーム
  • フィールド配列(動的リスト)

主な用語:

  • フォーム: フォーム全体の設定
  • フィールド: 個々のフォーム入力
  • 制約: HTML5 のバリデーション属性
  • 送信: フォーム送信の結果
  • フィールド配列: フィールドの動的リスト

インストール

npm install @conform-to/react @conform-to/zod zod

基本的なフォーム

バリデーション付きのシンプルなフォーム
// app/routes/login.tsx
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { Form, useActionData } from "@remix-run/react";
import { json, redirect } from "@remix-run/node";
import { z } from "zod";

// Define schema
const loginSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
  remember: z.boolean().optional(),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  
  // Parse with Zod
  const submission = parseWithZod(formData, { schema: loginSchema });
  
  // Return errors if validation fails
  if (submission.status !== "success") {
    return json({ submission: submission.reply() });
  }
  
  // Process valid data
  const { email, password, remember } = submission.value;
  await login(email, password, remember);
  
  return redirect("/dashboard");
}

export default function Login() {
  const lastResult = useActionData<typeof action>();
  
  const [form, fields] = useForm({
    lastResult: lastResult?.submission,
    onValidate: ({ formData }) => {
      return parseWithZod(formData, { schema: loginSchema });
    },
    shouldValidate: "onBlur",
    shouldRevalidate: "onInput",
  });
  
  return (
    <Form method="post" {...getFormProps(form)}>
      <div>
        <label htmlFor={fields.email.id}>Email</label>
        <input
          {...getInputProps(fields.email, { type: "email" })}
          key={fields.email.key}
        />
        <div>{fields.email.errors}</div>
      </div>
      
      <div>
        <label htmlFor={fields.password.id}>Password</label>
        <input
          {...getInputProps(fields.password, { type: "password" })}
          key={fields.password.key}
        />
        <div>{fields.password.errors}</div>
      </div>
      
      <div>
        <label>
          <input
            {...getInputProps(fields.remember, { type: "checkbox" })}
            key={fields.remember.key}
          />
          Remember me
        </label>
      </div>
      
      <button type="submit">Login</button>
    </Form>
  );
}

フォームの設定

useForm フックのオプション
const [form, fields] = useForm({
  // ID for the form (required for nested forms)
  id: "login-form",
  
  // Last submission result from action
  lastResult: actionData?.submission,
  
  // Default values
  defaultValue: {
    email: "[email protected]",
    remember: true,
  },
  
  // Client-side validation
  onValidate: ({ formData }) => {
    return parseWithZod(formData, { schema: loginSchema });
  },
  
  // When to validate
  shouldValidate: "onBlur", // or "onInput" | "onSubmit"
  shouldRevalidate: "onInput", // after first validation
  
  // Callbacks
  onSubmit: (event, context) => {
    // Optional: custom submit handling
    // event.preventDefault() if you want to prevent submission
  },
  
  // Constraint validation (HTML5)
  constraint: getZodConstraint(loginSchema),
});

フィールドの種類

テキスト入力
const schema = z.object({
  name: z.string().min(1, "Name is required"),
  bio: z.string().max(500, "Bio is too long").optional(),
});

// In component
<div>
  <label htmlFor={fields.name.id}>Name</label>
  <input
    {...getInputProps(fields.name, { type: "text" })}
    key={fields.name.key}
  />
  <div id={fields.name.errorId}>{fields.name.errors}</div>
</div>

<div>
  <label htmlFor={fields.bio.id}>Bio</label>
  <textarea
    {...getTextareaProps(fields.bio)}
    key={fields.bio.key}
    rows={4}
  />
  <div>{fields.bio.errors}</div>
</div>
数値入力
const schema = z.object({
  age: z.number().min(18, "Must be 18 or older"),
  price: z.number().positive("Price must be positive"),
});

<input
  {...getInputProps(fields.age, { type: "number" })}
  key={fields.age.key}
  min={18}
  max={120}
/>
セレクト/ドロップダウン
const schema = z.object({
  role: z.enum(["STUDENT", "TEACHER", "ADMIN"]),
  country: z.string().min(1, "Country is required"),
});

<select
  {...getSelectProps(fields.role)}
  key={fields.role.key}
>
  <option value="">Select role</option>
  <option value="STUDENT">Student</option>
  <option value="TEACHER">Teacher</option>
  <option value="ADMIN">Admin</option>
</select>

<div>{fields.role.errors}</div>
チェックボックス
const schema = z.object({
  terms: z.literal(true, {
    errorMap: () => ({ message: "You must accept terms" }),
  }),
  newsletter: z.boolean().optional(),
});

<label>
  <input
    {...getInputProps(fields.terms, { type: "checkbox" })}
    key={fields.terms.key}
  />
  I accept the terms and conditions
</label>
<div>{fields.terms.errors}</div>
ラジオボタン
const schema = z.object({
  plan: z.enum(["free", "pro", "enterprise"]),
});

<fieldset>
  <legend>Choose a plan</legend>
  
  <label>
    <input
      {...getInputProps(fields.plan, { type: "radio" })}
      value="free"
    />
    Free
  </label>
  
  <label>
    <input
      {...getInputProps(fields.plan, { type: "radio" })}
      value="pro"
    />
    Pro
  </label>
  
  <label>
    <input
      {...getInputProps(fields.plan, { type: "radio" })}
      value="enterprise"
    />
    Enterprise
  </label>
  
  <div>{fields.plan.errors}</div>
</fieldset>
日付入力
const schema = z.object({
  birthDate: z.string().refine(
    (date) => {
      const parsed = new Date(date);
      return !isNaN(parsed.getTime());
    },
    { message: "Invalid date" }
  ),
  scheduledDate: z.string().refine(
    (date) => new Date(date) > new Date(),
    { message: "Date must be in the future" }
  ),
});

<input
  {...getInputProps(fields.birthDate, { type: "date" })}
  key={fields.birthDate.key}
  max={new Date().toISOString().split("T")[0]} // Today or earlier
/>

<input
  {...getInputProps(fields.scheduledDate, { type: "datetime-local" })}
  key={fields.scheduledDate.key}
/>

ファイルのアップロード

単一ファイルのアップロード
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

const schema = z.object({
  avatar: z
    .instanceof(File)
    .refine(
      (file) => file.size <= MAX_FILE_SIZE,
      "File size must be less than 5MB"
    )
    .refine(
      (file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type),
      "Only JPEG, PNG, and WebP images are allowed"
    ),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  
  const submission = parseWithZod(formData, {
    schema: schema.transform(async (data) => {
      // Upload to S3
      const url = await uploadToS3(data.avatar);
      return { avatarUrl: url };
    }),
    async: true, // Required for async transforms
  });
  
  if (submission.status !== "success") {
    return json({ submission: submission.reply() });
  }
  
  // Use submission.value.avatarUrl
  await updateUser(userId, { avatarUrl: submission.value.avatarUrl });
  
  return redirect("/profile");
}

// In component
<div>
  <label htmlFor={fields.avatar.id}>Avatar</label>
  <input
    {...getInputProps(fields.avatar, { type: "file" })}
    key={fields.avatar.key}
    accept="image/jpeg,image/png,image/webp"
  />
  <div>{fields.avatar.errors}</div>
</div>
複数ファイルのアップロード
const schema = z.object({
  photos: z
    .array(z.instanceof(File))
    .min(1, "At least one photo is required")
    .max(5, "Maximum 5 photos allowed")
    .refine(
      (files) => files.every((file) => file.size <= MAX_FILE_SIZE),
      "Each file must be less than 5MB"
    ),
});

<input
  {...getInputProps(fields.photos, { type: "file" })}
  key={fields.photos.key}
  accept="image/*"
  multiple
/>

ネストされたオブジェクト

const schema = z.object({
  name: z.string().min(1),
  address: z.object({
    street: z.string().min(1),
    city: z.string().min(1),
    zipCode: z.string().regex(/^\d{5}$/, "Invalid ZIP code"),
  }),
});

export default function AddressForm() {
  const [form, fields] = useForm({
    onValidate: ({ formData }) => parseWithZod(formData, { schema }),
  });
  
  const address = fields.address.getFieldset();
  
  return (
    <Form method="post" {...getFormProps(form)}>
      <input {...getInputProps(fields.name, { type: "text" })} />
      
      <fieldset>
        <legend>Address</legend>
        
        <input
          {...getInputProps(address.street, { type: "text" })}
          placeholder="Street"
        />
        <div>{address.street.errors}</div>
        
        <input
          {...getInputProps(address.city, { type: "text" })}
          placeholder="City"
        />
        <div>{address.city.errors}</div>
        
        <input
          {...getInputProps(address.zipCode, { type: "text" })}
          placeholder="ZIP Code"
        />
        <div>{address.zipCode.errors}</div>
      </fieldset>
      
      <button type="submit">Submit</button>
    </Form>
  );
}

フィールド配列(動的リスト)

import { useFieldList } from "@conform-to/react";

const schema = z.object({
  name: z.string().min(1),
  emails: z
    .array(
      z.object({
        address: z.string().email(),
        primary: z.boolean().optional(),
      })
    )
    .min(1, "At least one email is required"),
});

export default function EmailsForm() {
  const [form, fields] = useForm({
    onValidate: ({ formData }) => parseWithZod(formData, { schema }),
    defaultValue: {
      emails: [{ address: "", primary: true }],
    },
  });
  
  const emails = useFieldList(form.ref, fields.emails);
  
  return (
    <Form method="post" {...getFormProps(form)}>
      <input {...getInputProps(fields.name, { type: "text" })} />
      
      <fieldset>
        <legend>Email Addresses</legend>
        
        {emails.map((email, index) => {
          const emailFields = email.getFieldset();
          
          return (
            <div key={email.key}>
              <input
                {...getInputProps(emailFields.address, { type: "email" })}
                placeholder="Email address"
              />
              <div>{emailFields.address.errors}</div>
              
              <label>
                <input
                  {...getInputProps(emailFields.primary, { type: "checkbox" })}
                />
                Primary
              </label>
              
              <button
                {...form.remove.getButtonProps({
                  name: fields.emails.name,
                  index,
                })}
              >
                Remove
              </button>
            </div>
          );
        })}
        
        <button
          {...form.insert.getButtonProps({
            name: fields.emails.name,
          })}
        >
          Add Email
        </button>
      </fieldset>
      
      <button type="submit">Submit</button>
    </Form>
  );
}

マルチステップフォーム

const step1Schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

const step2Schema = z.object({
  name: z.string().min(1),
  bio: z.string().optional(),
});

const fullSchema = step1Schema.merge(step2Schema);

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const step = formData.get("_step");
  
  if (step === "1") {
    // Validate step 1
    const submission = parseWithZod(formData, { schema: step1Schema });
    
    if (submission.status !== "success") {
      return json({ submission: submission.reply(), step: 1 });
    }
    
    // Move to step 2
    return json({ submission: submission.reply(), step: 2 });
  }
  
  // Final submission - validate everything
  const submission = parseWithZod(formData, { schema: fullSchema });
  
  if (submission.status !== "success") {
    return json({ submission: submission.reply(), step: 2 });
  }
  
  // Create account
  await createAccount(submission.value);
  return redirect("/dashboard");
}

export default function Signup() {
  const actionData = useActionData<typeof action>();
  const [currentStep, setCurrentStep] = useState(actionData?.step ?? 1);
  
  const [form, fields] = useForm({
    lastResult: actionData?.submission,
    onValidate: ({ formData }) => {
      const schema = currentStep === 1 ? step1Schema : fullSchema;
      return parseWithZod(formData, { schema });
    },
  });
  
  return (
    <Form method="post" {...getFormProps(form)}>
      <input type="hidden" name="_step" value={currentStep} />
      
      {currentStep === 1 && (
        <>
          <h2>Step 1: Account Details</h2>
          <input {...getInputProps(fields.email, { type: "email" })} />
          <div>{fields.email.errors}</div>
          
          <input {...getInputProps(fields.password, { type: "password" })} />
          <div>{fields.password.errors}</div>
          
          <button type="button" onClick={() => setCurrentStep(2)}>
            Next
          </button>
        </>
      )}
      
      {currentStep === 2 && (
        <>
          <h2>Step 2: Profile</h2>
          <input {...getInputProps(fields.name, { type: "text" })} />
          <div>{fields.name.errors}</div>
          
          <textarea {...getTextareaProps(fields.bio)} />
          <div>{fields.bio.errors}</div>
          
          <button type="button" onClick={() => setCurrentStep(1)}>
            Back
          </button>
          <button type="submit">Create Account</button>
        </>
      )}
    </Form>
  );
}

高度なパターン

依存フィールド
const schema = z
  .object({
    hasShippingAddress: z.boolean(),
    shippingAddress: z.object({
      street: z.string(),
      city: z.string(),
    }).optional(),
  })
  .refine(
    (data) => {
      if (data.hasShippingAddress) {
        return data.shippingAddress?.street && data.shippingAddress?.city;
      }
      return true;
    },
    {
      message: "Shipping address is required",
      path: ["shippingAddress"],
    }
  );

export default function ShippingForm() {
  const [form, fields] = useForm({
    onValidate: ({ formData }) => parseWithZod(formData, { schema }),
  });
  
  const hasShipping = fields.hasShippingAddress.value === "on";
  const shippingFields = fields.shippingAddress.getFieldset();
  
  return (
    <Form method="post" {...getFormProps(form)}>
      <label>
        <input
          {...getInputProps(fields.hasShippingAddress, { type: "checkbox" })}
        />
        Use different shipping address
      </label>
      
      {hasShipping && (
        <fieldset>
          <input {...getInputProps(shippingFields.street, { type: "text" })} />
          <input {...getInputProps(shippingFields.city, { type: "text" })} />
        </fieldset>
      )}
      
      <button type="submit">Submit</button>
    </Form>
  );
}
カスタムバリデーションメッセージ
const schema = z.object({
  email: z.string().email("Please enter a valid email address"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain an uppercase letter")
    .regex(/[a-z]/, "Password must contain a lowercase letter")
    .regex(/[0-9]/, "Password must contain a number"),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: "Passwords don't match",
    path: ["confirmPassword"],
  }
);
サーバーサイドのみのバリデーション
const clientSchema = z.object({
  email: z.string().email(),
  username: z.string().min(3),
});

const serverSchema = clientSchema.extend({
  email: z.string().email().refine(
    async (email) => {
      const existing = await db.user.findUnique({ where: { email } });
      return !existing;
    },
    { message: "Email already registered" }
  ),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  
  // Server-side validation with async checks
  const submission = await parseWithZod(formData, {
    schema: serverSchema,
    async: true,
  });
  
  if (submission.status !== "success") {
    return json({ submission: submission.reply() });
  }
  
  // Create user
  await createUser(submission.value);
  return redirect("/login");
}

export default function Signup() {
  const actionData = useActionData<typeof action>();
  
  const [form, fields] = useForm({
    lastResult: actionData?.submission,
    // Client-side validation (no async checks)
    onValidate: ({ formData }) => parseWithZod(formData, { schema: clientSchema }),
  });
  
  return <Form method="post" {...getFormProps(form)}>...</Form>;
}
フィールド単位のインテント(Blur/Change イベント)
import { useInputControl } from "@conform-to/react";

export default function UsernameField({ field }) {
  const control = useInputControl(field);
  const [availability, setAvailability] = useState<"available" | "taken" | null>(null);
  
  const checkAvailability = async (username: string) => {
    const response = await fetch(`/api/check-username?username=${username}`);
    const data = await response.json();
    setAvailability(data.available ? "available" : "taken");
  };
  
  return (
    <div>
      <input
        {...getInputProps(field, { type: "text" })}
        onBlur={() => {
          if (control.value) {
            checkAvailability(control.value);
          }
        }}
      />
      {availability === "available" && <span>✓ Available</span>}
      {availability === "taken" && <span>✗ Already taken</span>}
      <div>{field.errors}</div>
    </div>
  );
}

アクセシビリティ

Conform はアクセシブルなフォームを自動生成します。

// Generated HTML includes:
<form id="login-form" aria-invalid={form.errors ? true : undefined}>
  <label htmlFor="email-input">Email</label>
  <input
    id="email-input"
    name="email"
    type="email"
    aria-invalid={fields.email.errors ? true : undefined}
    aria-describedby={fields.email.errors ? "email-error" : undefined}
  />
  <div id="email-error" aria-live="polite">
    {fields.email.errors}
  </div>
</form>
手動でのアクセシビリティ対応
<div>
  <label htmlFor={fields.email.id}>
    Email
    <span aria-label="required">*</span>
  </label>
  <input
    {...getInputProps(fields.email, { type: "email" })}
    aria-required="true"
    aria-describedby={`${fields.email.errorId} email-hint`}
  />
  <div id="email-hint">We'll never share your email</div>
  <div id={fields.email.errorId} aria-live="polite">
    {fields.email.errors}
  </div>
</div>

テスト

import { parseWithZod } from "@conform-to/zod";
import { describe, test, expect } from "vitest";

describe("Login form", () => {
  test("validates email format", () => {
    const formData = new FormData();
    formData.set("email", "invalid");
    formData.set("password", "password123");
    
    const submission = parseWithZod(formData, { schema: loginSchema });
    
    expect(submission.status).toBe("error");
    expect(submission.reply().error?.email).toBeDefined();
  });
  
  test("accepts valid data", () => {
    const formData = new FormData();
    formData.set("email", "[email protected]");
    formData.set("password", "password123");
    
    const submission = parseWithZod(formData, { schema: loginSchema });
    
    expect(submission.status).toBe("success");
    expect(submission.value).toEqual({
      email: "[email protected]",
      password: "password123",
    });
  });
});

ベストプラクティス

  1. 常に key prop を使用する: React の状態に関する問題を防ぎます
  2. getInputProps ヘルパーを使用する: 適切な属性が設定されます
  3. まず blur 時にバリデーションする: キー入力のたびに検証するよりも優れた UX を実現します
  4. submission.reply() を返す: エラー発生時もフォームの状態を保持します
  5. Zod の制約を使用する: HTML5 バリデーションを有効にします
  6. ファイルのアップロードを適切に処理する: 非同期の変換を使用します
  7. バリデーションロジックをテストする: スキーマの単体テストを作成します
  8. アクセシビリティを最優先する: 必要に応じてセマンティック HTML と ARIA を使用します
  9. プログレッシブエンハンスメント: JavaScript がなくてもフォームが動作します
  10. 型安全性: スキーマから TypeScript に型を推論させます

よくある落とし穴

  1. key prop がない: ハイドレーションの問題が発生します
  2. reply() を使用しない: バリデーションエラー時にフォームの状態が失われます
  3. クライアント側での非同期バリデーション: サーバー専用のスキーマを使用してください
  4. 大容量ファイルのアップロード: 4.5MB を超えるファイルにはストリーミングが必要です
  5. フィールド配列の再レンダリング: 適切なキーを使用してください
  6. ネストされたオブジェクトの構文: ネストされたオブジェクトには getFieldset() を使用してください
  7. ファイルのバリデーション: z.string() ではなく instanceof File を使用する必要があります
  8. マルチステップのバリデーション: スキーマ全体ではなく、現在のステップを検証してください
  9. デフォルト値: スキーマではなく、useForm の defaultValue を使用してください
  10. フォームのリセット: form.reset() を使用するか、状態をクリアするためにページ遷移してください

リソース

コメント