This episode dissects forms in Remix: the Form component for submission, server-side validation with client feedback, per-field error rendering, and progressive enhancement that keeps forms working without JavaScript.

In episode 5 you saw actions handle form data. Episode 7 digs into that side thoroughly, because forms are the most underestimated part of the web — and in Remix, forms are one of the main reasons to choose this framework.
Remix's form model reuses browser behavior: a plain HTML form sends data to the server, and the server returns a response. Remix refines it by processing the form in an action, automatically refreshing loaders when done, and showing errors right in the UI — all without JavaScript. When JavaScript is enabled, the experience improves: smooth navigation, fast validation, and preserved state.
Episode 7 builds a complete form: submission, server-side validation, per-field errors, then the progressive enhancement layer with useFetcher.
The Form component from @remix-run/react replaces the plain form tag. Its behavior is essentially identical, but when JavaScript runs, submission is handled without a full page reload — the action still executes, loaders are refreshed, and the URL is updated.
import { Form } from "@remix-run/react";
export default function FormKontak() {
return (
<Form method="post">
<label>
Nama
<input type="text" name="nama" />
</label>
<button type="submit">Kirim</button>
</Form>
);
}The method="post" attribute on Form directs submission to the route's action. Every named input is sent along as FormData to the action.
In the action, form data is read with request.formData() and accessed via the get or getAll methods:
export async function action({ request }) {
const formData = await request.formData();
const nama = formData.get("nama");
const hobi = formData.getAll("hobi");
return { ok: Boolean(nama) };
}formData.getAll("hobi") returns all the values of inputs named hobi — useful for checkboxes or arrays. Never send data you don't need; only the named fields get sent.
The golden rule: client-side validation is only for convenience; server-side validation is for correctness. Remix puts validation in the action because data from the client can't be trusted. Manual validation is fine for small forms; for complex forms, use a library like Zod.
export async function action({ request }) {
const formData = await request.formData();
const email = formData.get("email");
const errors = {};
if (!email || !String(email).includes("@")) {
errors.email = "Format email tidak valid";
}
if (Object.keys(errors).length > 0) {
return { errors, fieldValue: { email } };
}
await simpanKeDatabase(email);
return redirect("/terima-kasih");
}The errors object follows the field names; the action returns it so the UI can display it. If there are errors, the action also returns the field values so the inputs aren't empty when the page re-renders.
For forms with many fields, Zod provides typed schemas and structured error messages. The Zod schema is parsed against Object.fromEntries(formData) inside the action — this is a common production pattern and will appear again in episode 18.
The component reads the action's errors with useActionData, then displays them right below the relevant input. Because useActionData returns empty before the action runs, the UI is clean on first load and errors appear only after submission.
import { useActionData, Form } from "@remix-run/react";
export default function FormEmail() {
const data = useActionData();
return (
<Form method="post">
<input type="email" name="email" defaultValue={data?.fieldValue?.email} />
{data?.errors?.email ? <p className="error">{data.errors.email}</p> : null}
<button type="submit">Kirim</button>
</Form>
);
}useActionData returns the last action result for this route. The defaultValue-plus-errors pattern keeps input values present after a re-render caused by errors.
For screen reader users, link errors to the input using aria-describedby. Correctly linked errors are read aloud when the input is focused — a small detail that separates good applications from merely working ones.
Test your forms by disabling JavaScript in the browser. The form still submits, the action still runs, errors still appear — because all of that is native browser behavior. This is progressive enhancement: forms work before JavaScript, and improve after it.
When a form needs to submit without leaving the page — like a like button, a filter, or a login panel in the sidebar — use useFetcher. The fetcher sends FormData to an action from anywhere, without navigation.
import { useFetcher } from "@remix-run/react";
export default function TombolSuka({ postId }) {
const fetcher = useFetcher();
return (
<fetcher.Form method="post">
<input type="hidden" name="postId" value={postId} />
<button type="submit">Suka</button>
</fetcher.Form>
);
}fetcher.Form sends to an action without leaving the page — the action receives the same data, and the route's loaders can read the new state afterwards. Submission status details are available in fetcher.state.
Episode 7 completes your form abilities: Form and FormData for submission, server-side validation as the source of truth, accessible per-field error rendering, and progressive enhancement via useFetcher. Your forms now work in every condition — with or without JavaScript.
The key takeaways:
In the next episode, episode 8, we'll discuss configuration and environment — the role of vite.config.ts and environment variables, choosing the build target, adapters and runtimes, managing assets in public, and optimizing the production build. Your forms work; now let's tidy up the house.