# Forms

> Build or edit any form in a Next.js 16 App Router app — create dialogs, edit panels, settings UIs, anything with input fields that persist to a backend — via one shared toolkit in `lib/forms/` with explicit dirty + valid Save gating, baseline reset on success, and discriminated-union error mapping. Supports two equally-supported underlying form libraries: **TanStack Form + Zod** (default when no…

- **Type:** Skill
- **Install:** `agentstack add skill-lukedj78-dev-flow-forms`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [lukedj78](https://agentstack.voostack.com/s/lukedj78)
- **Installs:** 0
- **Category:** [Search](https://agentstack.voostack.com/c/search)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [lukedj78](https://github.com/lukedj78)
- **Source:** https://github.com/lukedj78/dev-flow/tree/main/forms

## Install

```sh
agentstack add skill-lukedj78-dev-flow-forms
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# forms — one toolkit, dirty-gated Save, shared error mapping

This skill governs **where** the form layer fits in a Next.js 16 App Router app: which hook, which rendering layer, which red flags. It does not teach `@tanstack/react-form` (or `react-hook-form`) itself — for the library's own API surface defer to the official docs.

The codebase exposes **one** shared toolkit at `lib/forms/` regardless of which underlying library the project picked. Hook names, rendering layer, and error contract are identical across the two — consumer code looks the same. The choice between TanStack Form (default) and react-hook-form is made once at project scaffold time, written to `meta.json#stack.forms`, and never mixed.

## When this skill applies

- The user asks for a "form", "edit panel", "create dialog", "settings page", "save button".
- Orchestrator (`dev-flow`) routes here after scaffolding a route that needs persistence.
- The user reaches for `useState` to hold field values, raw `useForm` (bypassing the toolkit), hand-rolled dirty tracking, inline `toast.success`/`toast.error` on submit, or `` that calls `fetch`/services directly.
- The user asks to **audit** a Next.js codebase against the form rules.

## Contract

This skill follows the dev-flow contract — see `references/contracts.md` (vendored copy). Key facts:

- Reads `meta.json#stack.framework`, `stack.nextjs_version`, `stack.forms`, `stack.ui`. For `framework = "monorepo"`, reads the equivalent keys under `stack.monorepo.web`. `stack.forms` picks the state library ([selection logic](#selection-logic--decide-once-up-front)); `stack.ui` picks the rendering primitives ([branching table](#rendering-primitives--stackui)) — the two are independent and both must be resolved before writing code.
- **Refuses to apply** if any of these is true:
  - `stack.framework ∉ {"next", "monorepo"}` — non-Next.js targets are out of scope (RN forms use a different ecosystem; refer the user to the React Native form patterns).
  - `stack.nextjs_version != "16"` — Next.js 15 and earlier have different `searchParams`, no `use(promise)` baseline, no `revalidatePath/revalidateTag` defaults. Refuse to apply rather than silently guess.
  - The project uses Pages Router (`pages/` directory exists). App Router only.
  - The project standardizes on a form library other than `tanstack-form` or `react-hook-form` (Formik, plain controlled state, etc.). Refuse to mix.
- Appends a `history` entry to `meta.json` for every scaffold or edit.
- Does **not** bump `phase` — forms live inside the implementation phase and don't gate progression.

## The Rule

**Every form goes through one shared toolkit at `lib/forms/`. Every form has an explicit Save button gated by dirty + valid state. No auto-save, no save-on-blur, no debounce. Never `useState` for field values. Never raw `useForm` (TanStack or RHF) outside the toolkit. Never inline `toast.success`/`toast.error` from a form component.**

A "form" is any UI with `` / `` / `` / `` / `` / `` whose value persists to a backend (POST / PATCH / PUT). Display-only fields, search boxes that only filter, and fire-and-forget toggles that take no field state are not forms.

## Two patterns, one toolkit

| Form kind | Hook | Submit trigger | After success |
|---|---|---|---|
| **Edit** (panel, settings, inline editor) | `useEditForm` | `Save` — disabled unless `isDirty && canSubmit` | Form stays open. Hook resets baseline to the saved value so `isDirty` returns to `false`. |
| **Create** (dialog, new-record form) | `useCreateForm` | `Create` — disabled unless `canSubmit` | `onSuccess(result)` callback fires (close dialog, route, etc.). |

Both share the `` + `` rendering layer, the same Zod schema as validator, the same `mapFormError` error router, and the same `` button row. The differences: edit gates on `isDirty` and resets baseline after success; create closes via `onSuccess`.

## Library branching — `stack.forms`

The two libraries (TanStack Form + Zod, react-hook-form + Zod) are **equally supported** by this skill and produce **identical consumer code**. Only the toolkit implementation differs — neither is a second-class citizen.

| `meta.json#stack.forms` | Hooks | Schema validator | Library docs |
|---|---|---|---|
| `"tanstack-form"` *(default when nothing else indicates a preference)* | `useEditForm` + `useCreateForm` wrap `useForm` from `@tanstack/react-form` | Pass schema to `validators: { onChange, onBlur }` | https://tanstack.com/form/latest |
| `"react-hook-form"` *(fully supported; auto-detected when already present)* | Same hook names; wrap `useForm` from `react-hook-form` with `zodResolver` | `zodResolver(schema)` from `@hookform/resolvers/zod` | https://react-hook-form.com |

The consumer of `useEditForm` / `useCreateForm` never imports the underlying library. They get a typed hook with `state.isDirty`, `state.isSubmitting`, `state.canSubmit`, `handleSubmit()`, `reset(value?)`, `Subscribe(...)`, and a `` (TanStack) or `` (RHF wrapper) equivalent for raw escape-hatch usage.

### Selection logic — decide once, up front

Every time this skill runs, resolve `stack.forms` **before** touching any form code, in this order:

1. **`meta.json#stack.forms` is already set** (`"tanstack-form"` or `"react-hook-form"`) → use it. Never override silently, even if the codebase looks mixed — flag drift instead (see violation A in [Audit mode](#audit-mode)).
2. **Unset → auto-detect from the project.** Check, in order:
   - `react-hook-form` (or `@hookform/resolvers`) present in `package.json#dependencies` / `devDependencies`, **or** existing `import { useForm } from "react-hook-form"` / `` usage found in the codebase → the project has already standardized on **react-hook-form**. Set `stack.forms = "react-hook-form"` and proceed.
   - `@tanstack/react-form` present in `package.json`, **or** existing `import { useForm } from "@tanstack/react-form"` usage found → the project has already standardized on **TanStack Form**. Set `stack.forms = "tanstack-form"` and proceed.
3. **Ambiguous (both detected) or absent (neither detected, greenfield project) → ask the user once.** State the default (`"tanstack-form"`) and name the alternative; write whichever answer to `meta.json#stack.forms` before scaffolding anything. Do not guess silently in either direction — greenfield with no stated preference is the *only* case where defaulting without asking is acceptable, and even then, say out loud which one you're about to use.

Write the resolved value to `meta.json#stack.forms` immediately (Step 1 of the [Workflow](#workflow)) so every subsequent run of this skill — and every other dev-flow skill — reads the same answer.

## Rendering primitives — `stack.ui`

`stack.forms` picks the state-management library; `stack.ui` (a separate, orthogonal `meta.json` key) picks what `` and `` actually render. **All code samples in this skill assume `stack.ui = "shadcn"`** (`Field` / `FieldLabel` / `FieldError` / `Button`) — that assumption must be made explicit, not silently carried over to a project that picked something else.

| `meta.json#stack.ui` | `` renders | `` renders | Notes |
|---|---|---|---|
| `"shadcn"` / `"base-ui"` *(default; every example above)* | shadcn `Field` + `FieldLabel` + `FieldError` (see `references/toolkit-tanstack.md` / `references/toolkit-rhf.md`) | shadcn `Button` | `"base-ui"` here still means shadcn's component set on the Base UI primitive layer — the `Field` API is the same. |
| `"mui"` | MUI `TextField` (or `FormControl` + `InputLabel` + `FormHelperText` for non-text inputs) wired to the **same field API** the hook exposes (`value`, `setValue`, `onBlur`, `touched`, `isValid`, `errors`) | MUI `Button` / `LoadingButton`, gated on the same `canSubmit`/`isDirty` booleans | The dirty + valid gating logic lives in the hook (`useEditForm`/`useCreateForm`), not in `` — swapping shadcn primitives for MUI ones changes zero business logic. Example: ` setValue(e.target.value)} onBlur={onBlur} />`. |
| anything else (Chakra, Radix vanilla, …) | Not covered by this skill's reference implementations | — | Refuse to silently invent a mapping. Either ask the user which primitives to wire (then treat the result as a project-specific `FormField`/`FormActions` implementation, still behind the same hook contract), or use the raw [escape hatch](#escape-hatch--raw-library-field) until the project has one. |

When scaffolding (`scripts/scaffold_lib_forms.py`) or hand-writing `lib/forms/FormField.tsx` for a `stack.ui = "mui"` project, keep the render-prop shape identical to the shadcn version — only the JSX inside changes.

## Edit form pattern (manual save, dirty-gated)

The `save` callback calls a **Server Action** from `lib/actions/.actions.ts` — never the service layer directly. Per the `data-fetching` skill's contract, `lib/services/` is server-only code with no `"use server"` directive; a Client Component cannot import it. The Server Action re-validates with the same Zod schema, calls the service, and revalidates:

```ts
// lib/actions/projects.actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { projectsService } from "@/lib/services/projects.service";
import { ProjectSchema, type Project } from "@/lib/types/project";

export async function updateProjectAction(id: string, value: Project) {
  const parsed = ProjectSchema.parse(value);
  const saved = await projectsService.update(id, parsed);
  revalidatePath(`/projects/${id}`);
  return saved;
}
```

```tsx
"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
  FormActions,
  FormField,
  FormProvider,
  useEditForm,
} from "@/lib/forms";
import { updateProjectAction } from "@/lib/actions/projects.actions";
import { type Project, ProjectSchema } from "@/lib/types/project";

export function ProjectEditPanel({ project }: { project: Project }) {
  const form = useEditForm({
    schema: ProjectSchema,
    defaultValues: project,
    save: (value) => updateProjectAction(project.id, value),
  });

  return (
    
       {
          e.preventDefault();
          void form.handleSubmit();
        }}
      >
        
          {(field) => (
             field.setValue(e.target.value)}
              onBlur={field.onBlur}
              aria-invalid={field.touched && !field.isValid}
            />
          )}
        

        
          {(field) => (
             field.setValue(e.target.value)}
              onBlur={field.onBlur}
              aria-invalid={field.touched && !field.isValid}
            />
          )}
        

        
      
    
  );
}
```

**What the hook owns** (never reimplement in components):

- **Dirty tracking** — drives `state.isDirty` from a deep-equal compare of current values vs the current baseline (initial `defaultValues` on mount, then the most-recently-saved value).
- **Submit gating** — exposes `state.canSubmit = isValid && isDirty && !isSubmitting`. `` reads it via `form.Subscribe`.
- **Baseline reset on success** — after the `save` callback resolves, calls `form.reset(savedValue)` so the new clean state is the saved value. Editing back to the just-saved value should leave `isDirty === false`.
- **AbortController** on submit — if the user re-submits while a save is in flight, the hook ignores the earlier call's resolution once a newer one starts. **Note**: `save`/`submit` is passed `{ signal }` for parity with a raw service call, but a Server Action **cannot accept an `AbortSignal` as an argument** — it isn't a serializable value across the server boundary and passing it will throw. When `save` wraps a Server Action (the normal case), ignore the second parameter; the hook's own bookkeeping still guards against acting on a stale response.
- **Error mapping** — thrown errors flow through `mapFormError`. The `save` callback **must throw on failure** — never catch.
- **Optional success/error toast** — owned by `mapFormError`; not by form components.

**What the consumer owns:**

- The `` wrapper that calls `form.handleSubmit()`.
- Wiring `aria-invalid` on inputs.
- Calling the entity's **Server Action** from `save`/`submit` — never the service layer, which is server-only (see `data-fetching` skill).

## Create form pattern

Same rule: `submit` calls a Server Action, not the service directly.

```ts
// lib/actions/projects.actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { projectsService } from "@/lib/services/projects.service";
import {
  type ProjectCreateInput,
  ProjectCreateInputSchema,
} from "@/lib/types/project";

export async function createProjectAction(value: ProjectCreateInput) {
  const parsed = ProjectCreateInputSchema.parse(value);
  const created = await projectsService.create(parsed);
  revalidatePath("/projects");
  return created;
}
```

```tsx
"use client";

import { Input } from "@/components/ui/input";
import {
  FormActions,
  FormField,
  FormProvider,
  useCreateForm,
} from "@/lib/forms";
import { createProjectAction } from "@/lib/actions/projects.actions";
import {
  type ProjectCreateInput,
  ProjectCreateInputSchema,
} from "@/lib/types/project";

export function CreateProjectForm({
  onCreated,
}: {
  onCreated: (id: string) => void;
}) {
  const form = useCreateForm({
    schema: ProjectCreateInputSchema,
    defaultValues: { name: "", description: "" },
    submit: (value) => createProjectAction(value),
    onSuccess: (created) => onCreated(created.id),
  });

  return (
    
       { e.preventDefault(); void form.handleSubmit(); }}>
        
          {(field) => (
             field.setValue(e.target.value)}
              onBlur={field.onBlur}
            />
          )}
        
        
      
    
  );
}
```

Create forms gate the submit button on `canSubmit = isValid && !isSubmitting` — dirty tracking is irrelevant for new records. Some teams still gate on `isDirty` to prevent submitting an entirely-default form; that's a per-project preference codified once in `useCreateForm`, never per-component.

## `FormActions`

The shared submit/reset button row. Reads form state reactively via `form.Subscribe` (TanStack) or `useFormState`-equivalent (RHF) so the rest of the form doesn't re-render when only `isDirty`/`isSubmitting` changes:

```tsx

```

For custom layout (Cancel between Reset and Save, dialog-footer docking, etc.) drop `` and inline the equivalent subscribe block. The skill ships the subscribe pattern in `references/form-actions.md`.

## Schemas

Schemas live in `lib/types/.ts` and are the single source of truth for form and service layer. Use Zod v4:

```ts
import * as z from "zod";

export const ProjectSchema = z.object({
  id: z.string().uuid(),
  name: z.string().trim().min(1).max(120),
  description: z.string().trim().max(5000).nullable(),
});
export type Project = z.infer;

export const ProjectCreateInputSchema = ProjectSchema.omit({ id: true });
export type ProjectCreateInput = z.infer;
```

- **Edit form** → full entity schema.
- **Create form** → dedicated input schema (`Schema.omit({ id, createdAt, … })`).
- **Partial PATCH** → `Schema.partial()` if the backend accepts any subset.

Never write a standalone TypeScript type that mirrors a schema. Always `z.infer`.

## Unsaved-changes guard

Edit forms only persist on Save — leaving the route or closing the dialog with `isDirty === true` silently discards the user's work. Wire a guard at the boundary in `lib/forms/`:

- **Dialog/panel close**: subscribe to `form.state.isDirty`; prompt before closing (project-shaped — `confirm()`, shadcn ``, etc.).
- **Route navigation**: in App Router the pragmatic answer is a `beforeunload` for full-tab close + a custom `` wrapper for in-app nav.

The skill flags the requirement; the exact UX is project-shaped.

## Error handling

The `save` / `submit` callback **throws**. Do not catch inside it. The hook routes the thrown error through `mapFormError`:

| Error type | UI action |
|-

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [lukedj78](https://github.com/lukedj78)
- **Source:** [lukedj78/dev-flow](https://github.com/lukedj78/dev-flow)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-lukedj78-dev-flow-forms
- Seller: https://agentstack.voostack.com/s/lukedj78
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
