Reference
Forms
<Form> is a real <form> element with a client upgrade. It submits to a route handler without a full reload, tracks its in-flight status for you and hands the handler's JSON response back to your component as plain React state. Because it renders an ordinary <form> underneath, GET and POST submissions still work with no JavaScript at all.
import { Form } from "goribu";
import { useState } from "react";
export default function Subscribe() {
const [done, setDone] = useState(false);
if (done) return <p>Thanks!</p>;
return (
<Form method="post" onSuccess={() => setDone(true)}>
<input name="email" type="email" />
<button>Subscribe</button>
</Form>
);
}
export async function POST(req, res) {
const { email } = await req.body();
await subscribe(req, email);
return res.json({ ok: true });
}That's the whole loop. The handler owns the mutation and the React component owns the UI change. When the handler returns JSON the page stays put and onSuccess/onError fire by HTTP status. When it redirects, the browser navigates instead. action defaults to the current path, so a form posting to its own route needs only method.
Save and move to a new page
The most common shape: submit, then send the user somewhere else. Return res.redirect and <Form> performs the navigation, with no callbacks needed.
<Form action="/polls" method="post">
<input name="title" />
<button>Create</button>
</Form>export async function POST(req, res) {
const { title } = await req.body();
const poll = await createPoll(req, { title });
return res.redirect(`/polls/${poll.id}`);
}This is the right shape for create-then-visit, log-in-then-dashboard, delete-then-back-to-list and the end of a multi-step flow.
Handling Errors
When the user input is bad, return an error status with the details and react to it in onError. The status code alone decides which callback runs and there is no { ok: true } convention.
export default function SettingsPage({ user }) {
const [errors, setErrors] = useState(null);
const [saved, setSaved] = useState(false);
return (
<Form
method="post"
onSuccess={() => { setSaved(true); setErrors(null); }}
onError={(data) => { setSaved(false); setErrors(data?.errors ?? { _: "Something went wrong." }); }}
>
{({ submitting }) => (
<>
{saved && <p>Saved.</p>}
<input name="name" defaultValue={user.name} />
{errors?.name && <p className="text-red-700">{errors.name}</p>}
<button disabled={submitting}>{submitting ? "Saving…" : "Save"}</button>
</>
)}
</Form>
);
}
export async function POST(req, res) {
const { name } = await req.body();
if (!name?.trim()) {
return res.status(422).json({ errors: { name: "Please enter a name." } });
}
await updateUser(req, { name });
return res.json({ ok: true });
}onSuccess(data, response) runs on a 2xx response. onError(data, response) runs on 4xx/5xx and also on a transport failure with response as undefined. data is the parsed JSON body and response is the raw Response, so you can branch on status to tell validation (422) from auth (401) from conflict (409). Always validate on the server — client display is only a nicety.
If a form must also report errors without JavaScript, re-render the page instead:
return res.status(400).render(SettingsPage, { user, values, errors }). A render is a pure function of its props, so the error path must pass the full set the page needs, so forgettinguserhere throws. Share a small loader between theGETandPOSThandlers to keep them in sync.
Optimistic UIs
To stay on the page and apply the result yourself, return res.json and update React state from the callback. Use this to append to a list, flip a toggle, remove a row. For a truly instant feel, change state in onSubmit (before the request) and reconcile when the response lands:
function LikeButton({ post }) {
const [liked, setLiked] = useState(post.liked);
return (
<Form
action="/api/like"
method="post"
onSubmit={() => setLiked((v) => !v)} // optimistic
onSuccess={(data) => setLiked(data.liked)} // reconcile with the server
onError={() => setLiked(post.liked)} // roll back on failure
>
<button name="postId" value={post.id}>{liked ? "♥" : "♡"}</button>
</Form>
);
}The render prop gives you { submitting } and nothing more. A pending flag, not a managed optimistic store. You hold the optimistic value in your own state, exactly as above.
Loading state and the in-flight guard
Pass a function as children to get { submitting }, which is true from submit until the response returns. While a submit is in flight, <Form> blocks further submits from the same form, so a double-click can't fire twice. Disabling the button is the visible cue for the user on top of that guard.
<Form method="post">
{({ submitting }) => (
<button disabled={submitting}>{submitting ? "Creating…" : "Create"}</button>
)}
</Form>Going deeper
GET forms
A method="get" form encodes its fields into the URL and navigates. For example q=cloudflare becomes /search?q=cloudflare. Read it on the server with req.query() or req.searchParams.
<Form method="get" action="/search">
<input name="q" />
<button>Search</button>
</Form>Multi-button forms
The clicked submit button's name/value is included in the body, matching native behavior. This is handy for vote buttons and action menus.
{poll.options.map((o) => (
<button key={o.id} name="optionId" value={o.id}>{o.label}</button>
))}Methods and progressive enhancement
<Form> accepts get, post, put, patch and delete, and after hydration sends the method as-is over fetch. There is no hidden _method field. Without JavaScript browsers only submit GET and POST, so PUT/PATCH/DELETE require the client runtime. If a destructive action must work without JavaScript, route it through a POST URL (/polls/:id/delete) rather than relying on method="delete".
These enhancements all need JavaScript: the submitting flag, onSuccess/onError, optimistic state and PUT/PATCH/DELETE. For pages that must work fully without it, make the GET or POST response stand on its own.
Any endpoint can handle a form
A form can post to its own route, a sibling or an API-style endpoint. Behavior follows the response, not where the handler lives. JSON fires onSuccess/onError, a redirect navigates, any response Goribu can't read as a JSON form result (an HTML page, a redirect that landed elsewhere) falls back to browser navigation.
File uploads need a native form
<Form> rejects file inputs and bows out entirely if your onSubmit calls event.preventDefault(). For uploads, external actions (Stripe, Mailchimp) or full manual control, use a plain <form>:
<form action="/avatar" method="post" encType="multipart/form-data">
<input type="file" name="avatar" />
<button>Upload</button>
</form>export async function POST(req, res) {
const data = await req.form(); // FormData for multipart
const avatar = data.get("avatar");
// stream it to R2 or another store…
return res.redirect("/profile");
}req.body() returns {} for multipart, so reach for req.form() when files are involved, and stream large uploads straight to storage rather than buffering them.
Props
| Prop | Type | Notes |
|---|---|---|
action |
string | Target path. Defaults to the current route. |
method |
"get" … "delete" |
Lowercase. get/post work without JS. |
onSuccess |
(data, response) => void |
Fires on a 2xx JSON response. |
onError |
(data, response) => void |
Fires on 4xx/5xx JSON, or a transport failure (response undefined). |
onSubmit |
(event) => void |
Runs before submit. Call event.preventDefault() to opt out of the upgrade. |
children |
nodes or ({ submitting }) => nodes |
Use the function form for loading state. |
Standard <form> and DOM props (className, aria-*, …) pass through.