GioJSdocs
On this page

Forms and Mutations

Page actions and <GioForm>: forms that work without JavaScript and feel instant with it.

A page can handle its own form posts. Export an async action from page.tsx: a POST to the page's URL runs it. Render the form with <GioForm> from @gio.js/react - a real <form method="post">, so it works before (or without) any JavaScript, and submits through the client router once the page has hydrated.

app/contact/page.tsx
import { redirect, type ActionArgs, type WithActionData } from '@gio.js/core';
import { GioForm, useGioFormState } from '@gio.js/react';

export async function action(req: ActionArgs) {
  const form = await req.formData();
  const email = String(form.get('email') ?? '').trim();
  const message = String(form.get('message') ?? '');
  if (!email.includes('@')) {
    return { status: 422, data: { errors: { email: 'Enter a valid email' }, values: { email, message } } };
  }
  await db.messages.insert({ email, message });
  return redirect('/contact/thanks');           // 303 See Other
}

function SubmitButton() {
  const { pending } = useGioFormState();
  return <button type="submit" disabled={pending}>{pending ? 'Sending...' : 'Send'}</button>;
}

export default function Contact({ actionData }: WithActionData<typeof action>) {
  const errors = actionData?.errors;
  return (
    <main>
      <h1>Contact us</h1>
      <GioForm>
        <input name="email" defaultValue={actionData?.values.email} aria-invalid={errors?.email ? true : undefined} />
        {errors?.email && <p role="alert">{errors.email}</p>}
        <textarea name="message" defaultValue={actionData?.values.message} />
        <SubmitButton />
      </GioForm>
    </main>
  );
}

Progressive enhancement

<GioForm> renders method="post" and no action attribute (unless you pass one), so the browser posts to the page's own URL. What happens next depends on whether the page has hydrated:

  • Without JavaScript (or before the bundle loads): a normal form post. The browser follows the action's redirect, or shows the re-rendered page.
  • Hydrated: GioForm sends the same fields with fetch - the clicked button's name/value included, formAction / formEncType honored - and renders the answer like a soft navigation. A redirect's target is shown under its own URL; a re-render replaces the current history entry and keeps the scroll position. Layouts, the form itself and whatever the user typed survive, because the page is reconciled in the persistent React root instead of reloaded.

Plain <form method="post"> elements post to actions too - they just always do a full page load.

Writing an action

action(req) receives the same request object as a route handler - params, query, headers, cookies, ip, ... - plus formData(), which parses application/x-www-form-urlencoded and multipart/form-data bodies into a web-standard FormData (file fields are File objects). A body sent as anything else answers 415 unless you catch the error; a malformed one answers 400. Type the params with ActionArgs<{ id: string }>.

What the action returns decides the answer:

  • redirect(url) - a 303 See Other. Pass a status (301, 302, 303, 307, 308) or { status, headers } as the second argument. redirect() may also be thrown, from the action or anything it calls.
  • { status, data, headers } - an object whose keys are data and optionally status / headers - re-renders the page with data as its actionData prop, answering with status (default 200; 2xx or 4xx/5xx).
  • Any other value - re-renders the page with that value as actionData, status 200. Returning nothing gives actionData: null.
  • A web Response - sent as is (status, headers, body; binary bodies work).

notFound() answers 404 with the nearest not-found.tsx, and a thrown error answers 500 with the nearest error.tsx, exactly as during a render. getServerSideProps still runs for a re-render and sees the result as ctx.actionData (ctx.method is 'POST'); if it returns an actionData prop itself, that one wins.

Only POST runs an action - it is all an HTML form sends besides GET. PUT/PATCH/DELETE to a page answer 405 (with Allow: GET, HEAD, POST); give those their own route.ts. A page without an action answers every mutation with 405. A route.ts in the same folder that exports POST takes the POST; one that does not passes it on to the action (and a 405 from that folder lists both files' methods).

Headers the action returns with its data are sent whatever answers in the end: the re-rendered page, or a redirect, 404 or error page that replaces it. redirect() works in getServerSideProps too, returned or thrown - see Data Fetching.

Redirect after a change (Post/Redirect/Get)

After an action changes something, answer with redirect(). The 303 makes the browser GET the target, so reloading it never asks to resubmit the form and the back button behaves. Redirect to the same page to show the new state there:

tsx
export async function action(req: ActionArgs<{ id: string }>) {
  const form = await req.formData();
  if (form.get('intent') === 'delete') {
    await db.todos.delete(String(form.get('todoId')));
  } else {
    await db.todos.insert({ list: req.params.id, title: String(form.get('title')) });
  }
  return redirect(`/lists/${req.params.id}`);
}

// Two buttons, one form: the clicked one's name/value is sent.
<GioForm>
  <input type="hidden" name="todoId" value={todo.id} />
  <button name="intent" value="delete">Delete</button>
</GioForm>

A URL from user input (a ?next= parameter) must be checked before you redirect to it - otherwise the action is an open redirect. The Redirecting guide has a check you can copy, and every other way to redirect.

Redirects to other sites - a payment page, an identity provider - work from GioForm as well. Its requests carry x-gio-form: 1, and the server answers their 301/302/303 redirects with a 204 naming the target in x-gio-redirect (cookies included) instead: fetch would otherwise follow the redirect itself and fail the cross-origin check after the action had already run. GioForm then fetches a same-origin target (revalidating the HTTP cache) and hands any other to the browser. A 307/308, which repeats the POST, is still followed by fetch. Plain form posts always get the real redirect.

Validation errors

Return { status: 422, data } to show the form again with errors. The page re-renders with actionData - on the server for a plain post, swapped in place for GioForm - and the hydrated page receives the same prop. Send back the submitted values too and use them as defaultValues: without JavaScript the browser shows a fresh form.

WithActionData<typeof action, Props> adds an optional, typed actionData to your props; ActionData<typeof action> is the type alone. Both leave out the Response and redirect() branches, which never re-render. After a failed submission GioForm moves focus to the first field marked aria-invalid="true" (unless the page moved it); put error text in a role="alert" element so screen readers announce it.

GioForm

Every <form> prop passes through (className, encType, id, ...), plus:

  • action - where to post; default the current page.
  • onSuccess(result) - after a 2xx answer, a redirect's target included.
  • onError(result) - after any other answer (a 422 re-render too) or a failed request.
  • resetOnSuccess - clear the fields after a successful submission that kept the form on screen.
  • reloadDocument - never intercept: always a native, full page post (file downloads, for one).
  • children may be a function of the state: {({ pending }) => ...}.

useGioFormState(), called anywhere inside the form, returns { pending, lastResult }. lastResult (also what the callbacks receive) has ok, status, url, redirected, the rendered page's data (its actionData), and response or error where they apply. While a submission is pending, further submits are ignored and the form has aria-busy="true" (style it with form[aria-busy="true"]).

Answers the router cannot render fall back to what the browser would do - but a submission is never sent twice when the action may already have run:

  • A redirect to another site, or to something that is not a GioJS page (a file, JSON) - loaded as a full page (a GET). The form stays pending while the page unloads, unless the target is a download (Content-Disposition: attachment) or the back/forward cache brings the page back.
  • A refusal that comes before the action runs - the server's 413 for a too-large upload, a 429 from its rate limiter (both marked x-gio-refused: unread), a deployment change - onError runs (for the 413 and 429), then the form is submitted natively so the browser shows the real response.
  • Any other error that is not a GioJS page - a 500 when the action threw (and no error.tsx rendered it), a 502/504 from a proxy or a timeout while the action may still be running, an error Response the action returned (a 413 or 429 of its own included) - goes to onError with result.response. Nothing changes on screen and nothing is re-sent: show the failure from lastResult.
  • A 2xx that is not a page (an action returning Response.json(...)) - goes to onSuccess as result.response; nothing changes on screen and nothing is sent twice.
  • A network failure - onError with status: 0; the form stays as it is.

File uploads

Set encType="multipart/form-data" - the browser needs it to send file contents, with or without JavaScript - and read the files from formData():

tsx
export async function action(req: ActionArgs) {
  const file = (await req.formData()).get('avatar');
  if (!(file instanceof File) || file.size === 0) {
    return { status: 422, data: { error: 'Choose an image' } };
  }
  if (!['image/png', 'image/jpeg'].includes(file.type)) {
    return { status: 422, data: { error: 'PNG or JPEG only' } };
  }
  await storage.put(`avatars/${crypto.randomUUID()}`, Buffer.from(await file.arrayBuffer()));
  return redirect('/settings');
}

<GioForm encType="multipart/form-data">
  <input type="file" name="avatar" accept="image/png,image/jpeg" />
  <button>Upload</button>
</GioForm>

The whole request body is limited by max_body_bytes in gio.toml's [server] section (default 2 MiB): the Rust server answers 413 Payload Too Large before the action runs. Raise it for larger uploads - the body is buffered in memory and handed to the worker in one piece (binary bodies base64-encoded, so above roughly 48 MiB a body is a 413 whatever the setting says). Send large media straight to object storage (a presigned URL) instead. Never trust file.name or file.type: they are whatever the client sent.

Security

  • Cross-site form posts are refused with 403 in the Rust server before the action runs (CSRF protection, on by default - see Security). Same-origin posts, with or without JavaScript, pass. An endpoint other sites post to on purpose (an OAuth form_post callback) goes in [security.csrf] exempt.
  • Action answers and the pages they re-render are never cached, even on a page that exports revalidate, and never replace the page's cached entry.
  • action and everything only it imports are left out of the client bundle, like getServerSideProps. Still validate every field on the server: the action is a public endpoint anyone can post to.

Sessions and cookies

Read the session with getSession(req) and send cookies through the redirect's (or re-render's) headers - see Authentication:

app/login/page.tsx
import { redirect, type ActionArgs, type WithActionData } from '@gio.js/core';
import { GioForm } from '@gio.js/react';
import { sessions } from '../../lib/session.server.ts';

export async function action(req: ActionArgs) {
  const form = await req.formData();
  const user = await verifyPassword(String(form.get('email')), String(form.get('password')));
  if (user === null) return { status: 401, data: { error: 'Wrong email or password' } };
  const session = sessions.getSession(req);
  session.set('userId', user.id);
  return redirect('/dashboard', { headers: { 'set-cookie': sessions.commitSession(session) } });
}

export default function Login({ actionData }: WithActionData<typeof action>) {
  return (
    <GioForm>
      {actionData?.error && <p role="alert">{actionData.error}</p>}
      <input name="email" type="email" autoComplete="username" />
      <input name="password" type="password" autoComplete="current-password" />
      <button>Log in</button>
    </GioForm>
  );
}

Fresh data after a mutation

GioForm drops the router's prefetched pages when it posts, and the page it shows next is fetched fresh. Pages cached in the Rust server (revalidate) keep serving their cached copy until it expires: purge them from the action with revalidatePath() / revalidateTag() from @gio.js/core (on-demand revalidation - see Caching) before redirecting.

tsx
import { redirect, revalidatePath, type ActionArgs } from '@gio.js/core';

export async function action(req: ActionArgs) {
  await db.posts.publish(String((await req.formData()).get('postId')));
  revalidatePath('/blog');                    // the cached blog index shows it now
  return redirect('/blog');
}

Static export

Actions run in the server. A site deployed with gio export to a static host has no server to post to - point such forms at an external endpoint instead.

Testing

callRoute from @gio.js/core/testing posts to pages too: a URLSearchParams body is sent as a form.

ts
import { callRoute } from '@gio.js/core/testing';

it('rejects an invalid email with 422', async () => {
  const res = await callRoute('/contact', {
    method: 'POST',
    body: new URLSearchParams({ email: 'nope', message: 'hi' }),
  });
  expect(res.status).toBe(422);
  expect(await res.text()).toContain('Enter a valid email');
});

it('redirects after sending', async () => {
  const res = await callRoute('/contact', {
    method: 'POST',
    body: new URLSearchParams({ email: 'ada@example.com', message: 'hi' }),
  });
  expect(res.status).toBe(303);
  expect(res.headers['location']).toBe('/contact/thanks');
});