home / skills / gpolanco / skills-as-context / forms

forms skill

/skills/forms

This skill helps you build type-safe, accessible forms using React Hook Form and Zod with server validation for reliable UX.

npx playbooks add skill gpolanco/skills-as-context --skill forms

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
11.1 KB
---
name: forms
description: >
  React Hook Form + Zod patterns for type-safe, accessible forms with server validation.
  Trigger: Use when creating forms with React Hook Form, Zod schemas, or Next.js Server Actions.
license: Apache-2.0
metadata:
  author: gpolanco
  version: "1.0.0"
  scope: [root]
  auto_invoke: "Creating forms"
allowed-tools: Read
---

# Forms (React Hook Form + Zod)

## 🚨 CRITICAL: Reference Files are MANDATORY

**This SKILL.md provides OVERVIEW only. For EXACT patterns:**

| Task | MANDATORY Reading |
|------|-------------------|
| **Form Components & Patterns** | ⚠️ [reference/validation.md](reference/validation.md) |

**⚠️ DO NOT implement custom form wrappers without reading the reference files FIRST.**

---

## When to Use

- Creating forms with React Hook Form
- Validating user input with Zod
- Submitting to Next.js Server Actions
- Building reusable form components

**Cross-references:**

- For Zod patterns → See `zod-4` skill
- For React patterns → See `react-19` skill
- For Server Actions → See `nextjs` skill (reference/architecture.md)

---

## Core Principle

> **Zod is the single source of truth.**
> If a rule isn't in Zod, it doesn't exist.

---

## ALWAYS

- **Define validation in Zod schemas** (never in JSX)
- **Revalidate on server** with `safeParse()` before persisting
- **Use `mode: "onTouched"`** for better UX
- **Provide `defaultValues`** for all fields
- **Use `FormWrapper`** (never inline `FormProvider + form`)
- **Use `FormField`** (never inline `Label + Input + Error`)
- **Apply `aria-invalid` and `aria-describedby`** for accessibility
- **Use `applyActionErrors` util** for server field errors
- **Return typed ApiResponse** from Server Actions

## NEVER

- Never validate in JSX (`required`, `validate` props)
- Never persist without server validation
- Never use `action={}` if you need rich UX feedback
- Never use `Controller` by default (only for non-native inputs)
- Never duplicate `Label + Input + Error` markup
- Never throw business logic errors from Server Actions
- Never show field errors only in toasts

## DEFAULTS

- Validation mode: `onTouched`
- Submit: React Hook Form → Server Action
- Feedback: Loading state + field errors + global error/success
- Components: `FormWrapper` + `FormField`

---

## 🚫 Critical Anti-Patterns

- **DO NOT** validate in JSX (`required`, `validate` props) → Zod is the single source of truth.
- **DO NOT** use native `action={}` if you need field errors or rich UX feedback → use `onSubmit` handler.
- **DO NOT** duplicate `FormWrapper` or `FormField` logic → use the provided shared components.
- **DO NOT** show field errors ONLY in toasts → they MUST be shown inline with the input.

---

## Schema Definition

```typescript
// features/users/schemas.ts
import { z } from "zod";

export const createUserSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  age: z.coerce.number().int().min(18, "Must be 18 or older"),
  role: z.enum(["user", "admin"]),
});

export type CreateUserInput = z.infer<typeof createUserSchema>;
```

---

## Server Action Contract

```typescript
// features/shared/types/api.ts
export type ApiResponse<T, TField extends string = string> =
  | { ok: true; data: T; message?: string }
  | { ok: false; error: string; fieldErrors?: Partial<Record<TField, string>> };
```

```typescript
// features/users/actions.ts
"use server";

import { createUserSchema } from "./schemas";
import type { ApiResponse } from "@/features/shared/types/api";

export async function createUser(
  data: unknown,
): Promise<ApiResponse<User, keyof CreateUserInput>> {
  // 1. Validate
  const result = createUserSchema.safeParse(data);
  if (!result.success) {
    return {
      ok: false,
      error: "Validation failed",
      fieldErrors: result.error.flatten().fieldErrors as any,
    };
  }

  // 2. Business logic
  try {
    const user = await db.users.create(result.data);
    return { ok: true, data: user, message: "User created successfully" };
  } catch (error) {
    return { ok: false, error: "Failed to create user" };
  }
}
```

---

## Form Setup

```typescript
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createUserSchema, type CreateUserInput } from "./schemas";

const methods = useForm<CreateUserInput>({
  resolver: zodResolver(createUserSchema),
  mode: "onTouched",
  defaultValues: {
    name: "",
    email: "",
    age: 18,
    role: "user",
  },
});
```

---

## Form Component

```typescript
import { FormWrapper } from "@/features/shared/components/form/form-wrapper";
import { FormField } from "@/features/shared/components/form/form-field";
import { applyActionErrors } from "@/features/shared/components/form/utils";

export const CreateUserForm: React.FC = () => {
  const methods = useForm<CreateUserInput>({ /* ... */ });

  const onSubmit = async (data: CreateUserInput) => {
    const result = await createUser(data);

    if (!result.ok) {
      if (result.fieldErrors) {
        applyActionErrors({
          setError: methods.setError,
          fieldErrors: result.fieldErrors,
        });
      }
      methods.setError("root", { message: result.error });
      return;
    }

    // Success
    router.push("/users");
  };

  return (
    <FormWrapper methods={methods} onSubmit={onSubmit}>
      <FormField name="name" label="Full Name" type="text" />
      <FormField name="email" label="Email" type="email" />
      <FormField name="age" label="Age" type="number" />
      <FormField name="role" label="Role" type="select" options={roleOptions} />
    </FormWrapper>
  );
};
```

---

## FormWrapper (Required Component)

```typescript
// features/shared/components/form/form-wrapper/FormWrapper.tsx
import { FormProvider, type UseFormReturn } from "react-hook-form";

interface FormWrapperProps<T extends Record<string, any>> {
  methods: UseFormReturn<T>;
  onSubmit: (data: T) => void | Promise<void>;
  children: React.ReactNode;
  className?: string;
}

export const FormWrapper = <T extends Record<string, any>>({
  methods,
  onSubmit,
  children,
  className,
}: FormWrapperProps<T>) => {
  const globalError = methods.formState.errors.root?.message;

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)} className={className}>
        {globalError && (
          <div className="rounded-md bg-destructive/10 p-3 text-destructive">
            {globalError}
          </div>
        )}
        {children}
      </form>
    </FormProvider>
  );
};
```

---

## FormField (Required Component)

```typescript
// features/shared/components/form/form-field/FormField.tsx
import { useFormContext } from "react-hook-form";
import { TextField } from "./fields/TextField";
import { SelectField } from "./fields/SelectField";

interface FormFieldProps {
  name: string;
  label: string;
  type?: "text" | "email" | "number" | "password" | "select" | "textarea";
  description?: string;
  [key: string]: any;
}

export const FormField: React.FC<FormFieldProps> = ({ name, type = "text", ...props }) => {
  if (type === "select") return <SelectField name={name} {...props} />;
  if (type === "textarea") return <TextareaField name={name} {...props} />;

  return <TextField name={name} type={type} {...props} />;
};
```

---

## FieldWrapper (Required Component)

```typescript
// features/shared/components/form/form-field/FieldWrapper.tsx
import { useFormContext } from "react-hook-form";
import { Label } from "@/features/shared/ui/label";

interface FieldWrapperProps {
  name: string;
  label: string;
  description?: string;
  required?: boolean;
  children: React.ReactNode;
}

export const FieldWrapper: React.FC<FieldWrapperProps> = ({
  name,
  label,
  description,
  required,
  children,
}) => {
  const { formState } = useFormContext();
  const error = formState.errors[name]?.message as string | undefined;

  const fieldId = `field-${name}`;
  const errorId = `error-${name}`;
  const descId = description ? `desc-${name}` : undefined;

  return (
    <div>
      <Label htmlFor={fieldId} required={required}>
        {label}
      </Label>
      {description && <p id={descId} className="text-sm text-muted-foreground">{description}</p>}
      {children}
      {error && (
        <p id={errorId} className="text-sm text-destructive" role="alert">
          {error}
        </p>
      )}
    </div>
  );
};
```

---

## TextField Example

```typescript
// features/shared/components/form/form-field/fields/TextField.tsx
import { useFormContext } from "react-hook-form";
import { Input } from "@/features/shared/ui/input";
import { FieldWrapper } from "../FieldWrapper";
import type { ComponentPropsWithoutRef } from "react";

interface TextFieldProps extends Omit<ComponentPropsWithoutRef<"input">, "name"> {
  name: string;
  label: string;
  description?: string;
}

export const TextField: React.FC<TextFieldProps> = ({
  name,
  label,
  description,
  type = "text",
  ...rest
}) => {
  const { register, formState } = useFormContext();
  const error = formState.errors[name];
  const fieldId = `field-${name}`;
  const errorId = error ? `error-${name}` : undefined;
  const descId = description ? `desc-${name}` : undefined;

  return (
    <FieldWrapper name={name} label={label} description={description}>
      <Input
        id={fieldId}
        type={type}
        aria-invalid={!!error}
        aria-describedby={[descId, errorId].filter(Boolean).join(" ") || undefined}
        disabled={formState.isSubmitting}
        {...register(name)}
        {...rest}
      />
    </FieldWrapper>
  );
};
```

---

## Utility: applyActionErrors

```typescript
// features/shared/components/form/utils/applyActionErrors.ts
import type { Path, UseFormSetError } from "react-hook-form";

interface ApplyActionErrorsParams<T extends Record<string, any>> {
  setError: UseFormSetError<T>;
  fieldErrors: Partial<Record<keyof T, string>>;
}

export function applyActionErrors<T extends Record<string, any>>({
  setError,
  fieldErrors,
}: ApplyActionErrorsParams<T>) {
  Object.entries(fieldErrors).forEach(([field, message]) => {
    setError(field as Path<T>, {
      type: "manual",
      message: message as string,
    });
  });
}
```

---

## Async Data with Reset

```typescript
// Load existing data
useEffect(() => {
  if (user) {
    methods.reset({
      name: user.name,
      email: user.email,
      age: user.age,
      role: user.role,
    });
  }
}, [user, methods]);
```

---

## Performance

```typescript
// ✅ Watch specific fields
const age = useWatch({ control: methods.control, name: "age" });

// ❌ Don't watch everything
const values = methods.watch(); // Triggers re-render on every field change
```

---

## Conditional Fields

```typescript
const methods = useForm({
  shouldUnregister: true, // Unregister fields when hidden
});

{showAdvanced && <FormField name="advancedOption" label="Advanced" />}
```

---

## Resources

- **React Hook Form**: [Official Docs](https://react-hook-form.com)
- **Zod Integration**: [zodResolver](https://react-hook-form.com/get-started#SchemaValidation)
- **Accessibility**: [WAI-ARIA Form Patterns](https://www.w3.org/WAI/ARIA/apg/patterns/form/)

Overview

This skill provides opinionated React Hook Form + Zod patterns for building type-safe, accessible forms with server validation and Next.js Server Actions. It standardizes form components, error handling, and UX defaults so forms are consistent, resilient, and easy to reuse. The patterns enforce Zod as the source of truth and require server-side revalidation before persistence.

How this skill works

Define validation in Zod schemas and use zodResolver with useForm to type your form. Use a FormWrapper that wires React Hook Form's FormProvider and handles global errors, and use FormField (plus concrete field components) to ensure consistent label/input/error markup and proper ARIA attributes. Submit via a client-side onSubmit that calls a Server Action which safeParses the Zod schema and returns a typed ApiResponse; applyActionErrors maps server field errors back into RHF with setError.

When to use it

  • Creating new forms with React Hook Form and Zod schemas
  • Submitting to Next.js Server Actions while preserving rich UX feedback
  • Building reusable, accessible form fields across an app
  • Handling server-side validation and mapping field errors to inputs
  • Implementing conditional or async-loaded form data that must reset

Best practices

  • Keep all validation rules in Zod schemas — never validate in JSX
  • Use mode: "onTouched" and provide defaultValues for predictable UX
  • Always revalidate on the server with safeParse() before persisting
  • Use FormWrapper + FormField components — avoid duplicating label/input/error markup
  • Apply aria-invalid and aria-describedby for accessibility and use role="alert" for inline errors
  • Return a typed ApiResponse from Server Actions and use applyActionErrors to set field errors

Example use cases

  • A CreateUser form that validates client-side via Zod and revalidates in a Server Action before creating a DB record
  • An edit profile flow that loads user data, resets the form with methods.reset, and submits updates with rich field error feedback
  • A complex form with conditional sections using shouldUnregister: true so hidden fields are removed from values
  • A select or custom third-party input handled only with Controller when necessary; default to native inputs otherwise
  • A paged multi-step form that shares the same FormField components and centralized error handling

FAQ

Why must validation live in Zod and not in JSX?

Zod is the single source of truth: keeping rules in Zod prevents duplication, ensures consistent messages/types, and enables identical server-side revalidation.

How do server field errors get shown next to inputs?

Server Actions return fieldErrors in a typed ApiResponse. applyActionErrors maps those into react-hook-form via setError so errors render inline with aria attributes.