Forms in rune are plain controlled React, with two rune-specific upgrades: drafts in atoms (so they survive
navigation), and submission to a same-process API route.
0000000000import { useState } from "@ekko/react";
export default function Contact() {
const [form, setForm] = useState({ name: "", email: "", message: "" }); const set = (k: string) => (e: any) => setForm(f => ({ ...f, [k]: e.target.value })); async function submit(e: any) { const res = await fetch("/api/contact", { headers: { "Content-Type": "application/json" }, body: JSON.stringify(form), if (res.ok) setForm({ name: "", email: "", message: "" }); <input value={form.name} onChange={set("name")} placeholder="Name" /> <input value={form.email} onChange={set("email")} placeholder="Email" type="email" /> <textarea value={form.message} onChange={set("message")} placeholder="Message" /> <button type="submit">Send</button> }
Drafts that survive navigation
Use useState for a form that should reset when you leave; use an atom for a draft that should survive a
navigation (or a reload, with a session):
0000000000// atoms/contact.ts
import { atom } from "ekko:rune/mimir";
export const contactDraft = atom({ key: "contact:draft", default: { name: "", email: "", message: "" } });
0000000000import { useAtom } from "ekko:rune/mimir";
import { contactDraft } from "../atoms/contact";
const [form, setForm] = useAtom(contactDraft); // survives navigation; with a session, reloads too
Mark the atom persist: false if a reload should clear the draft.
The API handler , validate, then act
0000000000app.api("POST", "/api/contact", async (req, res) => {
const b = await req.json() ?? {}; if (typeof b.name !== "string" || !b.name.trim()) { res.status(400); return { error: "name required" }; } if (typeof b.email !== "string" || !b.email.includes("@")) { res.status(400); return { error: "valid email required" }; } saveMessage({ name: b.name.slice(0, 120), email: b.email.slice(0, 200), message: (b.message ?? "").slice(0, 5000) }); });
Always validate on the server (client validation is UX, not security). See
Validation & options.
Showing submission state
Track the request with local state (or atoms if other components care):
0000000000const [status, setStatus] = useState<"idle" | "sending" | "ok" | "error">("idle");
async function submit(e: any) {
const res = await fetch("/api/contact", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) }); setStatus(res.ok ? "ok" : "error"); }
// render: status === "sending" ? <Spinner /> : status === "ok" ? <p>Thanks!</p> : ...
Server-rendered, progressively enhanced
Because the page is server-rendered, the form is visible and meaningful before JS loads. After hydration, the
onSubmit handler takes over for the fetch-based submit. For a no-JS fallback, point the <form action> at
an API route and handle a normal POST there too, the same handler can serve both.
Warn about unsaved changes
Mark the draft dirty and guard the unload:
0000000000useEffect(() => {
const onUnload = (e: BeforeUnloadEvent) => { if (isDirty(form)) { e.preventDefault(); e.returnValue = ""; } }; window.addEventListener("beforeunload", onUnload); return () => window.removeEventListener("beforeunload", onUnload); }, [form]);
See Guards & redirects for the in-app navigation case.