-
Notifications
You must be signed in to change notification settings - Fork 274
docs(cookbook): network requests recipes #1655
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
19dd3bc
35a9e9e
81e7462
2c33c44
5a5cab2
4a2fb86
7fa6eb0
336c00e
f253045
ba36a0f
5d55fcd
ded40d1
d7acda3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import React, { useEffect, useState } from 'react'; | ||
import { Text } from 'react-native'; | ||
import { User } from './types'; | ||
import ContactsList from './components/ContactsList'; | ||
import FavoritesList from './components/FavoritesList'; | ||
import getAllContacts from './api/getAllContacts'; | ||
import getAllFavorites from './api/getAllFavorites'; | ||
|
||
export default () => { | ||
const [usersData, setUsersData] = useState<User[]>([]); | ||
const [favoritesData, setFavoritesData] = useState<User[]>([]); | ||
const [error, setError] = useState<string | null>(null); | ||
|
||
useEffect(() => { | ||
const _getAllContacts = async () => { | ||
const _data = await getAllContacts(); | ||
setUsersData(_data); | ||
}; | ||
const _getAllFavorites = async () => { | ||
const _data = await getAllFavorites(); | ||
setFavoritesData(_data); | ||
}; | ||
|
||
const run = async () => { | ||
try { | ||
await Promise.all([_getAllContacts(), _getAllFavorites()]); | ||
} catch (e) { | ||
const message = isErrorWithMessage(e) ? e.message : 'Something went wrong'; | ||
setError(message); | ||
} | ||
}; | ||
|
||
void run(); | ||
}, []); | ||
|
||
if (error) { | ||
return <Text>An error occurred: {error}</Text>; | ||
} | ||
|
||
return ( | ||
<> | ||
<FavoritesList users={favoritesData} /> | ||
<ContactsList users={usersData} /> | ||
</> | ||
); | ||
}; | ||
|
||
const isErrorWithMessage = ( | ||
e: unknown, | ||
): e is { | ||
message: string; | ||
} => typeof e === 'object' && e !== null && 'message' in e; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native'; | ||
import React from 'react'; | ||
import PhoneBook from '../PhoneBook'; | ||
import { | ||
mockServerFailureForGetAllContacts, | ||
mockServerFailureForGetAllFavorites, | ||
} from './test-utils'; | ||
|
||
jest.setTimeout(10000); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When this test suit runs in CI, the default 5s is not sufficient. Any idea what might be causing this on CI? @mdjastrzebski? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. According to my measuring:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems like the spec. tests "renders an empty task list" and "PhoneBook fetches contacts successfully and renders in list" are the slowest as they're the 1st tests in the file 🤷🏻 |
||
|
||
describe('PhoneBook', () => { | ||
it('fetches all contacts and favorites successfully and renders lists in sections correctly', async () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should the be a mock call here? Seems like the timeout might be caused by different test order execution between the test runs locally and on the CI. |
||
render(<PhoneBook />); | ||
|
||
await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); | ||
expect(await screen.findByText('Name: Mrs Ida Kristensen')).toBeOnTheScreen(); | ||
expect(await screen.findByText('Email: ida.kristensen@example.com')).toBeOnTheScreen(); | ||
expect(await screen.findAllByText(/name/i)).toHaveLength(3); | ||
expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen(); | ||
expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3); | ||
}); | ||
|
||
it('fails to fetch all contacts and renders error message', async () => { | ||
mockServerFailureForGetAllContacts(); | ||
render(<PhoneBook />); | ||
|
||
await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); | ||
expect( | ||
await screen.findByText(/an error occurred: error fetching contacts/i), | ||
).toBeOnTheScreen(); | ||
}); | ||
|
||
it('fails to fetch favorites and renders error message', async () => { | ||
mockServerFailureForGetAllFavorites(); | ||
render(<PhoneBook />); | ||
|
||
await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i)); | ||
expect(await screen.findByText(/error fetching favorites/i)).toBeOnTheScreen(); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import { User } from '../types'; | ||
import {http, HttpResponse} from "msw"; | ||
import {setupServer} from "msw/node"; | ||
|
||
// Define request handlers and response resolvers for random user API. | ||
// By default, we always return the happy path response. | ||
const handlers = [ | ||
http.get('https://randomuser.me/api/*', () => { | ||
return HttpResponse.json(DATA); | ||
}), | ||
]; | ||
|
||
export const server = setupServer(...handlers); | ||
|
||
export const mockServerFailureForGetAllContacts = () => { | ||
server.use( | ||
http.get('https://randomuser.me/api/', ({ request }) => { | ||
// Construct a URL instance out of the intercepted request. | ||
const url = new URL(request.url); | ||
// Read the "results" URL query parameter using the "URLSearchParams" API. | ||
const resultsLength = url.searchParams.get('results'); | ||
// Simulate a server error for the get all contacts request. | ||
// We check if the "results" query parameter is set to "25" | ||
// to know it's the correct request to mock, in our case get all contacts. | ||
if (resultsLength === '25') { | ||
vanGalilea marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return new HttpResponse(null, { status: 500 }); | ||
} | ||
|
||
return HttpResponse.json(DATA); | ||
}), | ||
); | ||
}; | ||
|
||
export const mockServerFailureForGetAllFavorites = () => { | ||
server.use( | ||
http.get('https://randomuser.me/api/', ({ request }) => { | ||
// Construct a URL instance out of the intercepted request. | ||
const url = new URL(request.url); | ||
// Read the "results" URL query parameter using the "URLSearchParams" API. | ||
const resultsLength = url.searchParams.get('results'); | ||
// Simulate a server error for the get all favorites request. | ||
// We check if the "results" query parameter is set to "10" | ||
// to know it's the correct request to mock, in our case get all favorites. | ||
if (resultsLength === '10') { | ||
return new HttpResponse(null, { status: 500 }); | ||
} | ||
|
||
return HttpResponse.json(DATA); | ||
}), | ||
); | ||
}; | ||
export const DATA: { results: User[] } = { | ||
vanGalilea marked this conversation as resolved.
Show resolved
Hide resolved
|
||
results: [ | ||
{ | ||
name: { | ||
title: 'Mrs', | ||
first: 'Ida', | ||
last: 'Kristensen', | ||
}, | ||
email: 'ida.kristensen@example.com', | ||
id: { | ||
name: 'CPR', | ||
value: '250562-5730', | ||
}, | ||
picture: { | ||
large: 'https://randomuser.me/api/portraits/women/26.jpg', | ||
medium: 'https://randomuser.me/api/portraits/med/women/26.jpg', | ||
thumbnail: 'https://randomuser.me/api/portraits/thumb/women/26.jpg', | ||
}, | ||
cell: '123-4567-890', | ||
}, | ||
{ | ||
name: { | ||
title: 'Mr', | ||
first: 'Elijah', | ||
last: 'Ellis', | ||
}, | ||
email: 'elijah.ellis@example.com', | ||
id: { | ||
name: 'TFN', | ||
value: '138117486', | ||
}, | ||
picture: { | ||
large: 'https://randomuser.me/api/portraits/men/53.jpg', | ||
medium: 'https://randomuser.me/api/portraits/med/men/53.jpg', | ||
thumbnail: 'https://randomuser.me/api/portraits/thumb/men/53.jpg', | ||
}, | ||
cell: '123-4567-890', | ||
}, | ||
{ | ||
name: { | ||
title: 'Mr', | ||
first: 'Miro', | ||
last: 'Halko', | ||
}, | ||
email: 'miro.halko@example.com', | ||
id: { | ||
name: 'HETU', | ||
value: 'NaNNA945undefined', | ||
}, | ||
picture: { | ||
large: 'https://randomuser.me/api/portraits/men/17.jpg', | ||
medium: 'https://randomuser.me/api/portraits/med/men/17.jpg', | ||
thumbnail: 'https://randomuser.me/api/portraits/thumb/men/17.jpg', | ||
}, | ||
cell: '123-4567-890', | ||
}, | ||
], | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { User } from '../types'; | ||
|
||
export default async (): Promise<User[]> => { | ||
const res = await fetch('https://randomuser.me/api/?results=25'); | ||
if (!res.ok) { | ||
throw new Error(`Error fetching contacts`); | ||
} | ||
const json = await res.json(); | ||
return json.results; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { User } from '../types'; | ||
|
||
export default async (): Promise<User[]> => { | ||
const res = await fetch('https://randomuser.me/api/?results=10'); | ||
if (!res.ok) { | ||
throw new Error(`Error fetching favorites`); | ||
} | ||
const json = await res.json(); | ||
return json.results; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
import { FlatList, Image, StyleSheet, Text, View } from 'react-native'; | ||
import React, { useCallback } from 'react'; | ||
import type { ListRenderItem } from '@react-native/virtualized-lists'; | ||
import { User } from '../types'; | ||
|
||
export default ({ users }: { users: User[] }) => { | ||
const renderItem: ListRenderItem<User> = useCallback( | ||
({ item: { name, email, picture, cell }, index }) => { | ||
const { title, first, last } = name; | ||
const backgroundColor = index % 2 === 0 ? '#f9f9f9' : '#fff'; | ||
return ( | ||
<View style={[{ backgroundColor }, styles.userContainer]}> | ||
<Image source={{ uri: picture.thumbnail }} style={styles.userImage} /> | ||
<View> | ||
<Text> | ||
Name: {title} {first} {last} | ||
</Text> | ||
<Text>Email: {email}</Text> | ||
<Text>Mobile: {cell}</Text> | ||
</View> | ||
</View> | ||
); | ||
}, | ||
[], | ||
); | ||
|
||
if (users.length === 0) return <FullScreenLoader />; | ||
|
||
return ( | ||
<View> | ||
<FlatList<User> | ||
data={users} | ||
renderItem={renderItem} | ||
keyExtractor={(item, index) => `${index}-${item.id.value}`} | ||
/> | ||
</View> | ||
); | ||
}; | ||
const FullScreenLoader = () => { | ||
return ( | ||
<View style={styles.loaderContainer}> | ||
<Text>Users data not quite there yet...</Text> | ||
</View> | ||
); | ||
}; | ||
|
||
const styles = StyleSheet.create({ | ||
userContainer: { | ||
padding: 16, | ||
flexDirection: 'row', | ||
alignItems: 'center', | ||
}, | ||
userImage: { | ||
width: 50, | ||
height: 50, | ||
borderRadius: 24, | ||
marginRight: 16, | ||
}, | ||
loaderContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' }, | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import { FlatList, Image, StyleSheet, Text, View } from 'react-native'; | ||
import React, { useCallback } from 'react'; | ||
import type { ListRenderItem } from '@react-native/virtualized-lists'; | ||
import { User } from '../types'; | ||
|
||
export default ({ users }: { users: User[] }) => { | ||
const renderItem: ListRenderItem<User> = useCallback(({ item: { picture } }) => { | ||
return ( | ||
<View style={styles.userContainer}> | ||
<Image | ||
source={{ uri: picture.thumbnail }} | ||
style={styles.userImage} | ||
accessibilityLabel={'favorite-contact-avatar'} | ||
/> | ||
</View> | ||
); | ||
}, []); | ||
|
||
if (users.length === 0) return <FullScreenLoader />; | ||
|
||
return ( | ||
<View style={styles.outerContainer}> | ||
<Text>⭐My Favorites</Text> | ||
<FlatList<User> | ||
horizontal | ||
showsHorizontalScrollIndicator={false} | ||
data={users} | ||
renderItem={renderItem} | ||
keyExtractor={(item, index) => `${index}-${item.id.value}`} | ||
/> | ||
</View> | ||
); | ||
}; | ||
const FullScreenLoader = () => { | ||
return ( | ||
<View style={styles.loaderContainer}> | ||
<Text>Figuring out your favorites...</Text> | ||
</View> | ||
); | ||
}; | ||
|
||
const styles = StyleSheet.create({ | ||
outerContainer: { | ||
padding: 8, | ||
}, | ||
userContainer: { | ||
padding: 8, | ||
flexDirection: 'row', | ||
alignItems: 'center', | ||
}, | ||
userImage: { | ||
width: 52, | ||
height: 52, | ||
borderRadius: 36, | ||
borderColor: '#9b6dff', | ||
borderWidth: 2, | ||
}, | ||
loaderContainer: { height: 52, justifyContent: 'center', alignItems: 'center' }, | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import * as React from 'react'; | ||
import PhoneBook from './PhoneBook'; | ||
|
||
export default function Example() { | ||
return <PhoneBook />; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
export type User = { | ||
name: { | ||
title: string; | ||
first: string; | ||
last: string; | ||
}; | ||
email: string; | ||
id: { | ||
name: string; | ||
value: string; | ||
}; | ||
picture: { | ||
large: string; | ||
medium: string; | ||
thumbnail: string; | ||
}; | ||
cell: string; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thinking out loud, perhaps we should showcase usage with TanStack Query instead of manual promise fetching. Implementing data fetching using useEffect to avoid race conditions is verbose. Wdyt @vanGalilea ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Showcase is a very good idea, as it is become and industry standard the last years.
With my approach I intended to keep things more straight-forward.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let me add it up, this week 👍🏻 In the meanwhile, you can review the rest ;)