Building Product Detail Hook
Building the Product Detail Hook
Welcome back! In the previous lesson, Building an API Client, you connected the storefront UI to the backend API. You introduced shared domain types, built a reusable API client, and created hooks and UI components that allow the home page and shop page to load and display products dynamically.
Now we are going to build the product detail flow. Instead of loading a collection of products like the shop page does, we want to load one specific product when the user navigates to its detail page.
In this lesson, you will:
- Build a reusable
useProducthook that loads a single product from the backend. - Create a dynamic route that reads the product ID from the URL.
- Implement a full Product Detail page component that displays the product using the shared UI system you built earlier.
- Connect the product cards in the catalog so users can navigate into the product detail view.
By the end of this lesson, the storefront will support a complete browsing flow:
Shop → Product Card → Product Detail Page
This pattern—hooks for data logic, components for UI rendering, and routes for navigation—is a foundational architectural pattern in modern React applications.
Designing the Product Detail Hook
The first step is creating a hook that loads a single product instead of a collection.
This hook will live in:
Just like the useProducts() hook from the previous lesson, this hook will manage the entire async lifecycle:
- loading state
- success state
- error state
The goal is simple: page components should not contain fetch logic. Instead, they should receive clean data like this:
That makes UI components easier to read and maintain.
Defining Hook State
The hook begins by importing the tools it needs and defining its state.
This section establishes the core state variables that represent the lifecycle of the request.
Explanation
-
useState<Product | null>(null)- This state stores the current product returned by the API.
- It starts as
nullbecause we haven't loaded anything yet. - When the request succeeds, the hook updates this state with the fetched product.
-
isLoading- This flag represents whether a request is currently in progress.
- Page components rely on this flag to show UI states like
LoadingState. - Managing loading explicitly makes the UI predictable and easier to reason about.
-
errorMessage- If something fails during the request, this state stores a user-friendly error message.
- The hook converts raw errors into readable text using
getErrorMessage(). - This prevents components from having to inspect different error types themselves.
-
requestIdRef- This is a small but very important piece of logic.
- It protects the UI from race conditions when multiple requests overlap.
- We will use it to ensure that only the most recent request can update the UI.
At this point, the hook has defined all of the internal state it needs.
Next, we implement the data-fetching logic.
Fetching the Product with useEffect
Now the hook needs to actually load the product whenever the productId changes.
React's useEffect() hook is perfect for this. It allows us to run side effects whenever dependencies change.
Explanation
-
useEffect(..., [productId])- The effect runs whenever
productIdchanges. - This means navigating to a new product automatically triggers a new request.
- The effect runs whenever
-
Creating a new
requestId- Each request receives a unique numeric identifier.
- The
requestIdRefstores the ID of the most recent request. - If an older request finishes after a newer one, its result will be ignored.
-
setIsLoading(true)- The hook sets the loading state before making the API call.
- This allows UI components to render loading indicators immediately.
-
Calling
getProduct(productId)- This function comes from the API module you built in the previous lesson.
- It uses the shared
apiRequest()client and returns a typedProduct.
-
Race condition guard
- The check
ensures outdated responses are ignored.
-
Handling success
- The fetched product is stored in state.
- Any previous error message is cleared so the UI reflects the successful state.
-
Handling errors
- If the request fails, the product is cleared and the error message is stored.
getErrorMessage()converts different error types into readable messages.
-
Finally block
- This ensures the loading flag turns off only if the request is still current.
This pattern ensures that the hook behaves correctly even if users navigate quickly between products.
Returning the Hook Interface
The final step of the hook is returning the state that UI components need.
Explanation
-
The hook returns a stable object interface.
-
Components consuming the hook can easily branch between UI states:
- loading
- error
- empty
- success
-
Importantly, the page component does not know anything about fetch logic.
-
This separation of responsibilities is one of the biggest advantages of custom hooks.
At this point, the hook is complete.
Now we need to connect it to a route.
Creating the Dynamic Product Route
Next we create a dynamic route so that each product has its own URL.
This file lives in:
In Next.js App Router, folders wrapped in brackets represent dynamic route segments.
This means URLs like:
All map to the same route file.
Here is the route implementation:
Explanation
-
params- Next.js provides route parameters through the
paramsobject. - In this case, the folder name
[id]creates a parameter calledid.
- Next.js provides route parameters through the
-
productId={params.id}- The route file extracts the product ID from the URL.
- It passes the ID into the client component as a prop.
-
Keeping route files thin
- This route file intentionally contains almost no logic.
- Its only job is connecting router context to the UI component tree.
Thin routes are an important architectural pattern in App Router projects.
Building the Product Detail Page Component
Now we implement the actual product detail screen.
This component lives in:
It will consume the useProduct() hook and render the appropriate UI state.
Explanation
-
'use client'- This directive ensures the component runs on the client.
- Hooks like
useProduct()rely on client-side React features.
-
useProduct(productId)- The hook fetches the product and exposes its lifecycle states.
- The component simply consumes these values without implementing fetch logic.
-
Shared UI imports
-
The component reuses existing UI primitives:
PageContainerLoadingStateErrorStateEmptyStateStatusBadge
-
This keeps the page consistent with the rest of the storefront.
Handling UI States
The component then renders different UI states depending on the hook result.
Explanation
-
Loading state
- Displays a shared skeleton UI using
LoadingState. - This prevents layout shifts and improves perceived performance.
- Displays a shared skeleton UI using
-
Error state
- Displays
ErrorStatewith a readable error message. - The hook already converted the error into a user-friendly message.
- Displays
-
Empty state
- This covers situations where the API returns no product.
- It gives the user clear feedback rather than showing a blank page.
This state branching pattern is extremely common in modern React applications.
Rendering the Product Details
If the product exists, the page renders the actual product information.
Explanation
-
Back navigation
- A simple button allows users to return to the shop page.
- This supports natural browsing behavior.
-
Displaying product metadata
- SKU appears as a small label above the title.
- The product name is the main visual heading.
-
StatusBadge
- Uses the shared component created earlier.
- This ensures consistent status styling across the entire app.
-
Description fallback
- If the product has no description, a fallback message appears.
- This prevents empty UI areas.
-
Price formatting
formatMoney()converts cents into a properly formatted currency string.
-
Inventory messaging
-
getInventoryLabel()creates readable inventory text like:- “In stock”
- “3 left”
- “Out of stock”
-
This ensures the detail page feels consistent with the rest of the storefront UI.
Linking Product Cards to the Detail Page
Finally, product cards should link to the new detail route.
The ProductCard component already renders product data.
We simply wrap the card content with a link.
Explanation
- Clicking a product card navigates to
/products/{id}. - The router loads the dynamic route.
- The route passes the ID into
ProductDetailPageClient. - The page calls
useProduct()and fetches the correct product.
This completes the catalog browsing flow.
Summary
In this lesson you built the product detail architecture for the storefront.
You learned how to:
- Build a reusable
useProducthook for loading a single product. - Protect async requests from race conditions using
useRef. - Create a dynamic Next.js route using
[id]. - Implement a complete product detail page using shared UI components.
- Connect product cards to the detail route.
This pattern—custom hooks for data logic and components for UI rendering—is one of the most powerful organizational tools in modern React development.
With this system in place, your storefront now supports a full product browsing experience.
