[]previous and next page

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.

For agentsUpdated

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.

src/server/notes.tsts
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() };
});
src/notes-page.tsxtsx
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:

src/save-button.tsxtsx
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:

src/quiet.tsts
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:

src/refresh.tsxtsx
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.

src/inbox-probe.tsxtsx
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:

src/optimistic-notes.tsxtsx
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():

src/server/archive.tsts
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 };
});
src/notes-notice.tsxtsx
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.

src/comments.tsxtsx
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:

src/signup.tsxtsx
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 shouldRevalidate predicate is not part of 1.0.0. A single-flight answer carries the loader data of every matched route. Use revalidate: false on a call that must not refresh the page.
  • Without useSubmission a submission is not shared between components. Mount the app with hydrateDusk (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.