Lesson 7 Guide — Forms with react-hook-form and the Shared Create/Edit Pattern¶
Goal: by the end of this lesson you have the Menu Item form — one component that
handles both Create and Edit, built with react-hook-form, validating fields,
and populating a Category FK dropdown from a fetch — wired to thin MenuItemCreatePage
and MenuItemEditPage route targets. You'll also learn the validation rule vocabulary
and why client validation is UX, not a security boundary.
The general pattern you're learning: create and edit are one form. A form registers
its fields with react-hook-form, loads default values (empty for create, the fetched record
for edit), validates on submit, and on success POSTs (no id) or PUTs (has id) then navigates
away. An FK dropdown is a <select> whose options are fetched from another entity. This
is the single most repeated pattern in the app.
How to use this guide. Sections headed ▶ Code along are ones you build into your project — type them as you go, and each ends with a quick Save and check. Unmarked sections are concept: read them (or watch). Each code block carries its file name as a title bar. The Build Steps at the end recap every ▶ Code along action.
Prerequisite: your API is running on the HTTP profile (
http, nothttps) with Menu Items and Categories seed data loaded (the form's dropdown fetches categories), and you have the Menu Items list + shell from Lessons 4–6.
1. Why react-hook-form¶
(Read this.)
You could wire each input with useState and an onChange (a "controlled input"). For a
multi-field form that's a lot of boilerplate and a re-render on every keystroke.
react-hook-form manages it: you register each field, and it tracks values, runs
validation, and hands you the final object on submit — with minimal re-rendering. Every form
in the app uses it. The pieces from useForm:
import { useForm, SubmitHandler } from "react-hook-form";
const {
register, // wire an input into react-hook-form's tracking
handleSubmit, // wraps your submit handler; runs validation first
formState: { errors }, // per-field validation errors
} = useForm<IMenuItem>({ defaultValues: async () => { /* … */ } });
formState: { errors }— that's nested destructuring, not a rename. The{ }after the colon means "reach intoformStateand pull out justerrors" — you get anerrorsvariable, not aformStateone. (A plain name would rename instead:formState: fshands you the whole object asfs.) Anderrorsis shorthand forerrors: errors— same property and variable name, so you write it once.useFormreturnsformStatewith lots of fields (isSubmitting,touchedFields, …); this grabs only the one you need.
2. Registering a field — what register actually returns¶
(Read this — you'll apply it in section 3.)
The one function you use on every input is register. Before the shorthand, it helps to
see what it hands back. register("name") returns a plain object of the props an input
needs to be tracked — log it and look:
Four things, each a piece the input needs:
name— the field's name ("name"), also the key react-hook-form stores its value under (it matches yourIMenuItemproperty).onChange— the handler react-hook-form uses to record the value as the user types.onBlur— fires when the field loses focus, so rhf knows it's been "touched."ref— a callback ref that gives react-hook-form direct access to the DOM<input>to read its value. (Reading the input directly, instead of re-rendering on every keystroke, is what makes rhf fast.)
Wire it by hand first, so there's no magic. You could take that object and set each prop on the input yourself:
const nameFieldProps = register("name"); // { name, onChange, onBlur, ref }
<input
id="name"
name={nameFieldProps.name}
ref={nameFieldProps.ref}
onChange={nameFieldProps.onChange}
onBlur={nameFieldProps.onBlur}
/>
That works — but setting four props on every field is tedious, and register returns
exactly the object you'd spread. So the shortcut does all four at once:
{...register("name")} is just the spread operator (Lesson 5) copying every property of
that returned object onto the <input> — same result, no boilerplate. This is how you'll
write every field from here on.
Adding the validation rules¶
register takes a second argument — a rules object — that declares validation.
errors (from formState) then drives a conditional is-invalid class + an
invalid-feedback message (the Lesson 6 conditional pattern, applied to validation):
<input
id="name"
{...register("name", { required: "Name is required" })}
className={`form-control ${errors?.name && "is-invalid"}`}
/>
<div className="invalid-feedback">{errors?.name?.message}</div>
- The rules object declares validation; the string is the message shown on failure.
errors?.name && "is-invalid"turns the field red only when it has an error;errors?.name?.messagerenders that message below it.
The rule vocabulary (you'll meet all four across the app's forms):
| Rule | Example | Notes |
|---|---|---|
required |
{ required: "Name is required" } |
nearly every field |
maxLength |
{ maxLength: { value: 50, message: "Too long" } } |
a rule with a message, not the native maxlength attribute |
min |
{ min: { value: 1, message: "At least 1" } } |
e.g. quantity |
pattern |
{ pattern: { value: /.../, message: "Invalid email" } } |
e.g. email format |
Two subtleties worth knowing:
valueAsNumber: trueon number/<select>fields returns a number, not a string:{...register("price", { valueAsNumber: true, required: "Price is required" })}.maxLengthis a rule, not the HTMLmaxlengthattribute. The native attribute blocks typing past the limit (and truncates a paste), so its message can never fire. The react-hook-form rule lets an over-long value in and then shows the message — which is what makes the "paste a long string and watch the error" demo possible.
3. ▶ Code along — Build the shared Menu Item form¶
First add the CRUD calls to the API module:
export const menuItemAPI = {
... // list (Lesson 4)
+ find(id: number): Promise<IMenuItem> {
+ return fetch(`${url}/${id}`).then((response) => response.json());
+ },
+ post(menuItem: IMenuItem): Promise<IMenuItem> {
+ return fetch(url, {
+ method: "POST",
+ body: JSON.stringify(menuItem),
+ headers: { "Content-Type": "application/json" },
+ }).then((response) => response.json());
+ },
+ put(menuItem: IMenuItem): Promise<IMenuItem> {
+ return fetch(`${url}/${menuItem.id}`, {
+ method: "PUT",
+ body: JSON.stringify(menuItem),
+ headers: { "Content-Type": "application/json" },
+ }).then((response) => response.json());
+ },
};
The form's Category dropdown fetches its options from another entity — Categories — so before the form, two small additions.
First, create the minimal Categories files the dropdown reads from — an interface and a
list(). You build the rest of Categories (find, delete, the cards, the form) in the
Lesson 8–10 labs; here you need just enough to fill the dropdown:
export interface ICategory {
id: number | undefined;
name: string;
sortOrder: number;
}
import { ICategory } from "./ICategory";
const url = "http://localhost:5556/api/categories";
export const categoryAPI = {
list(): Promise<ICategory[]> {
return fetch(url).then((response) => response.json());
},
};
Then extend IMenuItem with the category fields you deferred in Lesson 3 — now that
ICategory exists to import:
+ import { ICategory } from "../categories/ICategory";
+
export interface IMenuItem {
id: number | undefined;
name: string;
price: number | undefined;
+ categoryId: number | undefined;
+ category: ICategory | undefined;
}
Now the form — one component for both create and edit:
import { Link, useNavigate, useParams } from "react-router-dom";
import bootstrapIcons from "../assets/bootstrap-icons.svg";
import { useForm, SubmitHandler } from "react-hook-form";
import { useState } from "react";
import { IMenuItem } from "./IMenuItem";
import { ICategory } from "../categories/ICategory";
import { menuItemAPI } from "./MenuItemAPI";
import { categoryAPI } from "../categories/CategoryAPI";
import toast from "react-hot-toast";
const emptyMenuItem: IMenuItem = {
id: undefined, name: "", price: undefined, categoryId: undefined, category: {} as ICategory,
};
function MenuItemForm() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [categories, setCategories] = useState<ICategory[]>([]);
async function loadCategories() {
setCategories(await categoryAPI.list());
}
const { register, handleSubmit, formState: { errors } } = useForm<IMenuItem>({
defaultValues: async () => {
await loadCategories(); // dropdown options, either mode
if (!id) return emptyMenuItem; // Create → blank record
return await menuItemAPI.find(Number(id)); // Edit → fetch the record
},
});
const save: SubmitHandler<IMenuItem> = async (menuItem) => {
try {
delete menuItem.category; // send categoryId, not the embedded object
if (!menuItem.id) await menuItemAPI.post(menuItem); // no id → Create
else await menuItemAPI.put(menuItem); // has id → Edit
} catch (error: any) {
toast.error(error.message, { duration: 6000 });
return;
}
toast.success("Successfully saved.");
navigate("/menuitems");
};
return (
<form className="d-flex flex-wrap w-75 gap-2" onSubmit={handleSubmit(save)}>
<div className="mb-3 w-75">
<label htmlFor="name" className="form-label">Name</label>
<input id="name" type="text"
{...register("name", { required: "Name is required" })}
className={`form-control ${errors?.name && "is-invalid"}`} />
<div className="invalid-feedback">{errors?.name?.message}</div>
</div>
<div className="mb-3 w-25">
<label htmlFor="price" className="form-label">Price</label>
<input id="price" type="number" step="0.01"
{...register("price", { valueAsNumber: true, required: "Price is required" })}
className={`form-control ${errors?.price && "is-invalid"}`} />
<div className="invalid-feedback">{errors?.price?.message}</div>
</div>
<div className="mb-3 w-50">
<label htmlFor="categoryId" className="form-label">Category</label>
<select id="categoryId"
{...register("categoryId", { valueAsNumber: true, required: "Category is required" })}
className={`form-select ${errors?.categoryId && "is-invalid"}`}>
<option value="">Select Category…</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<div className="invalid-feedback">{errors?.categoryId?.message}</div>
</div>
<div className="d-flex justify-content-end w-100 mt-4">
<Link to="/menuitems" className="btn btn-outline-primary me-2">Cancel</Link>
<button type="submit" className="btn btn-primary">
<svg className="bi pe-none me-2" width={16} height={16} fill="#FFFFFF">
<use xlinkHref={`${bootstrapIcons}#save`} />
</svg>
Save menu item
</button>
</div>
</form>
);
}
export default MenuItemForm;
What's
toast?react-hot-toast(installed back in Lesson 3) shows a small, auto-dismissing pop-up for feedback —toast.success(...)after a good save,toast.error(...)in acatch. One catch: it only renders if a<Toaster>component is mounted at the app root, which you add in Lesson 11 (and Lesson 12 covers toasts + error handling properly). So these calls are wired now but won't show anything on screen until Lesson 11 — that's expected, not a bug. Here, navigating back to the list on success is your real confirmation for now.
The three ideas that make it one form for both modes:
defaultValues(async) is the create-vs-edit switch. No:idin the URL → returnemptyMenuItem(blank fields). An:id→menuItemAPI.find(id)→ the record's values pre-fill every registered field automatically. Either wayloadCategories()runs first so the dropdown has options.- The FK dropdown is a
<select>whose<option value={c.id}>{c.name}</option>come from the fetchedcategories.valuecarries the id (stored ascategoryId); on edit the record'scategoryIdauto-selects the matching option. savedecides create vs. edit by!menuItem.id— no id →post(Create), id →put(Edit).delete menuItem.categorydrops the nested nav object before sending. (See thetoastnote above — the calls are here for completeness; toasts render from Lesson 11 and are covered in Lesson 12.)
Try it — see what
registerreturns (ties back to §2). Temporarily drop aconsole.log(register("name"))just above thereturn. Once the routes exist (next section) and you open the form, the DevTools Console shows the{ name, onChange, onBlur, ref }object that{...register("name")}spreads onto the input — the manual wiring from §2, now live. Delete the log once you've seen it.
Save and check
- Nothing to open yet — the form has no routes until the next section.
- Check the editor — no red errors in
MenuItemForm.tsxorMenuItemAPI.ts.
Want to watch react-hook-form track the form as you type? Section 7 is an optional look under the hood, once the form is working.
4. ▶ Code along — Thin Create/Edit pages + routes¶
The route targets are tiny — a heading plus <MenuItemForm />. Both render the same form;
the URL (with or without :id) makes it create or edit:
import MenuItemForm from "./MenuItemForm";
function MenuItemCreatePage() {
return (
<section className="content container-fluid mx-5 my-2 py-4">
<div className="d-flex justify-content-between pb-4 mb-5 border-bottom border-2">
<h2>New Menu Item</h2>
</div>
<MenuItemForm />
</section>
);
}
export default MenuItemCreatePage;
MenuItemEditPage is identical but titled "Edit Menu Item". Add both routes under Layout
in main.tsx:
+ import MenuItemCreatePage from "./menuItems/MenuItemCreatePage";
+ import MenuItemEditPage from "./menuItems/MenuItemEditPage";
...
children: [
{ path: "menuitems", element: <MenuItemsPage /> },
+ { path: "menuitems/create", element: <MenuItemCreatePage /> },
+ { path: "menuitems/edit/:id", element: <MenuItemEditPage /> },
...
],
The :id in the edit path is what useParams reads in the form.
Now reach the form from the list — the routes exist, but nothing links to them yet. Add an
Add Item button to MenuItemsPage and an Edit item to a MenuItemCard ⋮ dropdown
(the same react-bootstrap Dropdown + sprite-icon pattern as Lesson 6's OrderRow):
+ import { Link } from "react-router-dom";
...
function MenuItemsPage() {
...
return (
<section className="content container-fluid mx-5 my-2 py-4">
- <h2 className="pb-4 mb-4 border-bottom border-2">Menu</h2>
+ <div className="d-flex justify-content-between pb-4 mb-4 border-bottom border-2">
+ <h2>Menu</h2>
+ <Link to="/menuitems/create" className="btn btn-primary">Add Item</Link>
+ </div>
...
</section>
);
}
+ import { Link } from "react-router-dom";
+ import Dropdown from "react-bootstrap/Dropdown";
+ import bootstrapIcons from "../assets/bootstrap-icons.svg";
...
function MenuItemCard({ menuItem }: IMenuItemCardProps) {
return (
<div className="card p-4" style={{ width: "23rem" }}>
+ <div className="d-flex justify-content-end">
+ <Dropdown>
+ <Dropdown.Toggle className="btn btn-light" style={{ background: "none" }}>
+ <svg className="bi pe-none" width={20} height={20} fill="#007AFF">
+ <use xlinkHref={`${bootstrapIcons}#three-dots-vertical`} />
+ </svg>
+ </Dropdown.Toggle>
+ <Dropdown.Menu>
+ <Dropdown.Item as={Link} to={`/menuitems/edit/${menuItem.id}`}>Edit</Dropdown.Item>
+ </Dropdown.Menu>
+ </Dropdown>
+ </div>
<span className="fs-4 fw-medium">{menuItem.name}</span>
<span className="fs-5 fw-light">${menuItem.price}</span>
</div>
);
}
(A Delete item joins this same ⋮ dropdown in Lesson 12, where menuItemAPI.delete +
toasts are added.)
Save and check
- From
/menuitems, click Add Item —/menuitems/createshows an empty form, with the Category dropdown listing your seeded categories. - Save with Name empty — the field turns red with "Name is required", and no network call fires.
- Fill in a name, price, and category, then Save — back to the list with the new card;
Network shows a 201
POST. - Open a card's ⋮ → Edit — the form is pre-filled; change the price and Save for a
200
PUT.
5. Client validation is UX, not a security boundary¶
(Read this — an important idea, plus a quick demo.)
react-hook-form's validation runs in the browser. It's UX — it guides the user to a valid entry before a request goes out. It is not a security boundary: anyone can bypass it. Two ways to see that:
- Disable JavaScript (DevTools → Command Palette → "Disable JavaScript") and the form's checks are simply gone.
- Call the API directly in Insomnia (from the API pass) — nothing on the client is even involved.
A production app validates again on the server and enforces auth there. This course
deliberately leaves those out — the same intentional simplifications as the API pass: no
DTOs, no server-side request validation, no [Authorize]. So treat the react-hook-form rules
as the friendly first line (good UX), and know that the real line — server validation and
authorization — is out of scope here on purpose. When you build the PRS forms, the same
applies: the client rules are for the user, not for security.
6. Verifying in the browser¶
You've checked each piece as you built it; this is the full pass. With your API running and
npm run dev up:
- From
/menuitems, Add Item → empty form, Category dropdown populated. Save empty → inline validation, no request. Fill + Save → back to the list, 201POSTin Network. - ⋮ → Edit a card → every field pre-filled (including the category); change + Save →
200
PUT. - Confirm create and edit are the same form — only the URL's
:iddiffers. - (Optional) Disable JavaScript and submit an empty form — the client checks vanish (the section 5 point).
- Console clean.
7. Under the hood — watch react-hook-form work (optional)¶
(Optional. The form is built, reachable, and verified — this is a look inside it. Skip it if you're satisfied and move on to the pattern recap.)
Now that you can open the form, you can see what react-hook-form is doing as you interact
with it — the register object from §2 and the value/touched/error tracking, live.
See what register returns (ties back to §2). Temporarily drop a
console.log(register("name")) just above the return, open the form, and the DevTools
Console shows the { name, onChange, onBlur, ref } object that {...register("name")}
spreads onto the input — the manual wiring from §2, now live. Delete the log once you've seen
it.
Watch the form's state change as you type. To see what rhf tracks moment to moment, pull a
few extra pieces from useForm and drop a debug <pre> inside the form. This is a throwaway
teaching aid — you'll delete it once it clicks.
So you don't lose your working form, comment out your original useForm and paste this
debug version right below it — reverting is then just "delete this, uncomment that." It pulls
watch and two more formState fields, and adds mode: "onTouched" so errors validate as
you go instead of only on submit:
function MenuItemForm() {
... // useParams, categories state, loadCategories — all unchanged
// ── TEMP debug view: comment out your original useForm, paste this below it ──
// const { register, handleSubmit, formState: { errors } } = useForm<IMenuItem>({
// defaultValues: async () => { … },
// });
const {
register, handleSubmit, watch, // added: watch
formState: { errors, touchedFields, dirtyFields }, // added: touchedFields, dirtyFields
} = useForm<IMenuItem>({
mode: "onTouched", // added: validate on blur/change → errors show live
defaultValues: async () => {
/* … same as your original … */
},
});
... // save handler, then the return (add the <pre> next)
}
Then add a debug <pre> just inside the <form> return, above the first field:
return (
<form className="d-flex flex-wrap w-75 gap-2" onSubmit={handleSubmit(save)}>
{/* 🔎 TEMPORARY — delete once you've seen how rhf tracks the form */}
<pre className="bg-light border rounded p-3 small w-100">
{JSON.stringify(
{
values: watch(), // updates as you TYPE (onChange)
touched: Object.keys(touchedFields), // appears when you BLUR (leave) a field
dirty: Object.keys(dirtyFields), // appears once a value differs from the default
errors: Object.fromEntries(
Object.entries(errors).map(([field, e]) => [field, e?.message]),
),
},
null,
2,
)}
</pre>
... {/* your Name, Price, and Category fields — unchanged */}
</form>
);
Interact with the form while watching the <pre>:
- Type in a field →
valuesupdates on every keystroke (that'sonChange), and the field name appears indirty. - Click into a field, then click out without a valid value → the field name appears in
touched(that'sonBlur), anderrorsgains its message (becausemode: "onTouched"). - Fix the field → its error disappears.
That's the difference between change (value tracking → values/dirty) and blur
(→ touched, where errors first appear), live — the onChange/onBlur from the register
object in §2, doing their jobs. (watch() is the same hook you'll use for real in Lesson 10's
derived fields.) To revert when you're done: delete the <pre> and the debug useForm,
then uncomment your original — the reference form validates on submit, without any of this.
The General Pattern (what to take away)¶
- One
Formcomponent serves create and edit.defaultValuesreturns a blank object when there's no:id, or the fetched record when there is;savePOSTs when!id, PUTs otherwise. - react-hook-form:
{...register("field", rules)}wires + validates each input;handleSubmit(save)runs validation then calls you;errors.fielddrives theis-invalid/invalid-feedbackconditional display. Rules:required,maxLength(rule, not attribute),min,pattern;valueAsNumberfor numbers. - FK dropdown = a
<select>whose<option value={id}>{name}</option>come from a fetched list. - Thin
CreatePage/EditPagewrappers render the same form; the route (:idor not) decides its mode. - Client validation is UX, not security — it's bypassable; the server (out of scope here) is the real boundary.
On PRS, the Product form is this exact pattern with a Vendor FK dropdown; the User
form has no FK (role checkboxes) and maxLength rules; the Request form is a shared
create/edit form too.
Build Steps¶
- Add
find(id),post(item),put(item)toMenuItemAPI.ts. ExtendIMenuItemwithcategoryId+category(deferred in Lesson 3), and create the minimalcategories/ICategory.ts+categories/CategoryAPI.ts(list()only) that the dropdown fetches from — you flesh Categories out into full CRUD in the Lesson 8–10 labs. - Build
MenuItemFormwithuseForm<IMenuItem>; read:idwithuseParams; indefaultValues(async)loadCategories()then returnemptyMenuItem(no:id) ormenuItemAPI.find(id). - Register Name (required), Price (required,
valueAsNumber), and the Category FK<select>(valueAsNumber, required) fromcategoriesstate; showerrorsviais-invalid+invalid-feedback. - Write
save(handleSubmit):delete menuItem.category, POST when!idelse PUT, thennavigate("/menuitems"). Check create + edit both work (201 / 200). - Create thin
MenuItemCreatePage/MenuItemEditPageand add both routes underLayout. - Add an Add Item button on
MenuItemsPage(→/menuitems/create) and an Edit item in aMenuItemCard⋮ dropdown (→/menuitems/edit/:id) so the forms are reachable. (Delete joins that dropdown in Lesson 12.) - Verify using section 6, including the "disable JS" bypass from section 5.