UNPKG

@rsc-kit/mcp

Version:

An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.

721 lines (561 loc) • 27.6 kB
# Forms > Progressive forms, pending state and validation errors. `<Form>` submits to a server action: a component that handles the state for you, and a hook for when you want to hold it yourself. Both cover validation errors, pending state, optimistic updates and GET-form navigation. ## The rule: uncontrolled, unless one field needs otherwise A form here is **uncontrolled by default**. Inputs keep their own value in the DOM, an initial value is React's `defaultValue`, and the action reads `FormData` on submit. No `useState` per field, no `value`/`onChange` pair, no re-render of the whole form on every keystroke — and after a refused submit the values are still there, because nothing re-rendered the inputs. Reach for a controlled field only where the UI has to react *as* the user types — a character count, a live preview, a dependent select — and bind that one field with `useField` (below), which scopes the re-render to it. Coming from react-hook-form this is the same default; coming from TanStack Form or from `useState`-per-input it is the opposite, and the difference is most of why these forms stay fast. ## The `<Form>` component The simplest way to handle forms. Works without any hooks — just pass a server action and use the render-prop for pending state and errors. ```tsx title="TodoForm.tsx" "use client"; import { Form } from "@rsc-kit/core/form"; import { addTodo } from "./actions"; type FormValues = { title: string }; export default function TodoForm() { return ( <Form<FormValues> action={addTodo}> {({ pending, error }) => ( <> <input name="title" placeholder="What needs to be done?" /> {error('title') && <span className="text-red-500">{error('title')}</span>} <button disabled={pending}> {pending ? 'Adding...' : 'Add Todo'} </button> </> )} </Form> ); } ``` The generic type parameter `<FormValues>` gives you autocomplete on `error()` and typed form data throughout the component. ### Two words: `error` and `field` There is one way to read a form, and it is the same everywhere — in the render prop, in a component of its own, with shadcn or without: - **`error('title')`** — the field's message, or `undefined`. Show it, or test it: `!!error('title')` is "invalid". Nested fields are paths: `error('address.city')`, `error('items[0].name')`. When the action says what it takes, the names are checked: `error('titel')` fails the typecheck instead of showing nothing, ever. See [Field names](#field-names). - **`formError`** — the refusal that is not about a field: the action's `serverError`, such as a payment required or a slot just taken, or a validation message for the whole form. `{formError && <p role="alert">{formError}</p>}`. It clears with the field errors. A failure thrown on the way, such as the network, still goes to `onError`. - **`field('title')`** — a binding to spread onto a control, for the few fields whose value this component has to hold. Most fields need nothing: they are uncontrolled, the DOM holds the value, and it is read back on submit. Beside them: `pending`, `dirty`, `succeeded`, `recentlySucceeded`, `reset()` and `clearErrors()`. A field is checked when it is left and when the form is submitted, so `error()` never lights up on a field nobody has reached. ### Props ```text action — server action function (POST) or URL string (GET) method — "get" | "post" (defaults to "post" for functions, "get" for strings) resetOnSuccess — auto-reset form on success (default: true) optimistic — callback for optimistic updates, called inside the transition onSuccess — called with the action result on success onError — called with validation errors on 422 onSubmit — called before submit, return false to cancel prefetch — "hover" (default) | "mount" | "none" (GET forms only) replace — replace history state (GET forms) preserveScroll — keep scroll position (GET forms) ``` ### In a component of its own `useForm()` returns exactly what the render prop gets, so a piece of a form moved into its own component is written the same way it was inline: ```tsx title="SubmitButton.tsx" "use client"; import { useForm } from "@rsc-kit/core/form"; export function SubmitButton() { const { pending } = useForm(); return ( <button type="submit" disabled={pending}> {pending ? 'Saving...' : 'Save'} </button> ); } ``` Not React's `useFormStatus`: that is a different hook with a different answer, which is why this one is not called that. --- ### Field names `error()` and `clearErrors()` are typed from the action's input, so a typo fails `tsc` rather than silently never matching: ```tsx <Form action={ordersCreate}> {({ error }) => ( <> {error('name')} {/* ok */} {error('address.city')} {/* ok: a nested path */} {error('items.0.sku')} {/* ok: a list's index */} {error('nmae')} {/* type error */} </> )} </Form> ``` Where the names come from: a generated stub's typed parameter (the backend already lists the fields of the struct or the FormRequest-shaped array it takes in `rsc-host.json`, so nothing is written for this), or `createActionClient()`'s `.input(schema)`. Fields the form declares in `defaultValues` count too. When the action says nothing — a function of a `FormData`, a url, a cast — nothing is claimed and any name is accepted, as before. A name the backend reports that its input does not have, such as a key it computes, is a type error you can answer by declaring it in `defaultValues`. **A wrapper you write around a stub** says nothing on its own: a function of a `FormData` could read anything. Declare the fields on its parameter, where the code that reads them lives: ```tsx import type { FormFields } from '@rsc-kit/core/form' export async function renameApp(form: FormFields<'id' | 'name'>) { 'use server' await appsRename(String(form.get('id')), String(form.get('name'))) } ``` `<Form action={renameApp}>` then closes `error()` to `id` and `name` — and to whatever `defaultValues` adds. A `FormFields` is a `FormData`; nothing runs, and the names are a claim the types keep, not a check the browser makes. It names what `error()` may be asked for, so a field the backend refuses under that the form does not post — `redirect`, say — belongs in the same list. **A team-scoped action**, `func(ctx, team string, in NewApp)`, is a form's action once the team is bound. A form fills the struct parameter, the one after whatever you bound, and the generated stub keeps its field names: ```tsx const create = appsCreate.bind(null, team) // once, not inline <Form action={create}>{({ error }) => error('name')}</Form> ``` Bind into a `const` first. Written inline in the JSX, TypeScript reads `bind`'s result before the form's render function is known and the names are not carried: the form still compiles, and is open as it always was. A stub whose parameters are all plain strings has no struct for a form to fill — wrap it, as above. **A form that posts nothing** — its values live in React state, or it is a button — has no `FormData` to name the fields on. Name them on the form: ```tsx <Form action={resize} fields={['size', 'copies']}> {({ error }) => error('size')} {/* error('sise') fails the typecheck */} </Form> ``` `fields` closes the set beside the action's own names — a bound stub's included — and `defaultValues`. It is types only: nothing is checked in the browser. It is also how to name a field the backend refuses under that the struct does not spell the same way, such as an untagged Go field `Host` that the backend reports as `host`; tagging the struct (`json:"host"`) is the cleaner fix. A component below the form types its own prop with `FieldNamesOf`: ```tsx import type { FieldNamesOf } from '@rsc-kit/core/form' type Props = { name: FieldNamesOf<typeof ordersCreate> } ``` ## Validation errors When a submit fails validation, `error('title')` answers with the field's message — there is no `try`/`catch` to write, and no state to hold. Where those errors come from depends on the host, and the components do not care. See [Validation](/guides/validation). An action can also name a field itself, for a refusal no schema could know about. Through the [action client](/guides/server-actions#failing-on-something-a-schema-cannot-know) `fieldErrors` arrives with the handler's arguments, typed to its input. A plain `"use server"` function has no input type to draw on, so it imports the same thing untyped: ```ts 'use server'; import { fieldErrors } from '@rsc-kit/core/action'; export async function addTodo(formData: FormData) { const title = String(formData.get('title')); if (await exists(title)) return fieldErrors({ title: 'Already on the list' }); await save(title); } ``` Either way, write `return fieldErrors(…)`. The form renders it under the field you named, exactly as it would a schema failure. --- ## Optimistic updates Optimistic updates go through React's `useOptimistic`. The callback runs inside the transition, so React reverts it automatically on error. ```tsx title="TodoList.tsx" "use client"; import { useOptimistic } from "react"; import { Form } from "@rsc-kit/core/form"; import { addTodo } from "./actions"; type Todo = { id: number; title: string; done: boolean }; export default function TodoList({ todos }: { todos: Todo[] }) { const [optimisticTodos, addOptimistic] = useOptimistic( todos, (state, newTodo: Todo) => [...state, newTodo] ); return ( <div> <ul> {optimisticTodos.map((todo) => ( <li key={todo.id}>{todo.title}</li> ))} </ul> <Form action={addTodo} optimistic={(data) => addOptimistic({ id: Date.now(), title: data.title as string, done: false }) } > <input name="title" /> <button>Add</button> </Form> </div> ); } ``` ## Search and filter forms When `action` is a URL string, the form navigates via RSC instead of doing a full page reload. Form fields are serialized as query parameters. Supports prefetching for instant navigation. ```tsx title="SearchForm.tsx" "use client"; import { Form } from "@rsc-kit/core/form"; export default function SearchForm() { return ( <Form action="/search" method="get" prefetch="hover"> <input name="q" placeholder="Search..." /> <select name="sort"> <option value="relevance">Relevance</option> <option value="date">Date</option> </select> <button>Search</button> </Form> ); } ``` This navigates to `/search?q=hello&sort=date` via SPA navigation. The nearest Suspense boundary streams in the results. With `prefetch="hover"`, hovering the submit button pre-warms the base URL for instant feedback. ## Using it with shadcn/ui It works, and mostly by doing nothing. `<Form>` reads a native `FormData`, so any component that ends up rendering a real form control is already compatible: ```tsx <Form action={createPost} schema={schema}> {({ pending, error }) => ( <> <Label htmlFor="title">Title</Label> <Input id="title" name="title" /> {error('title') && <p className="text-destructive">{error('title')}</p>} <Select name="kind"> <SelectTrigger><SelectValue /></SelectTrigger> <SelectContent> <SelectItem value="post">Post</SelectItem> </SelectContent> </Select> <Checkbox name="draft" /> <Button disabled={pending}>{pending ? 'Saving…' : 'Save'}</Button> </> )} </Form> ``` `Input`, `Textarea`, `Button` and `Label` are styled native elements, so `name` does what it always does. **`Select`, `Checkbox`, `Switch` and `RadioGroup` also work** — they are Radix underneath, and Radix renders a hidden native control whenever you give it a `name`, for exactly this. Omit the `name` and it is invisible to the form; that is the only thing to remember. :::caution[Not shadcn's own `<Form>`] shadcn's `<Form>`, `<FormField>` and `<FormControl>` are wrappers around [react-hook-form](https://react-hook-form.com), which is a different system for the same job — its own state, its own validation, its own submit. Use one or the other, not both. Ours gives you the field errors the *server* returned, which is the half a client-side library cannot do. ::: ### shadcn's `Field` components The newer `Field`, `FieldLabel`, `FieldError` and `FieldGroup` are plain presentational components — they take props rather than reading a form library's context, which is what the older `<FormField>` did. So they work here directly: ```tsx <Form action={reportBug} schema={formSchema}> {({ pending, error }) => ( <FieldGroup> <Field data-invalid={!!error('title')}> <FieldLabel htmlFor="title">Bug title</FieldLabel> <Input id="title" name="title" aria-invalid={!!error('title')} /> <FieldDescription>Keep it short and specific.</FieldDescription> <FieldError>{error('title')}</FieldError> </Field> <Button type="submit" disabled={pending}> {pending ? 'Sending…' : 'Submit'} </Button> </FieldGroup> )} </Form> ``` `FieldError` renders its children, and renders nothing when there are none — so `error('title')` goes straight in. Everything else is the same markup you would write with any other form library. The difference is where the errors came from. With TanStack Form or react-hook-form those are the *client's* validation; here `error()` answers with the client's **and** whatever the server sent back, because a refused action returns its fields rather than throwing them away. ### Setting the values The fields are uncontrolled, so an initial value is `defaultValue` — React's own, nothing of ours: ```tsx <Input id="title" name="title" defaultValue={post.title} /> ``` After a refused submit the values are still there, because the DOM kept them: nothing re-rendered the inputs, so nobody typed twice. That is the upside of not owning the value. The exception is a submit that happened **before hydration**, where the page genuinely reloads. Then the server renders the page again, and putting the values back is the server's job — return them from the action and render them as `defaultValue`. ### The schema is written for the shape it wants Every value in a `FormData` is a string or a file, and a control that is off is not there at all — a fact that used to leak into every schema as `z.coerce.number()` and a per-checkbox `.transform()`. It does not any more. The form is read *the way the schema means it*, on both sides: ```ts const settings = z.object({ notify: z.boolean(), // unchecked posts nothing → false; "on" → true limit: z.number().int().min(1), // "5" → 5 tags: z.array(z.string()), // one tag → ['a']; none ticked → [] policy: z.string().optional(), // hidden behind a switch → absent when off auth: z.discriminatedUnion('kind', [ // auth[kind] picks the branch z.object({ kind: z.literal('none') }), z.object({ kind: z.literal('bearer'), token: z.string().min(1) }), ]), rules: z.array(z.object({ on: z.boolean(), max: z.number() })), // rules[0][on], rules[0][max] }).refine((s) => !s.notify || (s.policy ?? '').length > 0, { path: ['policy'], message: 'Say what to notify about.', }) ``` Nothing in that schema knows it will meet a form. A schema describes itself (Standard JSON Schema — Zod 4 and ArkType do; Valibot not yet, and its values arrive as strings), and the decoder coerces to it: an absent `boolean` is `false`, `"on"`/`"1"` is `true`; a numeric string for a `number` is the number, an empty one for an optional number is absent; an `array` given one value is a list of one and given nothing is empty; nested names nest (`fields[0][name]` and `fields[0].name` alike); a union takes the branch its discriminator names; a control left blank is absent for a field the schema does not require, whatever its type — `z.email().optional()` accepts the empty input, an optional union is not read as its first branch — and is `""` for one it requires, so `z.string().min(1)` can say so. A value the schema refuses is still refused as itself — `"many"` for a number is the error you expect. A leaf JSON Schema cannot describe — `z.date()`, a custom check — costs only that leaf, which arrives as posted; the fields beside it are still read the way the schema means. A schema that cannot describe itself at all is said once, in development, with the library's reason. `<Form>` validates that object in the browser and the action decodes the same object on the server, from one codec, so a form that passes here passes there. A value sent through `<Form transform>` is encoded the same way back — a boolean as `"1"`/`"0"`, a nested object as `key[prop]` — and decodes to what was given. ### Lists of values A repeated name is an array: ```tsx <Checkbox name="tags" value="react" /> <Checkbox name="tags" value="vite" /> // → { tags: ['react', 'vite'] } ``` With one ticked that is `'react'`, a string — which no `z.array()` will accept. So for anything that is a list by nature, end the name in `[]` and it is always an array: ```tsx <Checkbox name="tags[]" value="react" /> // → { tags: ['react'] } ``` The brackets are dropped from the key, and it is the same spelling a value is serialised back into — so a list survives a round trip. ### Nested and repeating groups Names that describe a shape build it: ```tsx <input name="address.city" /> // → { address: { city } } <input name="items[0].name" /> // → { items: [{ name }] } <input name="items[0][name]" /> // the same field, other spelling ``` Which is the shape your schema was written against — and the shape whose errors come back keyed the same way, because Standard Schema issue paths join with dots too. A refused `address.city` is `error('address.city')`. Rows you add and remove are ordinary state; only the *names* have to line up: ```tsx {rows.map((row, i) => ( <input key={row.id} name={`items[${i}].name`} defaultValue={row.name} /> ))} ``` ### Controlling one field Most fields need nothing — the DOM holds the value and it is read back on submit. Two cases need more: a control with no native element behind it, and a value you want to show *as it is typed*. `field(name)` is for both. Spread it, the same way you would spread react-hook-form's `<Controller>` render props: ```tsx <Form action={reportBug} schema={formSchema} defaultValues={{ description: '' }}> {({ field, error }) => ( <Field data-invalid={!!error('description')}> <FieldLabel htmlFor="description">Description</FieldLabel> <InputGroup> <InputGroupTextarea id="description" {...field('description')} rows={6} /> <InputGroupAddon align="block-end"> <InputGroupText>{field('description').value.length}/100 characters</InputGroupText> </InputGroupAddon> </InputGroup> <FieldError>{error('description')}</FieldError> </Field> )} </Form> ``` It gives you `{ name, value, onChange, onBlur }` — the same four things `<Controller>` does, for the same reason. `onChange` takes either a DOM event or a bare value, so a native input and a Radix `Select` both work without a wrapper. A bound field is still an ordinary named input, so it arrives in `FormData` with everything else: there is one source of truth, and nothing merges. Mix freely. Bind the one field that needs a character count and leave the rest alone. ### When a field is checked **A field is checked when it is left, not as it is typed.** An error that appears while someone is halfway through an email address is a form arguing with them; leaving the field is the moment they have finished saying what they meant. So `error()` has nothing for a field nobody has reached yet, and there is no separate "touched" to consult before showing it. It works on ordinary uncontrolled fields too — the form listens for `focusout` rather than each field listening for `blur`, so `<Input name="title" />` is covered without being bound to anything. ### After a successful submit ```tsx {({ succeeded, recentlySucceeded }) => ( <Button type="submit">{recentlySucceeded ? 'Saved ✓' : 'Save'}</Button> )} ``` `recentlySucceeded` is the same thing for two seconds — the tick that appears and fades. It is state rather than a timer in every form that wants one, because the timer has to be cleared when the component goes away and that is the part people forget. ### Nothing to save yet ```tsx {({ dirty, reset }) => ( <> <Button type="submit" disabled={!dirty}>Save</Button> {dirty && <Button type="button" variant="ghost" onClick={reset}>Discard</Button>} </> )} ``` `dirty` is whether anything differs from what the form started with. It is read from the form itself — a snapshot of its `FormData` on mount, compared on every input — so an uncontrolled field counts, which is the reason it is the form's to answer and not something a component beside it could work out. A successful submit makes the current values the new baseline; `reset()` goes back to the first one. A bound control with no native element behind it counts too, through the store. Only a form that reads `dirty` is measured, and it renders once when the value first differs — not on the keystrokes after — so a field that subscribes for itself keeps its promise of rendering alone. ### Why not a `<Field>` component TanStack Form and react-hook-form both hand you a field through a render prop — `<form.Field name="title" children={…}>`, `<Controller render={…}>`. It looks like the more capable design, and the reason they need it is worth being precise about, because the three of us are not in the same position. **TanStack Form is controlled-first.** Every value lives in form state, so without per-field subscriptions one keystroke would re-render every field. The render prop is what scopes that, and the verbosity is the price of it. **react-hook-form is uncontrolled-first**, like this. Its `register` is refs, not state, so typing re-renders nothing — and `<Controller>` is the opt-in for the fields that cannot work that way. The render prop there is doing something narrower: it scopes the re-render of a *controlled* field to that field alone. So the architecture here is react-hook-form's. The difference is what the controlled opt-in costs: `field()` is a function call rather than a render prop, which keeps the markup flat and means a bound field re-renders this component rather than only itself. That is the right trade for the number of controlled fields a form usually has — one or two, for a character count or a control with no native element. When it is not, put the field in its own component and subscribe there: ```tsx import { useField } from '@rsc-kit/core/form' function Title() { const { field, error } = useField('title') return <Input {...field} aria-invalid={!!error} /> } ``` The same two words, for one name: `field` to spread, `error` to show. `useField` re-renders **that component and nothing else** — not the form, not its siblings. Which is what `<Controller>` achieves with a render prop, except that the component you were going to write anyway is the boundary. So the scoping is there when a form is large enough to need it, and the flat markup is there when it is not. ### Reading the values from elsewhere A preview, a summary, a count — something that shows a value without being the field — reads it the same way the field does: ```tsx function Preview() { const { field } = useField('title') return <h2>{field.value || 'Untitled'}</h2> } ``` Only a **bound** value can be read this way. An uncontrolled input's value belongs to the DOM, and nothing can know it changed without listening to it — bind the field with `field()` or `useField` and it can be read. :::note[Inside the form, not outside it] `useForm()` and `useField()` read a context, so they work anywhere below `<Form>`. That is usually enough, because the `<form>` element can wrap as much of the page as you like, and a submit button outside it is `form="the-id"`. Worth knowing what the alternatives do here, because it is not as different as it looks. Reaching a form from another component is a context in all three: react-hook-form has `<FormProvider>` and `useFormContext()`, and TanStack Form has `createFormHookContexts()` with `useFormContext()` — which its own documentation calls a bridge for integration constraints, to be avoided when passing the form as a prop is possible. Ours needs no extra provider only because `<Form>` already is one. Where they are genuinely more flexible is *where the state is created*: their `useForm()` is called by you, so it can be hoisted as far up as you like. For the case where that matters — something that is not a descendant — create the store yourself and hand it to the form: ```tsx function Page() { const store = useFormStore<{ title: string }>({ title: '' }) return ( <> <TopBar store={store} /> {/* not inside the form */} <Form action={save} store={store}>…</Form> </> ) } function TopBar({ store }) { const { field } = useField('title', store) return <h1>{field.value || 'Untitled'}</h1> } ``` `useFormStore` is the values and nothing else — no submit, no errors, no optimistic updates. Creating it does not subscribe to it, so the component holding it does not re-render on every keystroke and take the whole subtree with it. `useField(name, store)` takes one explicitly; without one it reads the context, which is what almost every form wants. ::: ## It works before hydration The action goes on the `<form>` element as well as into the submit handler, so the markup is submittable on its own. Someone who hits enter before the javascript arrives still reaches the server; the page reloads with the result instead of updating in place. What happens on the wire: React writes the action's id into the form as a hidden field and points the form at the page's own url, and the browser posts there. The host reads the fields, runs the action they name exactly as the enhanced path would have called it, and renders the page again with what it returned seated in the form that posted — so a refusal shows on its fields, `error('email')` included, for a visitor with no javascript at all. A `redirect()` the action throws is followed as a document's would be; a cookie it sets is on the answer. Same origin only, as an action is, and the answer is never stored. A stored page is the one most likely to be submitted this way — it paints before its runtime arrives — which is why this is not optional. The seating is React's own `useActionState`, which `<Form>` uses under a wrapper bound to your action: React hands a form-state action a `(previousState, formData)` pair, and your action keeps taking the `FormData` alone. A `useActionState` of your own works the same way, with an action written for the pair. The two do not fight. The handler calls `preventDefault()` first, and React does not run a form action for a submit that was cancelled — so the enhanced path wins whenever there is one, and the native path is what is left when there is not. Nothing to turn on. It is why the fields are real `name` attributes rather than controlled state: a browser can read them without help.