Managing Order Lifecycle Actions

Managing Order Lifecycle Actions

Welcome back. At this point, the storefront already lets shoppers browse order history, open a specific order, and view enriched line items with product names instead of only raw product IDs. That means the read side of the order experience is now stable, which makes this the right moment to add lifecycle actions on top of it.

Previously, you focused on making order detail informative and trustworthy. You loaded one order with useOrder(orderId), enriched related products with useOrderProducts(order?.items), and rendered a read-only detail page with line items and a summary sidebar. In this lesson, you will keep that detail experience intact while adding controlled actions for paying and cancelling an order, along with the supporting API helpers, hook refactor, pending UI state, and toast feedback needed to make those actions feel reliable.

What Changes in This Lesson

This lesson introduces mutation behavior into a page that was previously read-only. That changes the job of the order detail screen in an important way: it no longer just displays the current order state, it also lets the shopper request a valid transition in that state.

When a page gains actions like “Pay order” or “Cancel order,” the frontend needs a few new capabilities. It needs semantic mutation helpers in the API layer so components do not build request details inline. It needs a way to re-fetch canonical server data after a successful mutation. And it needs UI safeguards so users only see valid actions, cannot trigger overlapping requests, and receive clear feedback about what happened. That combination is what makes lifecycle actions feel trustworthy instead of fragile.

Adding Order Mutation Helpers to the API Module

The file src/lib/api/orders.ts already contains the read helpers for listing orders and loading a single order. In this lesson, it grows slightly to include small semantic helpers for the two mutations the UI needs: paying an order and cancelling an order.

Here is the updated import section and the existing read helpers:

import { apiRequest, toRequestInit } from '@/lib/api/client';
import { Order } from '@/types/domain';

export function listOrders() {
  return apiRequest<Order[]>('/api/orders');
}

export function getOrder(id: string) {
  return apiRequest<Order>(`/api/orders/${id}`);
}
  • Importing toRequestInit from @/lib/api/client is an important architectural choice. It lets the API module describe POST requests in a shared, centralized way instead of forcing page components to recreate request configuration details by hand.

  • The existing listOrders() and getOrder(id) helpers remain exactly where they belong. They already establish the style of this module: small, named helpers that describe intent clearly and keep transport details out of UI components.

  • Notice how the file is still intentionally small. This is good frontend design because the API layer is only expanding to cover what the current UI actually needs, rather than trying to predict every possible future order operation.

Now here are the new mutation helpers:

export function payOrder(id: string) {
  return apiRequest<Order>(`/api/orders/${id}/pay`, toRequestInit('POST'));
}

export function cancelOrder(id: string) {
  return apiRequest<Order>(`/api/orders/${id}/cancel`, toRequestInit('POST'));
}
  • payOrder(id) and cancelOrder(id) are symmetrical with the existing read helpers, and that symmetry makes the file easy to scan. A developer can look at this module and immediately understand the available order operations without reading low-level fetch setup.

  • Both functions return apiRequest<Order>(...), which means the frontend expects the server to respond with the updated canonical Order. Keeping the return type explicit is important because lifecycle mutations usually change fields like status, and the UI depends on reading that updated shape safely.

  • toRequestInit('POST') expresses the mutation transport detail once, close to the API call itself. That keeps the rest of the frontend at the level of intent, where a handler can say await payOrder(order.id) instead of manually constructing POST options and endpoint strings inside the component.

  • These helper names matter more than they might seem at first. In a UI codebase, readable semantic helpers reduce cognitive load because developers can understand what the page is doing without parsing raw HTTP details every time they read a click handler.

Refactoring useOrder for Re-fetching

Before this lesson, useOrder(orderId) could load the order when the page mounted, but it did not expose a reusable way to fetch the latest server state again after a mutation. Once actions like pay and cancel exist, that limitation becomes important because the page needs a clean way to refresh itself after a successful change.

The updated src/lib/hooks/useOrder.ts begins with imports and state setup that should already feel familiar:

import { useCallback, useEffect, useRef, useState } from 'react';
import { getErrorMessage } from '@/lib/api/client';
import { getOrder } from '@/lib/api/orders';
import { Order } from '@/types/domain';

export function useOrder(orderId: string) {
  const [order, setOrder] = useState<Order | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const requestIdRef = useRef(0);
  • The main difference here is the addition of useCallback in the imports. That is the React tool that will let the hook define a stable refresh() function without recreating it unnecessarily on every render.

  • The state model is intentionally unchanged from earlier lessons: order, isLoading, and errorMessage are still enough to represent the primary state of the detail page. That is a good sign, because adding mutation support should not force the hook to become bloated or abandon its clean API.

  • requestIdRef is still part of the hook because stale-request protection remains important even after the refactor. Re-fetching after mutations should preserve the same safety guarantees as the initial load.

The core request logic is now moved into a reusable refresh() function:

  const refresh = useCallback(async () => {
    const requestId = requestIdRef.current + 1;
    requestIdRef.current = requestId;
    setIsLoading(true);

    try {
      const response = await getOrder(orderId);
      if (requestId !== requestIdRef.current) return null;
      setOrder(response);
      setErrorMessage(null);
      return response;
    } catch (error) {
      if (requestId !== requestIdRef.current) return null;
      setOrder(null);
      setErrorMessage(getErrorMessage(error));
      return null;
    } finally {
      if (requestId === requestIdRef.current) {
        setIsLoading(false);
      }
    }
  }, [orderId]);
  • useCallback(async () => { ... }, [orderId]) wraps the refresh logic in a memoized function whose identity stays stable unless orderId changes. This matters because the hook wants to reuse the exact same request behavior both on initial mount and after later lifecycle actions.

  • The refresh logic itself preserves the existing loading flow instead of inventing a second request model. It generates a new request ID, stores it in requestIdRef, sets isLoading(true), then fetches the current order from the server.

  • Returning null when a stale response is detected is a small but useful detail. It keeps the function’s behavior predictable and prevents older requests from updating state after a newer refresh has started.

  • On success, the hook stores the fetched order and clears any previous error message. That reset matters because a successful refresh after a prior failure should not leave stale error UI hanging around.

  • On failure, the hook resets order to null and converts the thrown value into a user-facing message through getErrorMessage(error). The page can then render that message without needing to know anything about the internal error shape.

  • The finally block keeps the same guarded loading cleanup as before. This is important because one of the goals of the refactor is to make re-fetching possible without weakening the hook’s disciplined state transitions.

With refresh() extracted, the initial effect becomes much simpler:

  useEffect(() => {
    void refresh();
  }, [refresh]);
  • The effect no longer duplicates the entire request implementation. Instead, it simply calls refresh(), which means the hook has one source of truth for how an order should be loaded.

  • This is a very common and valuable refactor in React hooks. The UI may not visibly change when you do it, but it makes later features much easier to build because the request behavior can now be reused anywhere the hook needs it.

Finally, the hook returns its public API:

  return { order, isLoading, errorMessage, refresh };
}
  • Returning refresh is the whole reason this refactor matters for the page. After a user clicks “Pay order” or “Cancel order,” the component can ask for the newest canonical server state instead of guessing how to patch local state manually.

  • The hook API remains small and disciplined even after gaining new power. That is a strong design decision because the page gets exactly what it needs for lifecycle actions without opening the door to raw setters or unrelated helper methods.

Preparing the Detail Page for Mutations

The file src/components/orders/OrderDetailPageClient.tsx now has to do more than display information. It still needs to render the same heading, line items, and summary as before, but it also has to coordinate local pending state, lifecycle mutations, toast feedback, and server refreshes after successful actions.

Here is the top of the file, where the imports and local state are set up:

'use client';

import Link from 'next/link';
import { useState } from 'react';
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 { StatusBadge } from '@/components/ui/StatusBadge';
import { useToast } from '@/components/ui/Toast';
import { cancelOrder, payOrder } from '@/lib/api/orders';
import { getErrorMessage } from '@/lib/api/client';
import { useOrder } from '@/lib/hooks/useOrder';
import { useOrderProducts } from '@/lib/hooks/useOrderProducts';
import { canCancelOrder, canPayOrder, formatDateTime, formatMoney } from '@/lib/utils/format';
export function OrderDetailPageClient({ orderId }: { orderId: string }) {
  const { order, isLoading, errorMessage, refresh } = useOrder(orderId);
  const productMap = useOrderProducts(order?.items);
  const [isPaying, setIsPaying] = useState(false);
  const [isCancelling, setIsCancelling] = useState(false);
  const toast = useToast();
  const lineItems = order?.items ?? [];
  • useOrder(orderId) now returns refresh in addition to the primary order state. That is the key hook-level change this page needs, because lifecycle handlers should re-fetch the canonical server state after a successful mutation instead of trying to guess what changed locally.

  • useOrderProducts(order?.items) remains exactly where it was before, which is a good sign that the lesson is extending the detail page rather than replacing it. The enrichment behavior continues to do its job independently while the lifecycle actions focus on status changes.

  • isPaying and isCancelling are local pending flags that belong in the component. These are UI interaction states, not server data, so they are best managed close to the buttons that depend on them.

  • Keeping the flags separate instead of using one generic “isMutating” flag makes the UI more expressive. The page can show specific button labels like “Paying...” or “Cancelling...” and can reason clearly about which action is currently in progress.

  • useToast() gives the page a lightweight way to communicate success and error feedback. That is a great fit for lifecycle actions because the user usually needs a quick acknowledgement of what happened, not a large persistent block of new page content.

Implementing the Pay Action Handler

The first lifecycle action is the pay flow. The handler needs to mark the UI as pending, call the mutation helper, show feedback, refresh the order from the server, and always reset the pending state.

Here is handlePay():

  async function handlePay() {
    if (!order) return;
    setIsPaying(true);
    try {
      await payOrder(order.id);
      toast.success('Order marked as paid.');
      await refresh();
    } catch (error) {
      toast.error(getErrorMessage(error));
    } finally {
      setIsPaying(false);
    }
  }
  • The early if (!order) return; guard is a small defensive check that keeps the handler safe. Even though the button only appears in the happy path, event handlers should still avoid assuming required data exists without checking.

  • setIsPaying(true) updates local UI state immediately before the async work begins. This gives the user fast feedback and lets the button disable itself so duplicate clicks are prevented.

  • await payOrder(order.id) uses the semantic API helper rather than embedding transport logic in the component. That makes the handler much easier to read because the code expresses business intent directly.

  • toast.success('Order marked as paid.') gives the user lightweight positive feedback once the mutation succeeds. This is often better than changing the page layout itself for feedback, because it keeps the screen stable while still acknowledging the completed action.

  • await refresh() is one of the most important steps in the handler. After a lifecycle mutation, the page should trust the server as the source of truth and ask for the latest canonical order instead of patching status locally and hoping that matches backend reality.

  • The catch block converts any thrown error into a user-facing message through getErrorMessage(error) and displays it through a toast. This keeps mutation failures informative without polluting the persistent page layout with extra one-off state regions.

  • The finally block always resets isPaying, which is essential for reliable UI behavior. No matter whether the mutation succeeds or fails, the button should return to a usable state afterward.

Implementing the Cancel Action Handler

The cancel flow follows the same structure as the pay flow, which is exactly what you want. Lifecycle action handlers are easier to maintain when they share a clear, repeatable pattern.

Here is handleCancel():

  async function handleCancel() {
    if (!order) return;
    setIsCancelling(true);
    try {
      await cancelOrder(order.id);
      toast.success('Order cancelled.');
      await refresh();
    } catch (error) {
      toast.error(getErrorMessage(error));
    } finally {
      setIsCancelling(false);
    }
  }
  • This handler mirrors handlePay() closely, which makes the file easier to scan and reason about. Symmetry in mutation handlers is usually a good sign because it reduces the chance of one action behaving differently for accidental reasons.

  • await cancelOrder(order.id) keeps the UI logic focused on the meaning of the action rather than the HTTP details. That readability is exactly why the API module gained semantic mutation helpers earlier in the lesson.

  • The success toast text is specific to the action that just occurred. Small details like that matter because users should receive feedback that clearly reflects what they clicked.

  • Calling await refresh() after cancellation is just as important as after payment. The detail page should re-read the server’s order state so the heading, status badge, and action availability all update from canonical data.

  • Resetting isCancelling in finally guarantees the button does not stay stuck in a pending state after an error. This is one of the simplest but most important habits in async UI code.

Keeping the Existing Page State Branches Intact

Even though this lesson adds mutations, the page still needs the same primary loading, error, and missing-order branches that it had before. These states are about whether the page can render the order at all, so they should remain ahead of the happy-path layout.

Here is the loading branch:

  if (isLoading) {
    return (
      <PageContainer className="py-12 md:py-16">
        <LoadingState message="Loading order details..." />
      </PageContainer>
    );
  }
  • The primary loading branch still belongs at the top because the page cannot show meaningful lifecycle controls or detail content until the main order request finishes. This keeps the rendering model honest and predictable.

  • It is worth noticing that mutation support did not require changing this branch at all. That is a good sign that the new behavior is layered on cleanly instead of disrupting the page’s foundational state model.

Here is the error branch:

  if (errorMessage) {
    return (
      <PageContainer className="py-12 md:py-16">
        <ErrorState message={errorMessage} />
      </PageContainer>
    );
  }
  • This branch still handles primary order load failures, which are fundamentally different from mutation-specific toast errors. The page-level error state is for the situation where the order itself could not be loaded for display.

  • Keeping page errors and action toasts separate is a strong UI design choice. Persistent layout states should describe whether the page can render, while toasts are better for short-lived mutation feedback.

Here is the missing-order branch:

  if (!order) {
    return (
      <PageContainer className="py-12 md:py-16">
        <EmptyState
          title="Order unavailable"
          description="This order could not be found or is no longer available."
          action={
            <Link href="/orders">
              <Button>Back to orders</Button>
            </Link>
          }
        />
      </PageContainer>
    );
  }
  • This fallback remains useful because there can still be cases where the page is neither loading nor showing a transport error, but there is no usable order data to display. The UI needs a stable recovery path for that situation.

  • Sending the user back to /orders remains the right move because the order history page is now the natural place to continue browsing or verifying available orders.

Rendering the Detail Page Without Disrupting Existing Content

Showing Only Valid Lifecycle Actions

The summary card is where the new buttons live. This is a strong placement choice because order totals and order actions are closely related in the user’s mental model, especially for payment and cancellation decisions.

Here is the summary block and the new action area:

        <aside className="space-y-6">
          <div className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
            <p className="font-serif text-2xl text-stone-950">Order summary</p>
            <div className="mt-6 space-y-4 text-sm">
              <div className="flex items-center justify-between text-stone-600">
                <span>Subtotal</span>
                <span>{formatMoney(order.subtotal_cents, order.currency)}</span>
              </div>
              <div className="flex items-center justify-between text-stone-600">
                <span>Tax</span>
                <span>{formatMoney(order.tax_cents, order.currency)}</span>
              </div>
              <div className="flex items-center justify-between border-t border-stone-200 pt-4 text-base font-semibold text-stone-950">
                <span>Total</span>
                <span>{formatMoney(order.total_cents, order.currency)}</span>
              </div>
            </div>
            <div className="mt-6 flex flex-wrap gap-3">
              {canPayOrder(order) ? (
                <Button onClick={handlePay} disabled={isPaying || isCancelling}>
                  {isPaying ? 'Paying...' : 'Pay order'}
                </Button>
              ) : null}
              {canCancelOrder(order) ? (
                <Button variant="danger" onClick={handleCancel} disabled={isPaying || isCancelling}>
                  {isCancelling ? 'Cancelling...' : 'Cancel order'}
                </Button>
              ) : null}
            </div>
          </div>
  • canPayOrder(order) and canCancelOrder(order) are doing an important piece of business-rule work here. Instead of always rendering both buttons and then trying to reject invalid clicks later, the UI only shows the actions that make sense for the order’s current lifecycle state.

  • This approach preserves trust because the interface stays aligned with real order behavior. A user should not be encouraged to click buttons for actions that are no longer valid just because the component always renders them.

  • Both buttons use disabled={isPaying || isCancelling}, which prevents overlapping actions. That matters because lifecycle mutations can conflict with each other, and the safest UI behavior is to make the page wait until the current mutation settles.

  • The dynamic labels Paying... and Cancelling... make progress visible in the exact control the user interacted with. This is a better experience than leaving the label static, because it confirms that the click was received and the request is in flight.

  • The cancel button uses variant="danger", which is appropriate because cancellation is a destructive action. Consistent visual semantics help users understand the weight of an action before they click it.

Below the card, the page includes a small explanatory note:

          <div className="rounded-[2rem] bg-stone-50 p-6 text-sm leading-6 text-stone-600">
            Review totals here and take action while the order is still eligible for payment or cancellation.
          </div>
        </aside>
      </div>
    </PageContainer>
  );
}
  • This supporting text helps frame the role of the sidebar without making the page busy. It reinforces that actions depend on eligibility and that the summary area is the place where review and action come together.

  • Small guidance blocks like this can improve usability when a page gains new capabilities. They provide context without forcing the rest of the layout to become more complicated.

Why Refreshing Canonical Data Matters

One of the most important design ideas in this lesson is that the page does not manually patch order.status after a successful pay or cancel action. Instead, it calls refresh() and asks the server for the latest order.

That choice matters because the backend is the canonical source of truth for lifecycle state. A mutation may affect more than one field, and the safest, most maintainable frontend pattern is to re-read the updated resource rather than guessing which local fields need to change. This also keeps the hook and page responsibilities clean: handlers trigger semantic actions, and useOrder() remains the place where the authoritative order state is loaded.

Recap

In this lesson, you turned the order detail page from a read-only screen into a screen that can manage valid order lifecycle actions.

You started in src/lib/api/orders.ts, where toRequestInit was imported and two small semantic mutation helpers were added: payOrder(id) and cancelOrder(id). Then you refactored src/lib/hooks/useOrder.ts so the existing request logic lives inside a reusable refresh() function wrapped in useCallback(...), and the hook now returns refresh alongside order, isLoading, and errorMessage.

After that, src/components/orders/OrderDetailPageClient.tsx gained local pending flags for payment and cancellation, toast-based feedback with useToast(), and two action handlers that call the mutation helpers, surface success or error feedback, and refresh the order from canonical server data afterward. The page also uses canPayOrder(order) and canCancelOrder(order) to render only valid actions and disables both buttons while a mutation is in flight.

Just as importantly, the rest of the page stayed intact. The loading, error, and missing-order branches still come first, the enriched line items still render the same way, and the summary card still presents the financial breakdown clearly. The new lifecycle actions were added by extending the existing design, not by disturbing parts of the page that were already stable.

The main takeaway is that good lifecycle UI is not just about adding buttons. It is about exposing only valid actions, reflecting progress honestly, handling failure clearly, and refreshing from server truth after a change. That is what makes order actions feel dependable rather than risky.

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