smolter
Guides

Forms

Build route-backed search, editing, and validation with native form fields.

Compatibility

ReactAvailable in this renderer's browser integration.
React SSRAvailable to this renderer's SSR integration; browser event handlers run after hydration.
PreactAvailable in this renderer's browser integration.
Preact SSRAvailable to this renderer's SSR integration; browser event handlers run after hydration.

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>
  );
}

GET search with a shareable URL

Use named fields and method="get". Submission replaces the target query string with the successful form fields. Include hidden fields for query values that must survive. The loader reads request.url, and useLoaderData reads its result. GET does not invoke an action.

// routes/tasks.search/view.tsx
<Form action="/tasks/search" method="get">
  <label htmlFor="q">Search tasks</label>
  <input id="q" name="q" type="search" />
  <input name="sort" type="hidden" value="recent" />
  <button>Search</button>
</Form>;

// routes/tasks.search/loader.ts
import type { DataArgs } from "@kasperrt/smolter/react";
import { tasks } from "../../data/tasks";

export async function clientLoader({ request }: DataArgs) {
  const q = new URL(request.url).searchParams.get("q") ?? "";
  const results = await tasks.search(q);

  return { q, results };
}

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>
  );
}

Fields and submit buttons

Only successful named controls are serialized. Unchecked checkboxes and disabled inputs are omitted. Repeated names are available through form.getAll(name). A named submit button adds its own value. Inputs without name do not appear in the action request.

const form = await request.formData();
const title = String(form.get("title") ?? "");
const tags = form.getAll("tag").map(String);
const completed = form.get("completed") === "on";
const intent = form.get("intent");

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>

Override the target per button

formAction and formMethod on the clicked button override the form’s target and method. Its name/value is included in FormData.

<Form action="/tasks/42" method="post">
  <input name="title" />
  <button name="intent" value="save">
    Save
  </button>
  <button formAction="/tasks/42" formMethod="delete">
    Delete
  </button>
</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.