Tax API and Forms
Tax API and Forms
Welcome to the first unit of the new course, Tax Management and Full App Polish. In the previous course, you completed the first practical version of the admin product workspace. The app could already create, edit, archive, sort, and synchronize products, which meant the internal tooling had moved beyond simple data display and into real operational workflows. That was a major step because it established the same pattern you have used throughout the project: semantic API helpers, refresh-based UI state, small reusable components, and pages that stay focused on workflow instead of low-level request plumbing.
This new course shifts that same mindset toward tax management. In this lesson, you will start by building a small tax API module in src/lib/api/tax.ts, then create a reusable tax form in src/components/admin/TaxRateForm.tsx. The big idea is very similar to the product form lesson: keep the request layer compact and semantic, keep the form controlled and reusable, and make sure the component boundary stays clean so the page that uses the form can stay focused on higher-level workflow later.
Previously
Previously, the admin workspace became much more synchronized and trustworthy. src/components/admin/ProductAdminPageClient.tsx derived a sorted product list with useMemo, kept the selected product aligned with refreshed collection data through an effect, reset the create form by remounting it after successful creation, and added archive actions to the list. That work reinforced an important principle: internal tools feel reliable when they reflect canonical server state clearly and quickly.
This tax unit builds on the same frontend habits, but with a smaller surface area. Instead of a large two-column product workspace, you are starting with a tight tax API module and a compact tax-rate form. The lesson is intentionally focused: first define the named request helpers, then build a controlled form that can submit a tax rate cleanly and repeatedly.
Keeping the Tax API Module Small and Semantic
The file src/lib/api/tax.ts is intentionally compact. That is a very good design choice. API helper modules are easiest to use when they expose a small set of clear verbs that match the domain, rather than trying to contain state, UI logic, or transport setup scattered across components.
Here is the full tax API file:
-
The module imports
apiRequestandtoRequestInitfromsrc/lib/api/client.ts, which keeps it aligned with the shared request pattern used elsewhere in the app. That is important because page components should not have to remember how to set headers, stringify bodies, or buildRequestInitobjects themselves when the shared client already solves that problem. -
listTaxRates()returnsapiRequest<TaxRate[]>('/api/tax/rates'), which makes the collection read very explicit. TheTaxRate[]generic matters because it tells TypeScript and the rest of the UI that this helper resolves to an array of tax-rate records rather than a single object. -
getTaxRate(countryCode)rounds out the read surface even though the immediate form work does not rely on it yet. This is a good example of a helper that belongs in the module because it completes the tax read vocabulary and keeps future page code consistent with the rest of the API layer. -
upsertTaxRate(countryCode, input)usestoRequestInit('PUT', input), which is exactly the right pattern for mutation helpers in this project. The helper stays easy to read because it expresses intent — “upsert this tax rate” — while delegating transport details to the shared API client utilities. -
deleteTaxRate(countryCode)follows the same compact style withtoRequestInit('DELETE'). The function stays as direct and focused as the others, which keeps the whole module symmetrical and easy to scan. -
Just as importantly, notice what this file does not contain: no
useState, nouseEffect, no toasts, no UI conditions, and no local caching. This is purely a named request-helper module, and that narrow responsibility is one of its biggest strengths.
A page or component that later calls upsertTaxRate(...) or deleteTaxRate(...) will read much more clearly because the UI can stay focused on workflow instead of building URLs and request options inline.
Why the Shared API Client Matters Here
The compactness of src/lib/api/tax.ts depends on the shared logic already available in src/lib/api/client.ts. It is useful to briefly connect that foundation to this lesson, because the tax API helpers are only simple because the lower-level client has already centralized the noisy parts of request handling.
Here are the two most relevant helpers from the API client:
-
apiRequest<T>(...)already knows how to callfetch, attach headers, parse JSON, detect API error envelopes, and return thedatapayload from successful responses. Because that complexity is centralized, the tax module can stay clean and semantic instead of duplicating error and parsing logic. -
toRequestInit(...)is especially relevant for this lesson because it is what makesupsertTaxRate(...)anddeleteTaxRate(...)so concise. The mutation helpers do not need to remember how to stringify request bodies or construct theRequestInitshape manually. -
This reinforces a broader frontend lesson: semantic API helpers only stay simple when a lower-level shared client takes ownership of the repetitive transport work. That is exactly the pattern you have already used successfully in products and orders.
Starting the Tax Form with Controlled State
The second major file in this lesson is src/components/admin/TaxRateForm.tsx. This component begins by adding the two pieces of local state it needs: one for the country code and one for the numeric basis-points value. Those are the only two fields in the form, so the state model stays very compact.
Here is the top of the file:
-
useState('')forcountryCodeanduseState(0)forrateBpsgive the form a very clear controlled state model. In React, controlled inputs mean the rendered field values come directly from component state, and every user edit flows through an explicit state update. -
Controlled inputs are especially useful in admin interfaces because they keep the UI, the internal state, and the eventual submitted payload aligned. There is no ambiguity about what the current values are, because the form itself owns them directly.
-
The
onSubmitprop is a strong component-boundary decision. The form receives async behavior from the parent instead of hardcoding page-specific request logic internally, which keeps the form reusable and page-agnostic.
Building the Controlled Inputs
Once the local state exists, the form wires both fields as controlled inputs. This is where the visible UI and the internal state become tightly connected.
Here is the form layout and the two inputs:
-
The country-code input is controlled through
value={countryCode}andonChange={(event) => setCountryCode(event.target.value)}. That means every keystroke updates the component state immediately, keeping the rendered field and the internal value in sync. -
maxLength={2}is an important UI constraint because it communicates the expected shape of the field clearly. Even before any submission logic runs, the input itself helps the user understand that the value should be a two-character country code. -
The numeric rate field is also controlled, but its update path is slightly different:
setRateBps(Number(event.target.value)). Converting to a number as the value enters state is a very useful choice because it keeps the submit payload simpler and prevents the rest of the component from having to treat rate values as numeric-looking strings. -
type="number",min={0}, andmax={5000}provide helpful browser-level guidance about the expected range and shape of the tax rate value. That does not replace backend validation, but it does improve the editing experience and makes the field feel more intentional. -
The layout stays visually aligned with the rest of the admin interface by reusing the same rounded card, spacing, and border patterns used in other management forms. Even though the tax form is small, it still feels like it belongs beside the product-management tools from the previous course.
Implementing the Submit Handler in Two Stages
The submit handler is the most important behavior in this lesson, and it evolves in two clear stages conceptually. First, the form needs a basic submission structure: prevent the browser default and forward the current values to the parent. Then, it gets polished by normalizing the country code and resetting the form after the async submit succeeds.
Here is the final version of handleSubmit(...):
-
event.preventDefault()is the first required step because this is a client-controlled React form, not a traditional browser submission. Preventing the default keeps the page from reloading and allows the parent-provided async workflow to stay in control. -
The submitted payload uses
countryCode.trim().toUpperCase()before passing it toonSubmit(...). This normalization step is very useful because it makes the submitted country code predictable even if the user types lowercase letters or accidentally includes spaces around the value. -
rate_bps: rateBpsis passed through as-is from state. That is the correct choice for this lesson because the numeric input already provides the main shape constraints, and the focus here is not on extra numeric normalization logic. -
await onSubmit(...)is important because the form should wait for the parent’s async save flow to complete before clearing itself. This sequencing makes the component more pleasant for real admin work: the fields only reset after the submission has succeeded from the form’s perspective. -
setCountryCode('')andsetRateBps(0)reset the form after the awaited submission finishes. That makes repeated entry much smoother, which is especially important in an internal operations tool where an admin may add or update multiple tax rates in one session. -
It is also useful to notice what stays unchanged. Even after normalization and reset logic are added, the component remains typed, reusable, and page-agnostic. It still delegates the actual async behavior to the parent through
onSubmit(...)rather than taking on page-specific responsibilities itself.
Finishing the Submit Button Behavior
The final visible piece of the component is the submit button, which uses the shared Button component and reflects async saving state clearly.
Here is the button:
-
type="submit"ensures that the button participates in the form’s submission flow rather than acting like a generic click target. That keeps all submission logic centralized inhandleSubmit(...), which is easier to maintain and reason about. -
disabled={isSubmitting}prevents duplicate submissions while the parent async workflow is in progress. That is especially important in admin forms, where accidental repeated writes can cause confusing behavior or inconsistent data entry patterns. -
Switching the label between
'Save rate'and'Saving...'gives the user immediate progress feedback in the exact place they interacted. Small details like this make a compact admin form feel polished and dependable rather than bare-bones. -
Reusing the shared
Buttoncomponent also keeps the visual language of the app consistent. Even though this is a new area of the product, the controls still feel like part of the same system.
Why This Form Stays Reusable
It is worth pausing on a key design decision in src/components/admin/TaxRateForm.tsx: the form never imports tax API helpers and never shows toasts itself. That may sound like a limitation, but it is actually one of the component’s strengths.
Because the form receives onSubmit(...) and isSubmitting as props, it stays reusable and page-agnostic. A parent can decide later whether the form should create a new tax rate, update an existing one, or participate in a broader tax-management workflow. The form’s responsibility is simply to collect controlled values, normalize them appropriately, and submit them cleanly.
That same separation of responsibilities is one of the themes running through the whole project. The API module owns named request helpers, the form owns controlled input behavior, and the eventual page that uses the form will own workflow orchestration, loading states, and toast feedback.
Recap
In this lesson, you established the first two building blocks of the tax-management surface: a compact semantic API module and a reusable controlled form.
You started in src/lib/api/tax.ts, where listTaxRates(), getTaxRate(countryCode), upsertTaxRate(countryCode, input), and deleteTaxRate(countryCode) formed a small, stable set of named request helpers. Those helpers stayed purely about transport intent, using apiRequest(...) and toRequestInit(...) from the shared API client instead of mixing UI behavior into the module.
Then in src/components/admin/TaxRateForm.tsx, you added local controlled state for countryCode and rateBps, wired both inputs explicitly to that state, and implemented a submit handler that prevents the default browser submission, normalizes the country code by trimming and uppercasing it, awaits the parent onSubmit(...) callback, and resets the form afterward for repeated use. The result is a typed, reusable, page-agnostic form that fits naturally alongside the rest of the admin interface.
The main takeaway is that good admin tooling starts with clear boundaries. A small semantic API module keeps later pages readable, and a controlled reusable form keeps input state, rendered UI, and submitted payloads aligned without bloating the component’s responsibilities.
