smolter
API

Form

Submit to a registered route and navigate without reloading the document.

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.

Import from @kasperrt/smolter/react @kasperrt/smolter/preact

Signature

function Form(options: InternalFormProps): ReactNode;

Parameters

action defaults to the current URL. method defaults to post; get, post, put, patch, and delete are supported. Standard form attributes and onSubmit are forwarded.

Returns

A form element.

Usage

Import this API from one of the entry points listed above. Keep server and build helpers in their dedicated subpaths.

<Form action="/tasks/search" method="get">
  <input name="q" />
  <button>Search</button>
</Form>;

Behavior and edge cases

GET serializes form values into a new query string and calls the loader through navigation. Mutations call the action, then revalidate. Submitter formaction and formmethod override form defaults. GET files contribute their filenames. Use fetcher.Form to avoid navigation. Native markup uses GET or POST; PATCH/PUT/DELETE require the JavaScript handler.

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

The action returns validation data or a successful result. The fetcher is independent from the page loader, so display action feedback from fetcher.data and the refreshed saved value from useLoaderData. Keep a stable button label and show pending text separately to avoid moving controls.

// 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

The clicked submitter can supply formAction and formMethod. smolter reads these overrides and includes the selected submit button when creating FormData. Prefer separate forms when that makes the interaction easier to understand.

<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 before smolter intercepts the form. Calling event.preventDefault() stops smolter as well. Keep normal data work in the action instead of duplicating it in onSubmit. External form destinations are left to the browser.

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.

View implementation ↗