Full-page form
A create or edit form on its own route, with no dialog.
Not every form belongs in a dialog. A dedicated route suits long forms, and gives users a URL they can share and return to.
useCrudForm does not care where it renders: without a DialogLockProvider above it, the dialog lock is simply a no-op.
This recipe uses the actions from Add, edit and delete an entity.
A form for create and edit
"use client";
import { useCrudForm } from "submitly";
import { createProduct, updateProduct } from "@/app/actions";
import type { Product } from "@/lib/db";
import { productSchema, type ProductInput } from "@/lib/schema";
function toFormValues(product: Product): ProductInput {
return { name: product.name, price: String(product.price), sku: product.sku ?? "" };
}
export function ProductPageForm({ product }: { product?: Product }) {
const { form, onSubmit, serverErrors, isSubmitting } = useCrudForm({
schema: productSchema,
defaultValues: product ? toFormValues(product) : { name: "", price: "", sku: "" },
action: (values) => (product ? updateProduct({ ...values, id: product.id }) : createProduct(values)),
// Land on the saved entity's page. The page is about to change, so do not reset.
redirectTo: (result) => (result.data ? `/products/${result.data.id}` : null),
resetOnSuccess: false,
// Validate each field when the user leaves it, instead of only on submit.
formOptions: { mode: "onBlur" },
});
const { errors, isDirty } = form.formState;
return (
<form onSubmit={onSubmit} noValidate>
{serverErrors.length > 0 && (
<ul role="alert">
{serverErrors.map((message, index) => (
<li key={index}>{message}</li>
))}
</ul>
)}
<label>
Name
<input autoComplete="off" {...form.register("name")} />
</label>
{errors.name && <p>{errors.name.message}</p>}
<label>
Price
<input inputMode="decimal" {...form.register("price")} />
</label>
{errors.price && <p>{errors.price.message}</p>}
<button type="submit" disabled={isSubmitting || (Boolean(product) && !isDirty)}>
{isSubmitting ? "Saving..." : "Save"}
</button>
</form>
);
}The routes
The create page renders the form with no entity. The edit page loads the entity on the server and passes it down. Give
the form a key so that navigating between two products remounts it with the new values: react-hook-form reads
defaultValues once, on mount.
import { ProductPageForm } from "@/components/product-page-form";
export default function NewProductPage() {
return (
<main>
<h1>New product</h1>
<ProductPageForm />
</main>
);
}import { notFound } from "next/navigation";
import { ProductPageForm } from "@/components/product-page-form";
import { db } from "@/lib/db";
export default async function EditProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = db.get(Number(id));
if (!product) notFound();
return (
<main>
<h1>Edit {product.name}</h1>
<ProductPageForm key={product.id} product={product} />
</main>
);
}Notes
- Server errors. Field errors from the server (a duplicate name) appear on the field, and form-level
errorsin the alert list, exactly as in a dialog. - After a save.
redirectToruns after the success toast andonMutated. Skip the redirect (omitredirectTo) to stay on the page; keepresetOnSuccessat its defaulttrueto clear a create form for the next entry, or set it tofalseto keep the saved values on screen after an edit. - Leaving with unsaved changes.
form.formState.isDirtytells you when the form has unsaved edits. Use it with your router's blocking API or abeforeunloadhandler if you need a "discard changes?" prompt; submitly does not handle navigation guards.