Building Typed Product Forms
Building Typed Product Forms
Welcome back. In the previous lesson, you created the first admin product route, added Admin Products to the shared navbar, and built an internal catalog workspace that could load products, handle loading and empty states, and let the user select one product inside the page. That gave the project a real admin destination, but the workspace was still mostly about viewing and selecting products.
This lesson turns that workspace into something much more practical. You will build a reusable typed product form in src/components/admin/ProductForm.tsx, use it for both create and edit workflows, and connect those workflows to the existing admin page in src/components/admin/ProductAdminPageClient.tsx. The main idea is to make form state explicit, predictable, and reusable, so the admin screen can translate human-edited values into clean API-ready payloads without duplicating mapping logic in multiple places.
Previously
Last time, the admin area was introduced as a lightweight but structured workspace. src/app/admin/products/page.tsx stayed thin, src/components/layout/Navbar.tsx exposed the new internal route, and src/components/admin/ProductAdminPageClient.tsx reused useProducts() so the admin page could follow the same collection-loading patterns already trusted elsewhere in the app. The populated state also gained real product cards and local selectedProduct state so the workspace could respond to user selection.
That setup matters directly for this lesson. Because the page already knows how to load products and track which product is selected, it is now ready for form-driven workflows. Instead of adding random inputs directly inside the admin page, you will create a dedicated reusable form component and a set of helper functions that define the boundary between UI state and API payloads.
Defining a Stable Form Value Shape
The first important piece lives in src/components/admin/ProductForm.tsx. Before building any inputs, the file defines a stable interface for the form’s local state. This is a very good pattern in TypeScript React apps because it gives the component and its parent a shared, explicit vocabulary for what the form edits.
Here is the type definition:
-
ProductFormValuesis the local UI shape for the form, and that distinction matters. It is not exactly the same thing as a backend payload, because UI state often needs to stay easy to edit while API payloads usually need normalization and stricter formatting. -
The interface includes
sku,name,description,price_cents,inventory_count, andstatus, which gives the component a complete picture of the editable product fields required by this lesson. Keeping all of them together in one explicit type makes the form easier to reason about and much easier to reuse. -
Typing
statusasProduct['status']is a nice TypeScript detail because it reuses the domain model’s allowed status values instead of inventing a second status type. That keeps the form aligned with the rest of the project’s data model and reduces the chance of mismatched strings. -
A typed form value shape also improves parent-child communication. The parent page does not need to guess what the form submits, because both sides agree on the exact structure ahead of time.
Building a Reusable Initial Value Mapper
A reusable form becomes much more valuable when it can work in both create mode and edit mode. That is why src/components/admin/ProductForm.tsx includes a helper that converts an optional product into ProductFormValues.
Here is that helper:
-
toProductFormValues(product?)is one of the most important helpers in this lesson because it gives the form a single initialization path. If no product is provided, the form starts empty for create mode; if a product is provided, the form preloads that product for edit mode. -
This keeps the parent page clean. Without a helper like this,
ProductAdminPageClientwould have to manually map each product field into form state every time it wanted to render the form, which would create duplication and make the admin page harder to read. -
The fallback values are carefully chosen for editable UI state. Text fields become empty strings, number fields become
0, andstatusdefaults to'active', which gives the create form a stable starting state without requiring special-case rendering logic. -
The helper also reinforces a good frontend principle: the form component should understand how to shape its own editable values. Parent components should pass domain data in, but they should not have to micromanage field-by-field transformations.
Translating Form Values into Create Payloads
The next job of ProductForm.tsx is to define the translation boundary between human-edited form values and API-ready request bodies. The create flow needs a helper that normalizes fields before sending them to the backend.
Here is buildCreateProductInput(...):
-
This helper is not just a convenience function; it is a translation layer. The form is allowed to keep values in a user-friendly shape, while
buildCreateProductInput(...)is responsible for turning those values into the exact payload expected by the create API. -
Trimming
skuandnameis an important normalization step because identifiers and names should not accidentally include leading or trailing whitespace from user input. This kind of cleanup belongs close to the submit boundary, where raw editable values become committed data. -
description: values.description.trim() || nullis especially useful. It lets the form store description as a normal editable string, but if the user leaves it blank, the API payload sendsnullinstead of an empty string, which is often a more meaningful representation of “no description.” -
Including
currency: 'USD'directly in the helper makes the create payload complete without requiring the form UI to expose a currency field. That is a good example of business rules living in the transformation layer rather than being scattered across the page component.
Translating Form Values into Update Payloads
The update flow uses almost the same normalization rules, but it adds one important difference: edit mode can change a product’s operational status. That is why there is a separate update helper instead of reusing the create helper blindly.
Here is buildUpdateProductInput(...):
-
This helper mirrors the create helper closely, which is good because both flows should normalize shared fields in the same way. Consistency between create and update logic makes the admin system easier to understand and less error-prone.
-
The biggest difference is that the update payload includes
status. That reflects the workflow of the admin page: in edit mode, administrators are allowed to change operational state, so the update helper must carry that information to the backend. -
Notice that
skuis not included here. That aligns with the edit workflow later in the file, where the SKU field can be disabled so identifiers created up front are not casually changed during routine edits. -
Keeping create and update transformations as named helpers is a very strong design choice. It gives the page readable intent — “build create input” and “build update input” — while centralizing the rules that shape outbound API data.
Turning the Form into a Controlled Component
Once the helpers are defined, ProductForm itself creates local state from the incoming values prop. This is what makes the inputs controlled and editable, while still allowing the parent to choose the starting values.
Here is the component signature and local state setup:
-
The component receives
valuesfrom the parent, but immediately aliases that prop toinitialValues. That naming is helpful because once the form creates local state, the prop is no longer the current editable value — it is the source used to initialize or reset the form state. -
useState<ProductFormValues>(initialValues)makes the form controlled. In React, a controlled form means the input values come from component state, and each user edit updates that state explicitly throughsetValues(...). -
This controlled pattern is ideal for admin forms because it makes the relationship between UI and data very clear. Every field has a known value, every change is explicit, and the full form can be submitted as one stable object.
-
The prop surface is also intentionally clean. The form gets a title, initial values, submit label, submit handler, optional SKU disabling, and optional submission state — enough to support both create and edit workflows without clutter.
Resetting the Form When Parent Values Change
A reusable form needs to respond when the parent wants to edit a different product. That is why ProductForm includes an effect that resets local state whenever initialValues changes.
Here is that effect:
-
This effect is extremely important for edit mode. If the parent selects a different product in the admin page, the edit form should update to reflect the newly selected product instead of continuing to show the old one’s values.
-
Without this effect, the component would only use the original
initialValuesfrom its first render. That would be a common and frustrating bug in reusable forms: the parent changes the intended record, but the form UI remains stuck on stale local state. -
This is a good React lesson in general. When a component creates local editable state from props, it often also needs a synchronization rule for when those source props change meaningfully.
-
In this case, the rule is simple and appropriate: whenever the parent hands the form a new
initialValuesobject, the form resets its local state to match.
Handling Submit in One Place
The form then defines a small submit handler that prevents the browser’s default form submission and forwards the current controlled values through the provided callback.
Here is handleSubmit(...):
-
event.preventDefault()is required because the form is being handled in client-side React rather than letting the browser perform a full page reload submission. This keeps the admin workflow smooth and consistent with the rest of the app’s interactive behavior. -
await onSubmit(values)passes the current controlled form state back to the parent. That is exactly the right responsibility split: the form owns editing and input collection, while the parent page owns the actual mutation workflow. -
This makes the form reusable across create and edit flows. The component itself does not need to know whether it is creating a new product or updating an existing one — it simply submits
ProductFormValues, and the parent decides what that means. -
Keeping the submit handler this small also improves readability. The form is focused on being a polished UI surface, not on owning API mutation details.
Building the Form Fields
Now the component renders the actual field layout. This is where the typed form values become visible in the UI through controlled inputs for SKU, name, description, price, inventory count, and status.
Here is the beginning of the form layout and the SKU and name fields:
-
The form itself uses a standard
<form onSubmit={handleSubmit}>wrapper, which means pressing Enter or clicking the submit button both go through the same controlled submission path. That is cleaner and more accessible than wiring everything through button click handlers alone. -
The field layout is responsive through
grid gap-4 md:grid-cols-2, which helps the admin form feel like a real workspace tool rather than a temporary scaffold. Even though the lesson focuses heavily on typing and transformations, the final result still presents as a polished interface. -
The SKU field respects
disableSku, and that is a key workflow detail. In create mode, the admin can set a new identifier; in edit mode, the field can be locked so the form does not casually invite changes to identifiers that should remain stable. -
Each
onChange(...)is simple and explicit, updating exactly one key in thevaluesobject. This is excellent for learning because it makes the mapping between inputs andProductFormValuesimmediately visible.
Here is the description field:
-
The description spans both columns with
md:col-span-2, which gives the text area more room and matches how multiline content is usually handled in admin forms. This makes the form feel more intentional and easier to use. -
Even though description eventually gets normalized to
nullwhen empty, the controlled field still stores it as a string during editing. That is exactly the kind of UI/API distinction the transformation helpers were created to handle. -
The textarea shows a useful separation of responsibilities: the form is concerned with editing ergonomics, while the payload builder is concerned with final data normalization.
Here are the numeric fields for price and inventory:
-
Both fields use
type="number"andmin={0}, which makes the intended input constraints visible in the UI. This helps communicate that price and inventory are numeric operational fields, not arbitrary text. -
Converting with
Number(event.target.value)keeps the controlled state aligned with the numeric types declared inProductFormValues. That means the form state stays closer to the domain model instead of storing numeric-looking strings everywhere. -
These handlers are intentionally direct and readable. Rather than abstracting numeric field updates into a generic helper too early, the component makes each mapping explicit, which is often better for clarity in teaching-oriented code.
Here is the status field:
-
The status field uses a
<select>because the set of valid values is constrained. That is a better fit than a free-text input for operational state, where the UI should guide users toward known valid options. -
Casting
event.target.value as Product['status']keeps the update aligned with the typed form state. Since DOM event values come through as strings, this cast tells TypeScript that the select is intentionally producing one of the allowed product statuses. -
Putting status in the form matters especially for edit mode, where administrators may need to archive or reactivate a product as part of catalog management. It helps the same form support both current workflows cleanly.
Finishing the Submit Button Behavior
The last piece of the form is the submit button, which should behave correctly during async saves.
Here is the button:
-
type="submit"is important because it keeps the button tied to the form’s submit event rather than making it a generic clickable element. That preserves standard form behavior and keeps submission centralized inhandleSubmit(...). -
disabled={isSubmitting}prevents duplicate submissions while an async save is in progress. This is a simple but important trust-building detail in admin tools, where accidental double-submits can create confusing or costly results. -
Switching the label to
'Saving...'gives immediate progress feedback inside the exact control the user clicked. That is a small UX detail, but it makes the form feel much more polished and responsive. -
The layout class
w-full md:w-autoalso helps the component feel production-ready. On small screens the button spans the available width, while on larger screens it shrinks to fit naturally inside the form layout.
Wiring Create and Update Flows into the Admin Page
With the reusable form built, src/components/admin/ProductAdminPageClient.tsx can now use it for both create and edit workflows. The page adds local state for selection and for the two separate mutation flows.
Here is the top of the file:
-
ProductAdminPageClientnow imports the form component together with its helper functions. That is exactly the kind of reuse this lesson is aiming for: the page stays focused on workflow orchestration, while the form file owns the translation and input details. -
Separate
isCreatingandisUpdatingflags are a good design choice because the create form and edit form represent different async workflows. Keeping them separate avoids unnecessary coupling, so one side of the workspace does not have to appear blocked just because the other side is saving. -
useToast()is especially useful in admin flows because users often stay on the same page after a mutation. Toast feedback acknowledges success or failure without forcing the persistent layout to grow extra status panels for every action.
Creating Products from Typed Form Values
The create handler uses the reusable form values and transformation helper to build the outbound API payload.
Here is handleCreate(...):
-
The handler receives form values, not a raw DOM event. That makes the code much easier to read because the form component has already done the work of collecting controlled state into a typed object.
-
buildCreateProductInput(values)is the exact translation boundary established earlier in the lesson. The page does not trim fields or normalize descriptions itself; it delegates that responsibility to the helper designed for that purpose. -
Calling
await refresh()after a successful create is very important. The page should trust the server as the source of truth for the latest catalog state instead of assuming the local list already reflects exactly what the backend saved. -
The success and failure toast calls make the admin workflow feel responsive without disrupting the overall page structure. This is a strong fit for internal tooling where the user often creates or updates multiple records in one sitting.
Updating the Selected Product
The update handler follows the same overall pattern, but it only makes sense when a product is currently selected in the workspace.
Here is handleUpdate(...):
-
The early
if (!selectedProduct) return;guard is an important defensive check. The edit workflow depends on a selected record, so the handler should refuse to proceed if that precondition is not met. -
updateProduct(selectedProduct.id, buildUpdateProductInput(values))keeps the page logic very readable. The code clearly says “update this product using this normalized payload,” which is much easier to understand than embedding field-by-field API mapping inline. -
Refreshing after a successful update matters for the same reason it mattered after create. The workspace should render the server-confirmed catalog state, especially because updates may affect fields like status, price, or inventory that are shown in the product list immediately below.
-
The success toast helps the user understand that the save completed without forcing them to inspect the list visually for confirmation. That small feedback loop makes admin editing more comfortable and more trustworthy.
Replacing the Placeholder Workspace with Real Forms
The main layout of the admin page now becomes a two-column workspace, with a create form on the left and either an edit form or an edit placeholder on the right.
Here is that section:
-
The page heading is updated to reflect the broader role of the workspace. This is no longer only about reviewing the catalog — it is now also about creating products and maintaining existing records.
-
The grid layout creates a real admin-workspace feel by putting create and edit surfaces alongside each other. That is a useful design move because it helps the page read as an operational tool instead of a single vertical stack of unrelated pieces.
-
The left form uses
toProductFormValues()with no product argument, which means it starts empty in create mode. This is exactly why the initialization helper was so useful: the page can express its intent clearly without hand-constructing every field.
Here is the conditional right side:
-
When
selectedProductexists, the right side becomes an edit form preloaded with that product’s values. This makes the selection state meaningful immediately, because clicking a product now swaps the workspace into a real edit mode. -
disableSkuis passed for edit mode, which enforces the workflow rule that identifiers should not be casually changed after creation. This is a very good example of how a small prop can express an important operational constraint clearly. -
When no product is selected, the dashed placeholder remains as guidance instead of leaving the right side blank. This keeps the two-column layout informative even before the user starts editing.
Updating the Product List to Enter Edit Mode
The bottom section of the page still handles collection states and renders product rows, but the populated state now includes an Edit button that actually drives the workspace.
Here is the loaded list section:
-
The loading, error, and empty-state logic stays intact, which is good because those page-state patterns were already doing useful work in the previous lesson. New functionality should extend a stable page, not throw away working structure unnecessarily.
-
The list cards still show the product name, status, SKU, formatted price, and inventory count. That means the admin page continues to function as a quick catalog overview even while it grows into a richer editing workspace.
-
The row button now says Edit and calls
setSelectedProduct(product), which gives the selection state a clear operational purpose. Clicking a product no longer just changes a helper message — it activates the right-side edit form for that specific record. -
This is a strong example of a page becoming more interactive while staying easy to reason about. The list drives selection, selection drives the edit workspace, and the typed form plus helper functions handle the editing flow cleanly.
Why the Form Helpers Matter So Much
The most important architectural idea in this lesson is that ProductForm does not just render fields — it defines a translation boundary. ProductFormValues describes editable UI state, toProductFormValues(...) initializes that state consistently, and buildCreateProductInput(...) plus buildUpdateProductInput(...) translate it into request payloads.
That boundary matters because it keeps responsibilities separated. The form owns editing ergonomics, the helpers own normalization, and ProductAdminPageClient owns workflow orchestration. When those responsibilities stay clear, the admin system becomes much easier to grow in later lessons.
Recap
In this lesson, you turned the admin product workspace into a real typed form-driven interface.
You started in src/components/admin/ProductForm.tsx, where ProductFormValues defined a stable local UI shape for the form, toProductFormValues(product?) gave the form a reusable initialization path for both create and edit modes, and buildCreateProductInput(...) plus buildUpdateProductInput(...) established the translation boundary from editable values to API-ready payloads. Then the form itself became a controlled component with local state, a reset effect for changing initial values, a submit handler, polished field layout, SKU disabling for edit mode, and loading-aware submit button behavior.
After that, src/components/admin/ProductAdminPageClient.tsx reused the form twice inside a two-column workspace. The page added separate isCreating and isUpdating flags, implemented handleCreate(...) and handleUpdate(...) with semantic API helpers plus refresh(), surfaced feedback through toasts, and replaced the old placeholder workspace with a create form on the left and a conditional edit form on the right. Finally, the product list’s row action became a real Edit workflow by storing the clicked product in selectedProduct.
The main takeaway is that a good admin form is not only a set of inputs. It is a typed, reusable boundary between user-edited values and normalized API payloads, and that boundary makes the surrounding page much easier to read, maintain, and extend.
