Order Product Enrichment
Order Product Enrichment
Welcome back. The storefront can now show order history and load an individual order by its ID, which means shoppers already have a solid path from checkout into their order records. In this lesson, you will take that detail experience further by enriching each order item with related product information, so the page can show recognizable product names instead of only raw product IDs.
Previously, you built the order history layer and the first version of order detail loading. That work gave the app two important foundations: a way to fetch primary order data with useOrder(orderId), and a route structure that lets shoppers move from /orders into /orders/[id]. Now you will build on top of that foundation by adding a second, smaller hook that performs derived async enrichment for the line items, and then use that enriched data to make the order detail page feel much more informative.
What This Lesson Adds to the Order Detail Experience
Not all data needed by a page has to come from one API response in a fully display-ready form. In this project, an order already contains its line items, but those line items reference products by product_id. That is enough for the data model to stay compact, but it is not ideal for the user interface because shoppers usually want to recognize products by name, not by an internal identifier.
This lesson introduces a very useful frontend pattern: derived async enrichment. The order is still the primary resource, and useOrder(orderId) remains responsible for loading it. Then a second hook, useOrderProducts(items), looks at the order’s items, extracts the related product IDs, fetches those products, and returns a lookup object that the page can use during rendering. This keeps responsibilities clean: the main hook loads the page’s primary entity, while the enrichment hook adds related display data without taking over the page’s core loading flow.
Building the Product Enrichment Hook
The file src/lib/hooks/useOrderProducts.ts is the main new piece in this lesson. Its job is not to load the order itself. Instead, it accepts the order’s items and turns their product_id values into a lookup object where each key is a product ID and each value is either the fetched Product or null if that related fetch failed.
Here is the start of the hook, where the imports and state are defined:
-
useEffectanduseStateare enough for this hook because it is driven by incomingitemsand needs to store only the final lookup object. There is no separate loading or error state here, because this hook is doing derived enrichment rather than representing the page’s primary request lifecycle. -
The
itemsparameter is optional, which is an important detail. On the first render of the detail page, the order may not exist yet becauseuseOrder(orderId)is still loading, so the enrichment hook needs to behave correctly even when it receivesundefined. -
The state type
Record<string, Product | null>is a very good fit for rendering. Instead of storing products in an array and repeatedly searching with methods like.find(), the page can directly accessproductMap[item.product_id], which makes the consuming code simpler and easier to read. -
Allowing
nullas a value is also intentional. It gives the hook a graceful way to say, “we tried to fetch this related product, but it was unavailable,” without making the whole enrichment process fail.
The next part handles the empty case and sets up the effect lifecycle:
-
The
if (!items?.length)guard is the first thing the effect checks because empty input should be handled immediately. If there are no items, the correct enriched result is simply an empty object, so the hook resetsproductMapto{}and stops early. -
This early return is important for correctness and clarity. It avoids unnecessary asynchronous work and makes the hook’s behavior predictable when the order has no line items or when the primary order data has not arrived yet.
-
The
activeflag is a lightweight stale-update guard. Since the effect contains async work, the component might unmount or theitemsinput might change before the requests finish, and this flag prevents late results from updating state after the effect is no longer current.
Now look at the asynchronous enrichment logic itself:
-
items.map((item) => item.product_id)extracts the related product IDs from the order items. That is the first step in turning order line items into richer display data. -
new Set(...)removes duplicates, which is a very important optimization and correctness habit. If the same product appears in multiple line items, the hook should not fetch that product multiple times just because it is referenced more than once. -
Spreading the set into an array with
[...new Set(...)]produces a de-duplicated list of IDs that can be mapped over normally. This is a clean JavaScript pattern for generating a unique array from repeated values. -
Promise.all(...)is the right choice here because the related products can be fetched in parallel. The hook does not need to wait for one product request to finish before starting the next, so parallel resolution makes the enrichment step faster and keeps the code compact. -
Each mapped async callback returns a tuple shaped like
[productId, product]or[productId, null]. That structure is deliberate because the final goal is not an array of products; it is an object keyed by ID, and these tuples are exactly whatObject.fromEntries(...)expects later. -
The
try/catchinside each mapped request is one of the most important details in this hook. If one product fetch fails, the hook storesnullfor that one ID instead of letting the wholePromise.all(...)reject, which means the order detail page can still render the rest of the enriched line items instead of failing completely.
The final part of the effect applies the result and handles cleanup:
-
if (!active) return;protects the state update from running after the effect is no longer valid. This is especially useful in React because asynchronous work may complete after unmount or after a dependency change starts a newer effect run. -
Object.fromEntries(entries)converts the array of[id, value]tuples into the lookup object the page actually wants. That is why the tuple shape earlier matters so much: it turns the final state update into a single clean transformation. -
void loadProducts();starts the async enrichment without awaiting it inside the effect body. This is the same general pattern you have already used in other custom hooks across the project. -
The cleanup function sets
active = false, which is the mechanism that invalidates late async results. It is a small pattern, but it reflects an important React concept: effects that start async work should also think about what happens if that work finishes after the relevant render context is gone. -
The dependency array is
[items], which means the enrichment reruns whenever the incoming items reference changes. That is exactly what you want, because the product lookup should stay aligned with the current order items and no others. -
Returning only
productMapkeeps the hook focused and intentionally small. This hook is not a general resource manager; it is a specialized enrichment helper, and its narrow API is one of its strengths.
Keeping the Dynamic Route Thin
The route file src/app/orders/[id]/page.tsx stays very small, just like other route files in this storefront. Its role is to receive the dynamic route parameter and hand that value to the actual client component that owns the interactive and async logic.
Here is the full route file:
-
The route imports
OrderDetailPageClientand delegates the real UI work to it. This keeps the file declarative and easy to scan, which is a strong convention in the App Router structure used throughout the project. -
The function awaits
paramsand extractsid, which becomes theorderIdprop passed into the client component. That is the only routing concern this file needs to handle. -
Keeping this file thin is helpful because route files become much easier to maintain when they do not mix routing, data hooks, conditional rendering, and layout details all in one place. The route defines where the page lives, and the client component defines how the page behaves.
Loading the Primary Order and Enriched Product Data
The file src/components/orders/OrderDetailPageClient.tsx is where the main order detail experience comes together. This component consumes the primary order hook and the product enrichment hook, then uses their outputs to render a read-only detail screen.
The top of the file sets up that relationship:
-
The
'use client'directive is required because this component depends on client-side React hooks. BothuseOrder(orderId)anduseOrderProducts(order?.items)rely on React state and effects, so this file must be a client component. -
useOrder(orderId)remains the primary data source for the page. That is important architecturally because the order itself is still the main entity being displayed, and the rest of the screen should be organized around whether that main data exists. -
useOrderProducts(order?.items)depends on the result of the primary order hook. This is a good example of derived async enrichment: the second hook does not replace the first one, but instead reacts to its data and adds related product information for display purposes. -
const lineItems = order?.items ?? [];gives the component a safe array to render from later. This avoids repeatedly checking whetherorder?.itemsexists and keeps the happy-path JSX cleaner.
Handling Loading, Error, and Missing States First
Before rendering any detailed layout, the page checks the request states that matter most. This is a strong UI habit: users should see immediate, clear feedback about the page state before the component tries to render the full happy-path structure.
Here is the loading branch:
-
The loading state appears before any happy-path layout because the page cannot render meaningful detail content until the primary order has been fetched. This keeps the component honest and prevents partial or confusing UI from showing too early.
-
Wrapping
LoadingStateinPageContainerpreserves the page’s overall spacing and layout rhythm even during intermediate states. That helps the loading UI feel like part of the real page instead of a disconnected placeholder.
Here is the error branch:
-
This branch handles explicit failures from the primary order request. It is important that the page treats this differently from an empty or missing order, because a fetch failure communicates a different problem than “there is simply no usable order here.”
-
Using the shared
ErrorStatecomponent keeps the project’s failure UI consistent. Reusing those primitives also means the visual language of the app stays stable as new features are added.
Here is the missing-order fallback:
-
This branch is a defensive fallback for the case where loading has finished and there is no explicit error message, but the page still has no order to show. That is a useful distinction because the recovery path here is different from a transport failure.
-
Sending the user back to
/ordersis the right recovery choice now that the app has an order history page. Once that section exists, it becomes the most natural place for the shopper to continue exploring their order data. -
It is also worth noticing that these branches all happen before the main layout. That ordering keeps the happy-path JSX much easier to read because the component can assume
orderexists once it reaches the main render return.
Presenting the Order Header Clearly
Rendering Enriched Line Items
Below the heading, the page renders the order’s line items. This is where the productMap from useOrderProducts() becomes useful, because the component can try to show a real product name and fall back to the raw product ID only when enrichment is unavailable.
Here is the line-item section:
-
The outer layout uses a responsive grid so the line items and the summary can sit side by side on larger screens. This helps the page feel informative without becoming cluttered, because related information is grouped into clear visual regions.
-
Inside
lineItems.map(...), the page looks up each related product withproductMap[item.product_id]. This is exactly why the hook returns aRecord<string, Product | null>rather than an array: the component gets fast, readable access to the enriched product data. -
product?.name ?? item.product_idis a very important fallback pattern. If the related product lookup succeeds, the shopper sees a familiar product name; if that lookup fails, the page still renders using the product ID instead of crashing or hiding the line item. -
This fallback behavior reflects graceful degradation, which is one of the central lessons of this feature. The page’s primary purpose is to display the order, so a missing related product enrichment should make the display less polished, not completely unusable.
-
The line item card also shows quantity and a computed line total.
formatMoney(item.quantity * item.unit_price_cents, order.currency)keeps monetary presentation consistent with the rest of the storefront and makes each item financially meaningful, not just descriptive. -
Structuring each item as its own
articlehelps the repeated content feel intentional and easy to scan. A good order detail page should let the shopper move down the list and quickly understand what was purchased without digging through dense, table-like UI.
Showing the Order Summary Alongside the Items
The detail page also includes a summary sidebar that presents subtotal, tax, and total. This is an important complement to the item list because it gives the shopper a compact financial overview without forcing them to mentally reconstruct the order totals from the individual lines.
Here is the summary section:
-
The
asideelement is a good semantic choice because this content supports the main line-item content without replacing it. It gives the page a secondary information area that still feels clearly related to the order. -
Showing subtotal, tax, and total separately is valuable because it helps shoppers understand the financial structure of the order. This is often more useful than showing only one final total, especially when taxes vary by country or rate.
-
formatMoney(...)is used for every monetary field so the values remain consistent in style and currency handling. Reusing the same formatting utility across the storefront prevents subtle inconsistencies in how prices appear. -
The final total is visually emphasized with stronger typography and a top border. That design choice follows a natural hierarchy: supporting financial details first, then the most important total amount at the bottom.
-
This summary is also intentionally read-only. In this unit, the goal is to stabilize the detail presentation and make it trustworthy before introducing any future lifecycle actions or order mutations.
Updating the Checkout Success Page to Connect the Flow
The file src/components/orders/CheckoutSuccessPageClient.tsx already existed from the previous lesson, but now that the app has a real order detail route, the confirmation screen should guide the shopper toward that destination. This is a good example of a small UI change making the whole product feel more connected.
The top of the file and its primary state handling remain the same:
-
The component still relies on
useOrder(orderId)to load the just-created order, which means the core architecture from the previous lesson remains intact. That continuity is important because good lesson progression usually extends working patterns rather than rewriting them without need. -
Keeping the loading, error, and missing-order branches intact is the right decision here. Those states were already doing their job correctly, so this lesson improves the navigation and action flow without disturbing reliable state-handling logic.
Here is the missing-order fallback, which has now been adjusted to point to the orders section:
-
Redirecting the shopper toward
/ordersis more natural now than sending them back to the shop immediately. Once the app has order history, that section becomes the best recovery surface for someone trying to locate a recently created order. -
This is a subtle but important UX improvement. Confirmation screens should help users decide what to do next, and those next steps should evolve as the product gains new routes and capabilities.
The happy-path action area is where the strongest connection to the new feature appears:
-
The primary button now leads directly to
/orders/${order.id}, which makes the confirmation screen feel connected to the new order detail route. This is a much stronger continuation path than a generic “continue shopping” action alone, because it acknowledges that the product now has a richer post-checkout experience. -
The secondary action still points to
/shop, which preserves the original browsing continuation path. That balance works well because some shoppers want to review their order immediately, while others want to keep exploring products. -
The presence of both actions reflects the transitional role of the page. A checkout confirmation screen is not just informational; it is also a handoff point, and the best next actions should match the routes the app currently supports.
How the Enrichment Pattern Differs from Primary Page Loading
It is useful to be very clear about the distinction between the two hooks involved in this lesson. useOrder(orderId) is responsible for primary page loading. It decides whether the page is loading, failed, missing data, or ready to render. Because of that role, it exposes order, isLoading, and errorMessage.
By contrast, useOrderProducts(items) is responsible for derived async enrichment. It assumes that some upstream data source already defines the page’s core state, and it adds related product data only to improve presentation. That is why the hook can return only productMap and let the page degrade gracefully when enrichment is partial or missing. Keeping that distinction clear will help learners design much cleaner hooks in larger applications.
Recap
In this lesson, you expanded the order detail feature so it can show enriched product information instead of relying only on raw product IDs.
You started with src/lib/hooks/useOrderProducts.ts, where the hook accepts optional order items, handles the empty case early, derives a de-duplicated list of product_id values, fetches related products in parallel with Promise.all(...), stores failed lookups as null, and uses an active flag plus Object.fromEntries(...) to safely produce a Record<string, Product | null> lookup map.
Then you kept src/app/orders/[id]/page.tsx small and declarative by awaiting the dynamic params and passing the extracted ID into OrderDetailPageClient. Inside src/components/orders/OrderDetailPageClient.tsx, you used useOrder(orderId) for the primary order data, useOrderProducts(order?.items) for derived enrichment, and rendered the page in a clean hierarchy: state branches first, order heading next, line items after that, and a summary sidebar alongside them.
Finally, you updated src/components/orders/CheckoutSuccessPageClient.tsx so the confirmation screen now acknowledges the routes that exist in the product. The missing-order fallback points to /orders, the primary happy-path action leads into /orders/${order.id}, and the secondary action still lets shoppers continue browsing through /shop.
The main takeaway is that not every piece of displayed data belongs in one giant request or one giant hook. Sometimes the best design is to load the primary resource first, then enrich it with small, focused derived logic that improves the UI without taking over the page’s core loading model.
