Loading...
Loading...
Server actions let you write a Rust function that the build mirrors into a
typed TypeScript function you can call from React - as a plain RPC, as a
<form action> handler, or with useActionState. It's the mutation counterpart
to #[handler]: use #[handler] to read the data a page needs, and a server
action to do something (create, update, delete, submit a form).
Inspired by React/Next.js server actions, adapted for Ossido's Rust backend.
Ossido is not React Server Components - there is no 'use server'
server-reference mechanism. Instead the CLI generates a concrete, typed
function per action into .ossido/actions.ts. Each generated function is a
normal client function that POSTs to a conventional endpoint and hides the
fetch, serialization and error handling. React never treats it as "special"; it
simply matches the call signatures React expects.
Take this Rust action:
At build time the CLI turns it into a typed client function you import and call
like any other - no fetch or serialization at the call site:
In between, the #[action] macro registers an axum handler at
POST /__ossido/action/<module>/create_user, and the CLI writes the matching
createUser wrapper into .ossido/actions.ts - the POST, serialization and
error handling all live inside it.
Everything React gives you for free once a function is wired into
<form action> / useActionState - isPending, useFormStatus() - works,
because the generated function is a real function.
Actions live in actions.rs (or *.actions.rs) files anywhere under
src/routes/. A single file can hold many actions.
The macro reads everything from the function signature (one source of truth). Arguments are classified like this:
PrevState<T> - the previous useActionState value; its presence makes the
action stateful.Files - uploaded files from a multipart submission (see
File uploads).logger - the framework Logger.ApplicationState field, matched by name (just like
#[handler]).Rules and requirements:
#[Type] (or #[Props]) structs so their
TypeScript is generated and importable.Result<T, ActionError> or a bare T. A bare return
is treated as success.#[handler]
and #[api], so state and logging work identically.By default the generated TypeScript function takes the camelCased Rust name
(create_user → createUser). To pick the name yourself, pass it to the macro -
either a bare identifier or a string literal:
useActionState)Add a PrevState<T> first parameter to receive the previous state. React
round-trips it on every submit; PrevState::get() is None on the first render.
PrevState<T>is client-supplied and round-tripped verbatim - treat it as untrusted input, never as authorization state.
The build generates one importable function per action into
.ossido/actions.ts, named the camelCased Rust name by default
(create_user → createUser) or whatever you passed to #[action(...)]. It
works in three ways - all against the same generated function. Import the
runtime helpers from @ossido-labs/ossido/actions.
<Form> - progressive enhancement<Form> renders a real <form method="post" action={endpoint}>. When hydrated,
submission is intercepted and the action is called with the form's FormData;
with JS disabled, the browser does a native POST and the server replies with a
303 Post/Redirect/Get.
React's own
<form action={fn}>also works when hydrated, but has no no-JS fallback (there's no URL). Use<Form>when you want progressive enhancement.
useActionStatePass a stateful action (one with a PrevState<T>) straight to
useActionState. You get back [state, formAction, isPending]; isPending and
useFormStatus() come for free from React.
Actions accept file uploads through a Files parameter - recognized by the
macro just like PrevState and logger, and excluded from the generated
TypeScript input type. Text fields still decode into your #[Type] input; files
arrive separately via Files.
A Files value is a map of form-field name → uploaded files, and each
UploadedFile carries its bytes plus metadata. The full surface you have to work
with:
An action can also take only Files, with no text input.
On the client it's automatic. The generated function inspects what you pass and picks the encoding:
FormData that contains file entries → multipart/form-data (the browser
sets the boundary);FormData → URL-encoded;So the imperative call and <Form> you already use just work. For the no-JS form
path, set the encoding explicitly so the native POST is multipart:
Files come through the separate
Filesparam rather than as fields on the#[Type]input because multipart text fields are strings coerced through serde, which raw file bytes can't round-trip through. Keeping them separate lets both decode cleanly and keeps the generated TypeScript input file-free.
There are two distinct failure channels:
Err(ActionError). The response is
422 { "error": { message, fields? } }.
OssidoActionError (with .message and
optional .fields) - catch it with try/catch.useActionState flow, prefer modelling recoverable errors inside
the returned state (Ok(FormState { ok: false, ... })) so they land in state
instead of throwing.500 in
production.@ossido-labs/ossido/actions packageForm - a <form> wired to an action with progressive enhancement.useActionState - re-export of React's hook (one import site).useFormStatus - re-export of React DOM's hook.OssidoActionError - the error thrown on a failed action call (.message,
.fields).createAction / createStatefulAction - factories used by the generated
actions.ts (rarely called directly).ActionError, ActionFn, StatefulActionFn - types.The Rust side exports action, ActionError, PrevState, Files,
UploadedFile and ActionInputError from the ossido crate.
Endpoint. Each action is registered at:
POST /__ossido/action/<module>/<fn_name>
where <module> is the action file's route path, mangled to a single safe
segment (newsletter/actions.rs → newsletter_actions). It's always a POST - the
"verb" is the function name.
Wire protocol (x-ossido-action-version: 1). One endpoint serves two callers,
chosen by request headers:
fetch): sends x-ossido-action: 1. The body is
application/json { "input": <I>, "__ossido_prev_state"?: <P> }, a URL-encoded
form, or multipart/form-data when the FormData carries files. The response is
{ "data": <T> } or { "error": <ActionError> }.multipart/form-data body,
Accept: text/html, no marker header → a 303 redirect back to the referring
page.Multipart bodies are parsed once, from the buffered request body, with multer
(the same crate axum uses) into a single decoded form that yields the typed input,
PrevState and Files together - so a large body isn't parsed twice.
Codegen. On every .rs change the CLI regenerates:
.ossido/main.rs - module declarations and POST routes for every action..ossido/actions.ts - one typed exported function per action..ossido/types.ts - the #[Type] structs, importable from
@ossido-labs/ossido/types.You never edit the .ossido/ directory.
#[api] POST path), so a configurable limit is a
sensible follow-up before accepting uploads from untrusted clients.middleware.rs does not automatically wrap them yet.
Authentication and CSRF for actions are a planned follow-up; the
x-ossido-action header guards the JSON path, but the no-JS form path still
needs a real CSRF token.fetch; they're
client-interaction primitives and are not meant to be called during SSR.