Tax Rate Management Workspace

Tax Rate Management Workspace

Welcome back. In the previous lesson, you laid the foundation for tax management by creating a compact tax API module in src/lib/api/tax.ts and a reusable controlled form in src/components/admin/TaxRateForm.tsx. That gave the project two important building blocks: a small set of semantic tax request helpers and a form that can collect, normalize, and submit tax-rate values cleanly without hardcoding page-specific logic.

This lesson turns those building blocks into a real admin workspace. You will add the tax admin route, create a dedicated TaxAdminPageClient page, load the current tax-rate collection with a read-only workflow, and then connect the reusable TaxRateForm so the page can save and delete rates while refreshing the visible list from canonical server data after every write. The main idea is to make the tax screen feel like a trustworthy internal operations surface: clear heading, honest page states, meaningful data cards, and simple refresh-after-write behavior.

Previously

Last time, the focus was on boundaries. src/lib/api/tax.ts stayed purely about named request helpers like listTaxRates(), getTaxRate(countryCode), upsertTaxRate(...), and deleteTaxRate(...), while src/components/admin/TaxRateForm.tsx stayed purely about controlled input state and normalized submission. The form collected countryCode and rateBps, normalized the country code on submit, awaited the parent onSubmit(...), and reset itself for repeated admin use.

That separation matters directly in this lesson. Because the API helpers and form are already cleanly separated, TaxAdminPageClient can focus on page-level workflow: loading the collection, deciding which UI state to show, handling saves and deletes, and refreshing the list afterward.

Starting the Tax Admin Route with a Thin Page File

As in the rest of this project, the route file itself stays intentionally simple. The page route in src/app/admin/tax/page.tsx is a thin handoff that activates the route and delegates all UI behavior to a dedicated client component.

Here is the full route file:

import { TaxAdminPageClient } from '@/components/admin/TaxAdminPageClient';

export default function AdminTaxPage() {
  return <TaxAdminPageClient />;
}
  • This follows the same architectural pattern used throughout the path. Route files stay lightweight, which makes them easy to scan and keeps routing concerns separate from client-side state, async workflows, and UI branching.

  • Returning TaxAdminPageClient directly is also a good consistency move. Once a learner has seen this structure across products, orders, and admin screens, they can quickly recognize where route setup ends and page behavior begins.

  • In the earliest stage of this lesson, the point is mostly structural progress: the route should exist, it should resolve correctly, and it should have a dedicated place for the tax-management workspace to grow.

Introducing the Tax Workspace with a Dedicated Page Component

The main page logic lives in src/components/admin/TaxAdminPageClient.tsx. This file becomes the admin workspace for tax rates, just as ProductAdminPageClient did for products.

Here is the start of the file, including imports and the component shell:

'use client';

import { useEffect, useState } from 'react';
import { TaxRateForm } from '@/components/admin/TaxRateForm';
import { PageContainer } from '@/components/layout/PageContainer';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { ErrorState } from '@/components/ui/ErrorState';
import { LoadingState } from '@/components/ui/LoadingState';
import { SectionHeading } from '@/components/ui/SectionHeading';
import { useToast } from '@/components/ui/Toast';
import { deleteTaxRate, listTaxRates, upsertTaxRate } from '@/lib/api/tax';
import { getErrorMessage } from '@/lib/api/client';
import { formatDateTime, formatTaxRate } from '@/lib/utils/format';
import { TaxRate } from '@/types/domain';
  • The 'use client' directive is required because this page uses React state, effects, and the toast hook. That matches the rest of the admin surfaces in the app, where client components own interactive workflow logic.

  • Importing TaxRateForm, the shared UI states, and the tax API helpers immediately shows the page’s role clearly. It is not a low-level form component and it is not an API module; it is the workflow layer that orchestrates reading, saving, deleting, and rendering.

  • The page also imports formatTaxRate and formatDateTime, which signals that the populated state is meant to show meaningful, polished information rather than raw backend values. That is an important design choice in admin tools: internal screens still need readable presentation, not just technical correctness.

Modeling the Core Read State of the Workspace

Before save and delete actions are added, the tax page needs a reliable read-only state model. The page defines three core values for that: rates, isLoading, and errorMessage.

Here is that state setup:

export function TaxAdminPageClient() {
  const [rates, setRates] = useState<TaxRate[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const [isSaving, setIsSaving] = useState(false);
  const toast = useToast();
  • rates, isLoading, and errorMessage are the minimum state needed to represent the major UI branches of the page: loading, failure, empty success, and populated success. This mirrors the same simple and disciplined page-state model used in other parts of the app.

  • rates starts as an empty array because the page has not loaded the collection yet. Once data arrives successfully, that array becomes the rendered tax-rate list.

  • isLoading starts as true, which makes sense because the page should show a loading state immediately on first mount while the initial request is in flight.

  • errorMessage is stored as a user-facing string instead of a raw error object. That keeps the render logic simpler because the page only needs display-ready state.

  • isSaving is separate from isLoading, and that distinction matters. Collection loading and form submission are not the same workflow, so they should not share one state flag and accidentally confuse the page UI.

  • useToast() gives the page a lightweight way to communicate save and delete outcomes without cluttering the layout with persistent action-specific status blocks.

Loading the Tax Rate Collection with refreshRates

The page reads tax rates through a small reusable helper function named refreshRates(). This keeps the page’s loading behavior in one place so it can be reused both on initial mount and after mutations.

Here is refreshRates():

  async function refreshRates() {
    setIsLoading(true);
    try {
      const response = await listTaxRates();
      setRates(response);
      setErrorMessage(null);
    } catch (error) {
      setErrorMessage(getErrorMessage(error));
    } finally {
      setIsLoading(false);
    }
  }
  • setIsLoading(true) runs at the start of every refresh so the page can reflect that a collection read is in progress. This makes the state transitions explicit and predictable.

  • await listTaxRates() uses the semantic helper from src/lib/api/tax.ts rather than building a fetch call inline. That keeps the page readable because the code speaks in domain terms: “refresh tax rates” instead of “send a GET request to this URL.”

  • On success, setRates(response) stores the latest server data and setErrorMessage(null) clears any stale failure. Clearing the old error matters because a successful load should remove an outdated error message rather than leaving the page stuck in a previously failed state.

  • On failure, the page converts the thrown value through getErrorMessage(error) and stores the result as errorMessage. This matches the project’s broader pattern of keeping UI-facing error state as plain readable text.

  • The finally block always clears loading after the request settles, which keeps the page from getting stuck in a loading state even if the request fails.

  • A reusable refresh function like this is especially valuable once write actions exist. The page can keep one consistent read path and simply call it again after saves or deletes instead of duplicating collection-fetching logic in multiple places.

Triggering the Initial Read with useEffect

Once refreshRates() exists, the page uses useEffect() to trigger the initial collection read automatically when the component mounts.

Here is that effect:

  useEffect(() => {
    void refreshRates();
  }, []);
  • This effect gives the page its initial data-loading behavior. As soon as the tax admin page mounts, it starts reading the current collection of tax rates without requiring the user to click anything.

  • Using void refreshRates(); is a small but useful convention when starting an async function inside an effect. It makes it clear that the promise is intentionally being kicked off and not awaited directly in the effect body.

  • The empty dependency array means the initial load happens once on mount for the current page visit. That matches the expected behavior of an admin collection view, where the page should load its data automatically when opened.

  • Keeping the initial read in useEffect() rather than inline in the component body is an important React pattern. Data-fetching side effects belong in effects, while the render body should stay focused on describing UI.

Rendering the Workspace Heading and the Tax Form

Before the page gets into collection states, it starts by rendering a heading and wiring in the reusable TaxRateForm.

Here is the top section of the rendered workspace:

  return (
    <PageContainer className="space-y-8 py-12 md:py-16">
      <SectionHeading
        eyebrow="Admin"
        title="Tax rate management"
        description="Review country tax rates and keep checkout totals accurate."
      />

      <TaxRateForm onSubmit={handleSave} isSubmitting={isSaving} />
  • SectionHeading introduces the tax-management area in the same visual language used across the rest of the admin tools. This makes the route feel like a real part of the internal operations surface rather than a disconnected side page.

  • The heading text is focused and calm, which is appropriate for an internal tool. It tells the user what this workspace is for without overexplaining or pretending the screen is larger than it currently is.

  • Rendering the TaxRateForm near the top of the page is a strong workflow choice. The page lets the admin act immediately while still keeping the current collection visible below for review and deletion.

  • onSubmit={handleSave} and isSubmitting={isSaving} preserve the clean component boundary established in the previous lesson. The form stays reusable and page-agnostic, while the page owns the actual save workflow and mutation-related feedback.

Showing Loading, Error, and Empty States Clearly

The tax workspace then renders the same shared page-state components already used elsewhere in the app. That consistency is one of the strengths of the codebase: admin tools and storefront pages both use a familiar visual language for asynchronous state.

Here are the loading, error, and empty branches:

      {isLoading ? <LoadingState message="Loading tax rates..." /> : null}
      {!isLoading && errorMessage ? <ErrorState message={errorMessage} /> : null}
      {!isLoading && !errorMessage && rates.length === 0 ? (
        <EmptyState title="No custom tax rates" description="Add a country code and rate above to override the default tax behavior." />
      ) : null}
  • LoadingState appears while the collection read is in flight, using a message tailored to the tax screen. This keeps the page explicit and friendly instead of leaving users to guess what is happening.

  • The error branch appears only after loading has finished, which prevents overlapping or confusing UI states. A page should not try to look “loading” and “failed” at the same time.

  • ErrorState uses the shared error presentation pattern, which helps the admin tools feel consistent with the rest of the application. Reusing these state components reduces design drift and keeps the UI vocabulary familiar.

  • The empty state communicates a successful but empty collection, which is an important distinction from failure. The description also gives the user a practical next step by pointing them toward the tax form above.

  • This is a good reminder that page states are part of UX, not just fallback code. A tax screen with no data still needs to feel intentional and informative, not blank or broken.

Building the Read-Only Populated State

Once the collection loads successfully and has data, the page maps over rates and renders one card per country code. This is the read-only stage of the workspace, so the goal is to show meaningful information clearly before introducing write actions.

Here is the populated-state rendering:

      {!isLoading && !errorMessage && rates.length > 0 ? (
        <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
          {rates.map((rate) => (
            <article key={rate.country_code} className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
              <p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">{rate.country_code}</p>
              <p className="mt-3 font-serif text-3xl text-stone-950">{formatTaxRate(rate.rate_bps)}</p>
              <p className="mt-2 text-sm text-stone-500">Updated {formatDateTime(rate.updated_at)}</p>
              <Button variant="danger" className="mt-6" onClick={() => void handleDelete(rate.country_code)}>
                Delete rate
              </Button>
            </article>
          ))}
        </div>
      ) : null}
  • Each card uses rate.country_code as its key, which makes sense because country codes uniquely identify tax-rate records in this collection.

  • The country code is shown as a small uppercase metadata label, which helps the card read as an internal operations record rather than a customer-facing content tile.

  • formatTaxRate(rate.rate_bps) is very important because the UI should display a human-friendly percentage instead of raw basis points. Formatting functions like this turn backend-oriented values into information that is immediately meaningful for an admin user.

  • formatDateTime(rate.updated_at) helps the screen communicate freshness. Showing the last update timestamp gives the user useful operational context instead of just the rate value alone.

  • The grid layout makes the workspace feel more polished and easier to scan, especially as more tax-rate cards appear. This is a good internal-tool pattern: present records in a compact but readable structure rather than a dense undifferentiated list.

  • Even before mutation logic is explained, the page already reads as a useful management surface because the cards show the right pieces of information: country, rate, and last update time.

Adding Save Behavior with a Separate isSaving Flag

The next workflow layer is saving. The page uses isSaving and useToast() so the form can reflect save work without confusing that state with collection loading.

Here is handleSave(...):

  async function handleSave(values: { country_code: string; rate_bps: number }) {
    setIsSaving(true);
    try {
      await upsertTaxRate(values.country_code, { rate_bps: values.rate_bps });
      toast.success('Tax rate saved.');
      await refreshRates();
    } catch (error) {
      toast.error(getErrorMessage(error));
    } finally {
      setIsSaving(false);
    }
  }
  • setIsSaving(true) is separate from isLoading, which is exactly the right distinction. Saving the form and loading the collection are related but different workflows, and the form should only react to the save state that actually affects it.

  • upsertTaxRate(values.country_code, { rate_bps: values.rate_bps }) keeps the page readable because it uses the semantic helper from the API module. The page logic reads like workflow logic, not like request-assembly code.

  • toast.success('Tax rate saved.') provides lightweight immediate feedback that fits nicely in an admin page where the user often stays on the same screen after a save. The page does not need to invent a large persistent success panel for a short confirmation.

  • await refreshRates() after success is one of the most important choices in this lesson. Instead of trying to surgically patch the local rates array by hand, the page refreshes the whole collection from the server so the visible list always reflects canonical backend state.

  • On failure, the page converts the thrown value with getErrorMessage(error) and shows it through toast.error(...). That keeps mutation feedback clear without polluting the persistent page-state layout.

  • finally resets isSaving no matter what happened, which keeps the form from getting stuck in a submission state after an error.

Adding Delete Behavior with the Same Refresh-After-Write Pattern

Delete uses the same overall philosophy as save: perform the mutation, show feedback, then refresh the collection from the server rather than maintaining local list state by hand.

Here is handleDelete(...):

  async function handleDelete(countryCode: string) {
    try {
      await deleteTaxRate(countryCode);
      toast.success('Tax rate removed.');
      await refreshRates();
    } catch (error) {
      toast.error(getErrorMessage(error));
    }
  }
  • deleteTaxRate(countryCode) uses the semantic helper, which keeps the page focused on business meaning rather than URL and method details. That readability is exactly why the compact tax API module was valuable in the previous lesson.

  • The success toast confirms that the removal worked without forcing navigation or a full-screen success state. This is ideal for an internal management workspace where users may perform repeated actions quickly.

  • await refreshRates() again keeps the visible collection aligned with the backend as the source of truth. This refresh-after-write pattern is especially helpful in teaching code because it makes the page behavior explicit and avoids a more complex local synchronization story.

  • The delete handler deliberately mirrors the save handler’s philosophy. That symmetry makes the code easier to read and maintain because both lifecycle operations follow the same mental model: do the write, give feedback, refresh the collection.

Connecting the Form and the Delete Buttons to the Page Workflow

Once handleSave(...) and handleDelete(...) exist, the page simply wires them into the form and the card buttons.

Here is the form wiring again:

      <TaxRateForm onSubmit={handleSave} isSubmitting={isSaving} />
  • This line shows the strength of the TaxRateForm boundary. The form does not need to know anything about tax API helpers, toasts, or collection refreshes — it only needs an async submit callback and a saving flag.

  • Passing isSaving into the form lets the button reflect active work accurately without mixing save state into the page’s collection-loading state.

Here is the delete button inside each card:

              <Button variant="danger" className="mt-6" onClick={() => void handleDelete(rate.country_code)}>
                Delete rate
              </Button>
  • The button is intentionally direct: it calls the country-specific delete handler for the current card. This keeps the mapping between rendered record and record-specific action very easy to understand.

  • Using variant="danger" is the right visual choice because deletion is a destructive action. Consistent visual semantics help users interpret the weight of the button quickly.

  • This is a nice example of a page becoming operational without becoming complicated. The list remains readable, the form remains reusable, and the page coordinates the workflows through small explicit handlers.

Why Refresh-After-Write Works Well Here

It is worth calling out one of the key teaching patterns in this lesson: refresh after every write. Some production apps eventually move toward more local optimistic updates or more surgical client-side synchronization, but for this course, refresh-after-write is a very good pattern.

It keeps the code explicit. After a save or delete, the page simply asks the server for the latest list again. That means the user sees fresh canonical data, the page logic stays easy to follow, and the lesson avoids introducing a more complex client-side state maintenance story right at the end of the course.

That same principle has already worked well in earlier admin workflows, and tax management continues that same style successfully.

Recap

In this lesson, you turned the tax form and tax API helpers into a real tax-management workspace.

You started with src/app/admin/tax/page.tsx, where the route stayed thin by rendering TaxAdminPageClient directly. Then in src/components/admin/TaxAdminPageClient.tsx, you introduced local page state for rates, isLoading, and errorMessage, built refreshRates() around listTaxRates(), triggered the initial collection read with useEffect(), and rendered LoadingState, ErrorState, EmptyState, and a populated read-only card grid for tax-rate records.

After that, you added isSaving and useToast(), implemented handleSave(...) with upsertTaxRate(...), wired TaxRateForm into the page, and added handleDelete(countryCode) using deleteTaxRate(countryCode). Both write actions follow the same pattern: perform the mutation, show lightweight toast feedback, and refresh the collection from the server afterward so the list remains sourced from fresh canonical data.

The main takeaway is that a good admin workspace is not only about rendering records. It is about giving the page a stable read model, keeping form and page responsibilities separate, and using explicit refresh-after-write behavior so the visible UI stays aligned with the backend in a way that is easy to understand and trust.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal