
Your schema already knows the shape. Let it render the form.
Form, Please turns a Standard Schema and a typed definition into a React form. Field paths are checked against the schema. Validation messages come from the schema. The submit callback receives the transformed output type. React Hook Form (RHF) runs the state. Your components render it.
npm install form-please react-hook-form zodSee it before you install
Each tab is one complete file. Hover any identifier in the code to see its inferred type. The form on the right is rendered by that exact code.
One Zod schema, one definition, one hook. Every path in the definition must exist in the schema.
import type { } from "form-please"
import { as } from "form-please/preset-native"
import { } from "react"
import { } from "zod"
const = .({
: .().(2, "Enter at least two characters"),
: .().("Enter a valid email"),
})
const = .(, () => [
.("name", { : "text", : "Name", : true }),
.("email", {
: "text",
: "Email",
: true,
: { : "email" },
}),
])
export function () {
const [, ] = <<typeof >>()
const = .(, {
: { : "Ada Lovelace", : "ada@example.com" },
: ({ }) => (),
})
let = "Submit to see output"
if ( !== ) = .(, null, 2)
return (
<>
<. ={}>
<.>Create account</.>
</.>
<>{}</>
</>
)
}Rendered form
Submit to see output
Try this. Clear the email and submit. The Zod message appears next to the field.
Edit this scenario in the playgroundcolumns on a section and span on a field put the fields in a grid. The definition sets the layout, and form-please/layout.css makes it follow the section width. You write no grid CSS.
import type { } from "form-please"
import { as } from "form-please/preset-native"
import { } from "react"
import { } from "zod"
const = .({
: .().(2, "Enter at least two characters"),
: .().("Enter a valid email"),
: .({
: .().(1, "Enter a street"),
: .().(1, "Enter a city"),
: .().(1, "Enter a postcode"),
}),
})
const = .(, () => [
.("contact", {
: "Contact",
: 2,
: [
.("name", { : "text", : "Name", : true }),
.("email", {
: "text",
: "Email",
: true,
: { : "email" },
}),
],
}),
.("address", {
: "Delivery address",
: 2,
: [
.("address.street", {
: "text",
: "Street",
: true,
: "full",
}),
.("address.city", {
: "text",
: "City",
: true,
}),
.("address.postcode", {
: "text",
: "Postcode",
: true,
}),
],
}),
])
export function () {
const [, ] = <<typeof >>()
const = .(, {
: {
: "Ada Lovelace",
: "ada@example.com",
: {
: "12 St James's Square",
: "London",
: "SW1Y 4LE",
},
},
: ({ }) => (),
})
let = "Submit to see output"
if ( !== ) = .(, null, 2)
return (
<>
<. ={}>
<.>Ship order</.>
</.>
<>{}</>
</>
)
}Rendered form
Try this. Make the browser window narrow. Each section changes to one column when it is narrower than 40rem.
Edit this scenario in the playgroundThe schema transform adds a handle. The submit callback receives the transformed output type, not the input.
import type { } from "form-please"
import { as } from "form-please/preset-native"
import { } from "react"
import { } from "zod"
const =
.({
: .().(2, "Enter at least two characters"),
: .().("Enter a valid email"),
})
.(() => ({
...,
: `@${..().().(" ", "-")}`,
}))
const = .(, () => [
.("name", { : "text", : "Name", : true }),
.("email", {
: "text",
: "Email",
: true,
: { : "email" },
}),
])
export function () {
const [, ] = <<typeof >>()
const = .(, {
: { : "Ada Lovelace", : "ada@example.com" },
: ({ valueThe validated and possibly transformed schema output.
}) => (),
})
let = "Submit to see output"
if ( !== ) = .(, null, 2)
return (
<>
<. ={}>
<.>Save profile</.>
</.>
<>{}</>
</>
)
}Rendered form
Try this. Submit and compare the output with the fields. Hover value in the code to see its type.
Edit this scenario in the playgroundVisibility and required state are functions of the typed input. Hidden fields keep their values.
import type { } from "form-please"
import { as } from "form-please/preset-native"
import { } from "react"
import { } from "zod"
const = .({
: .("Enter a valid email"),
})
const = .("accountType", [
.({ : .("personal") }),
.({
: .("company"),
:
.()
.()
.(1, "Enter the company name")
.(""),
}),
])
const = .(, () => [
.("accountType", {
: "select",
: "Account type",
: [
{ : "personal", : "Personal" },
{ : "company", : "Company" },
],
}),
.("companyName", {
: "text",
: "Company name",
: true,
: ({ }) => === "company",
}),
.("email", {
: "text",
: "Email",
: true,
: { : "email" },
}),
])
export function () {
const [, ] = <<typeof >>()
const = .(, {
: {
: "personal",
: "ada@example.com",
},
: ({ }) => (),
})
let = "Submit to see output"
if ( !== ) = .(, null, 2)
return (
<>
<. ={}>
<.>Open account</.>
</.>
<>{}</>
</>
)
}Rendered form
Try this. Switch the account type to Company. A required company name appears.
Edit this scenario in the playgroundbeforeUpdate in the definition keeps the total in sync and can cancel a change. The total commits with its sources, without an effect or a second render.
import type { } from "form-please"
import { as } from "form-please/preset-native"
import { } from "react"
import { } from "zod"
const = .({
: .().(1, "Order at least one"),
: .().(0),
: .(),
})
const = .(
,
() => [
.("quantity", { : "number", : "Quantity" }),
.("unitPrice", { : "number", : "Unit price" }),
.("total", { : "number", : "Total", : true }),
],
{
() {
if (. > 50) return false
. = .(. * . * 100) / 100
},
},
)
export function () {
const [, ] = <<typeof >>()
const = .(, {
: { : 2, : 15, : 30 },
: ({ }) => (),
})
let = "Submit to see output"
if ( !== ) = .(, null, 2)
return (
<>
<. ={}>
<.>Place order</.>
</.>
<>{}</>
</>
)
}Rendered form
Try this. Change the quantity and watch the total. Then enter 60: the hook cancels the change, and the quantity stays.
Edit this scenario in the playgroundArray rows get stable keys, reorder buttons, and per-row fields typed against the item schema.
import type { } from "form-please"
import { as } from "form-please/preset-native"
import { } from "react"
import { } from "zod"
const = .({
: .().(1, "Name the team"),
:
.(
.({
: .().("Enter a valid email"),
: .(["owner", "editor", "viewer"]),
}),
)
.(1, "Invite at least one member"),
})
const = .(, () => [
.("team", { : "text", : "Team", : true }),
.("members", {
: "Members",
: { : "", : "viewer" },
: () => [
.("email", {
: "text",
: "Email",
: true,
: { : "email" },
}),
.("role", {
: "select",
: "Role",
: [
{ : "owner", : "Owner" },
{ : "editor", : "Editor" },
{ : "viewer", : "Viewer" },
],
}),
],
}),
])
export function () {
const [, ] = <<typeof >>()
const = .(, {
: {
: "Analytical Engine",
: [{ : "ada@example.com", : "owner" }],
},
: ({ }) => (),
})
let = "Submit to see output"
if ( !== ) = .(, null, 2)
return (
<>
<. ={}>
<.>Save team</.>
</.>
<>{}</>
</>
)
}Rendered form
Try this. Add a member, leave the email empty, and submit. The row error stays with its row.
Edit this scenario in the playgroundThe generated fields and your React Hook Form code share one form. useWatch, useFormState, and register work next to the generated fields.
import type { } from "form-please"
import { as } from "form-please/preset-native"
import { } from "react"
import { , } from "react-hook-form"
import { } from "zod"
const = .({
: .().(2, "Enter at least two characters"),
: .().(80, "Use 80 characters or fewer"),
: .(),
})
const = .(, () => [
.("name", { : "text", : "Name", : true }),
.("message", { : "textarea", : "Message" }),
])
export function () {
const [, ] = <<typeof >>()
const = .(, {
: { : "Ada Lovelace", : "", : "" },
: ({ }) => (),
})
// React Hook Form hooks read the same state as the generated fields.
const = ({ : .., : "message" })
const { } = ({ : .. })
let = "No changes"
if () = "Unsaved changes"
let = "Submit to see output"
if ( !== ) = .(, null, 2)
return (
<>
<. ={}>
<>{.} of 80 characters</>
<>
Referral code
< {.....("referral")} />
</>
<>{}</>
<.>Send invite</.>
</.>
<>{}</>
</>
)
}Rendered form
Try this. Type a message and watch the counter. Then enter a referral code and submit: the output includes it.
Edit this scenario in the playgroundA misspelled path and a text control on a boolean. Both fail at compile time, and the message lists the valid options.
// Both errors below are intentional. TypeScript catches them
// before the form ever reaches a browser.
import type { } from "form-please"
import { as } from "form-please/preset-native"
import { } from "react"
import { } from "zod"
const = .({
: .().(2, "Enter at least two characters"),
: .().("Enter a valid email"),
: .(),
})
const = .(, () => [
.("name", { : "text", : "Name", : true }),
.("emial", { : "text", : "Email", : true }), .("newsletter", { : "text", : "Product news" }),])
export function () {
const [, ] = <<typeof >>()
const = .(, {
: {
: "Ada Lovelace",
: "ada@example.com",
: true,
},
: ({ }) => (),
})
let = "Submit to see output"
if ( !== ) = .(, null, 2)
return (
<>
<. ={}>
<.>Create account</.>
</.>
<>{}</>
</>
)
}Nothing to render
TypeScript rejected this definition before it reached a browser. Hover the red marks to read the message: it lists the valid paths and the controls that accept the value.
Try this. Hover the red marks. Then fix emial and change the last control to checkbox.
Edit this scenario in the playgroundThe first tab is a complete program. Copy it into a file, install the three
packages, and render SignupForm. Get started walks through
the same file one step at a time.
Start from a task
- Validate with ValibotPass a Valibot schema. Zod, Yup, and ArkType also work through Standard Schema. Rules, messages, and transforms stay in your schema.Shadcn with Valibot
- Use my design systemWrap your controls and slots one time. The native, Material UI, and shadcn kits are working examples.Form kits
- Put fields in columnsSet columns on a section and span on a field. The grid follows the section width, not the viewport, and you write no grid CSS.Columns and spans
- Keep a total in syncWrite the rule once in the definition. It runs before each change commits, can cancel the change, and needs no effect.Value middleware
- Save a draftKeep the schema input in storage that you own and restore it when the user returns.Persistence
- Build a multi-step formShow one screen at a time, validate each screen, and submit a draft or the final form.Product workflows
- Use React Hook Form directlyUse register, Controller, useWatch, and useFieldArray next to the generated fields, in the same form.Compose generated and custom UI
- Migrate a React Hook Form appKeep your schemas and default values. Rewrite one form at a time, and move a large form in steps.Migrate from React Hook Form
- Set up my coding agentGive your agent the llms.txt index or the installable skill, so that it writes correct definitions.AI agents
How it compares
| Form, Please | React Hook Form | TanStack Form | Formik | |
|---|---|---|---|---|
| Schema library | Any Standard Schema | Any, via resolvers | Any Standard Schema | Yup, or a validate function |
| Field paths checked against the schema | Yes | Yes | Yes | No, string names |
| Fields generated from a definition | Yes | No | No | No |
| Grid layout declared in the definition | Yes, columns and span | No | No | No |
| Conditional fields and arrays declared, not hand-wired | Yes | No | No | No |
| Dependent values commit with their sources | Yes, beforeUpdate | No, useEffect and setValue | Field listeners, after the change | No, useEffect and setFieldValue |
| Submit receives the transformed output type | Yes | With a resolver | No, input values only | No |
| Design system | Wrapped once per kit | Wired per field | Wired per field | Wired per field |
| Escape hatch | Full React Hook Form API | Is the API | Full TanStack API | Full Formik API |
| Draft persistence and undo history | Optional entries | No | No | No |
| Devtools | Optional entry | Official package | Official package | No |
Checked against each library's documentation in September 2026. Open an issue if a row is stale.
Proof
- React 18 and 19
- 28 kB gzipped for the native preset
- Zod, Valibot, and Yup examples
- Native, Material UI, and shadcn kits
- Eleven browser-tested live examples
- MIT
The size covers every entry the first tab imports. Material UI, persistence, history, and devtools are separate entries and add nothing until imported. Browse the live examples or the API.
What stays explicit
Each part of a form has one owner: the schema, React Hook Form, the definition, the kit, or your application. Read Ownership.
Use RHF register, Controller, and hooks such as useWatch when a generated
definition is not enough. Read You keep the React Hook Form
API.
Kudos
Kudos to Evgeniy Ivaha for the idea and the example implementation.