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!
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
inhooks.ts
to calluseEffect()
andfetch
data from${process.env.PMO_API}/states
. - Update
StateList.tsx
to calluseState()
and use theStateResponse
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.
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¶m2=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:
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 returnstrue
if the response’s status code is in the range200
-299
, indicating a successful request..status
: This property returns the status code of the response (e.g.200
for success,404
forNot 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 fromfetch()
. - Update the
useCities
anduseStates
Hooks to use thedata
anderror
returned fromapiRequest
.
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:
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 newuseRestaurants
Hook. - Update
RestaurantList.tsx
to use our newuseRestaurants
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.