agents/skills/form-submissions/SKILL.md
Form submission patterns using useDynamicSubmitter, await submitJson, and formAction. Use when implementing forms, handling form submissions, or processing form data in React Router routes.
npx skillsauth add firtoz/cf-multiworker-starter-kit form-submissionsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
useDynamicSubmitter (v9+)Always use await submitter.submitJson(...) (or await submitter.submit(...)) instead of React Router's <Form> for programmatic submissions. In v9, the object from useDynamicSubmitter is stable and does not expose reactive state / data — use local useState (or useDynamicSubmitterFetcher, see below) for UI that depends on loading or last result.
For internal React UI submissions, prefer formAction + useDynamicSubmitter. Do not reach for plain HTML forms unless you intentionally want browser-native form behavior.
MaybeError tooMatch actions: loaders should return Promise<MaybeError<LoaderData>> with success / fail, not a bare object. That keeps loaderData typed the same way as submitter/fetcher results and avoids ambiguous error shapes. See routing/SKILL.md.
import { formAction, type RoutePath, useDynamicSubmitter } from "@firtoz/router-toolkit";
import { success } from "@firtoz/maybe-error";
import { z } from "zod";
export const route: RoutePath<"/admin/settings"> = "/admin/settings";
export const formSchema = z.object({
siteName: z.string().optional(),
siteUrl: z.string().optional(),
// ... other fields
});
export const action = formAction({
schema: formSchema,
handler: async ({ request }, formData) => {
// formData is typed and validated by Zod
await updateSettings(formData);
// Return success() for successful operations
return success();
// Throw for redirects
// throw redirect("/somewhere");
},
});
Use controlled inputs with React state. Track busy and last result yourself; await the submitter. Use a sequence ref (flight counter) if overlapping submits are possible, so a slower response does not overwrite a newer one. In catch, ignore SubmitterSupersededError and SubmitterUnmountedError.
import {
SubmitterSupersededError,
SubmitterUnmountedError,
type SubmitterSettledData,
useDynamicSubmitter,
} from "@firtoz/router-toolkit";
import { useCallback, useRef, useState } from "react";
type RouteMod = typeof import("./my-route");
export default function MyForm() {
const submitter = useDynamicSubmitter<RouteMod>("/my-route");
const submitSeq = useRef(0);
const [busy, setBusy] = useState(false);
const [actionResult, setActionResult] = useState<SubmitterSettledData<RouteMod> | null>(null);
const [field1, setField1] = useState("");
const [field2, setField2] = useState("");
const handleSubmit = useCallback(async () => {
const id = ++submitSeq.current;
setBusy(true);
try {
const data = await submitter.submitJson({ field1, field2 });
if (id !== submitSeq.current) return;
setActionResult(data);
} catch (err) {
if (err instanceof SubmitterSupersededError || err instanceof SubmitterUnmountedError) {
return;
}
if (id !== submitSeq.current) return;
throw err;
} finally {
if (id === submitSeq.current) setBusy(false);
}
}, [field1, field2, submitter]);
return (
<div>
<Input value={field1} onChange={(e) => setField1(e.target.value)} />
<Input value={field2} onChange={(e) => setField2(e.target.value)} />
<Button onClick={() => void handleSubmit()} disabled={busy}>
{busy ? "Saving..." : "Save"}
</Button>
</div>
);
}
Validation and handler errors from formAction arrive on the resolved value: !data.success with data.error.type === "validation" (field errors live under data.error.error.properties) or data.error.type === "handler". Only treat the promise as failed when the network or router throws, not when the action returns fail(...).
useDynamicSubmitterFetcherUse useDynamicSubmitterFetcher(submitter) when you need reactive fetcher.state / fetcher.data (e.g. a declarative form that still uses submitter.Form and disables the button from fetcher.state === "submitting"). Prefer the promise + local state pattern for new work unless you have a clear need for fetcher-driven render updates.
<Form> from react-router for type-safe formAction flows (use submitter + JSON or the submitter’s Form with fetcher if needed)submitter.state / submitter.data on the v9 useDynamicSubmitter return value (not reactive)value + onChange)useDynamicSubmitter for the typed submitJson / submit / Form / fetcherKeyawait submitter.submitJson({ ... }) and local state for results and loadingSubmitterSettledData (or the resolved shape) for typing the last action payloadroute, formSchema, and wrap action with formAction()success() from action handlers for successful operationsthrow (don't return) redirects and responsesReact Router index actions require an explicit ?index target for external clients, terminal tests, non-router-aware callers, and plain HTML forms.
useDynamicSubmitter<RouteMod>("/some-route").submitJson(...)action="/?index"POST /?index, not POST /Avoid teaching new app features to post plain forms to index routes. If an endpoint must be called externally, prefer a non-index resource route such as /sessions/new. If a home/index route intentionally has an action for plain forms, add a nearby comment and explicit action="/?index".
formAction + useDynamicSubmitter work best for in-app flows when the action returns structured data the UI can use:
return success({ ... }) (or a typed code/slug) and navigate in the component with the router (e.g. href(...) from @firtoz/router-toolkit or your app’s useNavigate pattern) after await submitter.submitJson(...) resolves and data.success is true.throw redirect(…) when you want a full server-driven redirect response (e.g. external URL, or when you truly need a Location response). Redirects in actions are handled on the fetcher/submitter code path, which differs from a full document navigation; for SPA-style “create then go to detail,” success + client navigate is often clearer.submitJson + success + explicit client navigation; reserve raw <form method="post"> for cases that need native form semantics.See also routing/SKILL.md for RoutePath and patterns.
This approach provides:
development
Repo-root commands, typegen and typecheck cadence, lint, deploy, adding packages with bun, and Alchemy app layout. Use at the start of a task, before PR, or when choosing turbo/typegen commands.
development
Fork and template gotchas (env import, routes, typegen, forms, D1, Turbo, HMR, new DO packages). Use when working on apps/web or durable-objects, or when behavior diverges from this stack’s conventions.
testing
Turborepo task configuration patterns for monorepo management. Use when configuring turbo.json tasks, setting up task dependencies, managing cache inputs/outputs, or working with cross-package dependencies in the monorepo.
development
React Router v7 routing patterns and environment variable configuration. Use whenever you touch React Router–related code (routes, links, params, loaders, actions, route config, or env in route context).