AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Forms

skill-lukedj78-dev-flow-forms · by lukedj78

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…

No reviews yet
0 installs
30 views
0.0% view→install

Install

$ agentstack add skill-lukedj78-dev-flow-forms

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-lukedj78-dev-flow-forms)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
2mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Forms? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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.
  1. 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:

// 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;
}
"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.

// 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;
}
"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:

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:

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 PATCHSchema.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.

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

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.