Forms
A contacts CRUD with react-hook-form. The server validates; the form shows what it says.
The form
function ContactForm(props: {
title: string;
defaultValues: ContactValues;
submitLabel: string;
busyLabel: string;
save: (values: ContactValues) => Promise<SaveResult>;
onSaved: () => void;
onCancel: () => void;
}) {
const form = useForm<ContactValues>({ defaultValues: props.defaultValues });
const save = useAction(
async (values: ContactValues) => {
const response = await props.save(values);
if (response.result === "invalid-body") showServerIssues(form, response.issues);
else if (response.result === "transport-error")
form.setError("root.server", {
message: "Not saved: the connection dropped. Try again.",
});
else props.onSaved();
return response;
},
{
// Only a successful save changed anything worth refetching.
refresh: { success: refreshes.contacts },
// Anything the form doesn't handle is shown with notify; the form keeps what the user typed.
config: { errors: { onError: "notify" } },
},
);
const { errors } = form.formState;
return (
<section aria-label={props.title}>
<h2>{props.title}</h2>
{/* `run` resolves when the save is done, so react-hook-form's
`isSubmitting` covers it, and it never rejects. */}
<form onSubmit={form.handleSubmit(save.run)} noValidate>
<Field
label="Name"
error={errors.name?.message}
{...form.register("name")}
autoComplete="name"
/>
<Field
label="Email"
error={errors.email?.message}
{...form.register("email")}
type="email"
autoComplete="email"
/>
<fieldset>
<legend>Address</legend>
<Field
label="Street"
error={errors.address?.street?.message}
{...form.register("address.street")}
autoComplete="street-address"
/>
<div className="row">
<Field
label="City"
error={errors.address?.city?.message}
{...form.register("address.city")}
autoComplete="address-level2"
/>
<Field
label="Zip"
error={errors.address?.zip?.message}
{...form.register("address.zip")}
autoComplete="postal-code"
/>
</div>
</fieldset>
{errors.root?.server && <p className="error">{errors.root.server.message}</p>}
<div className="row">
<BusyButton type="submit" busy={save.isPending} busyLabel={props.busyLabel}>
{props.submitLabel}
</BusyButton>
<button type="button" onClick={props.onCancel} disabled={save.isPending}>
Cancel
</button>
</div>
</form>
</section>
);
}
In this example:
-
form.handleSubmit(save.run):runresolves when the save and its refresh are done, and never rejects, so react-hook-form'sisSubmittingcovers the whole save. -
thenKeep(["success", "invalid-body", "transport-error"])keeps what the form can explain as values: the server's validation errors go on the fields, a dropped connection on the form, and the user's input stays. -
Anything else (a 500, a bug) is unhandled;
errors.onError: "notify"shows it without replacing the form. -
refresh: { success: [...] }: only a successful save changed anything worth refetching. -
NOTE: To clear the form after a save, call
form.reset()afterrunresolves, not inside the action.resetalso clearsisSubmitting, which would end the submission before the refresh.
Server errors on the fields
/**
* Puts the server's validation errors on the form's fields. Here the request
* body has the form's shape, so an issue's path is the field's name
* (`["address", "zip"]` → `address.zip`). When the shapes differ, translate the
* path here. An issue without a path goes to the form as a whole.
*/
function showServerIssues<T extends FieldValues>(
form: UseFormReturn<T>,
issues: ServerIssue[],
) {
issues.forEach((issue, i) => {
const error = { type: "server", message: issue.message };
if (issue.path.length === 0) form.setError("root.server", error);
else
form.setError(issue.path.join(".") as FieldPath<T>, error, {
shouldFocus: i === 0,
});
});
}
An issue's path is the field's name, nested ones included: ["address", "zip"] becomes address.zip. Rules only the server can check (an email already in use) come back the same way, so the form needs no special case for them.
Create and edit
function NewContact({ onDone }: { onDone: () => void }) {
return (
<ContactForm
title="New contact"
defaultValues={emptyContact}
submitLabel="Add contact"
busyLabel="Adding"
save={(values) =>
fetchCuple(client.createContact.post, { body: values }).thenKeep([
"success",
"invalid-body",
"transport-error",
])
}
onSaved={onDone}
onCancel={onDone}
/>
);
}
function EditContact({ id, onDone }: { id: number; onDone: () => void }) {
const found = useGet(
client.getContact,
{ params: { id } },
{ resolveAlso: ["contact-not-found"] },
);
if (found.result === "contact-not-found")
return <p className="muted">This contact was deleted.</p>;
const { name, email, address } = found.contact;
return (
<ContactForm
title={`Edit ${name}`}
// Loaded before the form mounts, so react-hook-form starts with the real values.
defaultValues={{ name, email, address }}
submitLabel="Save changes"
busyLabel="Saving"
save={(values) =>
fetchCuple(client.updateContact.put, {
params: { id },
body: values,
}).thenKeep(["success", "invalid-body", "transport-error"])
}
onSaved={onDone}
onCancel={onDone}
/>
);
}
- The edit form loads its contact before it mounts, so react-hook-form starts with the real values. There's no
resetafter load, and no flash of an empty form. - Each contact's form has its own
key, so opening another contact starts a fresh form.
Delete
function ContactRow(props: {
contact: { id: number; name: string; email: string };
onEdit: (id: number) => void;
onDeleted: (id: number) => void;
}) {
const remove = useAction(
async () => {
await fetchCuple(client.deleteContact.delete, {
params: { id: props.contact.id },
}).thenKeepSuccess();
props.onDeleted(props.contact.id); // close its form if it's open
},
{ refresh: refreshes.contacts, config: { errors: { onError: "notify" } } },
);
return (
<li className={remove.isPending ? "row pending" : "row"} aria-busy={remove.isPending}>
<span className="grow">
{props.contact.name} <span className="muted">{props.contact.email}</span>
</span>
<button type="button" onClick={() => props.onEdit(props.contact.id)}>
Edit
</button>
<BusyButton
busy={remove.isPending}
busyLabel="Deleting"
onClick={() => remove.run()}
>
Delete
</BusyButton>
</li>
);
}
Deleting the contact whose form is open closes the form inside the action, so the deleted contact is never refetched.