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 useProduct hook 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:

src/lib/hooks/useProduct.ts

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:

{ product, isLoading, errorMessage }

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.

'use client';

import { useEffect, useRef, useState } from 'react';
import { getErrorMessage } from '@/lib/api/client';
import { getProduct } from '@/lib/api/products';
import { Product } from '@/types/domain';

export function useProduct(productId: string) {
  const [product, setProduct] = useState<Product | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const requestIdRef = useRef(0);

Explanation

  • useState<Product | null>(null)

    • This state stores the current product returned by the API.
    • It starts as null because 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.

  useEffect(() => {
    async function loadProduct() {
      const requestId = requestIdRef.current + 1;
      requestIdRef.current = requestId;

      setIsLoading(true);

      try {
        const response = await getProduct(productId);

        if (requestId !== requestIdRef.current) return;

        setProduct(response);
        setErrorMessage(null);
      } catch (error) {
        if (requestId !== requestIdRef.current) return;

        setProduct(null);
        setErrorMessage(getErrorMessage(error));
      } finally {
        if (requestId === requestIdRef.current) {
          setIsLoading(false);
        }
      }
    }

    void loadProduct();
  }, [productId]);

Explanation

  • useEffect(..., [productId])

    • The effect runs whenever productId changes.
    • This means navigating to a new product automatically triggers a new request.
  • Creating a new requestId

    • Each request receives a unique numeric identifier.
    • The requestIdRef stores 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 typed Product.
  • Race condition guard

    • The check
    if (requestId !== requestIdRef.current) return;

    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.

  return { product, isLoading, errorMessage };
}

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:

src/app/products/[id]/page.tsx

In Next.js App Router, folders wrapped in brackets represent dynamic route segments.

This means URLs like:

/products/123
/products/abc
/products/product-456

All map to the same route file.

Here is the route implementation:

import { ProductDetailPageClient } from '@/components/products/ProductDetailPageClient';

export default function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  return <ProductDetailPageClient productId={params.id} />;
}

Explanation

  • params

    • Next.js provides route parameters through the params object.
    • In this case, the folder name [id] creates a parameter called id.
  • 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:

src/components/products/ProductDetailPageClient.tsx

It will consume the useProduct() hook and render the appropriate UI state.

'use client';

import Link from 'next/link';
import { PageContainer } from '@/components/layout/PageContainer';
import { StatusBadge } from '@/components/ui/StatusBadge';
import { LoadingState } from '@/components/ui/LoadingState';
import { ErrorState } from '@/components/ui/ErrorState';
import { EmptyState } from '@/components/ui/EmptyState';
import { Button } from '@/components/ui/Button';
import { formatMoney, getInventoryLabel } from '@/lib/utils/format';
import { useProduct } from '@/lib/hooks/useProduct';

export function ProductDetailPageClient({ productId }: { productId: string }) {
  const { product, isLoading, errorMessage } = useProduct(productId);

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:

      • PageContainer
      • LoadingState
      • ErrorState
      • EmptyState
      • StatusBadge

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.

  if (isLoading) {
    return (
      <PageContainer className="py-12">
        <LoadingState message="Loading product details..." />
      </PageContainer>
    );
  }

  if (errorMessage) {
    return (
      <PageContainer className="py-12">
        <ErrorState message={errorMessage} />
      </PageContainer>
    );
  }

  if (!product) {
    return (
      <PageContainer className="py-12">
        <EmptyState
          title="Product not found"
          description="The requested product could not be found."
        />
      </PageContainer>
    );
  }

Explanation

  • Loading state

    • Displays a shared skeleton UI using LoadingState.
    • This prevents layout shifts and improves perceived performance.
  • Error state

    • Displays ErrorState with a readable error message.
    • The hook already converted the error into a user-friendly message.
  • 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.

  return (
    <PageContainer className="py-12 space-y-8">
      <Link href="/shop">
        <Button variant="secondary">Back to shop</Button>
      </Link>

      <div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm">
        <div className="flex items-start justify-between">
          <div>
            <p className="text-xs uppercase tracking-[0.2em] text-stone-500">
              {product.sku}
            </p>
            <h1 className="mt-2 font-serif text-4xl text-stone-950">
              {product.name}
            </h1>
          </div>
          <StatusBadge status={product.status} />
        </div>

        <p className="mt-6 text-stone-600 leading-7">
          {product.description?.trim() ||
            'This product currently has no description.'}
        </p>

        <div className="mt-8">
          <p className="text-3xl font-semibold text-stone-950">
            {formatMoney(product.price_cents, product.currency)}
          </p>
          <p className="text-sm text-stone-500 mt-1">
            {getInventoryLabel(product)}
          </p>
        </div>
      </div>
    </PageContainer>
  );
}

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.

src/components/products/ProductCard.tsx
import Link from 'next/link';
<Link href={`/products/${product.id}`}>
  {/* product card content */}
</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 useProduct hook 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.

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