<GioForm>
A form that posts to a page action, works without JavaScript, and once hydrated shows the answer through the client router without a reload.
import { redirect, type ActionArgs } from '@gio.js/core';
import { GioForm } from '@gio.js/react';
export async function action(req: ActionArgs) {
const email = String((await req.formData()).get('email') ?? '');
await subscribers.add(email);
return redirect('/newsletter/thanks');
}
export default function Newsletter() {
return (
<GioForm>
<input type="email" name="email" required />
<button>Subscribe</button>
</GioForm>
);
}GioForm renders <form method="post"> with no action attribute unless you pass one, so the browser posts to the page's own URL, where the page's action export runs. Before the page hydrates, that is a normal form post. After, the same fields are sent with fetch and the answer - the redirect target, or the page re-rendered with its actionData - is rendered in place like a soft navigation: layouts, focus and typed input survive.
Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
action | string | the current URL | Where to post. Same-origin URLs are submitted through the router; others are left to the browser. |
onSuccess | (result: GioFormResult) => void | - | Called after an answer with a 2xx final status, a redirect's target included. |
onError | (result: GioFormResult) => void | - | Called after any other answer (a 422 re-render too) and after a failed request. |
resetOnSuccess | boolean | false | Reset the fields after a successful submission that kept the form on screen. |
reloadDocument | boolean | false | Never intercept: always a native, full-page post. |
children | React.ReactNode | ((state: GioFormState) => React.ReactNode) | - | The fields, or a function of the form state. |
onSubmit | React.FormEventHandler<HTMLFormElement> | - | Runs before the submission is sent. Call event.preventDefault() to cancel it. |
ref | React.Ref<HTMLFormElement> | - | Forwarded to the <form>. |
Every other <form> attribute passes through (className, id, encType, noValidate, aria-*, ...), except method, which is always post. The props type is exported as GioFormProps.
GioFormState
GioFormState is what useGioFormState() returns inside the form, and what function children receive.
| Field | Type | Default | Description |
|---|---|---|---|
pending | boolean | - | A submission is in flight. |
lastResult | GioFormResult | null | - | How the last finished submission ended; null before the first. |
GioFormResult
GioFormResult is what onSuccess and onError receive, and lastResult holds.
| Field | Type | Default | Description |
|---|---|---|---|
ok | boolean | - | The final status is 2xx. |
status | number | - | The final response's status, after redirects. 0 when the request failed. For a redirect the browser follows itself (another site), the submission's own 2xx. |
url | string | - | Path and query of the final response - the page now shown. A redirect to another site gives its absolute URL. |
redirected | boolean | - | The action answered with a redirect. |
data | unknown | - | The actionData of the page the answer rendered, if any. |
response | Response | - | The answer when it was not a GioJS page, such as an action's Response.json(...). |
error | unknown | - | Why the request failed (a network error, or a redirect to a URL that is not http(s)). |
What is intercepted
Once the page has hydrated, GioForm calls your onSubmit, then takes over the submission unless one of these leaves it to the browser:
onSubmitcalledpreventDefault()(then nothing is sent at all), orreloadDocumentis set;- the form or the clicked button has a
targetother than_self(formTargeton the button); - the clicked button switches the method to
getordialog(formMethod); - the
action(or the button'sformAction) is on another origin.
The body is what the browser would send: every field plus the clicked button's name/value, URL-encoded - or FormData when encType (or the button's formEncType) is multipart/form-data. The request carries x-gio-form: 1 and the page's x-deployment-id. When the action, the page's getServerSideProps or a route.ts answers such a request with a 301, 302 or 303 redirect, the worker turns it into a 204 that names the target in x-gio-redirect (cookies and other headers kept), so fetch does not follow it itself. A 307 or 308 repeats the POST, and fetch follows those.
How answers are handled
| Answer | On screen | Callback |
|---|---|---|
The page re-rendered (data, or { status: 422, data }) | Swapped in; the history entry is replaced and the scroll position kept. After a non-2xx, focus moves to the first [aria-invalid="true"] field. | onSuccess for 2xx, else onError |
| A redirect to a page of this app | The target is fetched fresh and shown under its own URL, as a new history entry scrolled to the top. A redirect back to the URL already shown (Post/Redirect/Get to the same page) replaces the entry and keeps the scroll position instead. | By the target's status |
| A redirect to another site, or to something that is not a GioJS page | The browser loads it (a GET). The form stays pending while the page unloads, unless the target is a download. | By the status |
A 2xx that is not a page (Response.json(...)) | Nothing changes. | onSuccess, with result.response |
An error that is not a page (a 500, a proxy's 504, an error Response) | Nothing changes and nothing is sent again: the action may already have run. | onError, with result.response |
The server's own 413 or 429 (x-gio-refused: unread) | Submitted again natively, so the browser shows the real response. The action never ran. | onError, first |
A 409 deployment-skew answer | Submitted again natively, to the new build. | None |
| A network failure | Nothing changes; the input stays. | onError, status: 0 |
While a submission is pending, further submits are dropped before your onSubmit runs, and the form carries aria-busy="true". Every submission empties the router's prefetch cache.
Examples
Validation errors and a pending button
import { redirect, type ActionArgs, type WithActionData } from '@gio.js/core';
import { GioForm } from '@gio.js/react';
export async function action(req: ActionArgs) {
const form = await req.formData();
const name = String(form.get('name') ?? '').trim();
if (name.length < 2) {
return { status: 422, data: { error: 'Enter your name', name } };
}
await accounts.create(name);
return redirect('/welcome');
}
export default function Signup({ actionData }: WithActionData<typeof action>) {
return (
<GioForm>
{({ pending }) => (
<>
<input name="name" defaultValue={actionData?.name} aria-invalid={actionData?.error ? true : undefined} />
{actionData?.error && <p role="alert">{actionData.error}</p>}
<button disabled={pending}>{pending ? 'Creating...' : 'Create account'}</button>
</>
)}
</GioForm>
);
}Several buttons, one form
The clicked button's name and value are sent, as in a native post.
import { redirect, type ActionArgs } from '@gio.js/core';
import { GioForm } from '@gio.js/react';
interface Todo {
id: string;
title: string;
}
export async function getServerSideProps() {
return { props: { items: await todos.list() } };
}
export async function action(req: ActionArgs) {
const form = await req.formData();
const id = String(form.get('id'));
if (form.get('intent') === 'delete') await todos.remove(id);
else await todos.complete(id);
return redirect('/todos');
}
export default function Todos({ items }: { items: Todo[] }) {
return (
<ul>
{items.map((todo) => (
<li key={todo.id}>
{todo.title}
<GioForm>
<input type="hidden" name="id" value={todo.id} />
<button name="intent" value="done">Done</button>
<button name="intent" value="delete">Delete</button>
</GioForm>
</li>
))}
</ul>
);
}Posting to a route handler
An action prop can point at a route.ts. A JSON answer is not a page, so nothing changes on screen: read it in onSuccess.
import { useState } from 'react';
import { GioForm } from '@gio.js/react';
export function FeedbackForm() {
const [ticket, setTicket] = useState<string | null>(null);
return (
<GioForm
action="/api/feedback"
resetOnSuccess
onSuccess={async (result) => {
const body = (await result.response?.json()) as { ticket: string } | undefined;
setTicket(body?.ticket ?? null);
}}
onError={(result) => console.error('feedback failed', result.status)}
>
<textarea name="message" required />
<button>Send</button>
{ticket && <p>Thanks - ticket {ticket}</p>}
</GioForm>
);
}import type { GioRequest } from '@gio.js/core';
export async function POST(req: GioRequest) {
const message = String((await req.formData()).get('message') ?? '');
const ticket = await tickets.open(message);
return Response.json({ ticket }, { status: 201 });
}Without JavaScript the browser posts the same form and shows the JSON. Give such forms a page action when they must work before hydration.
Uploading files
<GioForm encType="multipart/form-data">
<input type="file" name="avatar" accept="image/png,image/jpeg" />
<button>Upload</button>
</GioForm>The request body is limited by [server] max_body_bytes (2 MiB by default); see File uploads.
A native post for downloads
<GioForm action="/api/export" reloadDocument>
<button>Export as CSV</button>
</GioForm>Good to know
- In the root layout a
GioFormis a plain form. The root layout never hydrates, so a form there always does a full-page post. methodis fixed topost. For a search form that puts its fields in the URL, use a plain<form method="get">, orrouter.push()from anonSubmit.- Without an
actionprop the form posts to the current URL, query string included. - A submission is never sent twice once the action may have run. Only answers the server marks as refused before any handler ran (
x-gio-refused: unread) and deployment skew are re-submitted. A413or429your own action orroute.tsreturns is not marked, so it is not re-sent. - Cross-site posts are refused with
403before the action runs (CSRF protection, on by default). AGioFormon your own pages always passes. - Action answers are never cached, also on a page that exports
revalidate. Purge cached pages that show the changed data withrevalidatePath()before redirecting. - A static export has no server to run actions: point the form at an external endpoint.
Related
- Forms and Mutations - the guide: actions, validation, sessions, uploads, testing.
action- the page export the form posts to.useGioFormState- the pending state in child components.redirect- Post/Redirect/Get.[security.csrf]- which cross-site posts are refused.
Version history
| Version | Changes |
|---|---|
v0.1.0-beta.8 | Introduced. |