Mutations, forms and results
Submit forms and server functions with one mutation model, read the outcome in the page, work without JavaScript and show deferred data with Await.
Use <Form> for a form submission, useSubmit() for a programmatic change, and useFetcher() for work that stays on the current page. Each gives the UI access to pending, success and failure states.
Start with Forms and mutations.
A form that submits in place#
<Form> takes a server function as its action. With JavaScript the submit goes to the function through RPC: the page does not
reload, the outcome is in the store, and the form is busy until the call settles. Without JavaScript the browser posts the form to
the function's endpoint (fn.url), and the server answers with a redirect back to the page.
import { fail, serverFn } from "@shadow-js/dusk/server";
export const saveNote = serverFn({ form: true }, (input: unknown) => {
const title = typeof input === "object" && input !== null ? Reflect.get(input, "title") : undefined;
if (typeof title !== "string" || title.trim() === "") {
// A failure is a result, not an exception: the form stays and the field is marked.
return fail(400, { issues: [{ message: "Title is required", path: ["title"] }] });
}
return { saved: title.trim() };
});
import { Form, useSubmission } from "@shadow-js/dusk/client";
import type { JSX } from "@shadow-js/web";
import { saveNote } from "./server/notes";
export default function NotesPage(): JSX.Element {
const last = useSubmission(saveNote);
return (
<section>
<Form action={saveNote} resetOnSuccess>
<label>
Title <input name="title" />
</label>
<button type="submit">Save</button>
</Form>
<p role="status">{last.pending ? "Saving" : last.status === "success" && last.failed ? "Fix the title" : last.status}</p>
</section>
);
}
The <form> the component renders has method="post", action="/_dusk/fn/<id>" and enctype="multipart/form-data". Its
aria-busy attribute is "true" while the call runs, and a second submit meanwhile is ignored. After a failure, the field named by
the first problem gets aria-invalid="true" and receives focus.
<Form> props that change the behavior:
| Prop | Default | What it does |
|---|---|---|
reloadDocument |
false |
Leaves the submit to the browser. |
resetOnSuccess |
false |
Resets the fields after a success (not after a failure). |
revalidate |
true |
false skips the refresh of the page's loader data after a success. |
focusOnError |
true |
Moves focus to the first field with a problem, or to [data-dusk-form-errors] (given tabindex="-1" when it has none, so a container can take focus). |
onResult |
none | Called with the result (a fail() result included) and the form. |
onError |
none | Called with the error of a thrown failure and the form. |
A string action is a URL, not a function. A method="get" form navigates the router to that URL with the text fields as the
query (replace replaces the history entry; scroll="keep" keeps the scroll position). navigate={false} leaves the GET to the
browser.
Reading a submission#
useSubmission(fn) returns the newest submission of a function as a view of plain values. It is reactive: a component that reads
status or result updates when the submission changes.
| Field | Value |
|---|---|
status |
"idle", "pending", "success" or "error" |
pending |
true while the call runs |
result |
the value the function returned (a fail() result too) |
failed |
true when the result is a fail() failure |
error |
the thrown error of an error submission |
id |
stable within the page: s1, s2, ... |
input |
the form data or the arguments |
clear() |
removes the submission and resets it to idle |
retry() |
runs the call again with the same input |
useSubmissions(fn) returns a function that gives the list of submissions of the function, oldest first (the newest 50 are kept).
Without an argument, useSubmission() reads the newest submission of any function.
Start a submission that is not a form with useSubmit(). The call is the same as a form's, and its state shows in useSubmission:
import { useSubmission, useSubmit } from "@shadow-js/dusk/client";
import type { JSX } from "@shadow-js/web";
import { saveNote } from "./server/notes";
export function SaveDraft(): JSX.Element {
const submit = useSubmit();
const last = useSubmission(saveNote);
return (
<button type="button" disabled={last.pending} onClick={() => submit(saveNote, { title: "Draft" })}>
{last.pending ? "Saving draft" : "Save draft"}
</button>
);
}
Revalidation#
After a successful submission of a function that is not single flight, the page's loader data is refreshed (the router's
invalidate()), so the routes on screen show the new data. A function marked singleFlight: true sends the refreshed data in its
own answer, and the page is not refreshed twice. A call that must not refresh the page passes revalidate: false:
import { serverFnStub, useSubmit } from "@shadow-js/dusk/client";
const touch = serverFnStub<{ id: string }, { ok: true }>("touchNote", {
method: "POST",
form: false,
base: "",
batch: false,
});
export function touchQuietly(id: string): void {
const submit = useSubmit();
submit(touch, { id }, { revalidate: false });
}
useRevalidator() refreshes the loader data on demand. Its state is "loading" while the refresh runs:
import { useRevalidator } from "@shadow-js/dusk/client";
import type { JSX } from "@shadow-js/web";
export function RefreshButton(): JSX.Element {
const revalidator = useRevalidator();
return (
<button type="button" disabled={revalidator.state() === "loading"} onClick={() => void revalidator.revalidate()}>
Refresh
</button>
);
}
Fetchers: a change that does not navigate#
useFetcher(key) has its own state and does not navigate. The same key in two components is the same fetcher, so one component
can show what another one started. state() is "idle", "loading" (a load) or "submitting" (a submit); data() holds the
loader data of the page that load(url) fetched, or the value of the last submit.
import { useFetcher } from "@shadow-js/dusk/client";
import type { JSX } from "@shadow-js/web";
import { saveNote } from "./server/notes";
export function InboxProbe(): JSX.Element {
const fetcher = useFetcher("inbox");
return (
<div>
<p>{fetcher.state()}</p>
<button type="button" onClick={() => void fetcher.load("/inbox")}>
Load inbox
</button>
<button
type="button"
onClick={() => {
const data = new FormData();
data.set("title", "From a fetcher");
void fetcher.submit(saveNote, data);
}}
>
Save without navigating
</button>
</div>
);
}
Optimistic UI#
useOptimistic from @shadow-js/core shows the expected value while a submission runs and drops it when the submission settles.
Pass the submission's done promise as the settle signal:
import { useOptimistic } from "@shadow-js/core";
import { useSubmit } from "@shadow-js/dusk/client";
import type { JSX } from "@shadow-js/web";
import { saveNote } from "./server/notes";
export function OptimisticNotes(props: { readonly saved: () => readonly string[] }): JSX.Element {
const [notes, addOptimistic] = useOptimistic(props.saved, (list: readonly string[], title: string) => [
...list,
title,
]);
const submit = useSubmit();
const add = (title: string): void => {
const sub = submit(saveNote, { title });
addOptimistic(title, sub.done);
};
return (
<div>
<ul>
{notes().map((title) => (
<li>{title}</li>
))}
</ul>
<button type="button" onClick={() => add("Draft")}>
Add
</button>
</div>
);
}
Without JavaScript: results and messages#
A <Form> or a plain <form method="post" action={saveNote.url}> works before any script runs. For such a post, Dusk answers
with a redirect back to the page, and it needs security.cookieSecrets to carry the outcome there. The outcome is stored in a
signed, one-time cookie; the next page that renders reads it, clears it, and shows it through useSubmission(), exactly as it
shows the outcome of a script submit. The server does not render the page from the post: the browser loads it again, so a
failure's HTTP status is not kept on the page (the page shows the failure).
Without cookie secrets nothing is carried. A success is the plain 303, and an error answers text/plain with its status, as before.
A function can also set a message of its own with setFlash. The next page reads it once with useFlash():
import { serverFn, setFlash } from "@shadow-js/dusk/server";
export const archiveNote = serverFn({ form: true }, async (input: unknown, event) => {
const id = typeof input === "object" && input !== null ? String(Reflect.get(input, "id")) : "";
await setFlash(event, { notice: `Archived note ${id}.` });
return { ok: true };
});
import { useFlash } from "@shadow-js/dusk/client";
import type { JSX } from "@shadow-js/web";
export function Notice(): JSX.Element {
const notice = useFlash<{ readonly notice: string }>();
return <p role="status">{notice()?.notice ?? ""}</p>;
}
A flash is read once. A redirect or an error page keeps it for the next page that renders. Keep the value small: a payload over 3000 characters keeps only the status of the outcome.
Deferred data with Await#
A route can show part of the page while a promise is pending. <Await> renders fallback until the promise settles, then
children(value), or errorElement(error) when it rejects. Without an errorElement the error goes to the nearest error boundary.
On a streamed page the fallback is sent first and the value follows when the promise resolves.
import { Await } from "@shadow-js/dusk/client";
import type { JSX } from "@shadow-js/web";
export function Comments(props: { readonly comments: Promise<readonly string[]> }): JSX.Element {
return (
<Await
resolve={props.comments}
fallback={<p>Loading comments</p>}
errorElement={(error) => <p role="alert">{error.message}</p>}
>
{(list) => (
<ul>
{list.map((text) => (
<li>{text}</li>
))}
</ul>
)}
</Await>
);
}
The error element receives an Error with the name and message of the rejection. The original error object does not reach the
browser.
Forms and @shadow-js/forms#
A <Form> does not replace useForm: a form with schema validation and field arrays still uses @shadow-js/forms, and the
fields that show problems are the ones useForm binds. <Form> only supplies the submit. Pass the result to applyServerErrors
in onResult, and the problems land on those fields the same way they do for a thrown error:
import { applyServerErrors, useForm } from "@shadow-js/forms";
import { Form } from "@shadow-js/dusk/client";
import type { JSX } from "@shadow-js/web";
import { saveNote } from "./server/notes";
export function Signup(): JSX.Element {
const state = useForm<{ title: string }>({ defaultValues: { title: "" } });
// The failure carries `data.issues`, which applyServerErrors reads as field problems.
return (
<Form action={saveNote} onResult={(result) => void applyServerErrors(state, result)}>
<input name="title" />
</Form>
);
}
Limits#
- A per-route
shouldRevalidatepredicate is not part of 1.0.0. A single-flight answer carries the loader data of every matched route. Userevalidate: falseon a call that must not refresh the page. - Without
useSubmissiona submission is not shared between components. Mount the app withhydrateDusk(which binds the store to the router) for the shared state; a page without it gets one store per hook call. - A flash needs
security.cookieSecrets. Without them a no-JavaScript post gets the plain answers of earlier versions. - The no-JavaScript result is shown by the next page load, not by the response to the post.
See also#
- Dusk: server functions for
serverFn, the wire format and form posts. - Forms for schema validation, field arrays and
applyServerErrors. - Data fetching for resources and optimistic state in the browser.