Making HTTP Requests page

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

Overview

In this section, we will:

  • Define interfaces for useState
  • Explore the useEffect Hook
  • Understand the effect callback function
  • Utilize the dependency array
  • Perform async operations inside useEffect
  • Implement cleanup functions
  • Manage environment variables
  • Include query parameters in API calls
  • Handle HTTP error statuses
  • Catch network errors

Objective 1: Add a fetch request for states

So far we’ve only had hard-coded data for our states, cities, and restaurants. Let’s start loading data from an API server, beginning with the list of states!

Defining interfaces for useState

When building React components, you may sometimes have local state variables that always change together, and thus would benefit by being in a single useState() variable together:

import { useState } from "react"

interface UserProfile {
  email: string
  name: string
}

const UserProfileComponent: React.FC = () => {
  const [userProfile, setUserProfile] = useState<UserProfile>({
    email: "grace.hopper@example.com",
    name: "Grace Hopper",
  })

  const updateProfile = () => {
    setUserProfile({
      email: "ada.lovelace@example.com",
      name: "Ada Lovelace",
    })
  }

  return (
    <form>
      <p>Name: {userProfile.name}</p>
      <p>Email: {userProfile.email}</p>
      <button onClick={updateProfile}>Update profile</button>
    </form>
  )
}

export default UserProfileComponent

In the example above, we have a UserProfile interface that keeps track of an email and name. We can use that interface when we call useState() so TypeScript is aware of the typing for the state variable and its setter.

The useEffect Hook

useEffect is a React Hook that lets you perform side effects in your function 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"

interface Coordinates {
  latitude: number
  longitude: number
}

const GeolocationComponent: React.FC = () => {
  const [location, setLocation] = useState<Coordinates | null>(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"

interface Coordinates {
  latitude: number
  longitude: number
}

const GeolocationComponent: React.FC = () => {
  const [location, setLocation] = useState<Coordinates | null>(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. This should not be needed.

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 = await 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 = await 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.

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.

Environment variables are dynamic-named values that can affect the way running processes on a computer will behave. In the context of software development, they are used to manage specific settings or configurations that should not be hardcoded within the application’s source code.

This is particularly useful for:

  • Security: Keeping sensitive data like API keys or database passwords out of the source code.
  • Flexibility: Allowing configurations to change depending on the environment (development, staging, production).
  • Convenience: Making it easier to update configuration settings without changing the application’s code.

In our project, we’ll utilize environment variables to set ourselves up to be able to differentiate between the development and production environments, especially in how we connect to different instances of our API.

Using environment variables with Vite

Vite, our build tool, provides an easy way to work with environment variables. In Vite, any environment variable prefixed with VITE_ is made available in our client-side source code. This prefix is necessary because Vite only exposes variables that are explicitly meant for client-side consumption, ensuring server-side variables remain secure.

Here’s how we can use it: in our project’s root directory, we can create a .env file with variables like this:

VITE_PMO_API = '//localhost:7070'

Then we can access this variable using import.meta.env:

const response = await fetch(`${import.meta.env.VITE_PMO_API}/data`, {
  method: "GET",
})

const data = await response.json()

Concatenating the two, this will be the equivalent of making this fetch request:

const response = await fetch(`//localhost:7070/data`, {
  method: "GET",
})

Setup 1

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": {
    "typecheck": "tsc",
    "eslint": "eslint vite.config.ts src",
    "prettier": "prettier --check vite.config.ts src",
    "test": "vitest",
    "dev": "vite",
    "build": "tsc && vite build"
  },
  "eslintConfig": {
    "root": true,
    "extends": "@bitovi/eslint-config/react"
  },
  "prettier": {
    "semi": false,
    "trailingComma": "all"
  },
  "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": {
    "@bitovi/eslint-config": "^1.8.0",
    "@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",
    "@vitejs/plugin-react": "^4.2.1",
    "eslint": "^8.55.0",
    "jsdom": "^24.0.0",
    "prettier": "^3.2.5",
    "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 third terminal tab for the npm run api command.

Create and update files

✏️ Create .env and update it to be:

VITE_PMO_API = '//localhost:7070'

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

import type { State } from "../../services/restaurant/interfaces"
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"

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

Verify 1

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

  • 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().

Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 1

Click to see the solution

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

import type { State } from "../../services/restaurant/interfaces"
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"

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

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

Objective 2: Move the fetch to a useStates Hook

In a previous section, we created a useCities Hook in our hooks.ts file so that our code would be more maintainable, then we used the Hook in our <RestaurantList> component.

Our <RestaurantList> component is starting to get long again with the new state list data fetching code that we just added. Let’s refactor it and move our fetch logic to a custom Hook!

Setup 2

✏️ 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 type { City, State } from "./interfaces"
import { useEffect, useState } from "react"

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 2

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

  • 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.

Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 2

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 type { City, State } from "./interfaces"
import { useEffect, useState } from "react"

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
}

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

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

Let’s continue our quest to load data from our API and update our useCities Hook to fetch data.

To do this, we’ll need to include query parameters in our API call to the /cities endpoint.

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 3

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

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

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

// 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", () => {
  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 3

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

Hint: Remember to include the state in the dependency array of the useEffect() in useCities().

Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 3

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 type { City, State } from "./interfaces"
import { useEffect, useState } from "react"

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
}

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

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

Now that we have two Hooks that fetch data in a similar way, let’s create an apiRequest helper function that both Hooks can use.

Handle HTTP error statuses

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).

Catch 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 4

✏️ 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 }> {
  return {
    data: null,
    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 type { City, State } from "./interfaces"
import { useEffect, useState } from "react"
import { apiRequest } from "../api"

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 4

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

  • 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",
})

Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 4

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 type { City, State } from "./interfaces"
import { useEffect, useState } from "react"
import { apiRequest } from "../api"

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
}

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

Objective 5: Fetch the list of restaurants

Let’s finish our quest to load data from our API by creating a Hook to fetch the list of restaurants and use it in our component.

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

A web page titled “Restaurants” from place-my-order.com showing that “Illinois” and “Chicago” are selected. Underneath the dropdowns is a list of restaurants that are in Chicago.

Setup 5

✏️ 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 type { City, Restaurant, State } from "./interfaces"
import { useEffect, useState } from "react"
import { apiRequest } from "../api"

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 5

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"

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

// 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,
    }
  }),
}))

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 5

  • Create a useRestaurants Hook for fetching the list of restaurants.
  • Update RestaurantList.tsx to use our new useRestaurants Hook.

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

Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 5

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 type { City, Restaurant, State } from "./interfaces"
import { useEffect, useState } from "react"
import { apiRequest } from "../api"

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
}

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

Next steps

Next, let’s learn how to have child routes within parent routes with React Router.