Making HTTP Requests page

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

Overview

In this section, you will:

  • Manage environment variables.
  • 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.
  • Catch network errors.
  • Handle HTTP error statuses.
  • Include query parameters in API calls.

Objective 1: Fetch states in a custom Hook

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!

Screenshot of the application when it makes an API call to the states endpoint and is populated the list of states.

Defining interfaces for useState

When building React Native 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"
import { View, Text, Pressable } from "react-native"

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 (
    <View>
      <Text>Name: {userProfile.name}</Text>
      <Text>Email: {userProfile.email}</Text>
      <Pressable onPress={updateProfile}>
        <Text>Update profile</Text>
      </Pressable>
    </View>
  )
}

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"
import { Keyboard, Text, View } from "react-native"

const GeolocationComponent: React.FC = () => {
  const [keyboardStatus, setKeyboardStatus] = useState("")

  useEffect(() => {
    // Effect callback function
    const showSubscription = Keyboard.addListener("keyboardDidShow", () => {
      setKeyboardStatus("Keyboard shown")
    })
    const hideSubscription = Keyboard.addListener("keyboardDidHide", () => {
      setKeyboardStatus("Keyboard hidden")
    })

    return () => {
      // Teardown function
      showSubscription.remove()
      hideSubscription.remove()
    }
  }, []) // Dependency array

  return (
    <View>
      {keyboardStatus ? (
        <Text>{keyboardStatus}</Text>
      ) : (
        <Text>Checking keyboard status…</Text>
      )}
    </View>
  )
}

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 UI in React Native components.

The key aspect of this function is that it’s executed after the component renders. The effects in useEffect don’t block the UI 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"
import { Keyboard, Text, View } from "react-native"

const GeolocationComponent: React.FC = () => {
  const [keyboardStatus, setKeyboardStatus] = useState("")

  useEffect(() => {
    // Effect callback function
    const showSubscription = Keyboard.addListener("keyboardDidShow", () => {
      setKeyboardStatus("Keyboard shown")
    })
    const hideSubscription = Keyboard.addListener("keyboardDidHide", () => {
      setKeyboardStatus("Keyboard hidden")
    })

    return () => {
      // Teardown function
      showSubscription.remove()
      hideSubscription.remove()
    }
  }, []) // Dependency array

  return (
    <View>
      {keyboardStatus ? (
        <Text>{keyboardStatus}</Text>
      ) : (
        <Text>Checking keyboard status…</Text>
      )}
    </View>
  )
}

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"
import { Pressable, Vibration, View } from "react-native"

function VibrateButtons() {
  const [vibrate, setVibrate] = useState(false)

  useEffect(() => {
    if (vibrate) {
      Vibration.vibrate(1000)
    }

    return () => {
      Vibration.cancel()
    }
  }, [vibrate])

  return (
    <View>
      <Pressable onPress={() => setVibrate(true)}>
        <Text>Vibrate</Text>
      </Pressable>
      <Pressable onPress={() => setVibrate(false)}>
        <Text>Stop vibrating</Text>
      </Pressable>
    </View>
  )
}

export default VibrateButtons

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"
import { Pressable, Text } from "react-native"

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

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

  return (
    <Pressable onPress={() => setCount(count + 1)}>
      <Text>Increment</Text>
    </Pressable>
  )
}

export default UpdateLogger

Async operations inside useEffect

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

import { useEffect, useState } from "react"
import { Text } from "react-native"

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 <Text>{data}</Text>
}

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"
import { Text } from "react-native"

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 <Text>{data}</Text>
}

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"
import { View, FlatList, Text } from "react-native"

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 (
    <View>
      <FlatList
        data={messages}
        renderItem={({ item }) => <Text>{item}</Text>}
        keyExtractor={(item, index) => index.toString()}
      />
    </View>
  )
}

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 UI, 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 react-native-dotenv

When using React Native with environment variables, you can utilize a package like "react-native-dotenv" to manage environment variables. Here’s how you can adapt the provided context for a React Native application:

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

PMO_API=http://localhost:7070

Then we can access this variable using process.env.PMO_API:

const response = await fetch(`${process.env.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(`http://localhost:7070/data`, {
  method: "GET",
})

Setup 1

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.

✏️ Install the new dev dependency:

npm install --save-dev place-my-order-api@1 react-native-dotenv@3

✏️ Create .env.example and update it to be:

PMO_API=http://localhost:7070

✏️ Duplicate .env.example to .env in your project.

It’s always a good idea to keep a .env.example file up to date (and committed to git) in your project, then include the actual secrets in your local .env file (and not committed to git).

We do not have any secret values yet, so our .env can be an exact duplicate of the .env.example file.

✏️ Update .gitignore to be:

# OSX
#
.DS_Store

# Windows
#
Thumbs.db

# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
**/.xcode.env.local

# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
*.keystore
!debug.keystore

# node.js
#
node_modules/
npm-debug.log
yarn-error.log

# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/

**/fastlane/report.xml
**/fastlane/Preview.html
**/fastlane/screenshots
**/fastlane/test_output

# Bundle artifact
*.jsbundle
*.tsbuildinfo

# Environment variables
.env

# Place My Order API
/db-data

# Ruby / CocoaPods
**/Pods/
/vendor/bundle/

# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*

# testing
/coverage

# Yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

✏️ Update babel.config.js to be:

module.exports = {
  plugins: ["module:react-native-dotenv"],
  presets: ["module:@react-native/babel-preset"],
}

✏️ Create src/env.d.ts and update it to be:

/* eslint-disable @typescript-eslint/no-namespace */

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      readonly PMO_API: string
    }
  }
}

export {}

✏️ Terminate the existing dev server and start it again:

npm run start

✏️ Update package.json to be:

{
  "name": "PlaceMyOrder",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "android": "react-native run-android",
    "android:linkLocalhost": "adb reverse tcp:7070 tcp:7070",
    "api": "npm run android:linkLocalhost && place-my-order-api --port 7070",
    "clean": "rm -rf tsconfig.tsbuildinfo coverage android/.gradle android/build android/app/build node_modules/.cache",
    "depcheck": "depcheck .",
    "devtools": "react-devtools",
    "eslint": "eslint .",
    "eslint:fix": "eslint --fix .",
    "ios": "react-native run-ios",
    "lint": "npm run eslint && npm run prettier",
    "lint:fix": "npm run eslint:fix && npm run prettier:fix",
    "precheck": "npm run typecheck && npm run lint && npm run depcheck && npm test",
    "prettier": "prettier --check .",
    "prettier:fix": "prettier --write .",
    "start": "react-native start --reset-cache --experimental-debugger",
    "test": "jest",
    "test:inspect": "node --inspect-brk ./node_modules/.bin/jest --watch",
    "test:watch": "jest --watch",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@react-navigation/bottom-tabs": "^6.5.20",
    "@react-navigation/native": "^6.1.17",
    "@react-navigation/stack": "^6.3.29",
    "react": "18.2.0",
    "react-native": "0.74.1",
    "react-native-gesture-handler": "^2.16.2",
    "react-native-safe-area-context": "^4.10.1",
    "react-native-screens": "^3.31.1",
    "react-native-vector-icons": "^10.1.0"
  },
  "devDependencies": {
    "@babel/core": "^7.20.0",
    "@babel/preset-env": "^7.20.0",
    "@babel/runtime": "^7.20.0",
    "@bitovi/eslint-config": "^1.8.0",
    "@react-native/babel-preset": "0.74.83",
    "@react-native/eslint-config": "0.74.83",
    "@react-native/metro-config": "0.74.83",
    "@react-native/typescript-config": "0.74.83",
    "@testing-library/react-native": "^12.5.0",
    "@types/jest": "^29.5.12",
    "@types/react": "^18.2.6",
    "@types/react-native-vector-icons": "^6.4.18",
    "@types/react-test-renderer": "^18.0.0",
    "babel-jest": "^29.6.3",
    "depcheck": "^1.4.7",
    "eslint": "^8.19.0",
    "identity-obj-proxy": "^3.0.0",
    "jest": "^29.6.3",
    "place-my-order-api": "^1.7.0",
    "prettier": "2.8.8",
    "react-devtools": "^5.2.0",
    "react-native-dotenv": "^3.4.11",
    "react-test-renderer": "18.2.0",
    "typescript": "5.0.4"
  },
  "engines": {
    "node": ">=20"
  }
}

✏️ In a new command-line interface (CLI) 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 command-line interface (CLI) tab for the npm run api command.

✏️ Update src/App.test.tsx to be:

import { render, screen } from "@testing-library/react-native"

import App from "./App"

const oldFetch = global.fetch
const mockFetch = jest.fn()
beforeAll(() => {
  global.fetch = mockFetch
})
afterAll(() => {
  global.fetch = oldFetch
})

describe("App", () => {
  const mockStateResponse = {
    data: [
      { short: "MI", name: "Michigan" },
      { short: "WI", name: "Wisconsin" },
      { short: "IL", name: "Illinois" },
    ],
  }

  it("renders", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockStateResponse),
      statusText: "OK",
      status: 200,
    })

    render(<App />)

    const placeMyOrderText = await screen.findAllByText(/Place my order/i)
    expect(placeMyOrderText).toHaveLength(2)
  })
})

✏️ Create src/services/pmo/restaurant/interfaces.ts and update it to be:

export interface State {
  name: string
  short: string
}

✏️ Create src/services/pmo/restaurant/hooks.ts and update it to be:

import { useEffect, useState } from "react"

import { State } from "./interfaces"

const baseUrl = process.env.PMO_API

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

export function useStates(): StatesResponse {
  // Exercise: Update the `useState` in `hooks.ts` to call `useEffect()` and `fetch` data from `${process.env.PMO_API}/states`.
}

✏️ Create src/services/pmo/restaurant/index.ts and update it to be:

export * from "./interfaces"
export * from "./hooks"

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

import { ActivityIndicator } from "react-native"

import Box from "../../design/Box"
import { useTheme } from "../../design/theme"
import Typography from "../../design/Typography"

const Loading: React.FC = () => {
  const theme = useTheme()

  return (
    <Box padding="l">
      <ActivityIndicator size="large" color={theme.palette.primary.main} />
      <Typography variant="body" style={{ textAlign: "center", marginTop: 8 }}>
        Loading…
      </Typography>
    </Box>
  )
}

export default Loading

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

import { render, screen } from "@testing-library/react-native"

import Loading from "./Loading"

describe("Components/Loading", () => {
  it("renders", () => {
    render(<Loading />)
    expect(screen.getByText(/Loading…/)).toBeOnTheScreen()
  })
})

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

export { default } from "./Loading"

✏️ Update src/screens/StateList/StateList.tsx to be:

import { useNavigation } from "@react-navigation/native"
import { FlatList } from "react-native"

import Loading from "../../components/Loading"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useStates } from "../../services/pmo/restaurant"

const StateList: React.FC = () => {
  const navigation = useNavigation()
  // Exercise: Update `StateList.tsx` to call `useState()` and use the `StateResponse` interface.

  return (
    <Screen noScroll>
      {states?.length ? (
        <FlatList
          data={states}
          renderItem={({ item: stateItem }) => (
            <Button
              onPress={() => {
                navigation.navigate("CityList", {
                  state: stateItem,
                })
              }}
            >
              {stateItem.name}
            </Button>
          )}
          keyExtractor={(item) => item.short}
        />
      ) : (
        <Typography>No states found</Typography>
      )}
    </Screen>
  )
}

export default StateList

Verify 1

✏️ Create src/services/pmo/restaurant/hooks.test.ts and update it to be:

import { renderHook, waitFor } from "@testing-library/react-native"

import { useStates } from "./hooks"

const oldFetch = global.fetch
const mockFetch = jest.fn()
beforeAll(() => {
  global.fetch = mockFetch
})
afterAll(() => {
  global.fetch = oldFetch
})

describe("Services/PMO/Restaurant/useStates", () => {
  it("returns states", async () => {
    const mockStates = [
      { id: 1, name: "State1" },
      { id: 2, name: "State2" },
    ]
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ message: "success", data: mockStates }),
      statusText: "OK",
      status: 200,
    })

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

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

✏️ Update src/screens/StateList/StateList.test.tsx to be:

import { NavigationContainer } from "@react-navigation/native"
import { render, screen } from "@testing-library/react-native"

import * as restaurantHooks from "../../services/pmo/restaurant/hooks"

import StateList from "./StateList"

describe("Screens/StateList", () => {
  // Mock the hooks and components used in StateList
  const mockStateResponse = {
    data: [
      { short: "MI", name: "Michigan" },
      { short: "WI", name: "Wisconsin" },
      { short: "IL", name: "Illinois" },
    ],
  }

  let useStates: jest.SpyInstance<ReturnType<typeof restaurantHooks.useStates>>
  beforeEach(() => {
    jest.resetAllMocks()
    useStates = jest.spyOn(restaurantHooks, "useStates")
  })

  it("renders", () => {
    useStates.mockReturnValue({
      ...mockStateResponse,
      error: undefined,
      isPending: false,
    })

    render(
      <NavigationContainer>
        <StateList />
      </NavigationContainer>,
    )
    expect(screen.getByText(/Michigan/i)).toBeOnTheScreen()
    expect(screen.getByText(/Wisconsin/i)).toBeOnTheScreen()
    expect(screen.getByText(/Illinois/i)).toBeOnTheScreen()
  })

  it("renders loading state", () => {
    useStates.mockReturnValue({
      data: undefined,
      error: undefined,
      isPending: true,
    })

    render(
      <NavigationContainer>
        <StateList />
      </NavigationContainer>,
    )

    expect(screen.getByText(/Loading/i)).toBeOnTheScreen()
  })
})

Exercise 1

  • Update the useState in hooks.ts to call useEffect() and fetch data from ${process.env.PMO_API}/states.
  • Update StateList.tsx to call useState() and use the StateResponse interface.

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

Hint: useState() return isPending and error. Use these states to inform the user the status.

Hint: Use Array.isArray(data) ? data : data?.data ?? undefined when you need to check for data being an Array or an Object response.

Solution 1

If you’ve implemented the solution correctly, the tests will pass when you run npm run test!

Click to see the solution

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

import { useEffect, useState } from "react"

import { State } from "./interfaces"

const baseUrl = process.env.PMO_API

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

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

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${baseUrl}/states`, {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
        },
      })

      const data = await response.json()

      setResponse({
        data: Array.isArray(data) ? data : data?.data ?? undefined,
        error: undefined,
        isPending: false,
      })
    }
    fetchData()
  }, [])

  return response
}

✏️ Update src/screens/StateList/StateList.tsx to be:

import { useNavigation } from "@react-navigation/native"
import { FlatList } from "react-native"

import Loading from "../../components/Loading"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useStates } from "../../services/pmo/restaurant"

const StateList: React.FC = () => {
  const navigation = useNavigation()
  const { data: states, error, isPending } = useStates()

  if (error) {
    return (
      <Screen>
        <Box padding="m">
          <Typography variant="heading">Error loading states: </Typography>
          <Typography variant="body">{error.message}</Typography>
        </Box>
      </Screen>
    )
  }

  if (isPending) {
    return <Loading />
  }

  return (
    <Screen noScroll>
      {states?.length ? (
        <FlatList
          data={states}
          renderItem={({ item: stateItem }) => (
            <Button
              onPress={() => {
                navigation.navigate("CityList", {
                  state: stateItem,
                })
              }}
            >
              {stateItem.name}
            </Button>
          )}
          keyExtractor={(item) => item.short}
        />
      ) : (
        <Typography>No states found</Typography>
      )}
    </Screen>
  )
}

export default StateList

Objective 2: Fetch cities in a custom Hook with query parameters

Let’s continue our quest to load data from our API and update our <CitiesList> to use a custom useCities Hook to fetch data.

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

Screenshot of the application when it makes an API call to the cities endpoint and is populated the list of cities.

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 2

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

export interface State {
  name: string
  short: string
}

export interface City {
  name: string
  state: string
}

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

import { useEffect, useState } from "react"

import { City, State } from "./interfaces"

const baseUrl = process.env.PMO_API

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

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

interface UseCitiesParams {
  state?: string
}

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

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${baseUrl}/states`, {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
        },
      })

      const data = await response.json()

      setResponse({
        data: Array.isArray(data) ? data : data?.data ?? undefined,
        error: undefined,
        isPending: false,
      })
    }
    fetchData()
  }, [])

  return response
}

export function useCities({ state }: UseCitiesParams): CitiesResponse {
  // Exercise: Update our `useCities()` Hook to fetch cities from the Place My Order API, given a selected state.
}

✏️ Update src/screens/CityList/CityList.tsx to be:

import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { FlatList } from "react-native"

import { RestaurantsStackParamList } from "../../App"
import Loading from "../../components/Loading"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useCities } from "../../services/pmo/restaurant"

export interface CityListProps
  extends StackScreenProps<RestaurantsStackParamList, "CityList"> {}

const CityList: React.FC<CityListProps> = ({ route }) => {
  const { state } = route.params
  const navigation = useNavigation()
  // Exercise: When calling the Place My Order API, include the `state` query parameter.

  return (
    <Screen noScroll>
      <FlatList
        data={cities}
        renderItem={({ item: cityItem }) => (
          <Button
            onPress={() => {
              navigation.navigate("RestaurantList", {
                state,
                city: cityItem,
              })
            }}
          >
            {cityItem.name}
          </Button>
        )}
        keyExtractor={(item) => item.name}
      />
    </Screen>
  )
}

export default CityList

Verify 2

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

import { renderHook, waitFor } from "@testing-library/react-native"

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

const oldFetch = global.fetch
const mockFetch = jest.fn()
beforeAll(() => {
  global.fetch = mockFetch
})
afterAll(() => {
  global.fetch = oldFetch
})

describe("Services/PMO/Restaurant/useStates", () => {
  it("returns states", async () => {
    const mockStates = [
      { id: 1, name: "State1" },
      { id: 2, name: "State2" },
    ]
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ message: "success", data: mockStates }),
      statusText: "OK",
      status: 200,
    })

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

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

describe("Services/PMO/Restaurant/useCities", () => {
  it("returns cities", async () => {
    const mockCities = [
      { id: 1, name: "City1" },
      { id: 2, name: "City2" },
    ]
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ message: "success", data: mockCities }),
      statusText: "OK",
      status: 200,
    })

    const { result } = renderHook(() => useCities({ state: "test-state" }))

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

✏️ Update src/screens/CityList/CityList.test.tsx to be:

import { NavigationContainer } from "@react-navigation/native"
import { render, screen } from "@testing-library/react-native"

import * as restaurantHooks from "../../services/pmo/restaurant/hooks"

import CityList from "./CityList"

const route = {
  key: "RestaurantDetails",
  name: "RestaurantDetails",
  params: {
    state: {
      name: "name",
      short: "short",
    },
  },
} as const

describe("Screens/CityList", () => {
  // Mock the hooks and components used in CityList

  const mockCitiesResponse = {
    data: [
      { name: "Detroit", state: "MI" },
      { name: "Ann Arbor", state: "MI" },
    ],
  }
  let useCities: jest.SpyInstance<ReturnType<typeof restaurantHooks.useCities>>
  beforeEach(() => {
    jest.resetAllMocks()
    useCities = jest.spyOn(restaurantHooks, "useCities")
  })

  it("renders", () => {
    useCities.mockReturnValue({
      ...mockCitiesResponse,
      error: undefined,
      isPending: false,
    })

    render(
      <NavigationContainer>
        {/* @ts-ignore */}
        <CityList route={route} />
      </NavigationContainer>,
    )
    expect(screen.getByText(/Detroit/i)).toBeOnTheScreen()
    expect(screen.getByText(/Ann Arbor/i)).toBeOnTheScreen()
  })

  it("renders loading state", () => {
    useCities.mockReturnValue({
      data: undefined,
      error: undefined,
      isPending: true,
    })

    render(
      <NavigationContainer>
        {/* @ts-ignore */}
        <CityList route={route} />
      </NavigationContainer>,
    )

    expect(screen.getByText(/Loading/i)).toBeOnTheScreen()
  })
})

Exercise 2

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

Solution 2

If you’ve implemented the solution correctly, the tests will pass when you run npm run test!

Click to see the solution

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

import { useEffect, useState } from "react"

import { City, State } from "./interfaces"

const baseUrl = process.env.PMO_API

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

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

interface UseCitiesParams {
  state?: string
}

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

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${baseUrl}/states`, {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
        },
      })

      const data = await response.json()

      setResponse({
        data: Array.isArray(data) ? data : data?.data ?? undefined,
        error: undefined,
        isPending: false,
      })
    }
    fetchData()
  }, [])

  return response
}

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

  useEffect(() => {
    const fetchData = async () => {
      if (state) {
        const response = await fetch(`${baseUrl}/cities?state=${state}`, {
          method: "GET",
          headers: {
            "Content-Type": "application/json",
          },
        })

        const data = await response.json()

        setResponse({
          data: Array.isArray(data) ? data : data?.data ?? undefined,
          error: undefined,
          isPending: false,
        })
      }
    }
    fetchData()
  }, [state])

  return response
}

✏️ Update src/screens/CityList/CityList.tsx to be:

import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { FlatList } from "react-native"

import { RestaurantsStackParamList } from "../../App"
import Loading from "../../components/Loading"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useCities } from "../../services/pmo/restaurant"

export interface CityListProps
  extends StackScreenProps<RestaurantsStackParamList, "CityList"> {}

const CityList: React.FC<CityListProps> = ({ route }) => {
  const { state } = route.params
  const navigation = useNavigation()
  const {
    data: cities,
    error,
    isPending,
  } = useCities({ state: state.short || "" })

  if (error) {
    return (
      <Screen>
        <Box padding="m">
          <Typography variant="heading">Error loading cities: </Typography>
          <Typography variant="body">{error.message}</Typography>
        </Box>
      </Screen>
    )
  }

  if (isPending) {
    return <Loading />
  }

  return (
    <Screen noScroll>
      <FlatList
        data={cities}
        renderItem={({ item: cityItem }) => (
          <Button
            onPress={() => {
              navigation.navigate("RestaurantList", {
                state,
                city: cityItem,
              })
            }}
          >
            {cityItem.name}
          </Button>
        )}
        keyExtractor={(item) => item.name}
      />
    </Screen>
  )
}

export default CityList

Objective 3: Create API request helper

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.

While we do this, let‘s add error handling for unsuccessful API requests:

Screenshot of the application when it makes an API call to the cities endpoint and is populated the list of cities. Screenshot of the application when it makes an API call and the API returns an error.

Handle HTTP error statuses

When you make a request with the Fetch API, it does not reject 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 3

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

const baseUrl = process.env.PMO_API

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

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

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

  // Exercise: Implement the `apiRequest` helper function to handle errors returned and thrown from `fetch()`.

  return output.join("&")
}

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

export * from "./api"

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

import { useEffect, useState } from "react"

import { apiRequest } from "../api"

import { City, State } from "./interfaces"

const baseUrl = process.env.PMO_API

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

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

interface UseCitiesParams {
  state?: string
}

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

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${baseUrl}/states`, {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
        },
      })

      const data = await response.json()

      setResponse({
        data: Array.isArray(data) ? data : data?.data ?? undefined,
        error: undefined,
        isPending: false,
      })
    }
    fetchData()
  }, [])

  return response
}

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

  useEffect(() => {
    const fetchData = async () => {
      if (state) {
        const response = await fetch(`${baseUrl}/cities?state=${state}`, {
          method: "GET",
          headers: {
            "Content-Type": "application/json",
          },
        })

        const data = await response.json()

        setResponse({
          data: Array.isArray(data) ? data : data?.data ?? undefined,
          error: undefined,
          isPending: false,
        })
      }
    }
    fetchData()
  }, [state])

  return response
}

Verify 3

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

import { renderHook, waitFor } from "@testing-library/react-native"

import * as api from "../api/api"

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

let apiRequest: jest.SpyInstance<ReturnType<typeof api.apiRequest>>
beforeEach(() => {
  jest.resetAllMocks()
  apiRequest = jest.spyOn(api, "apiRequest")
})

describe("Services/PMO/Restaurant/useStates", () => {
  it("returns states", async () => {
    const mockStates = [
      { id: 1, name: "State1" },
      { id: 2, name: "State2" },
    ]
    apiRequest.mockResolvedValue({
      data: { data: mockStates },
      error: undefined,
    })

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

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

  it("handles errors", async () => {
    const mockError = new Error("Error fetching states")
    apiRequest.mockResolvedValue({ data: undefined, error: mockError })

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

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

describe("Services/PMO/Restaurant/useCities", () => {
  it("returns cities", async () => {
    const mockCities = [
      { id: 1, name: "City1" },
      { id: 2, name: "City2" },
    ]
    apiRequest.mockResolvedValue({
      data: { data: mockCities },
      error: undefined,
    })

    const { result } = renderHook(() => useCities({ state: "test-state" }))

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

  it("handles errors", async () => {
    const mockError = new Error("Error fetching cities")
    apiRequest.mockResolvedValue({ data: undefined, error: mockError })

    const { result } = renderHook(() => useCities({ state: "test-state" }))

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

Exercise 3

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

If you’ve implemented the solution correctly, the tests will pass when you run npm run test!

Click to see the solution

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

const baseUrl = process.env.PMO_API

export async function apiRequest<
  Data = never,
  Params = unknown,
  Body = unknown,
>({
  method,
  params,
  path,
  body,
}: {
  method: string
  params?: Params
  path: string
  body?: Body
}): Promise<{ data: Data | undefined; error: Error | undefined }> {
  try {
    const query = params ? stringifyQuery(params) : ""
    const requestUrl = `${baseUrl}${path}?${query}`

    const response = await fetch(requestUrl, {
      method,
      headers: {
        "Content-Type": "application/json",
      },
      body: body ? JSON.stringify(body) : undefined,
    })

    const data = await response.json()

    if (!response.ok) {
      const error = new Error(`${response.status} (${response.statusText})`)
      return { data: data, error: error }
    }

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

export function stringifyQuery(
  input: Record<string, string | undefined | undefined>,
): 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/pmo/restaurant/hooks.ts to be:

import { useEffect, useState } from "react"

import { apiRequest } from "../api"

import { City, State } from "./interfaces"

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

interface UseCitiesParams {
  state?: string
}

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

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

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

      setResponse({
        data: Array.isArray(data) ? data : data?.data ?? undefined,
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, [state])

  return response
}

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

  useEffect(() => {
    const fetchData = async () => {
      setResponse({
        data: undefined,
        error: undefined,
        isPending: true,
      })

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

      setResponse({
        data: Array.isArray(data) ? data : data?.data ?? undefined,
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, [])

  return response
}

Objective 4: Fetch restaurants in a custom Hook

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:

Screenshot of the application when it makes an API call to the restaurants endpoint and is populated the list of restaurants. Screenshot of the application when it makes an API call to the restaurant endpoint and is populated populated with the restaurants details.

Setup 4

✏️ Update .env to be:

PMO_API=http://localhost:7070
PMO_ASSETS=https://www.place-my-order.com/

✏️ Update .env.example to be:

PMO_API=http://localhost:7070
PMO_ASSETS=https://www.place-my-order.com/

✏️ Update src/env.d.ts to be:

/* eslint-disable @typescript-eslint/no-namespace */

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      readonly PMO_API: string
      readonly PMO_ASSETS: string
    }
  }
}

export {}

✏️ Terminate the existing dev server and start it again:

npm run start

✏️ Update src/components/RestaurantHeader/RestaurantHeader.tsx to be:

import { ImageBackground, StyleSheet } from "react-native"

import Box from "../../design/Box"
import { Theme, useTheme } from "../../design/theme"
import Typography from "../../design/Typography"

const assetsUrl = process.env.PMO_ASSETS

export interface RestaurantHeaderProps {
  restaurant?: {
    _id: string
    address?: {
      city: string
      state: string
      street: string
      zip: string
    }
    images: {
      banner: string
    }
    name: string
    slug: string
  }
}

const RestaurantHeader: React.FC<RestaurantHeaderProps> = ({ restaurant }) => {
  const theme = useTheme()
  const styles = getStyles(theme)

  return (
    <Box>
      <ImageBackground
        style={styles.heroBackground}
        source={{ uri: `${assetsUrl}/${restaurant?.images.banner}` }}
      >
        <Box padding={["xs", "m"]} margin={["s", "none"]} style={styles.hero}>
          <Typography variant="heading" style={styles.heroText}>
            {restaurant?.name}
          </Typography>
        </Box>
      </ImageBackground>

      <Box padding="m">
        {restaurant?.address && (
          <Typography variant="body">
            {restaurant.address.street}
            {restaurant.address.city}, {restaurant.address.state}{" "}
            {restaurant.address.zip}
          </Typography>
        )}

        <Typography variant="body">
          $$$ Hours: M-F 10am-11pm Open Now
        </Typography>
      </Box>
    </Box>
  )
}

function getStyles(theme: Theme) {
  return StyleSheet.create({
    heroBackground: {
      width: "100%",
      maxWidth: 768,
      height: 180,
      margin: "auto",
      justifyContent: "flex-end",
      alignItems: "flex-start",
    },
    hero: {
      backgroundColor: theme.palette.secondary.main + "bb",
    },
    heroText: {
      color: theme.palette.secondary.contrast,
      fontSize: 32,
    },
  })
}

export default RestaurantHeader

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

export interface State {
  name: string
  short: string
}

export interface City {
  name: string
  state: string
}

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

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

interface Coordinate {
  latitude: number
  longitude: number
}

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

interface Menu {
  dinner: MenuItem[]
  lunch: MenuItem[]
}

interface MenuItem {
  name: string
  price: number
}

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

import { useEffect, useState } from "react"

import { apiRequest } from "../api/api"

import { City, Restaurant, State } from "./interfaces"

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

interface UseCitiesParams {
  state?: string
}
interface RestaurantResponse {
  data: Restaurant | undefined
  error: Error | undefined
  isPending: boolean
}

interface UseRestaurant {
  slug: string
}

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

interface UseRestaurants {
  state: string
  city: string
}

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

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

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

      setResponse({
        data: Array.isArray(data) ? data : data?.data ?? undefined,
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, [state])

  return response
}

export function useRestaurant({ slug }: UseRestaurant): RestaurantResponse {
  // Exercise: Fill in `useRestaurant` Hook for fetching the details of the restaurant.
}

export function useRestaurants({
  state,
  city,
}: UseRestaurants): RestaurantsResponse {
  // Exercise: Fill in `useRestaurants` Hook for fetching the list of restaurants.
}

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

  useEffect(() => {
    const fetchData = async () => {
      setResponse({
        data: undefined,
        error: undefined,
        isPending: true,
      })

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

      setResponse({
        data: Array.isArray(data)
          ? undefined
          : (data?.data as Restaurant | undefined),
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, [])

  return response
}

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

import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { FlatList } from "react-native"

import { RestaurantsStackParamList } from "../../App"
import Loading from "../../components/Loading"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurants } from "../../services/pmo/restaurant"

export interface RestaurantListProps
  extends StackScreenProps<RestaurantsStackParamList, "RestaurantList"> {}

const RestaurantList: React.FC<RestaurantListProps> = ({ route }) => {
  const navigation = useNavigation()
  // Exercise: Update `RestaurantList.tsx` to use our new `useRestaurants` Hook.

  return (
    <Screen noScroll>
      <FlatList
        data={restaurants}
        renderItem={({ item: restaurant }) => (
          <Button
            onPress={() => {
              navigation.navigate("RestaurantDetails", {
                slug: restaurant.slug,
              })
            }}
          >
            {restaurant.name}
          </Button>
        )}
        keyExtractor={(item) => item._id}
      />
    </Screen>
  )
}

export default RestaurantList

✏️ Update src/screens/RestaurantDetails/RestaurantDetails.tsx to be:

import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { useEffect } from "react"

import { RestaurantsStackParamList } from "../../App"
import Loading from "../../components/Loading"
import RestaurantHeader from "../../components/RestaurantHeader"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurant } from "../../services/pmo/restaurant"

export interface RestaurantDetailsProps
  extends StackScreenProps<RestaurantsStackParamList, "RestaurantDetails"> {}

const RestaurantDetails: React.FC<RestaurantDetailsProps> = ({ route }) => {
  const navigation = useNavigation()
  // Exercise: Update `RestaurantDetails.tsx` to use our new `useRestaurants` Hook.

  return (
    <Screen>
      <RestaurantHeader restaurant={restaurant} />
      <Button onPress={() => console.warn("Place an order")}>
        Place an order
      </Button>
    </Screen>
  )
}

export default RestaurantDetails

Verify 4

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

import { renderHook, waitFor } from "@testing-library/react-native"

import * as api from "../api/api"

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

// Mock the apiRequest function
let apiRequest: jest.SpyInstance<ReturnType<typeof api.apiRequest>>
beforeEach(() => {
  jest.resetAllMocks()
  apiRequest = jest.spyOn(api, "apiRequest")
})

describe("Services/PMO/Restaurant/useStates", () => {
  it("returns states", async () => {
    const mockStates = [
      { id: 1, name: "State1" },
      { id: 2, name: "State2" },
    ]
    apiRequest.mockResolvedValue({
      data: { data: mockStates },
      error: undefined,
    })

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

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

  it("handles errors", async () => {
    const mockError = new Error("Error fetching states")
    apiRequest.mockResolvedValue({ data: undefined, error: mockError })

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

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

describe("Services/PMO/Restaurant/useCities", () => {
  it("returns cities", async () => {
    const mockCities = [
      { id: 1, name: "City1" },
      { id: 2, name: "City2" },
    ]
    apiRequest.mockResolvedValue({
      data: { data: mockCities },
      error: undefined,
    })

    const { result } = renderHook(() => useCities({ state: "test-state" }))

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

  it("handles errors", async () => {
    const mockError = new Error("Error fetching cities")
    apiRequest.mockResolvedValue({ data: undefined, error: mockError })

    const { result } = renderHook(() => useCities({ state: "test-state" }))

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

describe("Services/PMO/Restaurant/useRestaurants", () => {
  it("returns restaurants", async () => {
    const mockRestaurants = [
      { id: 1, name: "Restaurant1" },
      { id: 2, name: "Restaurant2" },
    ]
    apiRequest.mockResolvedValue({
      data: { data: mockRestaurants },
      error: undefined,
    })

    const { result } = renderHook(() =>
      useRestaurants({ state: "test-state", city: "test-city" }),
    )

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

  it("handles errors", async () => {
    const mockError = new Error("Error fetching restaurants")
    apiRequest.mockResolvedValue({ data: undefined, error: mockError })

    const { result } = renderHook(() =>
      useRestaurants({ state: "test-state", city: "test-city" }),
    )

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

describe("Services/PMO/Restaurant/useRestaurant", () => {
  it("returns a restaurant", async () => {
    const mockRestaurant = { id: 1, name: "Restaurant1" }
    apiRequest.mockResolvedValue({
      data: { data: mockRestaurant },
      error: undefined,
    })

    const { result } = renderHook(() => useRestaurant({ slug: "test-slug" }))

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

  it("handles errors", async () => {
    const mockError = new Error("Error fetching restaurant")
    apiRequest.mockResolvedValue({ data: undefined, error: mockError })

    const { result } = renderHook(() => useRestaurant({ slug: "test-slug" }))

    await waitFor(() => {
      expect(result.current.isPending).toBeFalsy()
    })

    expect(result.current.data).toBeUndefined()
    expect(result.current.error).toEqual(mockError)
  })
})

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

import { NavigationContainer } from "@react-navigation/native"
import { render, screen } from "@testing-library/react-native"

import * as restaurantHooks from "../../services/pmo/restaurant/hooks"

import RestaurantList from "./RestaurantList"

const route = {
  key: "RestaurantList",
  name: "RestaurantList",
  params: {
    state: {
      name: "name",
      short: "short",
    },
    city: {
      name: "name",
      state: "state",
    },
    slug: "test",
  },
} as const

describe("Screens/RestaurantList", () => {
  // Mock the hooks and components used in RestaurantList

  const mockRestaurantsResponse = {
    data: [
      {
        name: "Bagel Restaurant",
        slug: "bagel-restaurant",
        images: {
          thumbnail:
            "node_modules/place-my-order-assets/images/3-thumbnail.jpg",
          owner: "node_modules/place-my-order-assets/images/1-owner.jpg",
          banner: "node_modules/place-my-order-assets/images/2-banner.jpg",
        },
        menu: {
          lunch: [
            { name: "Crab Pancakes with Sorrel Syrup", price: 35.99 },
            { name: "Steamed Mussels", price: 21.99 },
            { name: "Roasted Salmon", price: 23.99 },
          ],
          dinner: [
            { name: "Truffle Noodles", price: 14.99 },
            { name: "Spinach Fennel Watercress Ravioli", price: 35.99 },
            { name: "Herring in Lavender Dill Reduction", price: 45.99 },
          ],
        },
        address: {
          street: "285 W Adams Ave",
          city: "Detroit",
          state: "MI",
          zip: "60045",
        },
        coordinate: {
          latitude: 0,
          longitude: 0,
        },
        resources: {
          thumbnail: "api/resources/images/3-thumbnail.jpg",
          owner: "api/resources/images/4-owner.jpg",
          banner: "api/resources/images/1-banner.jpg",
        },
        _id: "5NVE3Z5MXxX3O57R",
      },
      {
        name: "Brunch Barn",
        slug: "brunch-barn",
        images: {
          thumbnail:
            "node_modules/place-my-order-assets/images/4-thumbnail.jpg",
          owner: "node_modules/place-my-order-assets/images/1-owner.jpg",
          banner: "node_modules/place-my-order-assets/images/4-banner.jpg",
        },
        menu: {
          lunch: [
            { name: "Gunthorp Chicken", price: 21.99 },
            { name: "Ricotta Gnocchi", price: 15.99 },
            { name: "Roasted Salmon", price: 23.99 },
          ],
          dinner: [
            { name: "Charred Octopus", price: 25.99 },
            { name: "Herring in Lavender Dill Reduction", price: 45.99 },
            { name: "Steamed Mussels", price: 21.99 },
          ],
        },
        address: {
          street: "285 W Adams Ave",
          city: "Detroit",
          state: "MI",
          zip: "60045",
        },
        coordinate: {
          latitude: 0,
          longitude: 0,
        },
        resources: {
          thumbnail: "api/resources/images/3-thumbnail.jpg",
          owner: "api/resources/images/2-owner.jpg",
          banner: "api/resources/images/4-banner.jpg",
        },
        _id: "AXUIBQBmxrk2EWq8",
      },
    ],
  }

  let useRestaurants: jest.SpyInstance<
    ReturnType<typeof restaurantHooks.useRestaurants>
  >
  beforeEach(() => {
    jest.resetAllMocks()
    useRestaurants = jest.spyOn(restaurantHooks, "useRestaurants")
  })

  it("renders", () => {
    useRestaurants.mockReturnValue({
      ...mockRestaurantsResponse,
      error: undefined,
      isPending: false,
    })

    render(
      <NavigationContainer>
        {/* @ts-ignore */}
        <RestaurantList route={route} />
      </NavigationContainer>,
    )
    expect(screen.getByText(/Bagel Restaurant/i)).toBeOnTheScreen()
    expect(screen.getByText(/Brunch Barn/i)).toBeOnTheScreen()
  })

  it("renders loading state", () => {
    useRestaurants.mockReturnValue({
      data: undefined,
      error: undefined,
      isPending: true,
    })

    render(
      <NavigationContainer>
        {/* @ts-ignore */}
        <RestaurantList route={route} />
      </NavigationContainer>,
    )

    expect(screen.getByText(/Loading/i)).toBeOnTheScreen()
  })

  it("renders error state", () => {
    useRestaurants.mockReturnValue({
      data: undefined,
      error: { name: "Oops", message: "This is the error" },
      isPending: false,
    })

    render(
      <NavigationContainer>
        {/* @ts-ignore */}
        <RestaurantList route={route} />
      </NavigationContainer>,
    )

    expect(screen.getByText(/Error loading restaurants:/)).toBeOnTheScreen()
    expect(screen.getByText(/This is the error/)).toBeOnTheScreen()
  })
})

✏️ Update src/screens/RestaurantDetails/RestaurantDetails.test.tsx to be:

import { NavigationContainer } from "@react-navigation/native"
import { render, screen } from "@testing-library/react-native"

import * as restaurantHooks from "../../services/pmo/restaurant/hooks"

import RestaurantDetails from "./RestaurantDetails"

const route = {
  key: "RestaurantDetails",
  name: "RestaurantDetails",
  params: {
    state: {
      name: "name",
      short: "short",
    },
    city: {
      name: "name",
      state: "state",
    },
    slug: "test",
  },
} as const

jest.mock("@react-navigation/native", () => {
  const actualNav = jest.requireActual("@react-navigation/native")
  return {
    ...actualNav,
    useNavigation: () => ({
      navigate: jest.fn(),
      setOptions: jest.fn(),
    }),
  }
})

describe("Screens/RestaurantDetails", () => {
  // Mock the hooks and components used in RestaurantDetails

  let useRestaurant: jest.SpyInstance<
    ReturnType<typeof restaurantHooks.useRestaurant>
  >
  beforeEach(() => {
    jest.resetAllMocks()
    useRestaurant = jest.spyOn(restaurantHooks, "useRestaurant")
  })

  const mockRestaurantData = {
    data: {
      _id: "1",
      name: "Test Restaurant",
      slug: "test-restaurant",
      images: {
        banner: "banner.jpg",
        owner: "owner.jpg",
        thumbnail: "thumbnail.jpg",
      },
      menu: {
        dinner: [{ name: "yum", price: 1 }],
        lunch: [{ name: "snack", price: 2 }],
      },
      coordinate: { latitude: 0, longitude: 0 },
    },
    isPending: false,
    error: undefined,
  }

  it("renders", () => {
    useRestaurant.mockReturnValue(mockRestaurantData)

    render(
      <NavigationContainer>
        {/* @ts-ignore */}
        <RestaurantDetails route={route} />
      </NavigationContainer>,
    )

    expect(screen.getByText("Test Restaurant")).toBeOnTheScreen()
  })

  it("renders before data loads", () => {
    useRestaurant.mockReturnValue({ ...mockRestaurantData, data: undefined })
    render(
      <NavigationContainer>
        {/* @ts-ignore */}
        <RestaurantDetails route={route} />
      </NavigationContainer>,
    )

    expect(screen.getByText("Place an order")).toBeOnTheScreen()
  })

  it("renders loading state", () => {
    useRestaurant.mockReturnValue({
      data: undefined,
      isPending: true,
      error: undefined,
    })

    render(
      <NavigationContainer>
        {/* @ts-ignore */}
        <RestaurantDetails route={route} />
      </NavigationContainer>,
    )

    expect(screen.getByText(/Loading/i)).toBeOnTheScreen()
  })

  it("renders error state", () => {
    useRestaurant.mockReturnValue({
      data: undefined,
      isPending: false,
      error: { name: "Error", message: "Mock error" },
    })

    render(
      <NavigationContainer>
        {/* @ts-ignore */}
        <RestaurantDetails route={route} />
      </NavigationContainer>,
    )

    expect(
      screen.getByText(/Error loading restaurant details:/i, {
        exact: false,
      }),
    ).toBeOnTheScreen()
    expect(screen.getByText(/Mock error/i)).toBeOnTheScreen()
  })
})

Exercise 4

  • Fill in useRestaurant Hook for fetching the details of the restaurant.
  • Fill in useRestaurants Hook for fetching the list of restaurants.
  • Update RestaurantDetails.tsx to use our new useRestaurants Hook.
  • Update RestaurantList.tsx to use our new useRestaurants Hook.

Hint: The useRestaurant() Hook should make a request like this: '/restaurants/${slug}'

Hint: The useRestaurants() Hook should make a request with these query parameters: '/restaurants?filter[address.state]=IL&filter[address.city]=Chicago'

Solution 4

If you’ve implemented the solution correctly, the tests will pass when you run npm run test!

Click to see the solution

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

import { useEffect, useState } from "react"

import { apiRequest } from "../api/api"

import { City, Restaurant, State } from "./interfaces"

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

interface UseCitiesParams {
  state?: string
}
interface RestaurantResponse {
  data: Restaurant | undefined
  error: Error | undefined
  isPending: boolean
}

interface UseRestaurant {
  slug: string
}

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

interface UseRestaurants {
  state: string
  city: string
}

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

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

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

      setResponse({
        data: Array.isArray(data) ? data : data?.data ?? undefined,
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, [state])

  return response
}

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

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

      setResponse({
        data: data && "data" in data ? data.data : data || undefined,
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, [slug])

  return response
}

export function useRestaurants({
  state,
  city,
}: UseRestaurants): RestaurantsResponse {
  const [response, setResponse] = useState<RestaurantsResponse>({
    data: undefined,
    error: undefined,
    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: Array.isArray(data) ? data : data?.data ?? undefined,
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, [state, city])

  return response
}

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

  useEffect(() => {
    const fetchData = async () => {
      setResponse({
        data: undefined,
        error: undefined,
        isPending: true,
      })

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

      setResponse({
        data: Array.isArray(data) ? data : data?.data ?? undefined,
        error: error,
        isPending: false,
      })
    }
    fetchData()
  }, [])

  return response
}

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

import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { FlatList } from "react-native"

import { RestaurantsStackParamList } from "../../App"
import Loading from "../../components/Loading"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurants } from "../../services/pmo/restaurant"

export interface RestaurantListProps
  extends StackScreenProps<RestaurantsStackParamList, "RestaurantList"> {}

const RestaurantList: React.FC<RestaurantListProps> = ({ route }) => {
  const navigation = useNavigation()

  const { state, city } = route.params
  const {
    data: restaurants,
    error,
    isPending,
  } = useRestaurants({ state: state.short, city: city.name })

  if (error) {
    return (
      <Screen>
        <Box padding="m">
          <Typography variant="heading">Error loading restaurants: </Typography>
          <Typography variant="body">{error.message}</Typography>
        </Box>
      </Screen>
    )
  }

  if (isPending) {
    return <Loading />
  }

  return (
    <Screen noScroll>
      <FlatList
        data={restaurants}
        renderItem={({ item: restaurant }) => (
          <Button
            onPress={() => {
              navigation.navigate("RestaurantDetails", {
                slug: restaurant.slug,
              })
            }}
          >
            {restaurant.name}
          </Button>
        )}
        keyExtractor={(item) => item._id}
      />
    </Screen>
  )
}

export default RestaurantList

✏️ Update src/screens/RestaurantDetails/RestaurantDetails.tsx to be:

import { useNavigation } from "@react-navigation/native"
import { StackScreenProps } from "@react-navigation/stack"
import { useEffect } from "react"

import { RestaurantsStackParamList } from "../../App"
import Loading from "../../components/Loading"
import RestaurantHeader from "../../components/RestaurantHeader"
import Box from "../../design/Box"
import Button from "../../design/Button"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurant } from "../../services/pmo/restaurant"

export interface RestaurantDetailsProps
  extends StackScreenProps<RestaurantsStackParamList, "RestaurantDetails"> {}

const RestaurantDetails: React.FC<RestaurantDetailsProps> = ({ route }) => {
  const { slug } = route.params
  const navigation = useNavigation()
  const { data: restaurant, error, isPending } = useRestaurant({ slug })

  useEffect(() => {
    if (restaurant) {
      navigation.setOptions({ title: `${restaurant.name}` })
    }
  }, [restaurant, navigation])

  if (error) {
    return (
      <Screen>
        <Box padding="m">
          <Typography variant="heading">
            Error loading restaurant details:{" "}
          </Typography>
          <Typography variant="body">{error.message}</Typography>
        </Box>
      </Screen>
    )
  }

  if (isPending) {
    return <Loading />
  }

  return (
    <Screen>
      <RestaurantHeader restaurant={restaurant} />
      <Button onPress={() => console.warn("Place an order")}>
        Place an order
      </Button>
    </Screen>
  )
}

export default RestaurantDetails

Next steps

Next, we will learn how to Handling User Inputs in React Native applications.