Making HTTP Requests page

Learn about how to make fetch requests and render requested data in React components.

Overview

TODO

Objective 1: Add a fetch request for states

In this section, we will:

  • Learn about the useEffect Hook
  • TODO: Review TypeScript generics?

The useEffect Hook

useEffect is a React Hook that lets you perform side effects in your functional components. It serves as a powerful tool to execute code in response to component renders or state changes.

Here is an example component with useEffect:

import { useEffect, useState } from 'react';

const GeolocationComponent: React.FC = () => {
    const [location, setLocation] = useState(null);

    useEffect(() => { // Effect callback function
        navigator.geolocation.getCurrentPosition(position => {
            setLocation(position.coords);
        }, (error) => {
            console.error(error);
        });
    }, []); // Dependency array

    return (
        <main>
            {location ? (
                <p>
                    Latitude: {location.latitude},
                    Longitude: {location.longitude}
                </p>
            ) : (
                <p>Requesting location…</p>
            )}
        </main>
    );
}

export default GeolocationComponent;

Let’s break this example down by the two arguments that useEffect takes:

Effect callback function

The first argument of useEffect is a function, often referred to as the “effect” function. This is where you perform your side effects, such as fetching data, setting up a subscription, or manually changing the DOM in React components.

The key aspect of this function is that it’s executed after the component renders. The effects in useEffect don’t block the browser from updating the screen, leading to more responsive UIs.

This effect function can optionally return another function, known as the “cleanup” function. The cleanup function is useful for performing any necessary cleanup activities when the component unmounts or before the component re-renders and the effect is re-invoked. Common examples include clearing timers, canceling network requests, or removing event listeners.

The dependency array

The second argument of useEffect is an array, called the “dependency array”, which determines when your effect function should be called. The behavior of the effect changes based on the contents of this array:

Consider three scenarios based on the dependency array:

Empty dependency array ([])

If the dependency array is an empty array, the effect runs once after the initial render.

import { useEffect, useState } from 'react';

const GeolocationComponent: React.FC = () => {
    const [location, setLocation] = useState(null);

    useEffect(() => { // Effect callback function
        navigator.geolocation.getCurrentPosition(position => {
            setLocation(position.coords);
        }, (error) => {
            console.error(error);
        });
    }, []); // Dependency array

    return (
        <main>
            {location ? (
                <p>
                    Latitude: {location.latitude},
                    Longitude: {location.longitude}
                </p>
            ) : (
                <p>Requesting location…</p>
            )}
        </main>
    );
}

export default GeolocationComponent;

Array with values

When you include values (variables, props, state) in the dependency array, the effect will only re-run if those specific values change between renders. This selective execution can optimize performance by avoiding unnecessary work.

import { useEffect, useState } from 'react';

function NameStorage() {
    const [name, setName] = useState('');

    useEffect(() => {
        localStorage.setItem('name', name);
    }, [name]);

    return (
        <label>
            Name
            <input
                onChange={event => setName(event.target.value)}
                type="text"
                value={name}
            />
        </label>
    );
}

export default NameStorage;
No dependency array

If the dependency array is omitted, the effect runs after every render of the component.

import { useEffect, useState } from 'react';

function UpdateLogger() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        console.info('Component updated!');
    }); // No dependency array, runs on every update

    return (
        <button onClick={() => setCount(count + 1)}>
            Increment
        </button>
    );
}

export default UpdateLogger;

Async operations inside useEffect

You can use APIs that return a Promise normally within a useEffect:

import { useEffect, useState } from 'react';

function DataFetcher() {
    const [data, setData] = useState(null);

    useEffect(() => {
        fetch('https://api.example.com/data')
            .then(response => {
                const parsedData = response.json();
                setData(parsedData);
            })
            .catch(error => {
                // Error should be shown to the user
                console.error('Error fetching data:', error)
            });
    }, []);

    return (
        <p>{data}</p>
    );
}

export default DataFetcher;

However, unlike traditional functions, useEffect functions can’t be marked as async. This is because returning a Promise from useEffect would conflict with its mechanism, which expects either nothing or a clean-up function to be returned.

To handle asynchronous operations, you typically define an async function inside the effect and then call it:

import { useEffect, useState } from 'react';

function DataFetcher() {
    const [data, setData] = useState(null);

    useEffect(() => {
        const fetchData = async () => {
            try {
                const response = await fetch('https://api.example.com/data');
                const parsedData = response.json();
                setData(parsedData);
            } catch (error) {
                // Error should be shown to the user
                console.error('Error fetching data:', error)
            }
        };

        fetchData();
    }, []);

    return (
        <p>{data}</p>
    );
}

export default DataFetcher;

When using async/await, error handling is typically done using try-catch blocks. This allows you to gracefully handle any errors that occur during the execution of your async operation.

In this example, if fetch throws an error, the catch block catches and handles it. This pattern is crucial to prevent unhandled promise rejections and ensure that your application can respond appropriately to failures in asynchronous tasks.

TypeScript generics

TODO? We’ve used generics, but maybe explain the ones we’re going to use so it’s a little familiar?

Cleanup functions

The effect function can optionally return another function, known as the “cleanup” function. The cleanup function is useful for performing any necessary cleanup activities when the component unmounts or before the component re-renders and the effect is re-invoked. Common examples include clearing timers, canceling network requests, or removing event listeners.

import { useEffect, useState } from 'react';

function WebSocketComponent() {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const socket = new WebSocket('wss://chat.donejs.com/');

    socket.onmessage = (event) => {
      setMessages(previousMessages => {
        return [ ...previousMessages, event.data ];
      });
    };

    return () => {
      // Clean up (tear down) the socket connection
      return socket.close();
    };
  }, []);

  return (
    <ol>
      {messages.map((message) => (
        <li key={message}>{message}</li>
      ))}
    </ol>
  );
}

export default WebSocketComponent;

In the example above, we’re creating a WebSocket connection to an API when the component is first rendered (note the empty dependency array).

When the component is removed from the DOM, the cleanup function will run and tear down the WebSocket connection.

Environment variables

The way we’re accessing our locally run API during development may be different than how we access it in production. To prepare for this, we’ll set an environment variable to do what we need.

TODO: Explain that setting environment variables is a generic thing you do, and on this project in particular, Vite will make anything prefixed with VITE_ available in our client-side source code.

Setup

✏️ Create .env and update it to be:

VITE_PMO_API = '//localhost:7070'

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

import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useEffect, useState } from 'react'
import ListItem from './ListItem'
import { useCities } from '../../services/restaurant/hooks'
import { State } from '../../services/restaurant/interfaces'

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

const RestaurantList: React.FC = () => {
  const [state, setState] = useState("")
  const [city, setCity] = useState("")

  const states = [
    { name: 'Illinois', short: 'IL' },
    { name: 'Wisconsin', short: 'WI' },
  ]

  const cities = useCities(state)

  const restaurants = {
    data: [
      {
        name: 'Cheese Curd City',
        slug: 'cheese-curd-city',
        images: {
          thumbnail: CheeseThumbnail,
        },
        address: {
          street: '2451 W Washburne Ave',
          city: 'Green Bay',
          state: 'WI',
          zip: '53295',
        },
        _id: 'Ar0qBJHxM3ecOhcr',
      },
      {
        name: 'Poutine Palace',
        slug: 'poutine-palace',
        images: {
          thumbnail: PoutineThumbnail,
        },
        address: {
          street: '230 W Kinzie Street',
          city: 'Green Bay',
          state: 'WI',
          zip: '53205',
        },
        _id: '3ZOZyTY1LH26LnVw',
      },
    ]
  };

  const updateState = (stateShortCode: string) => {
    setState(stateShortCode)
    setCity("")
  }

  const updateCity = (cityName: string) => {
    setCity(cityName)
  }

  return (
    <>
      <div className="restaurants">
        <h2 className="page-header">Restaurants</h2>

        <form className="form">
          <div className="form-group">
            <label className="control-label" htmlFor="stateSelect">
              State
            </label>
            <select
              className="form-control"
              id="stateSelect"
              onChange={event => updateState(event.target.value)}
              value={state}
            >
              <option key="choose_state" value="">
                {
                  statesResponse.isPending
                    ? "Loading states…"
                    : statesResponse.error
                      ? statesResponse.error.message
                      : "Choose a state"
                }
              </option>
              {statesResponse.data?.map(({ short, name }) => (
                <option key={short} value={short}>
                  {name}
                </option>
              ))}
            </select>
          </div>

          <div className="form-group">
            City:
            {state ? cities.map(({ name }) => (
              <button key={name} onClick={() => updateCity(name)} type="button">
                {name}
              </button>
            )) : <> Choose a state before selecting a city</>}
            <hr />
            <p>
              Current city: {city || "(none)"}
            </p>
          </div>
        </form>

        {restaurants.data ? (
          restaurants.data.map(({ _id, address, images, name, slug }) => (
            <ListItem
              key={_id}
              address={address}
              name={name}
              slug={slug}
              thumbnail={images.thumbnail}
            />
          ))
        ) : (
          <p>No restaurants.</p>
        )}
      </div>
    </>
  )
}

export default RestaurantList

Install the Place My Order API

Before we begin requesting data from our API, we need to install the place-my-order-api module, which will generate fake restaurant data and serve it from port 7070.

✏️ Run:

npm install place-my-order-api@1

✏️ Next add an API script to your package.json

{
  "name": "place-my-order",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "api": "place-my-order-api --port 7070",
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "test": "vitest",
    "preview": "vite preview"
  },
  "dependencies": {
    "place-my-order-api": "^1.3.0",
    "place-my-order-assets": "^0.2.2",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.22.0"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^6.4.2",
    "@testing-library/react": "^14.2.1",
    "@testing-library/user-event": "^14.5.2",
    "@types/react": "^18.2.43",
    "@types/react-dom": "^18.2.17",
    "@typescript-eslint/eslint-plugin": "^6.14.0",
    "@typescript-eslint/parser": "^6.14.0",
    "@vitejs/plugin-react": "^4.2.1",
    "eslint": "^8.55.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.5",
    "jsdom": "^24.0.0",
    "typescript": "^5.2.2",
    "vite": "^5.0.8",
    "vitest": "^1.2.2"
  }
}

✏️ In a new terminal window, start the API server by running:

npm run api

Double check the API by navigating to localhost:7070/restaurants. You should see a JSON list of restaurant data. It will be helpful to have a second terminal tab to run the api command from.

Verify

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

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

import * as restaurantHooks from '../../services/restaurant/hooks'
import RestaurantList from './RestaurantList';

// Mocking necessary modules
vi.mock('../../services/restaurant/hooks')

// Mocking the global fetch function
const mockFetch = vi.fn();

global.fetch = mockFetch;

beforeEach(() => {
  mockFetch.mockClear();

  mockFetch.mockResolvedValueOnce({
    ok: true,
    json: () => Promise.resolve({ message: 'success' }),
    statusText: 'OK',
    status: 200,
  });
});

afterEach(() => {
  mockFetch.mockClear();
});

describe('RestaurantList component', () => {
  beforeEach(async () => {
    vi.spyOn(restaurantHooks, 'useCities').mockReturnValue([
      { name: 'Green Bay' },
      { name: 'Madison' },
    ])
    render(<RestaurantList />);
    await act(() => {})
  })

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

  it('renders the restaurant images', () => {
    const images = screen.getAllByRole('img');
    expect(images[0]).toHaveAttribute('src', expect.stringContaining('2-thumbnail.jpg'));
    expect(images[0]).toHaveAttribute('width', '100');
    expect(images[0]).toHaveAttribute('height', '100');
    expect(images[1]).toHaveAttribute('src', expect.stringContaining('4-thumbnail.jpg'));
    expect(images[1]).toHaveAttribute('width', '100');
    expect(images[1]).toHaveAttribute('height', '100');
  });

  it('renders the addresses', () => {
    const addressDivs = screen.getAllByText(/Washburne Ave|Kinzie Street/i);
    expect(addressDivs[0]).toHaveTextContent('2451 W Washburne Ave');
    expect(addressDivs[0]).toHaveTextContent('Green Bay, WI 53295');
    expect(addressDivs[1]).toHaveTextContent('230 W Kinzie Street');
    expect(addressDivs[1]).toHaveTextContent('Green Bay, WI 53205');
  });

  it('renders the hours and price information for each restaurant', () => {
    const hoursPriceDivs = screen.getAllByText(/\$\$\$/i);
    hoursPriceDivs.forEach(div => {
      expect(div).toHaveTextContent('$$$');
      expect(div).toHaveTextContent('Hours: M-F 10am-11pm');
    });
  });

  it('indicates if the restaurant is open now for each restaurant', () => {
    const openNowTags = screen.getAllByText('Open Now');
    expect(openNowTags.length).toBeGreaterThan(0);
  });

  it('renders the details buttons with correct links for each restaurant', () => {
    const detailsButtons = screen.getAllByRole('link');
    expect(detailsButtons[0]).toHaveAttribute('href', '/restaurants/cheese-curd-city');
    expect(detailsButtons[1]).toHaveAttribute('href', '/restaurants/poutine-palace');
    detailsButtons.forEach(button => {
      expect(button).toHaveTextContent('Details');
    });
  });

  it('renders ListItem components for each restaurant', () => {
    const restaurantNames = screen.getAllByText(/Cheese Curd City|Poutine Palace/)
    expect(restaurantNames.length).toBe(2)
  })
});

Exercise

  • Update RestaurantList.tsx to call useState() and use the StateResponse interface.
  • Call useEffect() and fetch data from ${import.meta.env.VITE_PMO_API}/states.

Hint: Call your state setter after you parse the JSON response from fetch().

Solution

Click to see the solution

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

import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useEffect, useState } from 'react'
import ListItem from './ListItem'
import { useCities } from '../../services/restaurant/hooks'
import { State } from '../../services/restaurant/interfaces'

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

const RestaurantList: React.FC = () => {
  const [state, setState] = useState("")
  const [city, setCity] = useState("")

  const [statesResponse, setStatesResponse] = useState<StatesResponse>({
    data: null,
    error: null,
    isPending: true,
  })

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${import.meta.env.VITE_PMO_API}/states`, {
        method: "GET",
      })

      const data = await response.json()

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

  const cities = useCities(state)

  const restaurants = {
    data: [
      {
        name: 'Cheese Curd City',
        slug: 'cheese-curd-city',
        images: {
          thumbnail: CheeseThumbnail,
        },
        address: {
          street: '2451 W Washburne Ave',
          city: 'Green Bay',
          state: 'WI',
          zip: '53295',
        },
        _id: 'Ar0qBJHxM3ecOhcr',
      },
      {
        name: 'Poutine Palace',
        slug: 'poutine-palace',
        images: {
          thumbnail: PoutineThumbnail,
        },
        address: {
          street: '230 W Kinzie Street',
          city: 'Green Bay',
          state: 'WI',
          zip: '53205',
        },
        _id: '3ZOZyTY1LH26LnVw',
      },
    ]
  };

  const updateState = (stateShortCode: string) => {
    setState(stateShortCode)
    setCity("")
  }

  const updateCity = (cityName: string) => {
    setCity(cityName)
  }

  return (
    <>
      <div className="restaurants">
        <h2 className="page-header">Restaurants</h2>

        <form className="form">
          <div className="form-group">
            <label className="control-label" htmlFor="stateSelect">
              State
            </label>
            <select
              className="form-control"
              id="stateSelect"
              onChange={event => updateState(event.target.value)}
              value={state}
            >
              <option key="choose_state" value="">
                {
                  statesResponse.isPending
                    ? "Loading states…"
                    : statesResponse.error
                      ? statesResponse.error.message
                      : "Choose a state"
                }
              </option>
              {statesResponse.data?.map(({ short, name }) => (
                <option key={short} value={short}>
                  {name}
                </option>
              ))}
            </select>
          </div>

          <div className="form-group">
            City:
            {state ? cities.map(({ name }) => (
              <button key={name} onClick={() => updateCity(name)} type="button">
                {name}
              </button>
            )) : <> Choose a state before selecting a city</>}
            <hr />
            <p>
              Current city: {city || "(none)"}
            </p>
          </div>
        </form>

        {restaurants.data ? (
          restaurants.data.map(({ _id, address, images, name, slug }) => (
            <ListItem
              key={_id}
              address={address}
              name={name}
              slug={slug}
              thumbnail={images.thumbnail}
            />
          ))
        ) : (
          <p>No restaurants.</p>
        )}
      </div>
    </>
  )
}

export default RestaurantList

Objective 2: Move the fetch to a useStates Hook

In this section, we will:

  • Refactor our <RestaurantList> component to depend on a custom Hook.

Writing custom Hooks as services

In a previous section, we created a useCities Hook in our hooks.ts file.

Putting stateful logic into a custom Hook has numerous benefits:

Reusability: One of the primary reasons for creating custom Hooks is reusability. You might find yourself repeating the same logic in different components—for example, fetching data from an API, handling form input, or managing a subscription. By refactoring this logic into a custom Hook, you can easily reuse this functionality across multiple components, keeping your code DRY (Don't Repeat Yourself).

Separation of concerns: Custom Hooks allow you to separate complex logic from the component logic. This makes your main component code cleaner and more focused on rendering UI, while the custom Hook handles the business logic or side effects. It aligns well with the principle of single responsibility, where a function or module should ideally do one thing only.

Easier testing and maintenance: Isolating logic into custom Hooks can make your code easier to test and maintain. Since Hooks are just JavaScript functions, they can be tested independently of any component. This isolation can lead to more robust and reliable code.

Simplifying components: If your component is becoming too large and difficult to understand, moving some logic to a custom Hook can simplify it. This not only improves readability but also makes it easier for other developers to grasp what the component is doing.

Sharing stateful logic: Custom Hooks can contain stateful logic, which is not possible with regular JavaScript functions. This means you can have a Hook that manages its own state and shares this logic across multiple components, something that would be difficult or impossible with traditional class-based components.

Setup

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

import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useState } from 'react'
import ListItem from './ListItem'
import { useCities, useStates } from '../../services/restaurant/hooks'

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

const RestaurantList: React.FC = () => {
  const [state, setState] = useState("")
  const [city, setCity] = useState("")

  const [statesResponse, setStatesResponse] = useState<StatesResponse>({
    data: null,
    error: null,
    isPending: true,
  })

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${import.meta.env.VITE_PMO_API}/states`, {
        method: "GET",
      })

      const data = await response.json()

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

  const cities = useCities(state)

  const restaurants = {
    data: [
      {
        name: 'Cheese Curd City',
        slug: 'cheese-curd-city',
        images: {
          thumbnail: CheeseThumbnail,
        },
        address: {
          street: '2451 W Washburne Ave',
          city: 'Green Bay',
          state: 'WI',
          zip: '53295',
        },
        _id: 'Ar0qBJHxM3ecOhcr',
      },
      {
        name: 'Poutine Palace',
        slug: 'poutine-palace',
        images: {
          thumbnail: PoutineThumbnail,
        },
        address: {
          street: '230 W Kinzie Street',
          city: 'Green Bay',
          state: 'WI',
          zip: '53205',
        },
        _id: '3ZOZyTY1LH26LnVw',
      },
    ]
  };

  const updateState = (stateShortCode: string) => {
    setState(stateShortCode)
    setCity("")
  }

  const updateCity = (cityName: string) => {
    setCity(cityName)
  }

  return (
    <>
      <div className="restaurants">
        <h2 className="page-header">Restaurants</h2>

        <form className="form">
          <div className="form-group">
            <label className="control-label" htmlFor="stateSelect">
              State
            </label>
            <select
              className="form-control"
              id="stateSelect"
              onChange={event => updateState(event.target.value)}
              value={state}
            >
              <option key="choose_state" value="">
                {
                  statesResponse.isPending
                    ? "Loading states…"
                    : statesResponse.error
                      ? statesResponse.error.message
                      : "Choose a state"
                }
              </option>
              {statesResponse.data?.map(({ short, name }) => (
                <option key={short} value={short}>
                  {name}
                </option>
              ))}
            </select>
          </div>

          <div className="form-group">
            City:
            {state ? cities.map(({ name }) => (
              <button key={name} onClick={() => updateCity(name)} type="button">
                {name}
              </button>
            )) : <> Choose a state before selecting a city</>}
            <hr />
            <p>
              Current city: {city || "(none)"}
            </p>
          </div>
        </form>

        {restaurants.data ? (
          restaurants.data.map(({ _id, address, images, name, slug }) => (
            <ListItem
              key={_id}
              address={address}
              name={name}
              slug={slug}
              thumbnail={images.thumbnail}
            />
          ))
        ) : (
          <p>No restaurants.</p>
        )}
      </div>
    </>
  )
}

export default RestaurantList

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

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

export function useCities(state: string): City[] {
  const cities = [
    { name: 'Madison', state: 'WI' },
    { name: 'Springfield', state: 'IL' },
  ]
  return cities.filter(city => {
    return city.state === state
  })
}

Verify

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

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

import * as restaurantHooks from '../../services/restaurant/hooks'
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,
    }
  }),
  useStates: vi.fn(() => {
    return {
      data: null,
      error: null,
      isPending: false,
    }
  }),
}));

describe('RestaurantList component', () => {
  beforeEach(async () => {
    vi.spyOn(restaurantHooks, 'useCities').mockReturnValue([
      { name: 'Green Bay' },
      { name: 'Madison' },
    ])
    render(<RestaurantList />);
    await act(() => {})
  })

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

  it('renders the restaurant images', () => {
    const images = screen.getAllByRole('img');
    expect(images[0]).toHaveAttribute('src', expect.stringContaining('2-thumbnail.jpg'));
    expect(images[0]).toHaveAttribute('width', '100');
    expect(images[0]).toHaveAttribute('height', '100');
    expect(images[1]).toHaveAttribute('src', expect.stringContaining('4-thumbnail.jpg'));
    expect(images[1]).toHaveAttribute('width', '100');
    expect(images[1]).toHaveAttribute('height', '100');
  });

  it('renders the addresses', () => {
    const addressDivs = screen.getAllByText(/Washburne Ave|Kinzie Street/i);
    expect(addressDivs[0]).toHaveTextContent('2451 W Washburne Ave');
    expect(addressDivs[0]).toHaveTextContent('Green Bay, WI 53295');
    expect(addressDivs[1]).toHaveTextContent('230 W Kinzie Street');
    expect(addressDivs[1]).toHaveTextContent('Green Bay, WI 53205');
  });

  it('renders the hours and price information for each restaurant', () => {
    const hoursPriceDivs = screen.getAllByText(/\$\$\$/i);
    hoursPriceDivs.forEach(div => {
      expect(div).toHaveTextContent('$$$');
      expect(div).toHaveTextContent('Hours: M-F 10am-11pm');
    });
  });

  it('indicates if the restaurant is open now for each restaurant', () => {
    const openNowTags = screen.getAllByText('Open Now');
    expect(openNowTags.length).toBeGreaterThan(0);
  });

  it('renders the details buttons with correct links for each restaurant', () => {
    const detailsButtons = screen.getAllByRole('link');
    expect(detailsButtons[0]).toHaveAttribute('href', '/restaurants/cheese-curd-city');
    expect(detailsButtons[1]).toHaveAttribute('href', '/restaurants/poutine-palace');
    detailsButtons.forEach(button => {
      expect(button).toHaveTextContent('Details');
    });
  });

  it('renders ListItem components for each restaurant', () => {
    const restaurantNames = screen.getAllByText(/Cheese Curd City|Poutine Palace/)
    expect(restaurantNames.length).toBe(2)
  })
});

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

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

describe('useCities Hook', () => {
    it('should return cities from Wisconsin when state is WI', () => {
        const { result } = renderHook(() => useCities('WI'));
        expect(result.current).toHaveLength(1);
        expect(result.current[0].name).toBe('Madison');
    });

    it('should return cities from Illinois when state is IL', () => {
        const { result } = renderHook(() => useCities('IL'));
        expect(result.current).toHaveLength(1);
        expect(result.current[0].name).toBe('Springfield');
    });

    it('should return no cities for an unknown state', () => {
        const { result } = renderHook(() => useCities('CA'));
        expect(result.current).toHaveLength(0);
    });
});

describe('useStates Hook', () => {
    beforeEach(async () => {
        // Mocking the fetch function
        global.fetch = vi.fn();
    })

    it('should set the states data on successful fetch', async () => {
        const mockStates = [{ name: 'State1' }, { name: 'State2' }];
        fetch.mockResolvedValueOnce({
            json: () => Promise.resolve({ data: mockStates }),
        });

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

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

Exercise

  • Refactor the existing useState and useEffect logic into a new useStates Hook.

Hint: After moving the state and effect logic into hooks.ts, use your new Hook in RestaurantList.tsx.

Solution

Click to see the solution

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

import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useState } from 'react'
import { useCities, useStates } from '../../services/restaurant/hooks'
import ListItem from './ListItem'

const RestaurantList: React.FC = () => {
  const [state, setState] = useState("")
  const [city, setCity] = useState("")

  const statesResponse = useStates()

  const cities = useCities(state)

  const restaurants = {
    data: [
      {
        name: 'Cheese Curd City',
        slug: 'cheese-curd-city',
        images: {
          thumbnail: CheeseThumbnail,
        },
        address: {
          street: '2451 W Washburne Ave',
          city: 'Green Bay',
          state: 'WI',
          zip: '53295',
        },
        _id: 'Ar0qBJHxM3ecOhcr',
      },
      {
        name: 'Poutine Palace',
        slug: 'poutine-palace',
        images: {
          thumbnail: PoutineThumbnail,
        },
        address: {
          street: '230 W Kinzie Street',
          city: 'Green Bay',
          state: 'WI',
          zip: '53205',
        },
        _id: '3ZOZyTY1LH26LnVw',
      },
    ]
  };

  const updateState = (stateShortCode: string) => {
    setState(stateShortCode)
    setCity("")
  }

  const updateCity = (cityName: string) => {
    setCity(cityName)
  }

  return (
    <>
      <div className="restaurants">
        <h2 className="page-header">Restaurants</h2>

        <form className="form">
          <div className="form-group">
            <label className="control-label" htmlFor="stateSelect">
              State
            </label>
            <select
              className="form-control"
              id="stateSelect"
              onChange={event => updateState(event.target.value)}
              value={state}
            >
              <option key="choose_state" value="">
                {
                  statesResponse.isPending
                    ? "Loading states…"
                    : statesResponse.error
                      ? statesResponse.error.message
                      : "Choose a state"
                }
              </option>
              {statesResponse.data?.map(({ short, name }) => (
                <option key={short} value={short}>
                  {name}
                </option>
              ))}
            </select>
          </div>

          <div className="form-group">
            City:
            {state ? cities.map(({ name }) => (
              <button key={name} onClick={() => updateCity(name)} type="button">
                {name}
              </button>
            )) : <> Choose a state before selecting a city</>}
            <hr />
            <p>
              Current city: {city || "(none)"}
            </p>
          </div>
        </form>

        {restaurants.data ? (
          restaurants.data.map(({ _id, address, images, name, slug }) => (
            <ListItem
              key={_id}
              address={address}
              name={name}
              slug={slug}
              thumbnail={images.thumbnail}
            />
          ))
        ) : (
          <p>No restaurants.</p>
        )}
      </div>
    </>
  )
}

export default RestaurantList

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

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

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

export function useCities(state: string): City[] {
  const cities = [
    { name: 'Madison', state: 'WI' },
    { name: 'Springfield', state: 'IL' },
  ]
  return cities.filter(city => {
    return city.state === state
  })
}

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

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${import.meta.env.VITE_PMO_API}/states`, {
        method: "GET",
      })

      const data = await response.json()

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

  return response
}

Objective 3: Update the useCities Hook to fetch data from the API.

In this section, we will:

  • Learn about including query parameters in our API calls.

Including query parameters in API calls

Query parameters are a defined set of parameters attached to the end of a URL. They are used to define and pass data in the form of key-value pairs. The parameters are separated from the URL itself by a ? symbol, and individual key-value pairs are separated by the & symbol.

A basic URL with query parameters looks like this:

http://www.example.com/page?param1=value1&param2=value2

Here’s a breakdown of this URL:

  • Base URL: http://www.example.com/page
  • Query Parameter Indicator: ?
  • Query Parameters:
    • param1=value1
    • param2=value2

Setup

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

import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useState } from 'react'
import { useCities, useStates } from '../../services/restaurant/hooks'
import ListItem from './ListItem'

const RestaurantList: React.FC = () => {
  const [state, setState] = useState("")
  const [city, setCity] = useState("")

  const statesResponse = useStates()

  const cities = useCities(state)

  const restaurants = {
    data: [
      {
        name: 'Cheese Curd City',
        slug: 'cheese-curd-city',
        images: {
          thumbnail: CheeseThumbnail,
        },
        address: {
          street: '2451 W Washburne Ave',
          city: 'Green Bay',
          state: 'WI',
          zip: '53295',
        },
        _id: 'Ar0qBJHxM3ecOhcr',
      },
      {
        name: 'Poutine Palace',
        slug: 'poutine-palace',
        images: {
          thumbnail: PoutineThumbnail,
        },
        address: {
          street: '230 W Kinzie Street',
          city: 'Green Bay',
          state: 'WI',
          zip: '53205',
        },
        _id: '3ZOZyTY1LH26LnVw',
      },
    ]
  };

  const updateState = (stateShortCode: string) => {
    setState(stateShortCode)
    setCity("")
  }

  const updateCity = (cityName: string) => {
    setCity(cityName)
  }

  return (
    <>
      <div className="restaurants">
        <h2 className="page-header">Restaurants</h2>

        <form className="form">
          <div className="form-group">
            <label className="control-label" htmlFor="stateSelect">
              State
            </label>
            <select
              className="form-control"
              id="stateSelect"
              onChange={event => updateState(event.target.value)}
              value={state}
            >
              <option key="choose_state" value="">
                {
                  statesResponse.isPending
                    ? "Loading states…"
                    : statesResponse.error
                      ? statesResponse.error.message
                      : "Choose a state"
                }
              </option>
              {statesResponse.data?.map(({ short, name }) => (
                <option key={short} value={short}>
                  {name}
                </option>
              ))}
            </select>
          </div>

          <div className="form-group">
            <label className="control-label" htmlFor="citySelect">
              City
            </label>
            <select
              className="form-control"
              id="citySelect"
              onChange={event => updateCity(event.target.value)}
              value={city}
            >
              <option key="choose_city" value="">
                {
                  state
                    ? citiesResponse.isPending
                      ? "Loading cities…"
                      : citiesResponse.error
                        ? citiesResponse.error.message
                        : "Choose a city"
                    : "Choose a state before selecting a city"
                }
              </option>
              {state && citiesResponse.data?.map(({ name }) => (
                <option key={name} value={name}>
                  {name}
                </option>
              ))}
            </select>
          </div>
        </form>

        {restaurants.data ? (
          restaurants.data.map(({ _id, address, images, name, slug }) => (
            <ListItem
              key={_id}
              address={address}
              name={name}
              slug={slug}
              thumbnail={images.thumbnail}
            />
          ))
        ) : (
          <p>No restaurants.</p>
        )}
      </div>
    </>
  )
}

export default RestaurantList

Verify

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

import '@testing-library/jest-dom';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
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,
    }
  }),
  useStates: vi.fn(() => {
    return {
      data: null,
      error: null,
      isPending: false,
    }
  }),
}));

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

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

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

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

    render(<RestaurantList />);
    await act(() => {})

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

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

    render(<RestaurantList />);
    await act(() => {})

    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 });

    render(<RestaurantList />);
    await act(() => {})

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

    expect(screen.getByText('Cheese Curd City')).toBeInTheDocument();
  });
});

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

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

describe('useCities Hook', () => {
    beforeEach(() => {
        global.fetch = vi.fn();
    });

    it('initial state of useCities', async () => {
        vi.mocked(fetch).mockResolvedValueOnce({
            json: async () => ({ data: null }),
        });

        const { result } = renderHook(() => useCities('someState'));
        await waitFor(() => {
            expect(result.current.isPending).toBe(true);
            expect(result.current.data).toBeNull();
            expect(result.current.error).toBeNull();
        });
    });

    it('fetches cities successfully', async () => {
        const mockCities = [{ id: 1, name: 'City1' }, { id: 2, name: 'City2' }];
        vi.mocked(fetch).mockResolvedValueOnce({
            json: async () => ({ data: mockCities }),
        });

        const { result } = renderHook(() => useCities('someState'));

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

describe('useStates Hook', () => {
    beforeEach(async () => {
        // Mocking the fetch function
        global.fetch = vi.fn();
    })

    it('should set the states data on successful fetch', async () => {
        const mockStates = [{ name: 'State1' }, { name: 'State2' }];
        fetch.mockResolvedValueOnce({
            json: () => Promise.resolve({ data: mockStates }),
        });

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

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

Exercise

Update our useCities Hook to fetch cities from the Place My Order API, given a selected state.

When calling the Place My Order API, include the state query parameter:

http://localhost:7070/cities?state=MO

Solution

Click to see the solution

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

import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useState } from 'react'
import { useCities, useStates } from '../../services/restaurant/hooks'
import ListItem from './ListItem'

const RestaurantList: React.FC = () => {
  const [state, setState] = useState("")
  const [city, setCity] = useState("")

  const statesResponse = useStates()

  const citiesResponse = useCities(state)

  const restaurants = {
    data: [
      {
        name: 'Cheese Curd City',
        slug: 'cheese-curd-city',
        images: {
          thumbnail: CheeseThumbnail,
        },
        address: {
          street: '2451 W Washburne Ave',
          city: 'Green Bay',
          state: 'WI',
          zip: '53295',
        },
        _id: 'Ar0qBJHxM3ecOhcr',
      },
      {
        name: 'Poutine Palace',
        slug: 'poutine-palace',
        images: {
          thumbnail: PoutineThumbnail,
        },
        address: {
          street: '230 W Kinzie Street',
          city: 'Green Bay',
          state: 'WI',
          zip: '53205',
        },
        _id: '3ZOZyTY1LH26LnVw',
      },
    ]
  };

  const updateState = (stateShortCode: string) => {
    setState(stateShortCode)
    setCity("")
  }

  const updateCity = (cityName: string) => {
    setCity(cityName)
  }

  return (
    <>
      <div className="restaurants">
        <h2 className="page-header">Restaurants</h2>

        <form className="form">
          <div className="form-group">
            <label className="control-label" htmlFor="stateSelect">
              State
            </label>
            <select
              className="form-control"
              id="stateSelect"
              onChange={event => updateState(event.target.value)}
              value={state}
            >
              <option key="choose_state" value="">
                {
                  statesResponse.isPending
                    ? "Loading states…"
                    : statesResponse.error
                      ? statesResponse.error.message
                      : "Choose a state"
                }
              </option>
              {statesResponse.data?.map(({ short, name }) => (
                <option key={short} value={short}>
                  {name}
                </option>
              ))}
            </select>
          </div>

          <div className="form-group">
            <label className="control-label" htmlFor="citySelect">
              City
            </label>
            <select
              className="form-control"
              id="citySelect"
              onChange={event => updateCity(event.target.value)}
              value={city}
            >
              <option key="choose_city" value="">
                {
                  state
                    ? citiesResponse.isPending
                      ? "Loading cities…"
                      : citiesResponse.error
                        ? citiesResponse.error.message
                        : "Choose a city"
                    : "Choose a state before selecting a city"
                }
              </option>
              {state && citiesResponse.data?.map(({ name }) => (
                <option key={name} value={name}>
                  {name}
                </option>
              ))}
            </select>
          </div>
        </form>

        {restaurants.data ? (
          restaurants.data.map(({ _id, address, images, name, slug }) => (
            <ListItem
              key={_id}
              address={address}
              name={name}
              slug={slug}
              thumbnail={images.thumbnail}
            />
          ))
        ) : (
          <p>No restaurants.</p>
        )}
      </div>
    </>
  )
}

export default RestaurantList

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

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

interface CitiesResponse {
  data: City[] | 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 response = await fetch(`${import.meta.env.VITE_PMO_API}/cities?state=${state}`, {
        method: "GET",
      })

      const data = await response.json()

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

  return response
}

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

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${import.meta.env.VITE_PMO_API}/states`, {
        method: "GET",
      })

      const data = await response.json()

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

  return response
}

Objective 4: Create an apiRequest helper and use it in the Hooks.

In this section, we will learn how to:

  • Handle HTTP error statuses (e.g. 404 Not Found)
  • Catch network errors from fetch()

Checking for error responses

  • .ok
  • .status
  • .statusText

When you make a request with the Fetch API, it does not reject on HTTP error statuses (like 404 or 500). Instead, it resolves normally (with an ok status set to false), and it only rejects on network failure or if anything prevented the request from completing.

Here’s the API that fetch provides to handle these HTTP errors:

  • .ok: This is a shorthand property that returns true if the response’s status code is in the range 200-299, indicating a successful request.
  • .status: This property returns the status code of the response (e.g. 200 for success, 404 for Not Found, etc.).
  • .statusText: This provides the status message corresponding to the status code (e.g. 'OK', 'Not Found', etc.).
const response = await fetch('https://api.example.com/data', {
    method: "GET",
})

const data = await response.json()
const error = response.ok ? null : new Error(`${response.status} (${response.statusText})`)

In the example above, we check the response.ok property to see if the status code is in the 200-299 (successful) range. If not, we create an error object that contains the status code and text (e.g. 404 Not Found).

Handling network errors

Network errors occur when there is a problem in completing the request, like when the user is offline, the server is unreachable, or there is a DNS lookup failure.

In these cases, the fetch API will not resolve with data, but instead it will throw an error that needs to be caught.

Let’s take a look at how to handle these types of errors:

try {
    const response = await fetch('https://api.example.com/data', {
        method: "GET",
    })

    const data = await response.json()
    const error = response.ok ? null : new Error(`${response.status} (${response.statusText})`)
    // Do something with data and error

} catch (error) {
    const parsedError = error instanceof Error ? error : new Error('An unknown error occurred')
    // Do something with parsedError
}

In the example above, we catch the error and check its type. If it’s already an instanceof Error, then it will have a message property and we can use it as-is. If it’s not, then we can create our own new Error() so we always have an error to consume in our Hooks or components.

Setup

✏️ Create src/services/api.ts and update it to be:

export async function apiRequest<Data = never, Params = unknown>({
    method,
    params,
    path,
}: {
    method: string
    params?: Params
    path: string
}): Promise<{ data: Data | null, error: Error | null }> {
}

export function stringifyQuery(input: Record<string, string>): string {
    const output: string[] = []

    for (const [key, value] of Object.entries(input)) {
        if (typeof value !== "undefined" && value !== null) {
            output.push(`${key}=${value}`)
        }
    }

    return output.join("&")
}

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

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

interface CitiesResponse {
  data: City[] | 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 response = await fetch(`${import.meta.env.VITE_PMO_API}/cities?state=${state}`, {
        method: "GET",
      })

      const data = await response.json()

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

  return response
}

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

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${import.meta.env.VITE_PMO_API}/states`, {
        method: "GET",
      })

      const data = await response.json()

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

  return response
}

Verify

✏️ Create src/services/api.test.ts and update it to be:

import { apiRequest, stringifyQuery } from './api';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Mocking the global fetch function
const mockFetch = vi.fn();

global.fetch = mockFetch;

beforeEach(() => {
  mockFetch.mockClear();
});

afterEach(() => {
  mockFetch.mockClear();
});

describe('apiRequest function', () => {
  it('should handle a successful request', async () => {
    // Mock the fetch response
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ message: 'success' }),
      statusText: 'OK',
      status: 200,
    });

    const response = await apiRequest({
      method: 'GET',
      path: '/test',
    });

    expect(response).toEqual({ data: { message: 'success' }, error: null });
    expect(mockFetch).toHaveBeenCalledWith(`${import.meta.env.VITE_PMO_API}/test?`, { method: 'GET' });
  });

  it('should handle a failed request', async () => {
    // Mock the fetch response
    mockFetch.mockResolvedValueOnce({
      ok: false,
      json: () => Promise.resolve({ message: 'error' }),
      statusText: 'Bad Request',
      status: 400,
    });

    const response = await apiRequest({
      method: 'GET',
      path: '/test',
    });

    expect(response).toEqual({ data: { message: 'error' }, error: new Error('400 (Bad Request)') });
  });

  it('should handle network errors', async () => {
    // Mock a network error
    mockFetch.mockRejectedValueOnce(new Error('Network Error'));

    const response = await apiRequest({
      method: 'GET',
      path: '/test',
    });

    expect(response).toEqual({ data: null, error: new Error('Network Error') });
  });
});

describe('stringifyQuery function', () => {
  it('should correctly stringify query parameters', () => {
    const query = stringifyQuery({ foo: 'bar', baz: 'qux' });
    expect(query).toBe('foo=bar&baz=qux');
  });

  it('should omit undefined and null values', () => {
    const query = stringifyQuery({ foo: 'bar', baz: null, qux: undefined });
    expect(query).toBe('foo=bar');
  });
});

✏️ 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, 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('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

  • Implement the apiRequest helper function to handle errors returned and thrown from fetch().
  • Update the useCities and useStates Hooks to use the data and error returned from apiRequest.

Hint: Use the new stringifyQuery function to convert an object of query parameters to a string:

stringifyQuery({
    param1: "value1",
    param2: "value2",
})

Solution

Click to see the solution

✏️ Update src/services/api.ts to be:

export async function apiRequest<Data = never, Params = unknown>({
    method,
    params,
    path,
}: {
    method: string
    params?: Params
    path: string
}): Promise<{ data: Data | null, error: Error | null }> {
    try {
        const query = params ? stringifyQuery(params) : ""
        const response = await fetch(`${import.meta.env.VITE_PMO_API}${path}?${query}`, {
            method,
        })

        const data = await response.json()
        const error = response.ok ? null : new Error(`${response.status} (${response.statusText})`)

        return {
            data: data,
            error: error,
        }
    } catch (error) {
        return {
            data: null,
            error: error instanceof Error ? error : new Error('An unknown error occurred'),
        }
    }
}

export function stringifyQuery(input: Record<string, string>): string {
    const output: string[] = []

    for (const [key, value] of Object.entries(input)) {
        if (typeof value !== "undefined" && value !== null) {
            output.push(`${key}=${value}`)
        }
    }

    return output.join("&")
}

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

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

interface CitiesResponse {
  data: City[] | 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 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
}

Objective 5: Fetch restaurant data

In this section, we will:

  • Create a useRestaurants Hook for fetching the restaurant data.

Now that we are able to capture a user’s state and city preferences, we want to only return restaurants in the selected city.:

Setup

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

import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useState } from 'react'
import { useCities, useRestaurants, useStates } from '../../services/restaurant/hooks'
import ListItem from './ListItem'

const RestaurantList: React.FC = () => {
  const [state, setState] = useState("")
  const [city, setCity] = useState("")

  const statesResponse = useStates()

  const citiesResponse = useCities(state)

  const restaurants = {
    data: [
      {
        name: 'Cheese Curd City',
        slug: 'cheese-curd-city',
        images: {
          thumbnail: CheeseThumbnail,
        },
        address: {
          street: '2451 W Washburne Ave',
          city: 'Green Bay',
          state: 'WI',
          zip: '53295',
        },
        _id: 'Ar0qBJHxM3ecOhcr',
      },
      {
        name: 'Poutine Palace',
        slug: 'poutine-palace',
        images: {
          thumbnail: PoutineThumbnail,
        },
        address: {
          street: '230 W Kinzie Street',
          city: 'Green Bay',
          state: 'WI',
          zip: '53205',
        },
        _id: '3ZOZyTY1LH26LnVw',
      },
    ]
  };

  const updateState = (stateShortCode: string) => {
    setState(stateShortCode)
    setCity("")
  }

  const updateCity = (cityName: string) => {
    setCity(cityName)
  }

  return (
    <>
      <div className="restaurants">
        <h2 className="page-header">Restaurants</h2>

        <form className="form">
          <div className="form-group">
            <label className="control-label" htmlFor="stateSelect">
              State
            </label>
            <select
              className="form-control"
              id="stateSelect"
              onChange={event => updateState(event.target.value)}
              value={state}
            >
              <option key="choose_state" value="">
                {
                  statesResponse.isPending
                    ? "Loading states…"
                    : statesResponse.error
                      ? statesResponse.error.message
                      : "Choose a state"
                }
              </option>
              {statesResponse.data?.map(({ short, name }) => (
                <option key={short} value={short}>
                  {name}
                </option>
              ))}
            </select>
          </div>

          <div className="form-group">
            <label className="control-label" htmlFor="citySelect">
              City
            </label>
            <select
              className="form-control"
              id="citySelect"
              onChange={event => updateCity(event.target.value)}
              value={city}
            >
              <option key="choose_city" value="">
                {
                  state
                    ? citiesResponse.isPending
                      ? "Loading cities…"
                      : citiesResponse.error
                        ? citiesResponse.error.message
                        : "Choose a city"
                    : "Choose a state before selecting a city"
                }
              </option>
              {state && citiesResponse.data?.map(({ name }) => (
                <option key={name} value={name}>
                  {name}
                </option>
              ))}
            </select>
          </div>
        </form>

        {city && restaurantsResponse.error && (
          <p aria-live="polite" className="restaurant">
            Error loading restaurants: {restaurantsResponse.error.message}
          </p>
        )}

        {city && restaurantsResponse.isPending && (
          <p aria-live="polite" className="restaurant loading">
            Loading restaurants…
          </p>
        )}

        {city && restaurantsResponse.data && (
          restaurantsResponse.data.length === 0 ? (
            !restaurantsResponse.isPending && (
              <p aria-live="polite">No restaurants found.</p>
            )
          ) : (
            restaurantsResponse.data.map(({ _id, slug, name, address, images }) => (
              <ListItem
                key={_id}
                address={address}
                name={name}
                slug={slug}
                thumbnail={images.thumbnail}
              />
            ))
          )
        )}
      </div>
    </>
  )
}

export default RestaurantList

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

export interface City {
  name: string
  state: string
}

interface Item {
  name: string;
  price: number;
}

interface Menu {
  dinner: Item[];
  lunch: Item[];
}

interface Address {
  city: string;
  state: string;
  street: string;
  zip: string;
}

interface Images {
  banner: string;
  owner: string;
  thumbnail: string;
}

export interface Restaurant {
  _id: string;
  address?: Address;
  images: Images;
  menu: Menu;
  name: string;
  slug: string;
}

export interface State {
  name: string
  short: string
}

✏️ 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 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 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

If you’ve implemented the solution correctly, when you use the select boxes to choose state and city, you should see a list of just restaurants from the selected city returned.

✏️ 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 { 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'

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 });

    render(<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('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

  • Implement a useRestaurants Hook to fetch restaurant data.
  • Update RestaurantList.tsx to use your new useRestaurants Hook.

Hint: The requested URL with query parameters should look like this: '/api/restaurants?filter[address.state]=IL&filter[address.city]=Chicago'

Solution

Click to see the solution

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

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

const RestaurantList: React.FC = () => {
  const [state, setState] = useState("")
  const [city, setCity] = useState("")

  const statesResponse = useStates()

  const citiesResponse = useCities(state)

  const restaurantsResponse = useRestaurants(state, city)

  const updateState = (stateShortCode: string) => {
    setState(stateShortCode)
    setCity("")
  }

  const updateCity = (cityName: string) => {
    setCity(cityName)
  }

  return (
    <>
      <div className="restaurants">
        <h2 className="page-header">Restaurants</h2>

        <form className="form">
          <div className="form-group">
            <label className="control-label" htmlFor="stateSelect">
              State
            </label>
            <select
              className="form-control"
              id="stateSelect"
              onChange={event => updateState(event.target.value)}
              value={state}
            >
              <option key="choose_state" value="">
                {
                  statesResponse.isPending
                    ? "Loading states…"
                    : statesResponse.error
                      ? statesResponse.error.message
                      : "Choose a state"
                }
              </option>
              {statesResponse.data?.map(({ short, name }) => (
                <option key={short} value={short}>
                  {name}
                </option>
              ))}
            </select>
          </div>

          <div className="form-group">
            <label className="control-label" htmlFor="citySelect">
              City
            </label>
            <select
              className="form-control"
              id="citySelect"
              onChange={event => updateCity(event.target.value)}
              value={city}
            >
              <option key="choose_city" value="">
                {
                  state
                    ? citiesResponse.isPending
                      ? "Loading cities…"
                      : citiesResponse.error
                        ? citiesResponse.error.message
                        : "Choose a city"
                    : "Choose a state before selecting a city"
                }
              </option>
              {state && citiesResponse.data?.map(({ name }) => (
                <option key={name} value={name}>
                  {name}
                </option>
              ))}
            </select>
          </div>
        </form>

        {city && restaurantsResponse.error && (
          <p aria-live="polite" className="restaurant">
            Error loading restaurants: {restaurantsResponse.error.message}
          </p>
        )}

        {city && restaurantsResponse.isPending && (
          <p aria-live="polite" className="restaurant loading">
            Loading restaurants…
          </p>
        )}

        {city && restaurantsResponse.data && (
          restaurantsResponse.data.length === 0 ? (
            !restaurantsResponse.isPending && (
              <p aria-live="polite">No restaurants found.</p>
            )
          ) : (
            restaurantsResponse.data.map(({ _id, slug, name, address, images }) => (
              <ListItem
                key={_id}
                address={address}
                name={name}
                slug={slug}
                thumbnail={images.thumbnail}
              />
            ))
          )
        )}
      </div>
    </>
  )
}

export default RestaurantList

✏️ 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 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 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
}

Next steps

TODO