GioJSdocs
On this page

<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.

app/newsletter/page.tsx
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

PropTypeDefaultDescription
actionstringthe current URLWhere 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.
resetOnSuccessbooleanfalseReset the fields after a successful submission that kept the form on screen.
reloadDocumentbooleanfalseNever intercept: always a native, full-page post.
childrenReact.ReactNode | ((state: GioFormState) => React.ReactNode)-The fields, or a function of the form state.
onSubmitReact.FormEventHandler<HTMLFormElement>-Runs before the submission is sent. Call event.preventDefault() to cancel it.
refReact.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.

FieldTypeDefaultDescription
pendingboolean-A submission is in flight.
lastResultGioFormResult | null-How the last finished submission ended; null before the first.

GioFormResult

GioFormResult is what onSuccess and onError receive, and lastResult holds.

FieldTypeDefaultDescription
okboolean-The final status is 2xx.
statusnumber-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.
urlstring-Path and query of the final response - the page now shown. A redirect to another site gives its absolute URL.
redirectedboolean-The action answered with a redirect.
dataunknown-The actionData of the page the answer rendered, if any.
responseResponse-The answer when it was not a GioJS page, such as an action's Response.json(...).
errorunknown-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:

  • onSubmit called preventDefault() (then nothing is sent at all), or reloadDocument is set;
  • the form or the clicked button has a target other than _self (formTarget on the button);
  • the clicked button switches the method to get or dialog (formMethod);
  • the action (or the button's formAction) 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

AnswerOn screenCallback
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 appThe 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 pageThe 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 answerSubmitted again natively, to the new build.None
A network failureNothing 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

app/signup/page.tsx
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.

app/todos/page.tsx
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.

app/feedback-form.tsx
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>
  );
}
app/api/feedback/route.ts
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

tsx
<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

tsx
<GioForm action="/api/export" reloadDocument>
  <button>Export as CSV</button>
</GioForm>

Good to know

  • In the root layout a GioForm is a plain form. The root layout never hydrates, so a form there always does a full-page post.
  • method is fixed to post. For a search form that puts its fields in the URL, use a plain <form method="get">, or router.push() from an onSubmit.
  • Without an action prop 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. A 413 or 429 your own action or route.ts returns is not marked, so it is not re-sent.
  • Cross-site posts are refused with 403 before the action runs (CSRF protection, on by default). A GioForm on 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 with revalidatePath() before redirecting.
  • A static export has no server to run actions: point the form at an external endpoint.

Version history

VersionChanges
v0.1.0-beta.8Introduced.