Creating Nested Routes page

Learn how to have child routes within parent routes.

Overview

React Router nested routes provide a way to modify smaller portions of a page, rather than reloading everything. With nested routes, we can alter just sections of a page while also maintaining state in the URL so the page can still be reloaded or shared.

Imagine a manufacturer's product site. Not only do they have a lot of information about each product, but they also need to support both new and existing customers. New customers want to see list of features, specs, and where to buy the product. Old customers need to download owner's manuals and a way to contact customer support. Nested routes provide a mechanism to handle this structure

Our main product page loads at "/product/:id". The matching route will load the product name, picture, and basic description. Within the main product page, we can create a tabbed section with our various types of content. The route to show the main product info along with support information is "/product/:id/support". The full structure will look like the following tree:

  • /product/:id
    • /features
    • /specs
    • /where-to-buy
    • /downloads
    • /support

You could create this sort of page without nested routes, but using nested routes is a well-organized and performant solution that can be linked because it maintains state in the URL.

Objective 1: Create a nested route

We want to have a component to display individual restaurants details, and want the path to be nested under the restaurants path.

Screenshot of the Place My Order app with a nested route, displaying the “/restaurants/brunch-place” URL in the browser’s address bar.

Key concepts

  • Creating a nested routing configuration
  • Folder structure for nested components

Creating a nested route config

Recall our router config from the first React Router lesson.

import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import AboutPage from './pages/AboutPage'
import HomePage from './pages/HomePage'
import App from './App.tsx'

const router = createBrowserRouter(
  [
    {
      path: '/',
      element: <App />,
      children: [
        {
          index: true,
          element: <HomePage />,
        },
        {
          path: 'about',
          element: <AboutPage />,
        },
      ],
    },
  ],
  {
    basename: import.meta.env.BASE_URL,
  },
)

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
)

Notice that our home and about pages are under the children key. These are actually nested routes of the root path. Route children can accept a children property themselves, on and on, until the browser runs out of resources.

The route config for our product page could look like this:

import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import ProductPage from './pages/Product'
import Features from './pages/Product/components/Features'
import Support from './pages/Product/components/Support'
import WhereToBuy from './pages/Product/components/WhereToBuy'
import App from './App.tsx'

const router = createBrowserRouter(
  [
    {
      path: '/',
      element: <App />,
      children: [
        {
          path: 'product',
          element: <ProductPage />,
          children: [
            {
              index: "features",
              element: <Features />,
            },
            {
              index: "where-to-buy",
              element: <WhereToBuy />,
            },
            {
              index: "support",
              element: <Support />,
            },
          ]
        },
      ],
    },
  ],
  {
    basename: import.meta.env.BASE_URL,
  },
)

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
)

Folder structure

Notice that the child page components have simple names like <Support /> instead of <ProductSupport />. Assuming this component is specifically designed for the product page and will not be reused elsewhere, the component file can reside within the product page's folder. Since the files live together, longer names aren't needed and will make your JSX a little harder to read.

The larger directory tree should look like the following:

  • pages
    • Home
      • Home.tsx
      • Home.module.css
      • Home.test.tsx
      • index.ts
    • Product
      • components
        • Features
          • Features.tsx
          • Features.module.css
          • Features.test.tsx
          • index.ts
        • Support
          • Support.tsx
          • Support.module.css
          • Support.test.tsx
          • index.ts
      • Product.tsx
      • Product.module.css
      • Product.test.tsx
      • index.ts

Exercise 1

Let's reorganize the existing /restaurants routes so they are nested. For this exercise, change the routing config so the restaurant list and details pages are nested under /restaurants.

Setup

✏️ Create src/components/RestaurantHeader/index.ts and update it to be:

export { default } from "./RestaurantHeader"

✏️ Create src/components/RestaurantHeader/RestaurantHeader.tsx and update it to be:

import { Restaurant } from "../../services/restaurant/interfaces"

const RestaurantHeader: React.FC<{ restaurant: Restaurant }> = ({
  restaurant,
}) => {
  return (
    <div
      className="restaurant-header"
      style={{ backgroundImage: `url(/${restaurant.images.banner})` }}
    >
      <div className="background">
        <h2>{restaurant.name}</h2>

        {restaurant.address && (
          <div className="address">
            {restaurant.address.street}
            <br />
            {restaurant.address.city}, {restaurant.address.state}{" "}
            {restaurant.address.zip}
          </div>
        )}

        <div className="hours-price">
          $$$
          <br />
          Hours: M-F 10am-11pm
          <span className="open-now">Open Now</span>
        </div>

        <br />
      </div>
    </div>
  )
}

export default RestaurantHeader

✏️ Create src/pages/RestaurantDetails/index.ts and update it to be:

export { default } from "./RestaurantDetails"

✏️ Create src/pages/RestaurantDetails/RestaurantDetails.tsx and update it to be:

import { Link, useParams } from "react-router-dom"
import RestaurantHeader from "../../components/RestaurantHeader"
import { useRestaurant } from "../../services/restaurant/hooks"

const RestaurantDetails: React.FC = () => {
    const params = useParams() as { slug: string }
    const restaurant = useRestaurant(params.slug)

    if (restaurant.isPending) {
        return (
            <p aria-live="polite" className="loading">
                Loading restaurant…
            </p>
        )
    }

    if (restaurant.error) {
        return (
            <p aria-live="polite" className="error">
                Error loading restaurant: {restaurant.error.message}
            </p>
        )
    }

    if (!restaurant.data) {
        return <p aria-live="polite">No restaurant found.</p>;
    }

    return (
        <>
            <RestaurantHeader restaurant={restaurant.data} />

            <div className="restaurant-content">
                <h3>The best food this side of the Mississippi</h3>

                <p className="description">
                    <img alt="" src={`/${restaurant.data.images.owner}`} />
                    Description for {restaurant.data.name}
                </p>
                <p className="order-link">
                    <Link
                        className="btn"
                        to={`/restaurants/${restaurant.data.slug}/order`}
                    >
                        Order from {restaurant.data.name}
                    </Link>
                </p>
            </div>
        </>
    )
}

export default RestaurantDetails

✏️ Update src/pages/RestaurantList/ListItem.tsx to be:

import { Link } from "react-router-dom";

interface ListItemProps {
    address?: {
        city: string;
        state: string;
        street: string;
        zip: string;
    };
    name: string;
    slug: string;
    thumbnail: string;
}

const ListItem: React.FC<ListItemProps> = ({ address, name, slug, thumbnail }) => {
    return (
        <>
            <div className="restaurant">
                <img src={`/${thumbnail}`} alt="" width="100" height="100" />
                <h3>{name}</h3>

                {address && (
                    <div className="address">
                        {address.street}
                        <br />
                        {address.city}, {address.state} {address.zip}
                    </div>
                )}

                <div className="hours-price">
                    $$$
                    <br />
                    Hours: M-F 10am-11pm
                    <span className="open-now">Open Now</span>
                </div>

                <Link className="btn" to={`/restaurants/${slug}`}>
                    Details
                </Link>
                <br />
            </div>
        </>
    )
}

export default ListItem

✏️ Update src/services/restaurant/hooks.ts to be:

import { useEffect, useState } from 'react'
import { apiRequest } from '../api'
import type { City, Restaurant, State } from './interfaces'

interface CitiesResponse {
  data: City[] | null;
  error: Error | null;
  isPending: boolean;
}

interface RestaurantResponse {
  data: Restaurant | null;
  error: Error | null;
  isPending: boolean;
}

interface RestaurantsResponse {
  data: Restaurant[] | null;
  error: Error | null;
  isPending: boolean;
}

interface StatesResponse {
  data: State[] | null;
  error: Error | null;
  isPending: boolean;
}

export function useCities(state: string): CitiesResponse {
  const [response, setResponse] = useState<CitiesResponse>({
    data: null,
    error: null,
    isPending: true,
  })

  useEffect(() => {
    const fetchData = async () => {
      const { data, error } = await apiRequest<CitiesResponse>({
          method: "GET",
          path: "/cities",
          params: {
              state: state
          },
      })

      setResponse({
        data: data?.data || null,
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, [state]);

  return response
}

export function useRestaurant(slug: string): RestaurantResponse {
  const [response, setResponse] = useState<RestaurantResponse>({
    data: null,
    error: null,
    isPending: true,
  })

  useEffect(() => {
    const fetchData = async () => {
      const { data, error } = await apiRequest({
          method: "GET",
          path: `/restaurants/${slug}`,
      })

      setResponse({
        data: data || null,
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, [slug]);

  return response
}

export function useRestaurants(state: string, city: string): RestaurantsResponse {
  const [response, setResponse] = useState<RestaurantsResponse>({
    data: null,
    error: null,
    isPending: true,
  })

  useEffect(() => {
    const fetchData = async () => {
      const { data, error } = await apiRequest<RestaurantsResponse>({
          method: "GET",
          path: "/restaurants",
          params: {
              "filter[address.state]": state,
              "filter[address.city]": city,
          },
      })

      setResponse({
        data: data?.data || null,
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, [state, city]);

  return response
}

export function useStates(): StatesResponse {
  const [response, setResponse] = useState<StatesResponse>({
    data: null,
    error: null,
    isPending: true,
  })

  useEffect(() => {
    const fetchData = async () => {
      const { data, error } = await apiRequest<StatesResponse>({
          method: "GET",
          path: "/states",
      })

      setResponse({
        data: data?.data || null,
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, []);

  return response
}

Verify

The existing test already cover routing, so no new tests are needed. Ensure the existing tests pass when you run npm run test.

✏️ Create src/components/RestaurantHeader/RestaurantHeader.test.tsx and update it to be:

import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Restaurant } from '../../services/restaurant/interfaces';
import RestaurantHeader from "./RestaurantHeader";

describe('RestaurantHeader component', () => {
    const restaurantWithoutAddress: Restaurant = {
        _id: '3ZOZyTY1LH26LnVw',
        images: {
            banner: "banner-image.jpg",
            owner: "owner-image.jpg",
            thumbnail: "thumbnail-image.jpg",
        },
        menu: {
            lunch: [
                {
                    name: 'Crab Pancakes with Sorrel Syrup',
                    price: 35.99,
                },
                {
                    name: 'Steamed Mussels',
                    price: 21.99,
                },
                {
                    name: 'Spinach Fennel Watercress Ravioli',
                    price: 35.99,
                },
            ],
            dinner: [
                {
                    name: 'Gunthorp Chicken',
                    price: 21.99,
                },
                {
                    name: 'Herring in Lavender Dill Reduction',
                    price: 45.99,
                },
                {
                    name: 'Chicken with Tomato Carrot Chutney Sauce',
                    price: 45.99,
                },
            ],
        },
        name: "Test Restaurant",
        slug: 'poutine-palace',
    };
    const restaurantWithAddress = {
        ...restaurantWithoutAddress,
        address: {
            street: "123 Test St",
            city: "Testville",
            state: "TS",
            zip: "12345"
        },
    };

    it('renders the restaurant name', () => {
        render(<RestaurantHeader restaurant={restaurantWithAddress} />);
        expect(screen.getByText(restaurantWithAddress.name)).toBeInTheDocument();
    });

    it('renders the restaurant address when provided', () => {
        render(<RestaurantHeader restaurant={restaurantWithAddress} />);

        const addressDiv = screen.getByText(/123 Test St/i).closest('div');
        expect(addressDiv).toHaveTextContent(restaurantWithAddress.address.street);
        expect(addressDiv).toHaveTextContent(`${restaurantWithAddress.address.city}, ${restaurantWithAddress.address.state} ${restaurantWithAddress.address.zip}`);
    });

    it('does not render an address when not provided', () => {
        render(<RestaurantHeader restaurant={restaurantWithoutAddress} />);
        expect(screen.queryByText(restaurantWithAddress.address.street)).not.toBeInTheDocument();
    });

    it('renders static details like price and hours', () => {
        render(<RestaurantHeader restaurant={restaurantWithAddress} />);

        const hoursPriceDiv = screen.getByText(/\$\$\$/i).closest('div');
        expect(hoursPriceDiv).toHaveTextContent('$$$');
        expect(hoursPriceDiv).toHaveTextContent('Hours: M-F 10am-11pm');
    });

    it('renders the "Open Now" text', () => {
        render(<RestaurantHeader restaurant={restaurantWithAddress} />);
        expect(screen.getByText("Open Now")).toBeInTheDocument();
    });
});

✏️ Create src/pages/RestaurantDetails/RestaurantDetails.test.tsx and update it to be:

import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import RestaurantDetails from './RestaurantDetails';

// Mock the hooks and components used in RestaurantDetails
vi.mock('../../services/restaurant/hooks', () => ({
  useRestaurant: vi.fn()
}));

vi.mock('../../components/RestaurantHeader', () => ({
    default: vi.fn(() => (
      <div data-testid="mock-restaurant-header">
        Mock RestaurantHeader
      </div>
    ))
  }));
  
import { useRestaurant } from '../../services/restaurant/hooks';

const mockRestaurantData = {
  data: {
    _id: '1',
    name: 'Test Restaurant',
    slug: 'test-restaurant',
    images: { owner: 'owner.jpg' },
  },
  isPending: false,
  error: null
};

const renderWithRouter = (ui, { route = '/restaurants/test-restaurant' } = {}) => {
  window.history.pushState({}, 'Test page', route)
  return render(
    ui,
    { wrapper: ({ children }) => <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter> }
  );
};

describe('RestaurantDetails component', () => {
  it('renders loading state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: true, error: null });
    renderWithRouter(<RestaurantDetails />);
    expect(screen.getByText(/Loading restaurant…/i)).toBeInTheDocument();
  });

  it('renders error state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading' } });
    renderWithRouter(<RestaurantDetails />);
    expect(screen.getByText(/Error loading restaurant/i)).toBeInTheDocument();
  });

  it('renders no restaurant found state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: false, error: null });
    renderWithRouter(<RestaurantDetails />);
    expect(screen.getByText(/No restaurant found/i)).toBeInTheDocument();
  });

  it('renders the RestaurantHeader and content when data is available', () => {
    useRestaurant.mockReturnValue(mockRestaurantData);
    renderWithRouter(<RestaurantDetails />);

    expect(screen.getByTestId('mock-restaurant-header')).toBeInTheDocument();
    expect(screen.getByText(/The best food this side of the Mississippi/i)).toBeInTheDocument();
    expect(screen.getByText(/Description for Test Restaurant/i)).toBeInTheDocument();
    expect(screen.getByRole('link', { name: /Order from Test Restaurant/i })).toBeInTheDocument();
  });
});

✏️ Update src/pages/RestaurantList/ListItem.test.tsx to be:

import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import ListItem from './ListItem';

// Wrap component with MemoryRouter to mock routing
const renderWithRouter = (ui, { route = '/' } = {}) => {
  window.history.pushState({}, 'Test page', route)
  return render(ui, { wrapper: MemoryRouter });
};

describe('ListItem component', () => {
  it('renders the restaurant image', () => {
    renderWithRouter(<ListItem slug="test-slug" name="Test Name" thumbnail="test-thumbnail.jpg" />);
    const img = screen.getByRole('img');
    expect(img).toHaveAttribute('src', '/test-thumbnail.jpg');
    expect(img).toHaveAttribute('width', '100');
    expect(img).toHaveAttribute('height', '100');
  });

  it('renders the address', () => {
    const address = {
      city: 'Test City',
      state: 'Test State',
      street: 'Test Street',
      zip: '12345',
    };

    renderWithRouter(<ListItem slug="test-slug" name="Test Name" address={address} thumbnail="test-thumbnail.jpg" />);

    const addressDiv = screen.getByText(/Test Street/i).closest('div');
    expect(addressDiv).toHaveTextContent('Test Street');
    expect(addressDiv).toHaveTextContent('Test City, Test State 12345');
  });

  it('does not render the address section when address is not provided', () => {
    renderWithRouter(<ListItem slug="test-slug" name="Test Name" thumbnail="test-thumbnail.jpg" />);

    // Check that address-related text is not in the document
    expect(screen.queryByText('Test Street')).not.toBeInTheDocument();
    expect(screen.queryByText(/Test City, Test State 12345/)).not.toBeInTheDocument();
  });

  it('renders the hours and price information', () => {
    renderWithRouter(<ListItem slug="test-slug" name="Test Name" thumbnail="test-thumbnail.jpg" />);
    const hoursPriceDiv = screen.getByText(/\$\$\$/i).closest('div');
    expect(hoursPriceDiv).toHaveTextContent('$$$');
    expect(hoursPriceDiv).toHaveTextContent('Hours: M-F 10am-11pm');
  });

  it('indicates if the restaurant is open now', () => {
    renderWithRouter(<ListItem slug="test-slug" name="Test Name" thumbnail="test-thumbnail.jpg" />);
    expect(screen.getByText('Open Now')).toBeInTheDocument();
  });

  it('renders the details button with correct link', () => {
    renderWithRouter(<ListItem slug="test-slug" name="Test Name" thumbnail="test-thumbnail.jpg" />);
    const detailsButton = screen.getByRole('link');
    expect(detailsButton).toHaveAttribute('href', '/restaurants/test-slug');
    expect(screen.getByText('Details')).toBeInTheDocument();
  });
});

✏️ Update src/pages/RestaurantList/RestaurantList.test.tsx to be:

import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';

import RestaurantList from './RestaurantList';

// Mock the hooks used in the component
vi.mock('../../services/restaurant/hooks', () => ({
  useCities: vi.fn(() => {
    return {
      data: null,
      error: null,
      isPending: false,
    }
  }),
  useRestaurants: vi.fn(() => {
    return {
      data: null,
      error: null,
      isPending: false,
    }
  }),
  useStates: vi.fn(() => {
    return {
      data: null,
      error: null,
      isPending: false,
    }
  }),
}));

import { useCities, useRestaurants, useStates } from '../../services/restaurant/hooks'

// Wrap component with MemoryRouter to mock routing
const renderWithRouter = (ui, { route = '/' } = {}) => {
  window.history.pushState({}, 'Test page', route)
  return render(ui, { wrapper: MemoryRouter });
};

describe('RestaurantList component', () => {
  it('renders the Restaurants header', () => {
    render(<RestaurantList />);
    expect(screen.getByText(/Restaurants/i)).toBeInTheDocument();
  });

  it('renders state and city dropdowns', () => {
    render(<RestaurantList />)
    expect(screen.getByLabelText(/State/i)).toBeInTheDocument()
    expect(screen.getByLabelText(/City/i)).toBeInTheDocument()
  })

  it('renders correctly with initial states', () => {
    useStates.mockReturnValue({ data: null, isPending: true, error: null });
    useCities.mockReturnValue({ data: null, isPending: false, error: null });
    useRestaurants.mockReturnValue({ data: null, isPending: false, error: null });

    render(<RestaurantList />);

    expect(screen.getByText(/Restaurants/)).toBeInTheDocument();
    expect(screen.getByText(/Loading states…/)).toBeInTheDocument();
  });

  it('displays error messages correctly', () => {
    useStates.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading states' } });
    useCities.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading cities' } });
    useRestaurants.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading restaurants' } });

    render(<RestaurantList />);

    expect(screen.getByText(/Error loading states/)).toBeInTheDocument();
  });

  it('renders restaurants correctly', async () => {
    useStates.mockReturnValue({ data: [{ short: 'CA', name: 'California' }], isPending: false, error: null });
    useCities.mockReturnValue({ data: [{ name: 'Los Angeles' }], isPending: false, error: null });
    useRestaurants.mockReturnValue({ data: [{ _id: '1', slug: 'test-restaurant', name: 'Test Restaurant', address: '123 Test St', images: { thumbnail: 'test.jpg' } }], isPending: false, error: null });

    renderWithRouter(<RestaurantList />);

    await userEvent.selectOptions(screen.getByLabelText(/State/), 'CA');
    await userEvent.selectOptions(screen.getByLabelText(/City/), 'Los Angeles');

    expect(screen.getByText('Test Restaurant')).toBeInTheDocument();
  });
});

✏️ Update src/services/restaurant/hooks.test.ts to be:

import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { apiRequest } from '../api'
import { useCities, useRestaurants, useStates } from './hooks';

// Mock the apiRequest function
vi.mock('../api', () => ({
  apiRequest: vi.fn(),
}));

describe('Hooks', () => {
  beforeEach(() => {
    vi.resetAllMocks();
  });

  describe('useCities hook', () => {
    it('should return cities data successfully', async () => {
      const mockCities = [{ id: 1, name: 'City1' }, { id: 2, name: 'City2' }];
      apiRequest.mockResolvedValue({ data: { data: mockCities }, error: null });

      const { result } = renderHook(() => useCities('test-state'));

      await waitFor(() => {
        expect(result.current.isPending).toBeFalsy();
        expect(result.current.data).toEqual(mockCities);
        expect(result.current.error).toBeNull();
      });
    });

    it('should handle error when fetching cities data', async () => {
      const mockError = new Error('Error fetching cities');
      apiRequest.mockResolvedValue({ data: null, error: mockError });

      const { result } = renderHook(() => useCities('test-state'));

      await waitFor(() => {
        expect(result.current.isPending).toBeFalsy();
        expect(result.current.data).toBeNull();
        expect(result.current.error).toEqual(mockError);
      });
    });
  });

  describe('useRestaurant hook', () => {
    it('should return restaurant data successfully', async () => {
      const mockRestaurant = { id: 1, name: 'Restaurant1' };
      apiRequest.mockResolvedValue({ data: { data: mockRestaurant }, error: null });

      const { result } = renderHook(() => useRestaurants('test-state', 'test-city'));

      await waitFor(() => {
        expect(result.current.isPending).toBeFalsy();
        expect(result.current.data).toEqual(mockRestaurant);
        expect(result.current.error).toBeNull();
      });
    });

    it('should handle error when fetching restaurant data', async () => {
      const mockError = new Error('Error fetching restaurant');
      apiRequest.mockResolvedValue({ data: null, error: mockError });

      const { result } = renderHook(() => useRestaurants('test-state', 'test-city'));

      await waitFor(() => {
        expect(result.current.isPending).toBeFalsy();
        expect(result.current.data).toBeNull();
        expect(result.current.error).toEqual(mockError);
      });
    });
  });

  describe('useRestaurants hook', () => {
    it('should return restaurants data successfully', async () => {
      const mockRestaurants = [{ id: 1, name: 'Restaurant1' }, { id: 2, name: 'Restaurant2' }];
      apiRequest.mockResolvedValue({ data: { data: mockRestaurants }, error: null });

      const { result } = renderHook(() => useRestaurants('test-state', 'test-city'));

      await waitFor(() => {
        expect(result.current.isPending).toBeFalsy();
        expect(result.current.data).toEqual(mockRestaurants);
        expect(result.current.error).toBeNull();
      });
    });

    it('should handle error when fetching restaurants data', async () => {
      const mockError = new Error('Error fetching restaurants');
      apiRequest.mockResolvedValue({ data: null, error: mockError });

      const { result } = renderHook(() => useRestaurants('test-state', 'test-city'));

      await waitFor(() => {
        expect(result.current.isPending).toBeFalsy();
        expect(result.current.data).toBeNull();
        expect(result.current.error).toEqual(mockError);
      });
    });
  });

  describe('useStates hook', () => {
    it('should return states data successfully', async () => {
      const mockStates = [{ id: 1, name: 'State1' }, { id: 2, name: 'State2' }];
      apiRequest.mockResolvedValue({ data: { data: mockStates }, error: null });

      const { result } = renderHook(() => useStates());

      await waitFor(() => {
        expect(result.current.isPending).toBeFalsy();
        expect(result.current.data).toEqual(mockStates);
        expect(result.current.error).toBeNull();
      });
    });

    it('should handle error when fetching states data', async () => {
      const mockError = new Error('Error fetching states');
      apiRequest.mockResolvedValue({ data: null, error: mockError });

      const { result } = renderHook(() => useStates());

      await waitFor(() => {
        expect(result.current.isPending).toBeFalsy();
        expect(result.current.data).toBeNull();
        expect(result.current.error).toEqual(mockError);
      });
    });
  });
});

Exercise

Refactor the router config in src/main.tsx to nest the restaurant routes.

Solution

Click to see the solution

✏️ Update src/main.tsx to be:

import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import Home from './pages/Home'
import RestaurantDetails from './pages/RestaurantDetails'
import RestaurantList from './pages/RestaurantList'
import App from './App.tsx'
import './index.css'

const router = createBrowserRouter(
  [
    {
      path: '/',
      element: <App />,
      children: [
        {
          index: true,
          element: <Home />,
        },
        {
          path: '/restaurants',
          children: [
            {
              path: '',
              element: <RestaurantList />,
            },
            {
              path: ':slug',
              element: <RestaurantDetails />,
            },
          ],
        }
      ],
    },
  ],
  {
    basename: import.meta.env.BASE_URL,
  },
)

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
)

Having issues with your local setup? See the solution in StackBlitz or CodeSandbox.

Objective 2

We learned to create nested routes, let's practice by adding another page to our application. Add the order page to the route config and add a link to it.

Screenshot of the Place My Order app with a nested route, displaying the “/restaurants/brunch-place/order” URL in the browser’s address bar.

Setup

Add the order page files.

✏️ Create src/pages/RestaurantOrder/index.ts and update it to be:

export { default } from "./RestaurantOrder"

✏️ Create src/pages/RestaurantOrder/RestaurantOrder.tsx and update it to be:

import { useParams } from "react-router-dom"
import RestaurantHeader from "../../components/RestaurantHeader"
import { useRestaurant } from "../../services/restaurant/hooks"

const RestaurantOrder: React.FC = () => {
    const params = useParams() as { slug: string }

    const restaurant = useRestaurant(params.slug)

    if (restaurant.isPending) {
        return (
            <p aria-live="polite" className="loading">
                Loading restaurant…
            </p>
        )
    }

    if (restaurant.error) {
        return (
            <p aria-live="polite" className="error">
                Error loading restaurant: {restaurant.error.message}
            </p>
        )
    }

    if (!restaurant.data) {
        return <p aria-live="polite">No restaurant found.</p>;
    }

    return (
        <>
            <RestaurantHeader restaurant={restaurant.data} />

            <div className="order-form">
                <h3>Order from {restaurant.data.name}!</h3>
            </div>
        </>
    )
}

export default RestaurantOrder

Verify

✏️ Create src/pages/RestaurantOrder/RestaurantOrder.test.tsx and update it to be:

import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import RestaurantOrder from './RestaurantOrder';

// Mock the hooks and components used in RestaurantOrder
vi.mock('../../services/restaurant/hooks', () => ({
  useRestaurant: vi.fn()
}));

vi.mock('../../components/RestaurantHeader', () => ({
    default: vi.fn(() => (
      <div data-testid="mock-restaurant-header">
        Mock RestaurantHeader
      </div>
    ))
  }));
  
import { useRestaurant } from '../../services/restaurant/hooks';

const mockRestaurantData = {
  data: {
    _id: '1',
    name: 'Test Restaurant',
    slug: 'test-restaurant',
    images: { owner: 'owner.jpg' },
  },
  isPending: false,
  error: null
};

const renderWithRouter = (ui, { route = '/restaurants/test-restaurant' } = {}) => {
  window.history.pushState({}, 'Test page', route)
  return render(
    ui,
    { wrapper: ({ children }) => <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter> }
  );
};

describe('RestaurantOrder component', () => {
  it('renders loading state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: true, error: null });
    renderWithRouter(<RestaurantOrder />);
    expect(screen.getByText(/Loading restaurant…/i)).toBeInTheDocument();
  });

  it('renders error state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading' } });
    renderWithRouter(<RestaurantOrder />);
    expect(screen.getByText(/Error loading restaurant/i)).toBeInTheDocument();
  });

  it('renders no restaurant found state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: false, error: null });
    renderWithRouter(<RestaurantOrder />);
    expect(screen.getByText(/No restaurant found/i)).toBeInTheDocument();
  });

  it('renders the RestaurantHeader when data is available', () => {
    useRestaurant.mockReturnValue(mockRestaurantData);
    renderWithRouter(<RestaurantOrder />);

    expect(screen.getByTestId('mock-restaurant-header')).toBeInTheDocument();
  });
});

Exercise

Refactor the router config in src/main.tsx to contain the new RestaurantOrder components. You will need to come up with the route path on your own.

Add a link to the order page inside the ListItem component.

Solution

Click to see the solution

✏️ Update src/main.tsx to be:

import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import Home from './pages/Home'
import RestaurantDetails from './pages/RestaurantDetails'
import RestaurantList from './pages/RestaurantList'
import RestaurantOrder from './pages/RestaurantOrder'
import App from './App.tsx'
import './index.css'

const router = createBrowserRouter(
  [
    {
      path: '/',
      element: <App />,
      children: [
        {
          index: true,
          element: <Home />,
        },
        {
          path: '/restaurants',
          children: [
            {
              path: '',
              element: <RestaurantList />,
            },
            {
              path: ':slug',
              element: <RestaurantDetails />,
            },
            {
              path: ':slug/order',
              element: <RestaurantOrder />,
            },
          ],
        },
      ],
    },
  ],
  {
    basename: import.meta.env.BASE_URL,
  },
)

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
)

Having issues with your local setup? See the solution in StackBlitz or CodeSandbox.

Next steps

Next we'll learn to handle form data.