Forms
Build route-backed search, editing, and validation with native form fields.
Compatibility
Choose Form or fetcher.Form
Form navigates: GET reads the destination loader; mutations call its action. fetcher.Form keeps the URL and stores the result in fetcher.data. Either can navigate when a handler redirects.
Connect the form to a route
action is a registered URL, such as /settings. Mutations require an action or clientAction; GET requires a loader or clientLoader. See Actions for the complete /settings example.
// router.ts
import { createDataRouter, defineRoute } from "@kasperrt/smolter/react";
import * as settings from "./routes/settings/route";
export const settingsRoute = defineRoute("/settings", settings);
export const router = createDataRouter([settingsRoute], { browser: true });
// app.tsx
import { RouterProvider, Routes } from "@kasperrt/smolter/react";
import { router } from "./router";
export function App() {
return (
<RouterProvider router={router}>
<Routes />
</RouterProvider>
);
}Save without leaving the page
Show submission feedback from fetcher.data and saved page data from useLoaderData. Keep button labels stable; put pending feedback in a separate status element.
// routes/settings/view.tsx
import { useFetcher, useLoaderData } from "@kasperrt/smolter/react";
import type { clientAction } from "./action";
import type { clientLoader } from "./loader";
export default function Settings() {
const data = useLoaderData<typeof clientLoader>();
const fetcher = useFetcher<typeof clientAction>();
const busy = fetcher.state !== "idle";
return (
<section>
<h1>Settings</h1>
<p>Saved name: {data?.displayName}</p>
<fetcher.Form action="/settings" method="post" aria-busy={busy}>
<label htmlFor="displayName">Display name</label>
<input
id="displayName"
name="displayName"
defaultValue={data?.displayName}
required
minLength={2}
/>
<button disabled={busy}>Save name</button>
<p role="status">{busy && "Saving…"}</p>
{fetcher.data?.ok === false && <p role="alert">{fetcher.data.error}</p>}
{fetcher.data?.ok === true && <p role="status">Saved.</p>}
{fetcher.error && <p role="alert">{fetcher.error.message}</p>}
</fetcher.Form>
</section>
);
}Validate in the handler
required, minLength, and type="email" provide browser feedback, but handlers must validate submitted values too. Return a discriminated result such as { ok: false, error } for a correctable input problem. Connect field messages with aria-describedby and render async feedback in a status or alert region. Unexpected request failures belong in fetcher.error or a route error boundary.
// routes/settings/action.ts
import type { DataArgs } from "@kasperrt/smolter/react";
export async function clientAction({ request }: DataArgs) {
const form = await request.formData();
const displayName = String(form.get("displayName") ?? "").trim();
if (displayName.length < 2) {
return { ok: false as const, error: "Use at least two characters." };
}
localStorage.setItem("displayName", displayName);
return { ok: true as const, displayName };
}Target another route and method
A form can call a route other than the currently rendered page. Use a concrete URL for dynamic routes, such as /tasks/42. The pattern /tasks/:id is registered in the table; it is not the URL to submit. Each form below calls the same task route with a different method.
<fetcher.Form action="/tasks/42" method="patch">
<label>Title <input name="title" required /></label>
<button>Rename</button>
</fetcher.Form>
<fetcher.Form action="/tasks/42" method="delete">
<button>Delete task</button>
</fetcher.Form>Submit from application code
Use fetcher.submit for interactions that already have structured values. Pass a FormData instance and an explicit route URL. This invokes the same action as fetcher.Form. Calling submit does not run native input constraint validation; validate in the handler.
const body = new FormData();
body.set("title", "Review documentation");
await fetcher.submit(body, {
action: "/tasks/42",
method: "PATCH",
});Intercept submission deliberately
onSubmit runs first. event.preventDefault() also stops smolter. Keep requests in the action. External destinations use the browser’s normal form submission.
Files and encoding
Mutation forms create FormData, which can contain File values. Read them with request.formData() in the action. The server transport forwards multipart FormData; it does not implement upload storage, size limits, or streaming policies. GET serializes a selected file as its filename. Setting encType does not turn the intercepted mutation into a JSON request.
SSR and JavaScript requirements
Server rendering outputs a real form element, but a complete no-JavaScript submission flow requires an HTTP application that accepts the native form URL and returns HTML or a redirect. handleRequest returns data, not a complete HTML response. PATCH, PUT, and DELETE rely on the JavaScript handler; the underlying HTML form uses POST. Do not assume SSR alone provides progressive enhancement.