action
Handle a form POST to a page's own URL on the server, then redirect or re-render the page with the result.
import { redirect, type ActionArgs, type WithActionData } from '@gio.js/core';
import { GioForm } from '@gio.js/react';
import { db } from '../../lib/db.server.ts';
export async function action(req: ActionArgs) {
const form = await req.formData();
const email = String(form.get('email') ?? '').trim();
if (!email.includes('@')) {
return { status: 422, data: { error: 'Enter a valid email', email } };
}
await db.messages.insert({ email });
return redirect('/contact/thanks'); // 303 See Other
}
export default function Contact({ actionData }: WithActionData<typeof action>) {
return (
<GioForm>
<input name="email" defaultValue={actionData?.email} aria-invalid={actionData ? true : undefined} />
{actionData && <p role="alert">{actionData.error}</p>}
<button type="submit">Send</button>
</GioForm>
);
}A page that exports action answers POST requests to its URL by running it. A plain <form method="post"> posts there without any JavaScript, and <GioForm> does the same through the client router once the page has hydrated. The function runs only on the server and is not part of the browser bundle.
Reference
Parameters
req (ActionArgs<Route>) is the request a route handler receives, with params typed by Route:
| Field | Type | Default | Description |
|---|---|---|---|
formData() | Promise<FormData> | - | Parses an application/x-www-form-urlencoded or multipart/form-data body. File fields are File objects. Any other content type throws UnsupportedMediaTypeError (415 unless caught), a body that does not parse throws MalformedBodyError (400). |
json() | T | - | Parses a body sent as application/json or application/*+json; anything else throws UnsupportedMediaTypeError (415). |
body | string | null | - | The raw body: UTF-8 text, or base64 when bodyBase64 is true. |
params | ParamsOf<Route> | - | The dynamic segments of the page. |
query | Record<string, string> | - | The query string, one value per name: the last one when a name repeats. |
headers | Record<string, string> | - | The request headers, names lowercase. |
cookies | Record<string, string> | - | The Cookie header, parsed (sessions.getSession(req) reads it). |
method, path | string | - | method is always 'POST'; path is the routed path. |
locale, ip, scheme, host, requestId | string | undefined | - | As on a route handler's request. |
Returns
| Return value | Answer |
|---|---|
redirect(url, init?) | 303 See Other to url (or 301, 302, 307, 308), with init.headers. May also be thrown, from the action or anything it calls. |
{ data, status?, headers? } | Re-renders the page with data as its actionData prop, answering status (default 200) with headers. Only an object whose keys are data plus at most status and headers counts as this form. |
| Any other value | Re-renders the page with that value as actionData, status 200. undefined and null give actionData: null. |
A web Response | Sent as it is: status, headers and body (binary bodies included). The page does not render. Without a Content-Type it is sent as text/plain; charset=utf-8. |
A re-render's status must be a 2xx other than 204/205, or a 4xx/5xx. Anything else is a programming error that answers 500 and logs action returned status 302 - a re-render answers 2xx (not 204/205) or 4xx/5xx; use redirect() for 3xx, or return a Response.
Behavior
- POST only.
PUT,PATCHandDELETEto the page answer405withAllow: GET, HEAD, POST(a page without an action allowsGET, HEAD). Aroute.tsin the same folder that exportsPOSTtakes the request instead; one that does not passes it on to the action, and a405from that folder lists both files' methods. - The re-render.
getServerSidePropsruns for it with the result inctx.actionDataandctx.methodset to'POST'. The page receives anactionDataprop, unlessgetServerSidePropsreturned a prop of that name itself. Metadata, layouts and boundaries render as for aGET. - Headers carry through. Headers the action returns with its data (a session cookie, a flash message) are sent with whatever answers in the end: the re-rendered page, or a redirect, 404 or error page from
getServerSideProps. Headers the page returns win a clash; cookies add up. - Errors.
notFound()answers404with the nearestnot-found.tsx; any other thrown error answers500with the nearesterror.tsx, as during a render. - Never cached. Neither the action's answer nor the page it re-renders is stored, even when the page exports
revalidate. - GioForm redirects. A
POSTfrom<GioForm>carriesx-gio-form: 1, and the server answers its301,302and303redirects with a204that names the target inx-gio-redirect, cookies kept, sofetchdoes not follow a redirect to another site itself. A plain form post gets the real redirect. - Checks before it runs. The Rust server refuses a cross-site
POSTwith403([security.csrf]), a body over[server] max_body_byteswith413, and a request over a rate limit with429, before the action runs.
Types
| Type | What it types |
|---|---|
ActionArgs | ActionArgs<Route>: the request. Route is a pattern of your app ('/posts/:id') or a params shape. |
ActionResult | ActionResult<Data>: everything an action may return. |
ActionDataResult | ActionDataResult<Data>: the { data, status?, headers? } form. |
ActionData | ActionData<typeof action>: the actionData the page receives. Response and redirect() results never re-render, so they are left out. |
WithActionData | WithActionData<typeof action, Props>: Props plus an optional, typed actionData. |
Examples
Several buttons in one form
The clicked button's name and value are part of the form data:
import { redirect, type ActionArgs, type GetServerSideProps, type InferPageProps } from '@gio.js/core';
import { GioForm } from '@gio.js/react';
import { db, type Todo } from '../../../lib/db.server.ts';
export async function action(req: ActionArgs<'/lists/:id'>) {
const form = await req.formData();
const deleteId = form.get('delete'); // sent only by a Delete button
if (deleteId !== null) {
await db.todos.delete(String(deleteId));
} else {
await db.todos.insert({ list: req.params.id, title: String(form.get('title') ?? '') });
}
return redirect(`/lists/${req.params.id}`); // reload-safe: the browser GETs the list
}
export const getServerSideProps: GetServerSideProps<{ todos: Todo[] }, '/lists/:id'> = async (ctx) => ({
props: { todos: await db.todos.inList(ctx.params.id) },
});
export default function List({ todos }: InferPageProps<typeof getServerSideProps>) {
return (
<GioForm>
<input name="title" aria-label="New todo" />
<button>Add</button>{/* first in the form: Enter in the field adds */}
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.title} <button name="delete" value={todo.id}>Delete</button>
</li>
))}
</ul>
</GioForm>
);
}Set a cookie on the way out
import { redirect, type ActionArgs, type WithActionData } from '@gio.js/core';
import { GioForm } from '@gio.js/react';
import { sessions } from '../../lib/session.server.ts';
import { verifyPassword } from '../../lib/users.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>
<input name="email" type="email" autoComplete="username" aria-label="Email" />
<input name="password" type="password" autoComplete="current-password" aria-label="Password" />
{actionData && <p role="alert">{actionData.error}</p>}
<button>Sign in</button>
</GioForm>
);
}Answer with your own Response
import { buildReport } from '../../lib/reports.server.ts';
export async function action() {
const csv = await buildReport();
return new Response(csv, {
headers: {
'content-type': 'text/csv; charset=utf-8',
'content-disposition': 'attachment; filename="report.csv"',
},
});
}
// A plain form: the browser saves the file and stays on the page.
export default function ExportPage() {
return (
<form method="post">
<button>Download the report</button>
</form>
);
}Good to know
- A URL from user input (a
?next=parameter) must be checked before you redirect to it, or the action is an open redirect. - Return the submitted values with a validation error and use them as
defaultValues: without JavaScript the browser shows a fresh form. - After a failed submission
<GioForm>moves focus to the first field markedaria-invalid="true". - A static export has no server to run actions:
gio exportwrites the page'sGETrender, and a form on it posts to the static host. Point such forms at an external endpoint instead. - GioJS has no Server Actions (
'use server'): an action belongs to one page and answers that page's URL.gio migrateturns Server Action forms into a<GioForm>posting to the page's action.
Related
- Forms and Mutations - the guide
<GioForm>anduseGioFormStateredirect,notFound, request body errorsgetServerSideProps- Route handler methods - for
PUT,PATCHandDELETE
Version history
| Version | Changes |
|---|---|
v0.1.0-beta.8 | Introduced. |