Skip to main content

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): run resolves when the save and its refresh are done, and never rejects, so react-hook-form's isSubmitting covers 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() after run resolves, not inside the action. reset also clears isSubmitting, 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 reset after 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.