smolter
Guides

Actions

Define mutations beside the route, submit to its URL, and let the router refresh the page data.

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 where the action runs

clientAction runs in the browser. action runs on the server through the Vite integration or serverRoute. Export both when the browser needs a wrapper: clientAction takes precedence and can call serverAction. SSR skips clientAction.

Start with a complete client action

Read submitted fields with request.formData(). Return validation errors as data so the form can display them without replacing the page.

// routes/settings/clientAction.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 };
}

Read the saved value

The loader reads the saved value. Completed actions revalidate it, including actions that return validation data. Use shouldRevalidate to skip reads after validation failures.

// routes/settings/clientLoader.ts
export function clientLoader() {
  return { displayName: localStorage.getItem("displayName") ?? "" };
}

Export the route module

Keep the handler implementations in their own files. In file-router mode, routes/settings/route.ts maps to /settings. In manual mode, associate this same module with an explicit path in your route table.

// routes/settings/route.ts
export { clientLoader } from "./clientLoader";
export { clientAction } from "./clientAction";
export { default } from "./view";

Register the actual URL

Register /settings before submitting to it. Fetchers call route handlers, not arbitrary API endpoints. For Preact, use @kasperrt/smolter/preact.

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

Submit and display the result

Read submission feedback from fetcher.data and the saved value from useLoaderData. The fetcher keeps the current URL and does not reload the document.

// routes/settings/view.tsx
import { useFetcher, useLoaderData } from "@kasperrt/smolter/react";
import type { clientAction } from "./clientAction";
import type { clientLoader } from "./clientLoader";

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

POST, PUT, PATCH, and DELETE

Mutation methods call the matching route’s action. Dispatch on request.method. Forms use lowercase methods; submit options use uppercase methods.

// Inside a task action, after validating params.id:
if (request.method === "DELETE") {
  await tasks.remove(params.id);

  return { deleted: true };
}

if (request.method !== "PATCH") return new Error("Unsupported task operation");

const form = await request.formData();

return tasks.rename(params.id, String(form.get("title") ?? ""));

Multiple intents on one route

Named submit buttons contribute the selected button value to FormData. An intent is useful when multiple operations share the same HTTP method. Validate the intent in the action before writing anything.

<fetcher.Form action="/tasks/42" method="post">
  <button name="intent" value="complete">Complete</button>
  <button name="intent" value="reopen">Reopen</button>
</fetcher.Form>

// In the action:
const form = await request.formData();
const intent = form.get("intent");

if (intent !== "complete" && intent !== "reopen") {
  return new Error("Unknown task intent");
}

return tasks.setCompleted(params.id, intent === "complete");

Implement the server action

The application’s settings service authenticates and saves the request. The Vite plugin removes the server re-export from the browser module. Manual setups need serverRoute in the browser and handleRequest on the server.

// routes/settings/action.ts
import type { DataArgs } from "@kasperrt/smolter/react";
import { settings } from "../../server/settings";

export async function action({ request, signal }: 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." };
  }

  const saved = await settings.saveForRequest(request, { displayName, signal });

  if (saved instanceof Error) return saved;

  return { ok: true as const, displayName: saved.displayName };
}

Server action and client action together

With the Vite plugin, re-export action from its server implementation file and clientAction from a browser-safe file. The client wrapper may inspect the form and then call serverAction. The transport preserves a copy of the request body for that call. Import the server handler only as a type in browser code; never import your database implementation there.

// routes/settings/clientAction.ts
import type { ClientActionArgs } from "@kasperrt/smolter/react";
import type { action } from "./action";

export async function clientAction({
  serverAction,
}: ClientActionArgs<typeof action>) {
  const result = await serverAction();

  if (result.ok) sessionStorage.removeItem("settings-draft");

  return result;
}

// routes/settings/route.ts
export { action } from "./action";
export { clientAction } from "./clientAction";
export { loader } from "./loader";
export { default } from "./view";

Redirect after a mutation

Return a Response with a Location header to navigate after saving. A redirect also navigates when the submission came from a fetcher. Use an internal destination registered in the router. There is no smolter redirect helper to import.

return new Response(null, {
  status: 303,
  headers: { Location: "/tasks" },
});

Failures and cancellation

Return an Error for an operation failure, rather than a field validation result. Fetcher failures appear in fetcher.error; navigation failures can render the route ErrorBoundary. Pass signal to fetch calls in client handlers. Aborting a request prevents stale UI results but cannot undo a mutation already accepted by a server. Enforce authorization and write consistency in your backend.

Skip reads after validation failures

Re-export shouldRevalidate from the route module. Narrow actionResult before reading it: another route may have triggered the refresh. Returning false keeps this route’s current data; other routes and fetchers make their own decision.

// routes/settings/shouldRevalidate.ts
import type { RevalidationArgs } from "@kasperrt/smolter/react";

export function shouldRevalidate({
  reason,
  actionResult,
  defaultShouldRevalidate,
}: RevalidationArgs) {
  if (
    reason === "action" &&
    actionResult !== null &&
    typeof actionResult === "object" &&
    "ok" in actionResult &&
    actionResult.ok === false
  ) {
    return false;
  }

  return defaultShouldRevalidate;
}

// Add to routes/settings/route.ts:
export { shouldRevalidate } from "./shouldRevalidate";