Making HTTP Requests page
Learn about how to make fetch
requests and render requested data in React components.
Overview
TODO
Objective 1: Add a fetch
request for states
In this section, we will:
- Learn about the
useEffect
Hook - TODO: Review TypeScript generics?
The useEffect
Hook
useEffect
is a React Hook that lets you perform side effects in your functional components.
It serves as a powerful tool to execute code in response to component renders or state changes.
Here is an example component with useEffect
:
import { useEffect, useState } from 'react';
const GeolocationComponent: React.FC = () => {
const [location, setLocation] = useState(null);
useEffect(() => { // Effect callback function
navigator.geolocation.getCurrentPosition(position => {
setLocation(position.coords);
}, (error) => {
console.error(error);
});
}, []); // Dependency array
return (
<main>
{location ? (
<p>
Latitude: {location.latitude},
Longitude: {location.longitude}
</p>
) : (
<p>Requesting location…</p>
)}
</main>
);
}
export default GeolocationComponent;
Let’s break this example down by the two arguments that useEffect
takes:
Effect callback function
The first argument of useEffect
is a function, often referred to as the “effect” function.
This is where you perform your side effects, such as fetching data, setting up a subscription,
or manually changing the DOM in React components.
The key aspect of this function is that it’s executed after the component renders. The effects
in useEffect
don’t block the browser from updating the screen, leading to more responsive UIs.
This effect function can optionally return another function, known as the “cleanup” function. The cleanup function is useful for performing any necessary cleanup activities when the component unmounts or before the component re-renders and the effect is re-invoked. Common examples include clearing timers, canceling network requests, or removing event listeners.
The dependency array
The second argument of useEffect
is an array, called the “dependency array”, which determines
when your effect function should be called. The behavior of the effect changes based on the
contents of this array:
Consider three scenarios based on the dependency array:
Empty dependency array ([]
)
If the dependency array is an empty array, the effect runs once after the initial render.
import { useEffect, useState } from 'react';
const GeolocationComponent: React.FC = () => {
const [location, setLocation] = useState(null);
useEffect(() => { // Effect callback function
navigator.geolocation.getCurrentPosition(position => {
setLocation(position.coords);
}, (error) => {
console.error(error);
});
}, []); // Dependency array
return (
<main>
{location ? (
<p>
Latitude: {location.latitude},
Longitude: {location.longitude}
</p>
) : (
<p>Requesting location…</p>
)}
</main>
);
}
export default GeolocationComponent;
Array with values
When you include values (variables, props, state) in the dependency array, the effect will only re-run if those specific values change between renders. This selective execution can optimize performance by avoiding unnecessary work.
import { useEffect, useState } from 'react';
function NameStorage() {
const [name, setName] = useState('');
useEffect(() => {
localStorage.setItem('name', name);
}, [name]);
return (
<label>
Name
<input
onChange={event => setName(event.target.value)}
type="text"
value={name}
/>
</label>
);
}
export default NameStorage;
No dependency array
If the dependency array is omitted, the effect runs after every render of the component.
import { useEffect, useState } from 'react';
function UpdateLogger() {
const [count, setCount] = useState(0);
useEffect(() => {
console.info('Component updated!');
}); // No dependency array, runs on every update
return (
<button onClick={() => setCount(count + 1)}>
Increment
</button>
);
}
export default UpdateLogger;
Async operations inside useEffect
You can use APIs that return a Promise
normally within a useEffect
:
import { useEffect, useState } from 'react';
function DataFetcher() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('https://api.example.com/data')
.then(response => {
const parsedData = response.json();
setData(parsedData);
})
.catch(error => {
// Error should be shown to the user
console.error('Error fetching data:', error)
});
}, []);
return (
<p>{data}</p>
);
}
export default DataFetcher;
However, unlike traditional functions, useEffect
functions can’t be marked as async.
This is because returning a Promise
from useEffect
would conflict with its mechanism,
which expects either nothing or a clean-up function to be returned.
To handle asynchronous operations, you typically define an async
function inside the
effect and then call it:
import { useEffect, useState } from 'react';
function DataFetcher() {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('https://api.example.com/data');
const parsedData = response.json();
setData(parsedData);
} catch (error) {
// Error should be shown to the user
console.error('Error fetching data:', error)
}
};
fetchData();
}, []);
return (
<p>{data}</p>
);
}
export default DataFetcher;
When using async/await, error handling is typically done using try-catch blocks. This allows you to gracefully handle any errors that occur during the execution of your async operation.
In this example, if fetch
throws an error, the catch
block catches and handles it.
This pattern is crucial to prevent unhandled promise rejections and ensure that your application
can respond appropriately to failures in asynchronous tasks.
TypeScript generics
TODO? We’ve used generics, but maybe explain the ones we’re going to use so it’s a little familiar?
Cleanup functions
The effect function can optionally return another function, known as the “cleanup” function. The cleanup function is useful for performing any necessary cleanup activities when the component unmounts or before the component re-renders and the effect is re-invoked. Common examples include clearing timers, canceling network requests, or removing event listeners.
import { useEffect, useState } from 'react';
function WebSocketComponent() {
const [messages, setMessages] = useState([]);
useEffect(() => {
const socket = new WebSocket('wss://chat.donejs.com/');
socket.onmessage = (event) => {
setMessages(previousMessages => {
return [ ...previousMessages, event.data ];
});
};
return () => {
// Clean up (tear down) the socket connection
return socket.close();
};
}, []);
return (
<ol>
{messages.map((message) => (
<li key={message}>{message}</li>
))}
</ol>
);
}
export default WebSocketComponent;
In the example above, we’re creating a WebSocket connection to an API when the component is first rendered (note the empty dependency array).
When the component is removed from the DOM, the cleanup function will run and tear down the WebSocket connection.
Environment variables
The way we’re accessing our locally run API during development may be different than how we access it in production. To prepare for this, we’ll set an environment variable to do what we need.
TODO: Explain that setting environment variables is a generic thing you do, and on this
project in particular, Vite will make anything prefixed with VITE_
available in our
client-side source code.
Setup
✏️ Create .env and update it to be:
VITE_PMO_API = '//localhost:7070'
✏️ Update src/pages/RestaurantList/RestaurantList.tsx to be:
import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useEffect, useState } from 'react'
import ListItem from './ListItem'
import { useCities } from '../../services/restaurant/hooks'
import { State } from '../../services/restaurant/interfaces'
interface StatesResponse {
data: State[] | null;
error: Error | null;
isPending: boolean;
}
const RestaurantList: React.FC = () => {
const [state, setState] = useState("")
const [city, setCity] = useState("")
const states = [
{ name: 'Illinois', short: 'IL' },
{ name: 'Wisconsin', short: 'WI' },
]
const cities = useCities(state)
const restaurants = {
data: [
{
name: 'Cheese Curd City',
slug: 'cheese-curd-city',
images: {
thumbnail: CheeseThumbnail,
},
address: {
street: '2451 W Washburne Ave',
city: 'Green Bay',
state: 'WI',
zip: '53295',
},
_id: 'Ar0qBJHxM3ecOhcr',
},
{
name: 'Poutine Palace',
slug: 'poutine-palace',
images: {
thumbnail: PoutineThumbnail,
},
address: {
street: '230 W Kinzie Street',
city: 'Green Bay',
state: 'WI',
zip: '53205',
},
_id: '3ZOZyTY1LH26LnVw',
},
]
};
const updateState = (stateShortCode: string) => {
setState(stateShortCode)
setCity("")
}
const updateCity = (cityName: string) => {
setCity(cityName)
}
return (
<>
<div className="restaurants">
<h2 className="page-header">Restaurants</h2>
<form className="form">
<div className="form-group">
<label className="control-label" htmlFor="stateSelect">
State
</label>
<select
className="form-control"
id="stateSelect"
onChange={event => updateState(event.target.value)}
value={state}
>
<option key="choose_state" value="">
{
statesResponse.isPending
? "Loading states…"
: statesResponse.error
? statesResponse.error.message
: "Choose a state"
}
</option>
{statesResponse.data?.map(({ short, name }) => (
<option key={short} value={short}>
{name}
</option>
))}
</select>
</div>
<div className="form-group">
City:
{state ? cities.map(({ name }) => (
<button key={name} onClick={() => updateCity(name)} type="button">
{name}
</button>
)) : <> Choose a state before selecting a city</>}
<hr />
<p>
Current city: {city || "(none)"}
</p>
</div>
</form>
{restaurants.data ? (
restaurants.data.map(({ _id, address, images, name, slug }) => (
<ListItem
key={_id}
address={address}
name={name}
slug={slug}
thumbnail={images.thumbnail}
/>
))
) : (
<p>No restaurants.</p>
)}
</div>
</>
)
}
export default RestaurantList
Install the Place My Order API
Before we begin requesting data from our API, we need to install the
place-my-order-api
module, which will generate fake restaurant data and
serve it from port 7070
.
✏️ Run:
npm install place-my-order-api@1
✏️ Next add an API script to your package.json
{
"name": "place-my-order",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"api": "place-my-order-api --port 7070",
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"test": "vitest",
"preview": "vite preview"
},
"dependencies": {
"place-my-order-api": "^1.3.0",
"place-my-order-assets": "^0.2.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"jsdom": "^24.0.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^1.2.2"
}
}
✏️ In a new terminal window, start the API server by running:
npm run api
Double check the API by navigating to localhost:7070/restaurants.
You should see a JSON list of restaurant data. It will be helpful to have a second terminal tab to run the api
command from.
Verify
✏️ Update src/pages/RestaurantList/RestaurantList.test.tsx to be:
import '@testing-library/jest-dom';
import { act, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import * as restaurantHooks from '../../services/restaurant/hooks'
import RestaurantList from './RestaurantList';
// Mocking necessary modules
vi.mock('../../services/restaurant/hooks')
// Mocking the global fetch function
const mockFetch = vi.fn();
global.fetch = mockFetch;
beforeEach(() => {
mockFetch.mockClear();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ message: 'success' }),
statusText: 'OK',
status: 200,
});
});
afterEach(() => {
mockFetch.mockClear();
});
describe('RestaurantList component', () => {
beforeEach(async () => {
vi.spyOn(restaurantHooks, 'useCities').mockReturnValue([
{ name: 'Green Bay' },
{ name: 'Madison' },
])
render(<RestaurantList />);
await act(() => {})
})
it('renders the Restaurants header', () => {
expect(screen.getByText(/Restaurants/i)).toBeInTheDocument();
});
it('renders the restaurant images', () => {
const images = screen.getAllByRole('img');
expect(images[0]).toHaveAttribute('src', expect.stringContaining('2-thumbnail.jpg'));
expect(images[0]).toHaveAttribute('width', '100');
expect(images[0]).toHaveAttribute('height', '100');
expect(images[1]).toHaveAttribute('src', expect.stringContaining('4-thumbnail.jpg'));
expect(images[1]).toHaveAttribute('width', '100');
expect(images[1]).toHaveAttribute('height', '100');
});
it('renders the addresses', () => {
const addressDivs = screen.getAllByText(/Washburne Ave|Kinzie Street/i);
expect(addressDivs[0]).toHaveTextContent('2451 W Washburne Ave');
expect(addressDivs[0]).toHaveTextContent('Green Bay, WI 53295');
expect(addressDivs[1]).toHaveTextContent('230 W Kinzie Street');
expect(addressDivs[1]).toHaveTextContent('Green Bay, WI 53205');
});
it('renders the hours and price information for each restaurant', () => {
const hoursPriceDivs = screen.getAllByText(/\$\$\$/i);
hoursPriceDivs.forEach(div => {
expect(div).toHaveTextContent('$$$');
expect(div).toHaveTextContent('Hours: M-F 10am-11pm');
});
});
it('indicates if the restaurant is open now for each restaurant', () => {
const openNowTags = screen.getAllByText('Open Now');
expect(openNowTags.length).toBeGreaterThan(0);
});
it('renders the details buttons with correct links for each restaurant', () => {
const detailsButtons = screen.getAllByRole('link');
expect(detailsButtons[0]).toHaveAttribute('href', '/restaurants/cheese-curd-city');
expect(detailsButtons[1]).toHaveAttribute('href', '/restaurants/poutine-palace');
detailsButtons.forEach(button => {
expect(button).toHaveTextContent('Details');
});
});
it('renders ListItem components for each restaurant', () => {
const restaurantNames = screen.getAllByText(/Cheese Curd City|Poutine Palace/)
expect(restaurantNames.length).toBe(2)
})
});
Exercise
- Update
RestaurantList.tsx
to calluseState()
and use theStateResponse
interface. - Call
useEffect()
andfetch
data from${import.meta.env.VITE_PMO_API}/states
.
Hint: Call your state setter after you parse the JSON response from fetch()
.
Solution
Click to see the solution
✏️ Update src/pages/RestaurantList/RestaurantList.tsx to be:
import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useEffect, useState } from 'react'
import ListItem from './ListItem'
import { useCities } from '../../services/restaurant/hooks'
import { State } from '../../services/restaurant/interfaces'
interface StatesResponse {
data: State[] | null;
error: Error | null;
isPending: boolean;
}
const RestaurantList: React.FC = () => {
const [state, setState] = useState("")
const [city, setCity] = useState("")
const [statesResponse, setStatesResponse] = useState<StatesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`${import.meta.env.VITE_PMO_API}/states`, {
method: "GET",
})
const data = await response.json()
setStatesResponse({
data: data?.data || null,
error: null,
isPending: false,
})
}
fetchData()
}, []);
const cities = useCities(state)
const restaurants = {
data: [
{
name: 'Cheese Curd City',
slug: 'cheese-curd-city',
images: {
thumbnail: CheeseThumbnail,
},
address: {
street: '2451 W Washburne Ave',
city: 'Green Bay',
state: 'WI',
zip: '53295',
},
_id: 'Ar0qBJHxM3ecOhcr',
},
{
name: 'Poutine Palace',
slug: 'poutine-palace',
images: {
thumbnail: PoutineThumbnail,
},
address: {
street: '230 W Kinzie Street',
city: 'Green Bay',
state: 'WI',
zip: '53205',
},
_id: '3ZOZyTY1LH26LnVw',
},
]
};
const updateState = (stateShortCode: string) => {
setState(stateShortCode)
setCity("")
}
const updateCity = (cityName: string) => {
setCity(cityName)
}
return (
<>
<div className="restaurants">
<h2 className="page-header">Restaurants</h2>
<form className="form">
<div className="form-group">
<label className="control-label" htmlFor="stateSelect">
State
</label>
<select
className="form-control"
id="stateSelect"
onChange={event => updateState(event.target.value)}
value={state}
>
<option key="choose_state" value="">
{
statesResponse.isPending
? "Loading states…"
: statesResponse.error
? statesResponse.error.message
: "Choose a state"
}
</option>
{statesResponse.data?.map(({ short, name }) => (
<option key={short} value={short}>
{name}
</option>
))}
</select>
</div>
<div className="form-group">
City:
{state ? cities.map(({ name }) => (
<button key={name} onClick={() => updateCity(name)} type="button">
{name}
</button>
)) : <> Choose a state before selecting a city</>}
<hr />
<p>
Current city: {city || "(none)"}
</p>
</div>
</form>
{restaurants.data ? (
restaurants.data.map(({ _id, address, images, name, slug }) => (
<ListItem
key={_id}
address={address}
name={name}
slug={slug}
thumbnail={images.thumbnail}
/>
))
) : (
<p>No restaurants.</p>
)}
</div>
</>
)
}
export default RestaurantList
Objective 2: Move the fetch to a useStates
Hook
In this section, we will:
- Refactor our
<RestaurantList>
component to depend on a custom Hook.
Writing custom Hooks as services
In a previous section, we created a useCities
Hook in our hooks.ts
file.
Putting stateful logic into a custom Hook has numerous benefits:
Reusability: One of the primary reasons for creating custom Hooks is reusability. You might find yourself repeating the same logic in different components—for example, fetching data from an API, handling form input, or managing a subscription. By refactoring this logic into a custom Hook, you can easily reuse this functionality across multiple components, keeping your code DRY (Don't Repeat Yourself).
Separation of concerns: Custom Hooks allow you to separate complex logic from the component logic. This makes your main component code cleaner and more focused on rendering UI, while the custom Hook handles the business logic or side effects. It aligns well with the principle of single responsibility, where a function or module should ideally do one thing only.
Easier testing and maintenance: Isolating logic into custom Hooks can make your code easier to test and maintain. Since Hooks are just JavaScript functions, they can be tested independently of any component. This isolation can lead to more robust and reliable code.
Simplifying components: If your component is becoming too large and difficult to understand, moving some logic to a custom Hook can simplify it. This not only improves readability but also makes it easier for other developers to grasp what the component is doing.
Sharing stateful logic: Custom Hooks can contain stateful logic, which is not possible with regular JavaScript functions. This means you can have a Hook that manages its own state and shares this logic across multiple components, something that would be difficult or impossible with traditional class-based components.
Setup
✏️ Update src/pages/RestaurantList/RestaurantList.tsx to be:
import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useState } from 'react'
import ListItem from './ListItem'
import { useCities, useStates } from '../../services/restaurant/hooks'
interface StatesResponse {
data: State[] | null;
error: Error | null;
isPending: boolean;
}
const RestaurantList: React.FC = () => {
const [state, setState] = useState("")
const [city, setCity] = useState("")
const [statesResponse, setStatesResponse] = useState<StatesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`${import.meta.env.VITE_PMO_API}/states`, {
method: "GET",
})
const data = await response.json()
setStatesResponse({
data: data?.data || null,
error: null,
isPending: false,
})
}
fetchData()
}, []);
const cities = useCities(state)
const restaurants = {
data: [
{
name: 'Cheese Curd City',
slug: 'cheese-curd-city',
images: {
thumbnail: CheeseThumbnail,
},
address: {
street: '2451 W Washburne Ave',
city: 'Green Bay',
state: 'WI',
zip: '53295',
},
_id: 'Ar0qBJHxM3ecOhcr',
},
{
name: 'Poutine Palace',
slug: 'poutine-palace',
images: {
thumbnail: PoutineThumbnail,
},
address: {
street: '230 W Kinzie Street',
city: 'Green Bay',
state: 'WI',
zip: '53205',
},
_id: '3ZOZyTY1LH26LnVw',
},
]
};
const updateState = (stateShortCode: string) => {
setState(stateShortCode)
setCity("")
}
const updateCity = (cityName: string) => {
setCity(cityName)
}
return (
<>
<div className="restaurants">
<h2 className="page-header">Restaurants</h2>
<form className="form">
<div className="form-group">
<label className="control-label" htmlFor="stateSelect">
State
</label>
<select
className="form-control"
id="stateSelect"
onChange={event => updateState(event.target.value)}
value={state}
>
<option key="choose_state" value="">
{
statesResponse.isPending
? "Loading states…"
: statesResponse.error
? statesResponse.error.message
: "Choose a state"
}
</option>
{statesResponse.data?.map(({ short, name }) => (
<option key={short} value={short}>
{name}
</option>
))}
</select>
</div>
<div className="form-group">
City:
{state ? cities.map(({ name }) => (
<button key={name} onClick={() => updateCity(name)} type="button">
{name}
</button>
)) : <> Choose a state before selecting a city</>}
<hr />
<p>
Current city: {city || "(none)"}
</p>
</div>
</form>
{restaurants.data ? (
restaurants.data.map(({ _id, address, images, name, slug }) => (
<ListItem
key={_id}
address={address}
name={name}
slug={slug}
thumbnail={images.thumbnail}
/>
))
) : (
<p>No restaurants.</p>
)}
</div>
</>
)
}
export default RestaurantList
✏️ Update src/services/restaurant/hooks.ts to be:
import { useEffect, useState } from 'react'
import type { City, State } from './interfaces'
export function useCities(state: string): City[] {
const cities = [
{ name: 'Madison', state: 'WI' },
{ name: 'Springfield', state: 'IL' },
]
return cities.filter(city => {
return city.state === state
})
}
Verify
✏️ Update src/pages/RestaurantList/RestaurantList.test.tsx to be:
import '@testing-library/jest-dom';
import { act, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import * as restaurantHooks from '../../services/restaurant/hooks'
import RestaurantList from './RestaurantList';
// Mock the hooks used in the component
vi.mock('../../services/restaurant/hooks', () => ({
useCities: vi.fn(() => {
return {
data: null,
error: null,
isPending: false,
}
}),
useStates: vi.fn(() => {
return {
data: null,
error: null,
isPending: false,
}
}),
}));
describe('RestaurantList component', () => {
beforeEach(async () => {
vi.spyOn(restaurantHooks, 'useCities').mockReturnValue([
{ name: 'Green Bay' },
{ name: 'Madison' },
])
render(<RestaurantList />);
await act(() => {})
})
it('renders the Restaurants header', () => {
expect(screen.getByText(/Restaurants/i)).toBeInTheDocument();
});
it('renders the restaurant images', () => {
const images = screen.getAllByRole('img');
expect(images[0]).toHaveAttribute('src', expect.stringContaining('2-thumbnail.jpg'));
expect(images[0]).toHaveAttribute('width', '100');
expect(images[0]).toHaveAttribute('height', '100');
expect(images[1]).toHaveAttribute('src', expect.stringContaining('4-thumbnail.jpg'));
expect(images[1]).toHaveAttribute('width', '100');
expect(images[1]).toHaveAttribute('height', '100');
});
it('renders the addresses', () => {
const addressDivs = screen.getAllByText(/Washburne Ave|Kinzie Street/i);
expect(addressDivs[0]).toHaveTextContent('2451 W Washburne Ave');
expect(addressDivs[0]).toHaveTextContent('Green Bay, WI 53295');
expect(addressDivs[1]).toHaveTextContent('230 W Kinzie Street');
expect(addressDivs[1]).toHaveTextContent('Green Bay, WI 53205');
});
it('renders the hours and price information for each restaurant', () => {
const hoursPriceDivs = screen.getAllByText(/\$\$\$/i);
hoursPriceDivs.forEach(div => {
expect(div).toHaveTextContent('$$$');
expect(div).toHaveTextContent('Hours: M-F 10am-11pm');
});
});
it('indicates if the restaurant is open now for each restaurant', () => {
const openNowTags = screen.getAllByText('Open Now');
expect(openNowTags.length).toBeGreaterThan(0);
});
it('renders the details buttons with correct links for each restaurant', () => {
const detailsButtons = screen.getAllByRole('link');
expect(detailsButtons[0]).toHaveAttribute('href', '/restaurants/cheese-curd-city');
expect(detailsButtons[1]).toHaveAttribute('href', '/restaurants/poutine-palace');
detailsButtons.forEach(button => {
expect(button).toHaveTextContent('Details');
});
});
it('renders ListItem components for each restaurant', () => {
const restaurantNames = screen.getAllByText(/Cheese Curd City|Poutine Palace/)
expect(restaurantNames.length).toBe(2)
})
});
✏️ Update src/services/restaurant/hooks.test.ts to be:
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useCities, useStates } from './hooks';
describe('useCities Hook', () => {
it('should return cities from Wisconsin when state is WI', () => {
const { result } = renderHook(() => useCities('WI'));
expect(result.current).toHaveLength(1);
expect(result.current[0].name).toBe('Madison');
});
it('should return cities from Illinois when state is IL', () => {
const { result } = renderHook(() => useCities('IL'));
expect(result.current).toHaveLength(1);
expect(result.current[0].name).toBe('Springfield');
});
it('should return no cities for an unknown state', () => {
const { result } = renderHook(() => useCities('CA'));
expect(result.current).toHaveLength(0);
});
});
describe('useStates Hook', () => {
beforeEach(async () => {
// Mocking the fetch function
global.fetch = vi.fn();
})
it('should set the states data on successful fetch', async () => {
const mockStates = [{ name: 'State1' }, { name: 'State2' }];
fetch.mockResolvedValueOnce({
json: () => Promise.resolve({ data: mockStates }),
});
const { result } = renderHook(() => useStates());
await waitFor(() => {
expect(result.current.isPending).toBe(false);
expect(result.current.data).toEqual(mockStates);
expect(result.current.error).toBeNull();
});
});
});
Exercise
- Refactor the existing
useState
anduseEffect
logic into a newuseStates
Hook.
Hint: After moving the state and effect logic into hooks.ts
, use your new Hook in RestaurantList.tsx
.
Solution
Click to see the solution
✏️ Update src/pages/RestaurantList/RestaurantList.tsx to be:
import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useState } from 'react'
import { useCities, useStates } from '../../services/restaurant/hooks'
import ListItem from './ListItem'
const RestaurantList: React.FC = () => {
const [state, setState] = useState("")
const [city, setCity] = useState("")
const statesResponse = useStates()
const cities = useCities(state)
const restaurants = {
data: [
{
name: 'Cheese Curd City',
slug: 'cheese-curd-city',
images: {
thumbnail: CheeseThumbnail,
},
address: {
street: '2451 W Washburne Ave',
city: 'Green Bay',
state: 'WI',
zip: '53295',
},
_id: 'Ar0qBJHxM3ecOhcr',
},
{
name: 'Poutine Palace',
slug: 'poutine-palace',
images: {
thumbnail: PoutineThumbnail,
},
address: {
street: '230 W Kinzie Street',
city: 'Green Bay',
state: 'WI',
zip: '53205',
},
_id: '3ZOZyTY1LH26LnVw',
},
]
};
const updateState = (stateShortCode: string) => {
setState(stateShortCode)
setCity("")
}
const updateCity = (cityName: string) => {
setCity(cityName)
}
return (
<>
<div className="restaurants">
<h2 className="page-header">Restaurants</h2>
<form className="form">
<div className="form-group">
<label className="control-label" htmlFor="stateSelect">
State
</label>
<select
className="form-control"
id="stateSelect"
onChange={event => updateState(event.target.value)}
value={state}
>
<option key="choose_state" value="">
{
statesResponse.isPending
? "Loading states…"
: statesResponse.error
? statesResponse.error.message
: "Choose a state"
}
</option>
{statesResponse.data?.map(({ short, name }) => (
<option key={short} value={short}>
{name}
</option>
))}
</select>
</div>
<div className="form-group">
City:
{state ? cities.map(({ name }) => (
<button key={name} onClick={() => updateCity(name)} type="button">
{name}
</button>
)) : <> Choose a state before selecting a city</>}
<hr />
<p>
Current city: {city || "(none)"}
</p>
</div>
</form>
{restaurants.data ? (
restaurants.data.map(({ _id, address, images, name, slug }) => (
<ListItem
key={_id}
address={address}
name={name}
slug={slug}
thumbnail={images.thumbnail}
/>
))
) : (
<p>No restaurants.</p>
)}
</div>
</>
)
}
export default RestaurantList
✏️ Update src/services/restaurant/hooks.ts to be:
import { useEffect, useState } from 'react'
import type { City, State } from './interfaces'
interface StatesResponse {
data: State[] | null;
error: Error | null;
isPending: boolean;
}
export function useCities(state: string): City[] {
const cities = [
{ name: 'Madison', state: 'WI' },
{ name: 'Springfield', state: 'IL' },
]
return cities.filter(city => {
return city.state === state
})
}
export function useStates(): StatesResponse {
const [response, setResponse] = useState<StatesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`${import.meta.env.VITE_PMO_API}/states`, {
method: "GET",
})
const data = await response.json()
setResponse({
data: data?.data || null,
error: null,
isPending: false,
})
}
fetchData()
}, []);
return response
}
Objective 3: Update the useCities
Hook to fetch data from the API.
In this section, we will:
- Learn about including query parameters in our API calls.
Including query parameters in API calls
Query parameters are a defined set of parameters attached to the end of a URL.
They are used to define and pass data in the form of key-value pairs. The
parameters are separated from the URL itself by a ?
symbol, and individual
key-value pairs are separated by the &
symbol.
A basic URL with query parameters looks like this:
http://www.example.com/page?param1=value1¶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
✏️ Update src/pages/RestaurantList/RestaurantList.tsx to be:
import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useState } from 'react'
import { useCities, useStates } from '../../services/restaurant/hooks'
import ListItem from './ListItem'
const RestaurantList: React.FC = () => {
const [state, setState] = useState("")
const [city, setCity] = useState("")
const statesResponse = useStates()
const cities = useCities(state)
const restaurants = {
data: [
{
name: 'Cheese Curd City',
slug: 'cheese-curd-city',
images: {
thumbnail: CheeseThumbnail,
},
address: {
street: '2451 W Washburne Ave',
city: 'Green Bay',
state: 'WI',
zip: '53295',
},
_id: 'Ar0qBJHxM3ecOhcr',
},
{
name: 'Poutine Palace',
slug: 'poutine-palace',
images: {
thumbnail: PoutineThumbnail,
},
address: {
street: '230 W Kinzie Street',
city: 'Green Bay',
state: 'WI',
zip: '53205',
},
_id: '3ZOZyTY1LH26LnVw',
},
]
};
const updateState = (stateShortCode: string) => {
setState(stateShortCode)
setCity("")
}
const updateCity = (cityName: string) => {
setCity(cityName)
}
return (
<>
<div className="restaurants">
<h2 className="page-header">Restaurants</h2>
<form className="form">
<div className="form-group">
<label className="control-label" htmlFor="stateSelect">
State
</label>
<select
className="form-control"
id="stateSelect"
onChange={event => updateState(event.target.value)}
value={state}
>
<option key="choose_state" value="">
{
statesResponse.isPending
? "Loading states…"
: statesResponse.error
? statesResponse.error.message
: "Choose a state"
}
</option>
{statesResponse.data?.map(({ short, name }) => (
<option key={short} value={short}>
{name}
</option>
))}
</select>
</div>
<div className="form-group">
<label className="control-label" htmlFor="citySelect">
City
</label>
<select
className="form-control"
id="citySelect"
onChange={event => updateCity(event.target.value)}
value={city}
>
<option key="choose_city" value="">
{
state
? citiesResponse.isPending
? "Loading cities…"
: citiesResponse.error
? citiesResponse.error.message
: "Choose a city"
: "Choose a state before selecting a city"
}
</option>
{state && citiesResponse.data?.map(({ name }) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
</form>
{restaurants.data ? (
restaurants.data.map(({ _id, address, images, name, slug }) => (
<ListItem
key={_id}
address={address}
name={name}
slug={slug}
thumbnail={images.thumbnail}
/>
))
) : (
<p>No restaurants.</p>
)}
</div>
</>
)
}
export default RestaurantList
Verify
✏️ Update src/pages/RestaurantList/RestaurantList.test.tsx to be:
import '@testing-library/jest-dom';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest';
import RestaurantList from './RestaurantList';
// Mock the hooks used in the component
vi.mock('../../services/restaurant/hooks', () => ({
useCities: vi.fn(() => {
return {
data: null,
error: null,
isPending: false,
}
}),
useStates: vi.fn(() => {
return {
data: null,
error: null,
isPending: false,
}
}),
}));
import { useCities, useStates } from '../../services/restaurant/hooks'
describe('RestaurantList component', () => {
it('renders the Restaurants header', async () => {
render(<RestaurantList />);
await act(() => {})
expect(screen.getByText(/Restaurants/i)).toBeInTheDocument();
});
it('renders state and city dropdowns', async () => {
render(<RestaurantList />)
await act(() => {})
expect(screen.getByLabelText(/State/i)).toBeInTheDocument()
expect(screen.getByLabelText(/City/i)).toBeInTheDocument()
})
it('renders correctly with initial states', async () => {
useStates.mockReturnValue({ data: null, isPending: true, error: null });
useCities.mockReturnValue({ data: null, isPending: false, error: null });
render(<RestaurantList />);
await act(() => {})
expect(screen.getByText(/Restaurants/)).toBeInTheDocument();
expect(screen.getByText(/Loading states…/)).toBeInTheDocument();
});
it('displays error messages correctly', async () => {
useStates.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading states' } });
useCities.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading cities' } });
render(<RestaurantList />);
await act(() => {})
expect(screen.getByText(/Error loading states/)).toBeInTheDocument();
});
it('renders restaurants correctly', async () => {
useStates.mockReturnValue({ data: [{ short: 'CA', name: 'California' }], isPending: false, error: null });
useCities.mockReturnValue({ data: [{ name: 'Los Angeles' }], isPending: false, error: null });
render(<RestaurantList />);
await act(() => {})
await userEvent.selectOptions(screen.getByLabelText(/State/), 'CA');
await userEvent.selectOptions(screen.getByLabelText(/City/), 'Los Angeles');
expect(screen.getByText('Cheese Curd City')).toBeInTheDocument();
});
});
✏️ Update src/services/restaurant/hooks.test.ts to be:
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useCities, useStates } from './hooks';
describe('useCities Hook', () => {
beforeEach(() => {
global.fetch = vi.fn();
});
it('initial state of useCities', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
json: async () => ({ data: null }),
});
const { result } = renderHook(() => useCities('someState'));
await waitFor(() => {
expect(result.current.isPending).toBe(true);
expect(result.current.data).toBeNull();
expect(result.current.error).toBeNull();
});
});
it('fetches cities successfully', async () => {
const mockCities = [{ id: 1, name: 'City1' }, { id: 2, name: 'City2' }];
vi.mocked(fetch).mockResolvedValueOnce({
json: async () => ({ data: mockCities }),
});
const { result } = renderHook(() => useCities('someState'));
await waitFor(() => {
expect(result.current.data).toEqual(mockCities);
expect(result.current.isPending).toBe(false);
expect(result.current.error).toBeNull();
});
});
});
describe('useStates Hook', () => {
beforeEach(async () => {
// Mocking the fetch function
global.fetch = vi.fn();
})
it('should set the states data on successful fetch', async () => {
const mockStates = [{ name: 'State1' }, { name: 'State2' }];
fetch.mockResolvedValueOnce({
json: () => Promise.resolve({ data: mockStates }),
});
const { result } = renderHook(() => useStates());
await waitFor(() => {
expect(result.current.isPending).toBe(false);
expect(result.current.data).toEqual(mockStates);
expect(result.current.error).toBeNull();
});
});
});
Exercise
Update our useCities Hook to fetch cities from the Place My Order API, given a selected state.
When calling the Place My Order API, include the state
query parameter:
http://localhost:7070/cities?state=MO
Solution
Click to see the solution
✏️ Update src/pages/RestaurantList/RestaurantList.tsx to be:
import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useState } from 'react'
import { useCities, useStates } from '../../services/restaurant/hooks'
import ListItem from './ListItem'
const RestaurantList: React.FC = () => {
const [state, setState] = useState("")
const [city, setCity] = useState("")
const statesResponse = useStates()
const citiesResponse = useCities(state)
const restaurants = {
data: [
{
name: 'Cheese Curd City',
slug: 'cheese-curd-city',
images: {
thumbnail: CheeseThumbnail,
},
address: {
street: '2451 W Washburne Ave',
city: 'Green Bay',
state: 'WI',
zip: '53295',
},
_id: 'Ar0qBJHxM3ecOhcr',
},
{
name: 'Poutine Palace',
slug: 'poutine-palace',
images: {
thumbnail: PoutineThumbnail,
},
address: {
street: '230 W Kinzie Street',
city: 'Green Bay',
state: 'WI',
zip: '53205',
},
_id: '3ZOZyTY1LH26LnVw',
},
]
};
const updateState = (stateShortCode: string) => {
setState(stateShortCode)
setCity("")
}
const updateCity = (cityName: string) => {
setCity(cityName)
}
return (
<>
<div className="restaurants">
<h2 className="page-header">Restaurants</h2>
<form className="form">
<div className="form-group">
<label className="control-label" htmlFor="stateSelect">
State
</label>
<select
className="form-control"
id="stateSelect"
onChange={event => updateState(event.target.value)}
value={state}
>
<option key="choose_state" value="">
{
statesResponse.isPending
? "Loading states…"
: statesResponse.error
? statesResponse.error.message
: "Choose a state"
}
</option>
{statesResponse.data?.map(({ short, name }) => (
<option key={short} value={short}>
{name}
</option>
))}
</select>
</div>
<div className="form-group">
<label className="control-label" htmlFor="citySelect">
City
</label>
<select
className="form-control"
id="citySelect"
onChange={event => updateCity(event.target.value)}
value={city}
>
<option key="choose_city" value="">
{
state
? citiesResponse.isPending
? "Loading cities…"
: citiesResponse.error
? citiesResponse.error.message
: "Choose a city"
: "Choose a state before selecting a city"
}
</option>
{state && citiesResponse.data?.map(({ name }) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
</form>
{restaurants.data ? (
restaurants.data.map(({ _id, address, images, name, slug }) => (
<ListItem
key={_id}
address={address}
name={name}
slug={slug}
thumbnail={images.thumbnail}
/>
))
) : (
<p>No restaurants.</p>
)}
</div>
</>
)
}
export default RestaurantList
✏️ Update src/services/restaurant/hooks.ts to be:
import { useEffect, useState } from 'react'
import type { City, State } from './interfaces'
interface CitiesResponse {
data: City[] | null;
error: Error | null;
isPending: boolean;
}
interface StatesResponse {
data: State[] | null;
error: Error | null;
isPending: boolean;
}
export function useCities(state: string): CitiesResponse {
const [response, setResponse] = useState<CitiesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`${import.meta.env.VITE_PMO_API}/cities?state=${state}`, {
method: "GET",
})
const data = await response.json()
setResponse({
data: data?.data || null,
error: null,
isPending: false,
})
}
fetchData()
}, [state]);
return response
}
export function useStates(): StatesResponse {
const [response, setResponse] = useState<StatesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`${import.meta.env.VITE_PMO_API}/states`, {
method: "GET",
})
const data = await response.json()
setResponse({
data: data?.data || null,
error: null,
isPending: false,
})
}
fetchData()
}, []);
return response
}
Objective 4: Create an apiRequest
helper and use it in the Hooks.
In this section, we will learn how to:
- Handle HTTP error statuses (e.g.
404 Not Found
) - Catch network errors from
fetch()
Checking for error responses
.ok
.status
.statusText
When you make a request with the Fetch API, it does not reject on HTTP error
statuses (like 404
or 500
). Instead, it resolves normally (with an ok
status set to false
), and it only rejects on network failure or if anything
prevented the request from completing.
Here’s the API that fetch
provides to handle these HTTP errors:
.ok
: This is a shorthand property that 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
).
Handling network errors
Network errors occur when there is a problem in completing the request, like when the user is offline, the server is unreachable, or there is a DNS lookup failure.
In these cases, the fetch
API will not resolve with data, but instead it will
throw an error that needs to be caught.
Let’s take a look at how to handle these types of errors:
try {
const response = await fetch('https://api.example.com/data', {
method: "GET",
})
const data = await response.json()
const error = response.ok ? null : new Error(`${response.status} (${response.statusText})`)
// Do something with data and error
} catch (error) {
const parsedError = error instanceof Error ? error : new Error('An unknown error occurred')
// Do something with parsedError
}
In the example above, we catch
the error
and check its type. If it’s already an
instanceof Error
, then it will have a message
property and we can use it as-is.
If it’s not, then we can create our own new Error()
so we always have an error
to consume in our Hooks or components.
Setup
✏️ Create src/services/api.ts and update it to be:
export async function apiRequest<Data = never, Params = unknown>({
method,
params,
path,
}: {
method: string
params?: Params
path: string
}): Promise<{ data: Data | null, error: Error | null }> {
}
export function stringifyQuery(input: Record<string, string>): string {
const output: string[] = []
for (const [key, value] of Object.entries(input)) {
if (typeof value !== "undefined" && value !== null) {
output.push(`${key}=${value}`)
}
}
return output.join("&")
}
✏️ Update src/services/restaurant/hooks.ts to be:
import { useEffect, useState } from 'react'
import { apiRequest } from '../api'
import type { City, State } from './interfaces'
interface CitiesResponse {
data: City[] | null;
error: Error | null;
isPending: boolean;
}
interface StatesResponse {
data: State[] | null;
error: Error | null;
isPending: boolean;
}
export function useCities(state: string): CitiesResponse {
const [response, setResponse] = useState<CitiesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`${import.meta.env.VITE_PMO_API}/cities?state=${state}`, {
method: "GET",
})
const data = await response.json()
setResponse({
data: data?.data || null,
error: null,
isPending: false,
})
}
fetchData()
}, [state]);
return response
}
export function useStates(): StatesResponse {
const [response, setResponse] = useState<StatesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`${import.meta.env.VITE_PMO_API}/states`, {
method: "GET",
})
const data = await response.json()
setResponse({
data: data?.data || null,
error: null,
isPending: false,
})
}
fetchData()
}, []);
return response
}
Verify
✏️ Create src/services/api.test.ts and update it to be:
import { apiRequest, stringifyQuery } from './api';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mocking the global fetch function
const mockFetch = vi.fn();
global.fetch = mockFetch;
beforeEach(() => {
mockFetch.mockClear();
});
afterEach(() => {
mockFetch.mockClear();
});
describe('apiRequest function', () => {
it('should handle a successful request', async () => {
// Mock the fetch response
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ message: 'success' }),
statusText: 'OK',
status: 200,
});
const response = await apiRequest({
method: 'GET',
path: '/test',
});
expect(response).toEqual({ data: { message: 'success' }, error: null });
expect(mockFetch).toHaveBeenCalledWith(`${import.meta.env.VITE_PMO_API}/test?`, { method: 'GET' });
});
it('should handle a failed request', async () => {
// Mock the fetch response
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ message: 'error' }),
statusText: 'Bad Request',
status: 400,
});
const response = await apiRequest({
method: 'GET',
path: '/test',
});
expect(response).toEqual({ data: { message: 'error' }, error: new Error('400 (Bad Request)') });
});
it('should handle network errors', async () => {
// Mock a network error
mockFetch.mockRejectedValueOnce(new Error('Network Error'));
const response = await apiRequest({
method: 'GET',
path: '/test',
});
expect(response).toEqual({ data: null, error: new Error('Network Error') });
});
});
describe('stringifyQuery function', () => {
it('should correctly stringify query parameters', () => {
const query = stringifyQuery({ foo: 'bar', baz: 'qux' });
expect(query).toBe('foo=bar&baz=qux');
});
it('should omit undefined and null values', () => {
const query = stringifyQuery({ foo: 'bar', baz: null, qux: undefined });
expect(query).toBe('foo=bar');
});
});
✏️ Update src/services/restaurant/hooks.test.ts to be:
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { apiRequest } from '../api'
import { useCities, useStates } from './hooks';
// Mock the apiRequest function
vi.mock('../api', () => ({
apiRequest: vi.fn(),
}));
describe('Hooks', () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe('useCities hook', () => {
it('should return cities data successfully', async () => {
const mockCities = [{ id: 1, name: 'City1' }, { id: 2, name: 'City2' }];
apiRequest.mockResolvedValue({ data: { data: mockCities }, error: null });
const { result } = renderHook(() => useCities('test-state'));
await waitFor(() => {
expect(result.current.isPending).toBeFalsy();
expect(result.current.data).toEqual(mockCities);
expect(result.current.error).toBeNull();
});
});
it('should handle error when fetching cities data', async () => {
const mockError = new Error('Error fetching cities');
apiRequest.mockResolvedValue({ data: null, error: mockError });
const { result } = renderHook(() => useCities('test-state'));
await waitFor(() => {
expect(result.current.isPending).toBeFalsy();
expect(result.current.data).toBeNull();
expect(result.current.error).toEqual(mockError);
});
});
});
describe('useStates hook', () => {
it('should return states data successfully', async () => {
const mockStates = [{ id: 1, name: 'State1' }, { id: 2, name: 'State2' }];
apiRequest.mockResolvedValue({ data: { data: mockStates }, error: null });
const { result } = renderHook(() => useStates());
await waitFor(() => {
expect(result.current.isPending).toBeFalsy();
expect(result.current.data).toEqual(mockStates);
expect(result.current.error).toBeNull();
});
});
it('should handle error when fetching states data', async () => {
const mockError = new Error('Error fetching states');
apiRequest.mockResolvedValue({ data: null, error: mockError });
const { result } = renderHook(() => useStates());
await waitFor(() => {
expect(result.current.isPending).toBeFalsy();
expect(result.current.data).toBeNull();
expect(result.current.error).toEqual(mockError);
});
});
});
});
Exercise
- Implement the
apiRequest
helper function to handle errors returned and thrown 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
Click to see the solution
✏️ Update src/services/api.ts to be:
export async function apiRequest<Data = never, Params = unknown>({
method,
params,
path,
}: {
method: string
params?: Params
path: string
}): Promise<{ data: Data | null, error: Error | null }> {
try {
const query = params ? stringifyQuery(params) : ""
const response = await fetch(`${import.meta.env.VITE_PMO_API}${path}?${query}`, {
method,
})
const data = await response.json()
const error = response.ok ? null : new Error(`${response.status} (${response.statusText})`)
return {
data: data,
error: error,
}
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('An unknown error occurred'),
}
}
}
export function stringifyQuery(input: Record<string, string>): string {
const output: string[] = []
for (const [key, value] of Object.entries(input)) {
if (typeof value !== "undefined" && value !== null) {
output.push(`${key}=${value}`)
}
}
return output.join("&")
}
✏️ Update src/services/restaurant/hooks.ts to be:
import { useEffect, useState } from 'react'
import { apiRequest } from '../api'
import type { City, State } from './interfaces'
interface CitiesResponse {
data: City[] | null;
error: Error | null;
isPending: boolean;
}
interface StatesResponse {
data: State[] | null;
error: Error | null;
isPending: boolean;
}
export function useCities(state: string): CitiesResponse {
const [response, setResponse] = useState<CitiesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const { data, error } = await apiRequest<CitiesResponse>({
method: "GET",
path: "/cities",
params: {
state: state
},
})
setResponse({
data: data?.data || null,
error: error,
isPending: false,
})
}
fetchData()
}, [state]);
return response
}
export function useStates(): StatesResponse {
const [response, setResponse] = useState<StatesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const { data, error } = await apiRequest<StatesResponse>({
method: "GET",
path: "/states",
})
setResponse({
data: data?.data || null,
error: error,
isPending: false,
})
}
fetchData()
}, []);
return response
}
Objective 5: Fetch restaurant data
In this section, we will:
- Create a
useRestaurants
Hook for fetching the restaurant data.
Now that we are able to capture a user’s state and city preferences, we want to only return restaurants in the selected city.:
Setup
✏️ Update src/pages/RestaurantList/RestaurantList.tsx to be:
import CheeseThumbnail from 'place-my-order-assets/images/2-thumbnail.jpg'
import PoutineThumbnail from 'place-my-order-assets/images/4-thumbnail.jpg'
import { useState } from 'react'
import { useCities, useRestaurants, useStates } from '../../services/restaurant/hooks'
import ListItem from './ListItem'
const RestaurantList: React.FC = () => {
const [state, setState] = useState("")
const [city, setCity] = useState("")
const statesResponse = useStates()
const citiesResponse = useCities(state)
const restaurants = {
data: [
{
name: 'Cheese Curd City',
slug: 'cheese-curd-city',
images: {
thumbnail: CheeseThumbnail,
},
address: {
street: '2451 W Washburne Ave',
city: 'Green Bay',
state: 'WI',
zip: '53295',
},
_id: 'Ar0qBJHxM3ecOhcr',
},
{
name: 'Poutine Palace',
slug: 'poutine-palace',
images: {
thumbnail: PoutineThumbnail,
},
address: {
street: '230 W Kinzie Street',
city: 'Green Bay',
state: 'WI',
zip: '53205',
},
_id: '3ZOZyTY1LH26LnVw',
},
]
};
const updateState = (stateShortCode: string) => {
setState(stateShortCode)
setCity("")
}
const updateCity = (cityName: string) => {
setCity(cityName)
}
return (
<>
<div className="restaurants">
<h2 className="page-header">Restaurants</h2>
<form className="form">
<div className="form-group">
<label className="control-label" htmlFor="stateSelect">
State
</label>
<select
className="form-control"
id="stateSelect"
onChange={event => updateState(event.target.value)}
value={state}
>
<option key="choose_state" value="">
{
statesResponse.isPending
? "Loading states…"
: statesResponse.error
? statesResponse.error.message
: "Choose a state"
}
</option>
{statesResponse.data?.map(({ short, name }) => (
<option key={short} value={short}>
{name}
</option>
))}
</select>
</div>
<div className="form-group">
<label className="control-label" htmlFor="citySelect">
City
</label>
<select
className="form-control"
id="citySelect"
onChange={event => updateCity(event.target.value)}
value={city}
>
<option key="choose_city" value="">
{
state
? citiesResponse.isPending
? "Loading cities…"
: citiesResponse.error
? citiesResponse.error.message
: "Choose a city"
: "Choose a state before selecting a city"
}
</option>
{state && citiesResponse.data?.map(({ name }) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
</form>
{city && restaurantsResponse.error && (
<p aria-live="polite" className="restaurant">
Error loading restaurants: {restaurantsResponse.error.message}
</p>
)}
{city && restaurantsResponse.isPending && (
<p aria-live="polite" className="restaurant loading">
Loading restaurants…
</p>
)}
{city && restaurantsResponse.data && (
restaurantsResponse.data.length === 0 ? (
!restaurantsResponse.isPending && (
<p aria-live="polite">No restaurants found.</p>
)
) : (
restaurantsResponse.data.map(({ _id, slug, name, address, images }) => (
<ListItem
key={_id}
address={address}
name={name}
slug={slug}
thumbnail={images.thumbnail}
/>
))
)
)}
</div>
</>
)
}
export default RestaurantList
✏️ Update src/services/restaurant/interfaces.ts to be:
export interface City {
name: string
state: string
}
interface Item {
name: string;
price: number;
}
interface Menu {
dinner: Item[];
lunch: Item[];
}
interface Address {
city: string;
state: string;
street: string;
zip: string;
}
interface Images {
banner: string;
owner: string;
thumbnail: string;
}
export interface Restaurant {
_id: string;
address?: Address;
images: Images;
menu: Menu;
name: string;
slug: string;
}
export interface State {
name: string
short: string
}
✏️ Update src/services/restaurant/hooks.ts to be:
import { useEffect, useState } from 'react'
import { apiRequest } from '../api'
import type { City, Restaurant, State } from './interfaces'
interface CitiesResponse {
data: City[] | null;
error: Error | null;
isPending: boolean;
}
interface StatesResponse {
data: State[] | null;
error: Error | null;
isPending: boolean;
}
export function useCities(state: string): CitiesResponse {
const [response, setResponse] = useState<CitiesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const { data, error } = await apiRequest<CitiesResponse>({
method: "GET",
path: "/cities",
params: {
state: state
},
})
setResponse({
data: data?.data || null,
error: error,
isPending: false,
})
}
fetchData()
}, [state]);
return response
}
export function useStates(): StatesResponse {
const [response, setResponse] = useState<StatesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const { data, error } = await apiRequest<StatesResponse>({
method: "GET",
path: "/states",
})
setResponse({
data: data?.data || null,
error: error,
isPending: false,
})
}
fetchData()
}, []);
return response
}
Verify
If you’ve implemented the solution correctly, when you use the select boxes to choose state and city, you should see a list of just restaurants from the selected city returned.
✏️ Update src/pages/RestaurantList/RestaurantList.test.tsx to be:
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest';
import RestaurantList from './RestaurantList';
// Mock the hooks used in the component
vi.mock('../../services/restaurant/hooks', () => ({
useCities: vi.fn(() => {
return {
data: null,
error: null,
isPending: false,
}
}),
useRestaurants: vi.fn(() => {
return {
data: null,
error: null,
isPending: false,
}
}),
useStates: vi.fn(() => {
return {
data: null,
error: null,
isPending: false,
}
}),
}));
import { useCities, useRestaurants, useStates } from '../../services/restaurant/hooks'
describe('RestaurantList component', () => {
it('renders the Restaurants header', () => {
render(<RestaurantList />);
expect(screen.getByText(/Restaurants/i)).toBeInTheDocument();
});
it('renders state and city dropdowns', () => {
render(<RestaurantList />)
expect(screen.getByLabelText(/State/i)).toBeInTheDocument()
expect(screen.getByLabelText(/City/i)).toBeInTheDocument()
})
it('renders correctly with initial states', () => {
useStates.mockReturnValue({ data: null, isPending: true, error: null });
useCities.mockReturnValue({ data: null, isPending: false, error: null });
useRestaurants.mockReturnValue({ data: null, isPending: false, error: null });
render(<RestaurantList />);
expect(screen.getByText(/Restaurants/)).toBeInTheDocument();
expect(screen.getByText(/Loading states…/)).toBeInTheDocument();
});
it('displays error messages correctly', () => {
useStates.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading states' } });
useCities.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading cities' } });
useRestaurants.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading restaurants' } });
render(<RestaurantList />);
expect(screen.getByText(/Error loading states/)).toBeInTheDocument();
});
it('renders restaurants correctly', async () => {
useStates.mockReturnValue({ data: [{ short: 'CA', name: 'California' }], isPending: false, error: null });
useCities.mockReturnValue({ data: [{ name: 'Los Angeles' }], isPending: false, error: null });
useRestaurants.mockReturnValue({ data: [{ _id: '1', slug: 'test-restaurant', name: 'Test Restaurant', address: '123 Test St', images: { thumbnail: 'test.jpg' } }], isPending: false, error: null });
render(<RestaurantList />);
await userEvent.selectOptions(screen.getByLabelText(/State/), 'CA');
await userEvent.selectOptions(screen.getByLabelText(/City/), 'Los Angeles');
expect(screen.getByText('Test Restaurant')).toBeInTheDocument();
});
});
✏️ Update src/services/restaurant/hooks.test.ts to be:
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { apiRequest } from '../api'
import { useCities, useRestaurants, useStates } from './hooks';
// Mock the apiRequest function
vi.mock('../api', () => ({
apiRequest: vi.fn(),
}));
describe('Hooks', () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe('useCities hook', () => {
it('should return cities data successfully', async () => {
const mockCities = [{ id: 1, name: 'City1' }, { id: 2, name: 'City2' }];
apiRequest.mockResolvedValue({ data: { data: mockCities }, error: null });
const { result } = renderHook(() => useCities('test-state'));
await waitFor(() => {
expect(result.current.isPending).toBeFalsy();
expect(result.current.data).toEqual(mockCities);
expect(result.current.error).toBeNull();
});
});
it('should handle error when fetching cities data', async () => {
const mockError = new Error('Error fetching cities');
apiRequest.mockResolvedValue({ data: null, error: mockError });
const { result } = renderHook(() => useCities('test-state'));
await waitFor(() => {
expect(result.current.isPending).toBeFalsy();
expect(result.current.data).toBeNull();
expect(result.current.error).toEqual(mockError);
});
});
});
describe('useRestaurants hook', () => {
it('should return restaurants data successfully', async () => {
const mockRestaurants = [{ id: 1, name: 'Restaurant1' }, { id: 2, name: 'Restaurant2' }];
apiRequest.mockResolvedValue({ data: { data: mockRestaurants }, error: null });
const { result } = renderHook(() => useRestaurants('test-state', 'test-city'));
await waitFor(() => {
expect(result.current.isPending).toBeFalsy();
expect(result.current.data).toEqual(mockRestaurants);
expect(result.current.error).toBeNull();
});
});
it('should handle error when fetching restaurants data', async () => {
const mockError = new Error('Error fetching restaurants');
apiRequest.mockResolvedValue({ data: null, error: mockError });
const { result } = renderHook(() => useRestaurants('test-state', 'test-city'));
await waitFor(() => {
expect(result.current.isPending).toBeFalsy();
expect(result.current.data).toBeNull();
expect(result.current.error).toEqual(mockError);
});
});
});
describe('useStates hook', () => {
it('should return states data successfully', async () => {
const mockStates = [{ id: 1, name: 'State1' }, { id: 2, name: 'State2' }];
apiRequest.mockResolvedValue({ data: { data: mockStates }, error: null });
const { result } = renderHook(() => useStates());
await waitFor(() => {
expect(result.current.isPending).toBeFalsy();
expect(result.current.data).toEqual(mockStates);
expect(result.current.error).toBeNull();
});
});
it('should handle error when fetching states data', async () => {
const mockError = new Error('Error fetching states');
apiRequest.mockResolvedValue({ data: null, error: mockError });
const { result } = renderHook(() => useStates());
await waitFor(() => {
expect(result.current.isPending).toBeFalsy();
expect(result.current.data).toBeNull();
expect(result.current.error).toEqual(mockError);
});
});
});
});
Exercise
- Implement a
useRestaurants
Hook to fetch restaurant data. - Update
RestaurantList.tsx
to use your newuseRestaurants
Hook.
Hint: The requested URL with query parameters should look like this:
'/api/restaurants?filter[address.state]=IL&filter[address.city]=Chicago'
Solution
Click to see the solution
✏️ Update src/pages/RestaurantList/RestaurantList.tsx to be:
import { useState } from 'react'
import { useCities, useRestaurants, useStates } from '../../services/restaurant/hooks'
import ListItem from './ListItem'
const RestaurantList: React.FC = () => {
const [state, setState] = useState("")
const [city, setCity] = useState("")
const statesResponse = useStates()
const citiesResponse = useCities(state)
const restaurantsResponse = useRestaurants(state, city)
const updateState = (stateShortCode: string) => {
setState(stateShortCode)
setCity("")
}
const updateCity = (cityName: string) => {
setCity(cityName)
}
return (
<>
<div className="restaurants">
<h2 className="page-header">Restaurants</h2>
<form className="form">
<div className="form-group">
<label className="control-label" htmlFor="stateSelect">
State
</label>
<select
className="form-control"
id="stateSelect"
onChange={event => updateState(event.target.value)}
value={state}
>
<option key="choose_state" value="">
{
statesResponse.isPending
? "Loading states…"
: statesResponse.error
? statesResponse.error.message
: "Choose a state"
}
</option>
{statesResponse.data?.map(({ short, name }) => (
<option key={short} value={short}>
{name}
</option>
))}
</select>
</div>
<div className="form-group">
<label className="control-label" htmlFor="citySelect">
City
</label>
<select
className="form-control"
id="citySelect"
onChange={event => updateCity(event.target.value)}
value={city}
>
<option key="choose_city" value="">
{
state
? citiesResponse.isPending
? "Loading cities…"
: citiesResponse.error
? citiesResponse.error.message
: "Choose a city"
: "Choose a state before selecting a city"
}
</option>
{state && citiesResponse.data?.map(({ name }) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
</form>
{city && restaurantsResponse.error && (
<p aria-live="polite" className="restaurant">
Error loading restaurants: {restaurantsResponse.error.message}
</p>
)}
{city && restaurantsResponse.isPending && (
<p aria-live="polite" className="restaurant loading">
Loading restaurants…
</p>
)}
{city && restaurantsResponse.data && (
restaurantsResponse.data.length === 0 ? (
!restaurantsResponse.isPending && (
<p aria-live="polite">No restaurants found.</p>
)
) : (
restaurantsResponse.data.map(({ _id, slug, name, address, images }) => (
<ListItem
key={_id}
address={address}
name={name}
slug={slug}
thumbnail={images.thumbnail}
/>
))
)
)}
</div>
</>
)
}
export default RestaurantList
✏️ Update src/services/restaurant/hooks.ts to be:
import { useEffect, useState } from 'react'
import { apiRequest } from '../api'
import type { City, Restaurant, State } from './interfaces'
interface CitiesResponse {
data: City[] | null;
error: Error | null;
isPending: boolean;
}
interface RestaurantsResponse {
data: Restaurant[] | null;
error: Error | null;
isPending: boolean;
}
interface StatesResponse {
data: State[] | null;
error: Error | null;
isPending: boolean;
}
export function useCities(state: string): CitiesResponse {
const [response, setResponse] = useState<CitiesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const { data, error } = await apiRequest<CitiesResponse>({
method: "GET",
path: "/cities",
params: {
state: state
},
})
setResponse({
data: data?.data || null,
error: error,
isPending: false,
})
}
fetchData()
}, [state]);
return response
}
export function useRestaurants(state: string, city: string): RestaurantsResponse {
const [response, setResponse] = useState<RestaurantsResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const { data, error } = await apiRequest<RestaurantsResponse>({
method: "GET",
path: "/restaurants",
params: {
"filter[address.state]": state,
"filter[address.city]": city,
},
})
setResponse({
data: data?.data || null,
error: error,
isPending: false,
})
}
fetchData()
}, [state, city]);
return response
}
export function useStates(): StatesResponse {
const [response, setResponse] = useState<StatesResponse>({
data: null,
error: null,
isPending: true,
})
useEffect(() => {
const fetchData = async () => {
const { data, error } = await apiRequest<StatesResponse>({
method: "GET",
path: "/states",
})
setResponse({
data: data?.data || null,
error: error,
isPending: false,
})
}
fetchData()
}, []);
return response
}
Next steps
TODO