Adding Offline Support page
Learn device-first strategies for storing data and syncing it to a server.
Overview
In this section, you will:
- Listen for changes to the network connection state.
- Make an API call when the user signs in.
- Design offline-syncing behavior.
- Sync data between the device and server.
Objective 1: Show the current connection status
Most mobile applications use a network connection for critical functionality. It’s important to communicate to the user when their device is offline and some functionality in the application may be disabled because of their current connection status.
The way you communicate this info will depend on your application. In ours, we’re going to add some text to the Settings view that shows the current connection status.
Listening for the network connection state
The @react-native-community/netinfo
is an incredible useful package for detecting the network status of the device.
This package allows you to:
- Detect whether the device is connected to the internet.
- Determine the type of network connection (WiFi, cellular, etc.).
- React to changes in the network status, allowing the app to adapt accordingly.
Getting the current connection state
The useNetInfo
Hook provided by the package simplifies the process of accessing network state information in functional components.
This Hook returns an object containing details about the network status.
import { useNetInfo } from "@react-native-community/netinfo"
import React from "react"
import { Text, View } from "react-native"
const NetworkStatusComponent = () => {
const { isConnected } = useNetInfo()
return (
<View>
<Text>Is connected: {isConnected ? "Yes" : "No"}</Text>
</View>
)
}
export default NetworkStatusComponent
Setup 1
✏️ Install the new dependency:
npm install @react-native-community/netinfo@11
✏️ Create @types/@react-native-community/netinfo/jest/netinfo-mock.d.ts and update it to be:
declare module "@react-native-community/netinfo/jest/netinfo-mock"
✏️ Update jest-setup.ts to be:
import "@testing-library/react-native/extend-expect"
import "react-native-gesture-handler/jestSetup"
import "@react-native-google-signin/google-signin/jest/build/setup"
import mockRNCNetInfo from "@react-native-community/netinfo/jest/netinfo-mock"
jest.mock("@react-native-community/netinfo", () => mockRNCNetInfo)
jest.mock("@react-navigation/native", () => {
const actualNav = jest.requireActual("@react-navigation/native")
return {
...actualNav,
useNavigation: () => ({
navigate: jest.fn(),
setOptions: jest.fn(),
}),
}
})
jest.mock("./src/services/storage/storage", () =>
require("./src/services/storage/storage.mock"),
)
jest.mock("@react-native-async-storage/async-storage", () =>
require("@react-native-async-storage/async-storage/jest/async-storage-mock"),
)
const consoleError = console.error
console.error = (message, ...args) => {
if (
typeof message === "string" &&
message.match(
/Warning: An update to .+ inside a test was not wrapped in act\(\.\.\.\)\./,
)
) {
return
}
return consoleError(message, ...args)
}
✏️ Terminate the existing dev server and start it again:
npm run start
✏️ Update src/screens/Settings/Settings.tsx to be:
import { useNetInfo } from "@react-native-community/netinfo"
import { GoogleSigninButton } from "@react-native-google-signin/google-signin"
import { StyleSheet, Switch, View } from "react-native"
import Loading from "../../components/Loading"
import Button from "../../design/Button"
import Card from "../../design/Card"
import Screen from "../../design/Screen"
import { useThemeMode } from "../../design/theme"
import Typography from "../../design/Typography"
import { useAuthentication, useUser } from "../../services/auth"
const Settings: React.FC = () => {
const { error, isPending, signIn, signOut } = useAuthentication()
const user = useUser()
const { mode, setMode } = useThemeMode()
// Exercise: Get the current connection state with the `useNetInfo()` Hook.
return (
<Screen>
<Card>
{isPending ? (
<Loading />
) : user ? (
<>
<Typography variant="heading">Welcome back</Typography>
<Typography variant="body">
{user.name || "Unknown name"}
</Typography>
<Button onPress={signOut}>Sign out</Button>
{error ? (
<Typography variant="body">Error: {error.message}</Typography>
) : null}
</>
) : (
<>
<GoogleSigninButton onPress={signIn} style={{ width: "100%" }} />
{error ? (
<Typography variant="body">Error: {error.message}</Typography>
) : null}
</>
)}
</Card>
<Card>
<View style={styles.row}>
<Typography variant="heading">Dark mode</Typography>
<Switch
onValueChange={(value) => setMode(value ? "dark" : "light")}
value={mode === "dark"}
/>
</View>
</Card>
{/* Exercise: Display the connection state in the Settings view. */}
</Screen>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
})
export default Settings
Verify 1
✏️ Update src/screens/Settings/Settings.test.tsx to be:
import { NavigationContainer } from "@react-navigation/native"
import { fireEvent, render, screen } from "@testing-library/react-native"
import AuthProvider from "../../services/auth/AuthProvider"
import Settings from "./Settings"
const mockSetMode = jest.fn()
jest.mock("../../design/theme", () => ({
...jest.requireActual("../../design/theme"),
useThemeMode: () => ({
mode: "light",
setMode: mockSetMode,
}),
}))
describe("Screens/Settings", () => {
it("renders", async () => {
render(
<AuthProvider>
<NavigationContainer>
<Settings />
</NavigationContainer>
</AuthProvider>,
)
expect(screen.getByText(/Loading…/i)).not.toBeNull()
})
it("switches to dark mode", () => {
render(
<AuthProvider>
<NavigationContainer>
<Settings />
</NavigationContainer>
</AuthProvider>,
)
const switchElement = screen.getByRole("switch")
expect(switchElement.props.value).toBe(false)
fireEvent(switchElement, "onValueChange", true)
expect(mockSetMode).toHaveBeenCalledWith("dark")
})
it("displays the correct connection status", () => {
render(
<AuthProvider>
<NavigationContainer>
<Settings />
</NavigationContainer>
</AuthProvider>,
)
expect(screen.getByText(/Connection status: Online/i)).not.toBeNull()
})
})
Exercise 1
- Get the current connection state with the
useNetInfo()
Hook. - Display the connection state in the Settings view.
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/screens/Settings/Settings.tsx to be:
import { useNetInfo } from "@react-native-community/netinfo"
import { GoogleSigninButton } from "@react-native-google-signin/google-signin"
import { StyleSheet, Switch, View } from "react-native"
import Loading from "../../components/Loading"
import Button from "../../design/Button"
import Card from "../../design/Card"
import Screen from "../../design/Screen"
import { useThemeMode } from "../../design/theme"
import Typography from "../../design/Typography"
import { useAuthentication, useUser } from "../../services/auth"
const Settings: React.FC = () => {
const { error, isPending, signIn, signOut } = useAuthentication()
const user = useUser()
const { mode, setMode } = useThemeMode()
const { isConnected } = useNetInfo()
return (
<Screen>
<Card>
{isPending ? (
<Loading />
) : user ? (
<>
<Typography variant="heading">Welcome back</Typography>
<Typography variant="body">
{user.name || "Unknown name"}
</Typography>
<Button onPress={signOut}>Sign out</Button>
{error ? (
<Typography variant="body">Error: {error.message}</Typography>
) : null}
</>
) : (
<>
<GoogleSigninButton onPress={signIn} style={{ width: "100%" }} />
{error ? (
<Typography variant="body">Error: {error.message}</Typography>
) : null}
</>
)}
</Card>
<Card>
<View style={styles.row}>
<Typography variant="heading">Dark mode</Typography>
<Switch
onValueChange={(value) => setMode(value ? "dark" : "light")}
value={mode === "dark"}
/>
</View>
</Card>
<Card>
<Typography variant="title">
Connection status: {isConnected ? "Online" : "Offline"}
</Typography>
</Card>
</Screen>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
})
export default Settings
Objective 2: Store restaurant favorites on device
Now that you can detect when the device is online or offline, let’s build a feature that can work offline!
Let’s add the ability to “favorite” a restaurant.
In the RestaurantDetails
, we’ll add an “Add to favorites” button when the user is signed in, and if they favorite a restaurant, we’ll change it to “Remove from favorites.”
For right now, we’ll write the code for adding and removing favorites in a way that will gracefully handle the user’s device being offline. In the third objective, we’ll expand that to handle syncing when the device comes back online.
Defining the “favorites” feature
There’s a lot that goes into building even a one or two-button feature like the “add to favorites” and “remove from favorites” feature that we’re about to build.
Let’s think through what we want in the restaurant details view:
- When the user is not signed in or they haven’t added the restaurant as a favorite, there should be an “Add to favorites” button.
- When the user has added the restaurant as a favorite, there should be a “Remove from favorites” button.
- When the “Add” button is clicked and the user is not signed in, they should be sent through the sign-in flow.
- When the “Add” or “Remove” buttons are clicked and the user is signed in, that change should immediately be saved in our Async Storage and sent to the API.
- If there’s a problem with the API call (the server is down, the device is offline, etc.), then the change should still be saved to Async Storage.
Features like this are great to build into Hooks because it makes the logic more easily testable and reusable. Let’s look at the Hook code we are going to copy in the Setup step for this exercise.
Creating a useFavorite()
Hook
Our useFavorite()
Hook will accept two arguments:
import { useEffect, useState } from "react"
import { getData, storeData } from "../../storage"
import { apiRequest } from "../api"
import { Favorite, FavoriteResponse, StoredFavorites } from "./interfaces"
export const useFavorite = (
userId?: string,
restaurantId?: string,
): {
error: Error | undefined
isFavorite: boolean
isPending: boolean
toggleFavorite: () => void
} => {
const [error, setError] = useState<Error | undefined>()
const [isPending, setIsPending] = useState<boolean>(false)
const [favoriteRestaurants, setFavoriteRestaurants] = useState<
StoredFavorites | undefined
>()
useEffect(() => {
// Get the favorite restaurant data from storage.
const getStoredData = async () => {
const storedData = await getData<StoredFavorites>("favorite-restaurants")
setFavoriteRestaurants(storedData)
}
getStoredData()
}, [])
// Finding whether a restaurant is a favorite.
const favoriteRestaurant = favoriteRestaurants?.favorites.find(
(favorite) => favorite.restaurantId === restaurantId,
)
const toggleFavorite = async () => {
// updatedFavorite has the toggled “favorite” status.
const updatedFavorite = favoriteRestaurant
? {
...favoriteRestaurant,
favorite: !favoriteRestaurant.favorite, // Toggle it.
}
: ({
favorite: true, // Default to it being a new favorite.
restaurantId: restaurantId,
userId: userId,
} as Favorite)
// Update the datetime on the favorite
updatedFavorite.datetimeUpdated = Date.now()
// updatedFavorites will hold all the updated data before storage is updated.
const updatedFavorites =
favoriteRestaurants && favoriteRestaurants.favorites
? {
...favoriteRestaurants,
}
: {
favorites: [] as Favorite[],
lastSynced: 0,
}
// Update the full favorite restaurants array.
const favoriteIndex = favoriteRestaurants?.favorites.findIndex(
(favorite) => favorite.restaurantId === restaurantId,
)
if (favoriteIndex !== undefined && favoriteIndex >= 0) {
// Already a favorite, so update the array in place.
updatedFavorites.favorites[favoriteIndex] = updatedFavorite
} else {
// Brand new favorite, so add it to the array.
updatedFavorites.favorites.push(updatedFavorite)
}
try {
setError(undefined)
setIsPending(true)
const { data: updateFavoritesResponse, error } =
await apiRequest<FavoriteResponse>({
method: "POST",
path: "/favorites",
body: updatedFavorite,
})
if (updateFavoritesResponse && updateFavoritesResponse.data) {
// Assign the _id property created from the API call to the new favorite.
updatedFavorite._id = updateFavoritesResponse.data._id
}
// Update the stored data.
await storeData<StoredFavorites>("favorite-restaurants", updatedFavorites)
setError(error)
setFavoriteRestaurants(updatedFavorites)
setIsPending(false)
} catch (error) {
if (error instanceof Error) {
setError(error)
} else {
setError(new Error("Unknown error while updating favorites."))
}
setIsPending(false)
}
}
return {
error,
isFavorite: (favoriteRestaurant && favoriteRestaurant.favorite) || false,
isPending,
toggleFavorite,
}
}
You’ll remember that the Rules of Hooks state that Hooks must always be called at the top level of a component, so this Hook makes these properties optional because, for example, you may not be signed in (and thus won’t have a userId
).
Determining if a restaurant is a favorite
The Hook will return a true
or false
value for isFavorite
, depending on whether the restaurant is a favorite:
import { useEffect, useState } from "react"
import { getData, storeData } from "../../storage"
import { apiRequest } from "../api"
import { Favorite, FavoriteResponse, StoredFavorites } from "./interfaces"
export const useFavorite = (
userId?: string,
restaurantId?: string,
): {
error: Error | undefined
isFavorite: boolean
isPending: boolean
toggleFavorite: () => void
} => {
const [error, setError] = useState<Error | undefined>()
const [isPending, setIsPending] = useState<boolean>(false)
const [favoriteRestaurants, setFavoriteRestaurants] = useState<
StoredFavorites | undefined
>()
useEffect(() => {
// Get the favorite restaurant data from storage.
const getStoredData = async () => {
const storedData = await getData<StoredFavorites>("favorite-restaurants")
setFavoriteRestaurants(storedData)
}
getStoredData()
}, [])
// Finding whether a restaurant is a favorite.
const favoriteRestaurant = favoriteRestaurants?.favorites.find(
(favorite) => favorite.restaurantId === restaurantId,
)
const toggleFavorite = async () => {
// updatedFavorite has the toggled “favorite” status.
const updatedFavorite = favoriteRestaurant
? {
...favoriteRestaurant,
favorite: !favoriteRestaurant.favorite, // Toggle it.
}
: ({
favorite: true, // Default to it being a new favorite.
restaurantId: restaurantId,
userId: userId,
} as Favorite)
// Update the datetime on the favorite
updatedFavorite.datetimeUpdated = Date.now()
// updatedFavorites will hold all the updated data before storage is updated.
const updatedFavorites =
favoriteRestaurants && favoriteRestaurants.favorites
? {
...favoriteRestaurants,
}
: {
favorites: [] as Favorite[],
lastSynced: 0,
}
// Update the full favorite restaurants array.
const favoriteIndex = favoriteRestaurants?.favorites.findIndex(
(favorite) => favorite.restaurantId === restaurantId,
)
if (favoriteIndex !== undefined && favoriteIndex >= 0) {
// Already a favorite, so update the array in place.
updatedFavorites.favorites[favoriteIndex] = updatedFavorite
} else {
// Brand new favorite, so add it to the array.
updatedFavorites.favorites.push(updatedFavorite)
}
try {
setError(undefined)
setIsPending(true)
const { data: updateFavoritesResponse, error } =
await apiRequest<FavoriteResponse>({
method: "POST",
path: "/favorites",
body: updatedFavorite,
})
if (updateFavoritesResponse && updateFavoritesResponse.data) {
// Assign the _id property created from the API call to the new favorite.
updatedFavorite._id = updateFavoritesResponse.data._id
}
// Update the stored data.
await storeData<StoredFavorites>("favorite-restaurants", updatedFavorites)
setError(error)
setFavoriteRestaurants(updatedFavorites)
setIsPending(false)
} catch (error) {
if (error instanceof Error) {
setError(error)
} else {
setError(new Error("Unknown error while updating favorites."))
}
setIsPending(false)
}
}
return {
error,
isFavorite: (favoriteRestaurant && favoriteRestaurant.favorite) || false,
isPending,
toggleFavorite,
}
}
Getting favorites from storage
The Hook gets all the favorite restaurants from Async Storage:
import { useEffect, useState } from "react"
import { getData, storeData } from "../../storage"
import { apiRequest } from "../api"
import { Favorite, FavoriteResponse, StoredFavorites } from "./interfaces"
export const useFavorite = (
userId?: string,
restaurantId?: string,
): {
error: Error | undefined
isFavorite: boolean
isPending: boolean
toggleFavorite: () => void
} => {
const [error, setError] = useState<Error | undefined>()
const [isPending, setIsPending] = useState<boolean>(false)
const [favoriteRestaurants, setFavoriteRestaurants] = useState<
StoredFavorites | undefined
>()
useEffect(() => {
// Get the favorite restaurant data from storage.
const getStoredData = async () => {
const storedData = await getData<StoredFavorites>("favorite-restaurants")
setFavoriteRestaurants(storedData)
}
getStoredData()
}, [])
// Finding whether a restaurant is a favorite.
const favoriteRestaurant = favoriteRestaurants?.favorites.find(
(favorite) => favorite.restaurantId === restaurantId,
)
const toggleFavorite = async () => {
// updatedFavorite has the toggled “favorite” status.
const updatedFavorite = favoriteRestaurant
? {
...favoriteRestaurant,
favorite: !favoriteRestaurant.favorite, // Toggle it.
}
: ({
favorite: true, // Default to it being a new favorite.
restaurantId: restaurantId,
userId: userId,
} as Favorite)
// Update the datetime on the favorite
updatedFavorite.datetimeUpdated = Date.now()
// updatedFavorites will hold all the updated data before storage is updated.
const updatedFavorites =
favoriteRestaurants && favoriteRestaurants.favorites
? {
...favoriteRestaurants,
}
: {
favorites: [] as Favorite[],
lastSynced: 0,
}
// Update the full favorite restaurants array.
const favoriteIndex = favoriteRestaurants?.favorites.findIndex(
(favorite) => favorite.restaurantId === restaurantId,
)
if (favoriteIndex !== undefined && favoriteIndex >= 0) {
// Already a favorite, so update the array in place.
updatedFavorites.favorites[favoriteIndex] = updatedFavorite
} else {
// Brand new favorite, so add it to the array.
updatedFavorites.favorites.push(updatedFavorite)
}
try {
setError(undefined)
setIsPending(true)
const { data: updateFavoritesResponse, error } =
await apiRequest<FavoriteResponse>({
method: "POST",
path: "/favorites",
body: updatedFavorite,
})
if (updateFavoritesResponse && updateFavoritesResponse.data) {
// Assign the _id property created from the API call to the new favorite.
updatedFavorite._id = updateFavoritesResponse.data._id
}
// Update the stored data.
await storeData<StoredFavorites>("favorite-restaurants", updatedFavorites)
setError(error)
setFavoriteRestaurants(updatedFavorites)
setIsPending(false)
} catch (error) {
if (error instanceof Error) {
setError(error)
} else {
setError(new Error("Unknown error while updating favorites."))
}
setIsPending(false)
}
}
return {
error,
isFavorite: (favoriteRestaurant && favoriteRestaurant.favorite) || false,
isPending,
toggleFavorite,
}
}
Toggling a restaurant’s favorite status
The Hook returns a toggleFavorite
function that can be called to toggle whether the restaurant is a favorite or not:
import { useEffect, useState } from "react"
import { getData, storeData } from "../../storage"
import { apiRequest } from "../api"
import { Favorite, FavoriteResponse, StoredFavorites } from "./interfaces"
export const useFavorite = (
userId?: string,
restaurantId?: string,
): {
error: Error | undefined
isFavorite: boolean
isPending: boolean
toggleFavorite: () => void
} => {
const [error, setError] = useState<Error | undefined>()
const [isPending, setIsPending] = useState<boolean>(false)
const [favoriteRestaurants, setFavoriteRestaurants] = useState<
StoredFavorites | undefined
>()
useEffect(() => {
// Get the favorite restaurant data from storage.
const getStoredData = async () => {
const storedData = await getData<StoredFavorites>("favorite-restaurants")
setFavoriteRestaurants(storedData)
}
getStoredData()
}, [])
// Finding whether a restaurant is a favorite.
const favoriteRestaurant = favoriteRestaurants?.favorites.find(
(favorite) => favorite.restaurantId === restaurantId,
)
const toggleFavorite = async () => {
// updatedFavorite has the toggled “favorite” status.
const updatedFavorite = favoriteRestaurant
? {
...favoriteRestaurant,
favorite: !favoriteRestaurant.favorite, // Toggle it.
}
: ({
favorite: true, // Default to it being a new favorite.
restaurantId: restaurantId,
userId: userId,
} as Favorite)
// Update the datetime on the favorite
updatedFavorite.datetimeUpdated = Date.now()
// updatedFavorites will hold all the updated data before storage is updated.
const updatedFavorites =
favoriteRestaurants && favoriteRestaurants.favorites
? {
...favoriteRestaurants,
}
: {
favorites: [] as Favorite[],
lastSynced: 0,
}
// Update the full favorite restaurants array.
const favoriteIndex = favoriteRestaurants?.favorites.findIndex(
(favorite) => favorite.restaurantId === restaurantId,
)
if (favoriteIndex !== undefined && favoriteIndex >= 0) {
// Already a favorite, so update the array in place.
updatedFavorites.favorites[favoriteIndex] = updatedFavorite
} else {
// Brand new favorite, so add it to the array.
updatedFavorites.favorites.push(updatedFavorite)
}
try {
setError(undefined)
setIsPending(true)
const { data: updateFavoritesResponse, error } =
await apiRequest<FavoriteResponse>({
method: "POST",
path: "/favorites",
body: updatedFavorite,
})
if (updateFavoritesResponse && updateFavoritesResponse.data) {
// Assign the _id property created from the API call to the new favorite.
updatedFavorite._id = updateFavoritesResponse.data._id
}
// Update the stored data.
await storeData<StoredFavorites>("favorite-restaurants", updatedFavorites)
setError(error)
setFavoriteRestaurants(updatedFavorites)
setIsPending(false)
} catch (error) {
if (error instanceof Error) {
setError(error)
} else {
setError(new Error("Unknown error while updating favorites."))
}
setIsPending(false)
}
}
return {
error,
isFavorite: (favoriteRestaurant && favoriteRestaurant.favorite) || false,
isPending,
toggleFavorite,
}
}
Updating the API with the changed favorite status
The Hook calls the API to update the favorite status for the restaurant:
import { useEffect, useState } from "react"
import { getData, storeData } from "../../storage"
import { apiRequest } from "../api"
import { Favorite, FavoriteResponse, StoredFavorites } from "./interfaces"
export const useFavorite = (
userId?: string,
restaurantId?: string,
): {
error: Error | undefined
isFavorite: boolean
isPending: boolean
toggleFavorite: () => void
} => {
const [error, setError] = useState<Error | undefined>()
const [isPending, setIsPending] = useState<boolean>(false)
const [favoriteRestaurants, setFavoriteRestaurants] = useState<
StoredFavorites | undefined
>()
useEffect(() => {
// Get the favorite restaurant data from storage.
const getStoredData = async () => {
const storedData = await getData<StoredFavorites>("favorite-restaurants")
setFavoriteRestaurants(storedData)
}
getStoredData()
}, [])
// Finding whether a restaurant is a favorite.
const favoriteRestaurant = favoriteRestaurants?.favorites.find(
(favorite) => favorite.restaurantId === restaurantId,
)
const toggleFavorite = async () => {
// updatedFavorite has the toggled “favorite” status.
const updatedFavorite = favoriteRestaurant
? {
...favoriteRestaurant,
favorite: !favoriteRestaurant.favorite, // Toggle it.
}
: ({
favorite: true, // Default to it being a new favorite.
restaurantId: restaurantId,
userId: userId,
} as Favorite)
// Update the datetime on the favorite
updatedFavorite.datetimeUpdated = Date.now()
// updatedFavorites will hold all the updated data before storage is updated.
const updatedFavorites =
favoriteRestaurants && favoriteRestaurants.favorites
? {
...favoriteRestaurants,
}
: {
favorites: [] as Favorite[],
lastSynced: 0,
}
// Update the full favorite restaurants array.
const favoriteIndex = favoriteRestaurants?.favorites.findIndex(
(favorite) => favorite.restaurantId === restaurantId,
)
if (favoriteIndex !== undefined && favoriteIndex >= 0) {
// Already a favorite, so update the array in place.
updatedFavorites.favorites[favoriteIndex] = updatedFavorite
} else {
// Brand new favorite, so add it to the array.
updatedFavorites.favorites.push(updatedFavorite)
}
try {
setError(undefined)
setIsPending(true)
const { data: updateFavoritesResponse, error } =
await apiRequest<FavoriteResponse>({
method: "POST",
path: "/favorites",
body: updatedFavorite,
})
if (updateFavoritesResponse && updateFavoritesResponse.data) {
// Assign the _id property created from the API call to the new favorite.
updatedFavorite._id = updateFavoritesResponse.data._id
}
// Update the stored data.
await storeData<StoredFavorites>("favorite-restaurants", updatedFavorites)
setError(error)
setFavoriteRestaurants(updatedFavorites)
setIsPending(false)
} catch (error) {
if (error instanceof Error) {
setError(error)
} else {
setError(new Error("Unknown error while updating favorites."))
}
setIsPending(false)
}
}
return {
error,
isFavorite: (favoriteRestaurant && favoriteRestaurant.favorite) || false,
isPending,
toggleFavorite,
}
}
The API may return an error
, and that’s ok!
The Hook can check if the API response was good and store the _id
if it was.
If there was an issue with the API call (e.g. the server was down, the device was offline, etc.) then there won’t be an _id
in our Async Storage and we’ll know that we need to submit that favorite to the API.
Setup 2
✏️ Create src/services/pmo/favorite/interfaces.ts and update it to be:
export interface Favorite {
userId: string
restaurantId: string
favorite: boolean
datetimeUpdated: number
_id?: string
}
export interface FavoritesResponse {
data: Favorite[] | undefined
error: Error | undefined
isPending: boolean
}
export interface FavoriteResponse {
data: Favorite | undefined
error: Error | undefined
isPending: boolean
}
export interface StoredFavorites {
lastSynced: number
favorites: Favorite[]
}
✏️ Create src/services/pmo/favorite/hooks.ts and update it to be:
import { useEffect, useState } from "react"
import { getData, storeData } from "../../storage"
import { apiRequest } from "../api"
import { Favorite, FavoriteResponse, StoredFavorites } from "./interfaces"
export const useFavorite = (
userId?: string,
restaurantId?: string,
): {
error: Error | undefined
isFavorite: boolean
isPending: boolean
toggleFavorite: () => void
} => {
const [error, setError] = useState<Error | undefined>()
const [isPending, setIsPending] = useState<boolean>(false)
const [favoriteRestaurants, setFavoriteRestaurants] = useState<
StoredFavorites | undefined
>()
useEffect(() => {
// Get the favorite restaurant data from storage.
const getStoredData = async () => {
const storedData = await getData<StoredFavorites>("favorite-restaurants")
setFavoriteRestaurants(storedData)
}
getStoredData()
}, [])
// Finding whether a restaurant is a favorite.
const favoriteRestaurant = favoriteRestaurants?.favorites.find(
(favorite) => favorite.restaurantId === restaurantId,
)
const toggleFavorite = async () => {
// updatedFavorite has the toggled “favorite” status.
const updatedFavorite = favoriteRestaurant
? {
...favoriteRestaurant,
favorite: !favoriteRestaurant.favorite, // Toggle it.
}
: ({
favorite: true, // Default to it being a new favorite.
restaurantId: restaurantId,
userId: userId,
} as Favorite)
// Update the datetime on the favorite
updatedFavorite.datetimeUpdated = Date.now()
// updatedFavorites will hold all the updated data before storage is updated.
const updatedFavorites =
favoriteRestaurants && favoriteRestaurants.favorites
? {
...favoriteRestaurants,
}
: {
favorites: [] as Favorite[],
lastSynced: 0,
}
// Update the full favorite restaurants array.
const favoriteIndex = favoriteRestaurants?.favorites.findIndex(
(favorite) => favorite.restaurantId === restaurantId,
)
if (favoriteIndex !== undefined && favoriteIndex >= 0) {
// Already a favorite, so update the array in place.
updatedFavorites.favorites[favoriteIndex] = updatedFavorite
} else {
// Brand new favorite, so add it to the array.
updatedFavorites.favorites.push(updatedFavorite)
}
try {
setError(undefined)
setIsPending(true)
const { data: updateFavoritesResponse, error } =
await apiRequest<FavoriteResponse>({
method: "POST",
path: "/favorites",
body: updatedFavorite,
})
if (updateFavoritesResponse && updateFavoritesResponse.data) {
// Assign the _id property created from the API call to the new favorite.
updatedFavorite._id = updateFavoritesResponse.data._id
}
// Update the stored data.
await storeData<StoredFavorites>("favorite-restaurants", updatedFavorites)
setError(error)
setFavoriteRestaurants(updatedFavorites)
setIsPending(false)
} catch (error) {
if (error instanceof Error) {
setError(error)
} else {
setError(new Error("Unknown error while updating favorites."))
}
setIsPending(false)
}
}
return {
error,
isFavorite: (favoriteRestaurant && favoriteRestaurant.favorite) || false,
isPending,
toggleFavorite,
}
}
✏️ Create src/services/pmo/favorite/hooks.test.ts and update it to be:
import { renderHook, waitFor } from "@testing-library/react-native"
import * as storage from "../../storage/storage"
import * as api from "../api/api"
import { useFavorite } from "./hooks"
describe("Services/PMO/Favorite", () => {
// Mock the apiRequest function
let apiRequest: jest.SpyInstance<ReturnType<typeof api.apiRequest>>
let mockStorage: jest.SpyInstance<ReturnType<typeof storage.getData>>
beforeEach(() => {
jest.resetAllMocks()
apiRequest = jest.spyOn(api, "apiRequest")
mockStorage = jest.spyOn(storage, "getData")
})
describe("useFavorite", () => {
const mockFavorites = [
{
userId: "user-id",
restaurantId: "WKQjvzup7QWSFXvH",
favorite: false,
datetimeUpdated: "2024-04-03T14:12:16.314Z",
_id: "UslYVUxnBuBwqn0s",
},
{
userId: "user-id",
restaurantId: "7iiKc0akJPYzaMyw",
favorite: true,
datetimeUpdated: "2024-04-02T20:16:18.746Z",
_id: "dmTvyAYw3o0xjAIk",
},
]
beforeEach(() => {
jest.clearAllMocks()
})
it("should initialize with the correct default values", async () => {
apiRequest.mockResolvedValue({
data: { data: mockFavorites },
error: undefined,
})
mockStorage.mockResolvedValue({
lastSynced: Date.now(),
favorites: mockFavorites,
})
const { result } = renderHook(() =>
useFavorite("user-id", "7iiKc0akJPYzaMyw"),
)
await waitFor(() => {
expect(result.current.isFavorite).toBe(true)
})
expect(result.current.error).toBeUndefined()
expect(result.current.isPending).toBe(false)
})
it("should set isFavorite to false if the restaurant is not a favorite", async () => {
apiRequest.mockResolvedValue({
data: { data: mockFavorites },
error: undefined,
})
mockStorage.mockResolvedValue({
lastSynced: Date.now(),
favorites: mockFavorites,
})
const { result } = renderHook(() =>
useFavorite("user-id", "WKQjvzup7QWSFXvH"),
)
await waitFor(() => {
expect(result.current.isFavorite).toBe(false)
})
})
})
})
✏️ Create src/services/pmo/favorite/index.ts and update it to be:
export * from "./hooks"
✏️ 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 {
useAuthenticated,
useUser,
useAuthentication,
} from "../../services/auth"
import { useFavorite } from "../../services/pmo/favorite/hooks"
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 })
// Exercise: Add a button that uses the `toggleFavorite` helper.
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} />
{/*
Exercise:
- If the user is logged out: Render a button that says “Sign in to favorite this restaurant” and call the `signIn` method.
- If the user is logged in: Render a button that says “Add to favorites” or “Remove from favorites”, depending on whether the restaurant is a favorite.
- If a request is pending: Change the button text to “Saving…”.
- If there’s an error: Render the error message.
*/}
<Button
onPress={() => {
navigation.navigate("RestaurantOrder", { slug: slug })
}}
>
Place an order
</Button>
</Screen>
)
}
export default RestaurantDetails
Verify 2
✏️ Update src/screens/RestaurantDetails/RestaurantDetails.test.tsx to be:
import { NavigationContainer } from "@react-navigation/native"
import { render, screen } from "@testing-library/react-native"
import AuthProvider from "../../services/auth/AuthProvider"
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>
<AuthProvider>
{/* @ts-ignore */}
<RestaurantDetails route={route} />
</AuthProvider>
</NavigationContainer>,
)
expect(screen.getByText("Test Restaurant")).toBeOnTheScreen()
})
it("renders before data loads", () => {
useRestaurant.mockReturnValue({ ...mockRestaurantData, data: undefined })
render(
<NavigationContainer>
<AuthProvider>
{/* @ts-ignore */}
<RestaurantDetails route={route} />
</AuthProvider>
</NavigationContainer>,
)
expect(screen.getByText("")).toBeOnTheScreen()
})
it("renders loading state", () => {
useRestaurant.mockReturnValue({
data: undefined,
isPending: true,
error: undefined,
})
render(
<NavigationContainer>
<AuthProvider>
{/* @ts-ignore */}
<RestaurantDetails route={route} />
</AuthProvider>
</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>
<AuthProvider>
{/* @ts-ignore */}
<RestaurantDetails route={route} />
</AuthProvider>
</NavigationContainer>,
)
expect(
screen.getByText(/Error loading restaurant details:/i, {
exact: false,
}),
).toBeOnTheScreen()
expect(screen.getByText(/Mock error/i)).toBeOnTheScreen()
})
})
Exercise 2
In RestaurantDetails
, add a button that uses the toggleFavorite
helper:
- If the user is logged out: Render a button that says “Sign in to favorite this restaurant” and call the
signIn
method. - If the user is logged in: Render a button that says “Add to favorites” or “Remove from favorites”, depending on whether the restaurant is a favorite.
- If a request is pending: Change the button text to “Saving…”.
- If there’s an error: Render the error message.
Solution 2
If you’ve implemented the solution correctly, you will be able to sign in and out of your Google account within the application!
You can test the error message by tapping “Sign in” and dismissing the modal.
Click to see the solution
✏️ 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 {
useAuthenticated,
useUser,
useAuthentication,
} from "../../services/auth"
import { useFavorite } from "../../services/pmo/favorite/hooks"
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 })
const isAuthenticated = useAuthenticated()
const user = useUser()
const { signIn } = useAuthentication()
const {
error: favoriteError,
isFavorite,
isPending: favoriteIsPending,
toggleFavorite,
} = useFavorite(user?.id, restaurant?._id)
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={() => {
if (isAuthenticated) {
toggleFavorite()
} else {
signIn()
}
}}
>
{isAuthenticated
? favoriteIsPending
? "Saving…"
: isFavorite
? "Remove from favorites"
: "Add to favorites"
: "Sign in to favorite this restaurant"}
</Button>
{favoriteError ? (
<Box padding="s">
<Typography variant="body">{favoriteError.message}</Typography>
</Box>
) : null}
<Button
onPress={() => {
navigation.navigate("RestaurantOrder", { slug: slug })
}}
>
Place an order
</Button>
</Screen>
)
}
export default RestaurantDetails
Objective 3: Sync offline data when connectivity changes
Our app can handle when the API calls fail and it’ll still store the favorites on device in Async Storage. When the user’s device is offline, we can improve the app a lot by syncing the favorites to the API when the device comes back online.
Designing the sync behavior
Here’s an overview of how we want our sync to work.
If a favorite is modified:
- By another device while our current device is offline, our device should fetch those changes when it comes back online.
- On our current device while it’s offline, that change should be synced back to the API as soon as the device comes online.
- In both places (in the API and on the device), the data with the last modified date should “win.”
Fetching the favorites modified on the server
The Hook’s syncWithServer
will first fetch the favorites from the server:
import { getData, storeData } from "../../storage"
import { apiRequest } from "../api"
import {
Favorite,
FavoriteResponse,
FavoritesResponse,
StoredFavorites,
} from "./interfaces"
export const syncWithServer = async (userId: string): Promise<void> => {
// Fetch the list of favorites from the server.
const { data: apiResponse } = await apiRequest<FavoritesResponse>({
method: "GET",
path: "/favorites",
params: {
userId: userId,
},
})
// Get the list of favorites in storage.
const favoriteRestaurants = await getData<StoredFavorites>(
"favorite-restaurants",
)
// Create an Object with the restaurantId as the key and full Favorite as the value.
const favoriteRestaurantMap: { [key: string]: Favorite } = {}
if (favoriteRestaurants && favoriteRestaurants.favorites) {
favoriteRestaurants.favorites.forEach((storedFavorite) => {
favoriteRestaurantMap[storedFavorite.restaurantId] = storedFavorite
})
}
if (apiResponse && apiResponse.data) {
// Iterate through the list of favorites returned by the server:
const favoritesUpdatedOnServer = apiResponse.data.filter((apiFavorite) => {
const storedFavorite = favoriteRestaurantMap[apiFavorite.restaurantId]
// If the server datetimeUpdated is later than the datetimeUpdated in storage, or if the favorite is not in storage:
const serverDatetimeIsLaterThanStorage = storedFavorite
? apiFavorite.datetimeUpdated > storedFavorite.datetimeUpdated
: true
return serverDatetimeIsLaterThanStorage
})
const idsOfFavoritesUpdatedOnServer = favoritesUpdatedOnServer.map(
(apiFavorite) => {
if (favoriteRestaurantMap[apiFavorite.restaurantId]) {
// Update the object that came from storage.
favoriteRestaurantMap[apiFavorite.restaurantId]._id = apiFavorite._id
favoriteRestaurantMap[apiFavorite.restaurantId].datetimeUpdated =
apiFavorite.datetimeUpdated
favoriteRestaurantMap[apiFavorite.restaurantId].favorite =
apiFavorite.favorite
} else {
// Create the favorite in the map; this will be added to storage later.
favoriteRestaurantMap[apiFavorite.restaurantId] = apiFavorite
}
// Keep this in an array/set to reference below…
return apiFavorite._id
},
)
// Query storage for favorites updated since the lastSynced datetime:
const favoritesUpdatedWhileOffline =
favoriteRestaurants?.favorites.filter((storedFavorite) => {
const favoriteUpdatedSinceLastSync =
storedFavorite.datetimeUpdated > favoriteRestaurants?.lastSynced
return favoriteUpdatedSinceLastSync
}) || []
// If the favorite isn’t in the array/set created above
const favoritesToUpdateOnServer = favoritesUpdatedWhileOffline.filter(
(storedFavorite) => {
const storedFavoriteIsMoreRecentThanServer =
idsOfFavoritesUpdatedOnServer.includes(storedFavorite._id) === false
return storedFavoriteIsMoreRecentThanServer
},
)
// Call the API to update the favorite
favoritesToUpdateOnServer.map(async (storedFavorite) => {
const { data: updateFavoritesResponse } =
await apiRequest<FavoriteResponse>({
method: "POST",
path: "/favorites",
body: storedFavorite,
})
// Update the object that will be stored:
if (updateFavoritesResponse && updateFavoritesResponse.data) {
favoriteRestaurantMap[storedFavorite.restaurantId] =
updateFavoritesResponse.data
}
})
// Update the lastSynced datetime in storage
const updatedFavoriteRestaurants = {
favorites: [] as Favorite[],
lastSynced: Date.now(),
}
for (const restaurantId in favoriteRestaurantMap) {
const favoriteRestaurant = favoriteRestaurantMap[restaurantId]
updatedFavoriteRestaurants.favorites.push(favoriteRestaurant)
}
// Update the stored data.
await storeData<StoredFavorites>(
"favorite-restaurants",
updatedFavoriteRestaurants,
)
}
}
Updating favorites modified on the device
Next, the Hook will send any favorites that were modified while the device was offline to the API:
import { getData, storeData } from "../../storage"
import { apiRequest } from "../api"
import {
Favorite,
FavoriteResponse,
FavoritesResponse,
StoredFavorites,
} from "./interfaces"
export const syncWithServer = async (userId: string): Promise<void> => {
// Fetch the list of favorites from the server.
const { data: apiResponse } = await apiRequest<FavoritesResponse>({
method: "GET",
path: "/favorites",
params: {
userId: userId,
},
})
// Get the list of favorites in storage.
const favoriteRestaurants = await getData<StoredFavorites>(
"favorite-restaurants",
)
// Create an Object with the restaurantId as the key and full Favorite as the value.
const favoriteRestaurantMap: { [key: string]: Favorite } = {}
if (favoriteRestaurants && favoriteRestaurants.favorites) {
favoriteRestaurants.favorites.forEach((storedFavorite) => {
favoriteRestaurantMap[storedFavorite.restaurantId] = storedFavorite
})
}
if (apiResponse && apiResponse.data) {
// Iterate through the list of favorites returned by the server:
const favoritesUpdatedOnServer = apiResponse.data.filter((apiFavorite) => {
const storedFavorite = favoriteRestaurantMap[apiFavorite.restaurantId]
// If the server datetimeUpdated is later than the datetimeUpdated in storage, or if the favorite is not in storage:
const serverDatetimeIsLaterThanStorage = storedFavorite
? apiFavorite.datetimeUpdated > storedFavorite.datetimeUpdated
: true
return serverDatetimeIsLaterThanStorage
})
const idsOfFavoritesUpdatedOnServer = favoritesUpdatedOnServer.map(
(apiFavorite) => {
if (favoriteRestaurantMap[apiFavorite.restaurantId]) {
// Update the object that came from storage.
favoriteRestaurantMap[apiFavorite.restaurantId]._id = apiFavorite._id
favoriteRestaurantMap[apiFavorite.restaurantId].datetimeUpdated =
apiFavorite.datetimeUpdated
favoriteRestaurantMap[apiFavorite.restaurantId].favorite =
apiFavorite.favorite
} else {
// Create the favorite in the map; this will be added to storage later.
favoriteRestaurantMap[apiFavorite.restaurantId] = apiFavorite
}
// Keep this in an array/set to reference below…
return apiFavorite._id
},
)
// Query storage for favorites updated since the lastSynced datetime:
const favoritesUpdatedWhileOffline =
favoriteRestaurants?.favorites.filter((storedFavorite) => {
const favoriteUpdatedSinceLastSync =
storedFavorite.datetimeUpdated > favoriteRestaurants?.lastSynced
return favoriteUpdatedSinceLastSync
}) || []
// If the favorite isn’t in the array/set created above
const favoritesToUpdateOnServer = favoritesUpdatedWhileOffline.filter(
(storedFavorite) => {
const storedFavoriteIsMoreRecentThanServer =
idsOfFavoritesUpdatedOnServer.includes(storedFavorite._id) === false
return storedFavoriteIsMoreRecentThanServer
},
)
// Call the API to update the favorite
favoritesToUpdateOnServer.map(async (storedFavorite) => {
const { data: updateFavoritesResponse } =
await apiRequest<FavoriteResponse>({
method: "POST",
path: "/favorites",
body: storedFavorite,
})
// Update the object that will be stored:
if (updateFavoritesResponse && updateFavoritesResponse.data) {
favoriteRestaurantMap[storedFavorite.restaurantId] =
updateFavoritesResponse.data
}
})
// Update the lastSynced datetime in storage
const updatedFavoriteRestaurants = {
favorites: [] as Favorite[],
lastSynced: Date.now(),
}
for (const restaurantId in favoriteRestaurantMap) {
const favoriteRestaurant = favoriteRestaurantMap[restaurantId]
updatedFavoriteRestaurants.favorites.push(favoriteRestaurant)
}
// Update the stored data.
await storeData<StoredFavorites>(
"favorite-restaurants",
updatedFavoriteRestaurants,
)
}
}
Updating storage with the changes
Last, the Hook will update Async Storage with all the changes that have accumulated through the sync process:
import { getData, storeData } from "../../storage"
import { apiRequest } from "../api"
import {
Favorite,
FavoriteResponse,
FavoritesResponse,
StoredFavorites,
} from "./interfaces"
export const syncWithServer = async (userId: string): Promise<void> => {
// Fetch the list of favorites from the server.
const { data: apiResponse } = await apiRequest<FavoritesResponse>({
method: "GET",
path: "/favorites",
params: {
userId: userId,
},
})
// Get the list of favorites in storage.
const favoriteRestaurants = await getData<StoredFavorites>(
"favorite-restaurants",
)
// Create an Object with the restaurantId as the key and full Favorite as the value.
const favoriteRestaurantMap: { [key: string]: Favorite } = {}
if (favoriteRestaurants && favoriteRestaurants.favorites) {
favoriteRestaurants.favorites.forEach((storedFavorite) => {
favoriteRestaurantMap[storedFavorite.restaurantId] = storedFavorite
})
}
if (apiResponse && apiResponse.data) {
// Iterate through the list of favorites returned by the server:
const favoritesUpdatedOnServer = apiResponse.data.filter((apiFavorite) => {
const storedFavorite = favoriteRestaurantMap[apiFavorite.restaurantId]
// If the server datetimeUpdated is later than the datetimeUpdated in storage, or if the favorite is not in storage:
const serverDatetimeIsLaterThanStorage = storedFavorite
? apiFavorite.datetimeUpdated > storedFavorite.datetimeUpdated
: true
return serverDatetimeIsLaterThanStorage
})
const idsOfFavoritesUpdatedOnServer = favoritesUpdatedOnServer.map(
(apiFavorite) => {
if (favoriteRestaurantMap[apiFavorite.restaurantId]) {
// Update the object that came from storage.
favoriteRestaurantMap[apiFavorite.restaurantId]._id = apiFavorite._id
favoriteRestaurantMap[apiFavorite.restaurantId].datetimeUpdated =
apiFavorite.datetimeUpdated
favoriteRestaurantMap[apiFavorite.restaurantId].favorite =
apiFavorite.favorite
} else {
// Create the favorite in the map; this will be added to storage later.
favoriteRestaurantMap[apiFavorite.restaurantId] = apiFavorite
}
// Keep this in an array/set to reference below…
return apiFavorite._id
},
)
// Query storage for favorites updated since the lastSynced datetime:
const favoritesUpdatedWhileOffline =
favoriteRestaurants?.favorites.filter((storedFavorite) => {
const favoriteUpdatedSinceLastSync =
storedFavorite.datetimeUpdated > favoriteRestaurants?.lastSynced
return favoriteUpdatedSinceLastSync
}) || []
// If the favorite isn’t in the array/set created above
const favoritesToUpdateOnServer = favoritesUpdatedWhileOffline.filter(
(storedFavorite) => {
const storedFavoriteIsMoreRecentThanServer =
idsOfFavoritesUpdatedOnServer.includes(storedFavorite._id) === false
return storedFavoriteIsMoreRecentThanServer
},
)
// Call the API to update the favorite
favoritesToUpdateOnServer.map(async (storedFavorite) => {
const { data: updateFavoritesResponse } =
await apiRequest<FavoriteResponse>({
method: "POST",
path: "/favorites",
body: storedFavorite,
})
// Update the object that will be stored:
if (updateFavoritesResponse && updateFavoritesResponse.data) {
favoriteRestaurantMap[storedFavorite.restaurantId] =
updateFavoritesResponse.data
}
})
// Update the lastSynced datetime in storage
const updatedFavoriteRestaurants = {
favorites: [] as Favorite[],
lastSynced: Date.now(),
}
for (const restaurantId in favoriteRestaurantMap) {
const favoriteRestaurant = favoriteRestaurantMap[restaurantId]
updatedFavoriteRestaurants.favorites.push(favoriteRestaurant)
}
// Update the stored data.
await storeData<StoredFavorites>(
"favorite-restaurants",
updatedFavoriteRestaurants,
)
}
}
Setup 3
✏️ Update src/App.tsx to be:
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"
import { NavigationContainer } from "@react-navigation/native"
import { createStackNavigator } from "@react-navigation/stack"
import { Pressable } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import Icon from "react-native-vector-icons/Ionicons"
import Box from "./design/Box"
import ThemeProvider, { useTheme } from "./design/theme/ThemeProvider"
import Typography from "./design/Typography"
import CityList from "./screens/CityList"
import RestaurantDetails from "./screens/RestaurantDetails"
import RestaurantList from "./screens/RestaurantList"
import RestaurantOrder from "./screens/RestaurantOrder"
import Settings from "./screens/Settings"
import StateList from "./screens/StateList"
import AuthProvider from "./services/auth/AuthProvider"
import DataMigration from "./services/DataMigration"
import FavoritesSync from "./services/pmo/favorite"
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace ReactNavigation {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface RootParamList extends RestaurantsStackParamList {}
}
}
export type RestaurantsStackParamList = {
StateList: undefined
CityList: {
state: {
name: string
short: string
}
}
RestaurantList: {
state: {
name: string
short: string
}
city: {
name: string
state: string
}
}
RestaurantDetails: {
slug: string
}
RestaurantOrder: {
slug: string
}
}
const RestaurantsStack = createStackNavigator<RestaurantsStackParamList>()
const RestaurantsNavigator: React.FC = () => {
return (
<RestaurantsStack.Navigator
initialRouteName="StateList"
screenOptions={{
header: ({ route, navigation }) => {
if (!navigation.canGoBack()) return null
return (
<Pressable onPress={navigation.goBack}>
<Box
padding="m"
style={{ flexDirection: "row", gap: 8, alignItems: "center" }}
>
<Icon name="arrow-back" size={20} />
<Typography variant="heading">
{/* @ts-ignore */}
{[route.params?.city?.name, route.params?.state?.name]
.filter(Boolean)
.join(", ")}
</Typography>
</Box>
</Pressable>
)
},
}}
>
<RestaurantsStack.Screen name="StateList" component={StateList} />
<RestaurantsStack.Screen name="CityList" component={CityList} />
<RestaurantsStack.Screen
name="RestaurantList"
component={RestaurantList}
/>
<RestaurantsStack.Screen
name="RestaurantDetails"
component={RestaurantDetails}
/>
<RestaurantsStack.Screen
name="RestaurantOrder"
component={RestaurantOrder}
/>
</RestaurantsStack.Navigator>
)
}
const AppTabs = createBottomTabNavigator()
const AppNavigator: React.FC = () => {
const theme = useTheme()
return (
<AppTabs.Navigator
initialRouteName="RestaurantsStack"
screenOptions={({ route }) => ({
headerStyle: {
backgroundColor: theme.palette.screen.main,
},
headerTitleStyle: {
color: theme.palette.screen.contrast,
...theme.typography.title,
},
tabBarStyle: {
backgroundColor: theme.palette.screen.main,
},
tabBarActiveTintColor: theme.palette.primary.strong,
tabBarInactiveTintColor: theme.palette.screen.contrast,
tabBarIcon: ({ focused, color }) => {
let icon = "settings"
if (route.name === "Settings") {
icon = focused ? "settings" : "settings-outline"
} else if (route.name === "Restaurants") {
icon = focused ? "restaurant" : "restaurant-outline"
}
return <Icon name={icon} size={20} color={color} />
},
})}
>
<AppTabs.Screen
name="Restaurants"
component={RestaurantsNavigator}
options={{ title: "Place My Order" }}
/>
<AppTabs.Screen
name="Settings"
component={Settings}
options={{ title: "Settings" }}
/>
</AppTabs.Navigator>
)
}
// Exercise: Add the `FavoritesSync` component to the `App` JSX.
const App: React.FC = () => {
return (
<SafeAreaView style={{ flex: 1 }}>
<ThemeProvider>
<AuthProvider>
<DataMigration>
<NavigationContainer>
<AppNavigator />
</NavigationContainer>
</DataMigration>
</AuthProvider>
</ThemeProvider>
</SafeAreaView>
)
}
export default App
✏️ Create src/services/pmo/favorite/favorite.tsx and update it to be:
import { useNetInfo } from "@react-native-community/netinfo"
import { useEffect } from "react"
import { useUser } from "../../auth"
import { syncWithServer } from "./sync"
const FavoritesSync: React.FC = () => {
// Exercise: When the user is signed in and has a network connection, sync with the server.
return null
}
export default FavoritesSync
✏️ Create src/services/pmo/favorite/sync.ts and update it to be:
import { getData, storeData } from "../../storage"
import { apiRequest } from "../api"
import {
Favorite,
FavoriteResponse,
FavoritesResponse,
StoredFavorites,
} from "./interfaces"
export const syncWithServer = async (userId: string): Promise<void> => {
// Fetch the list of favorites from the server.
const { data: apiResponse } = await apiRequest<FavoritesResponse>({
method: "GET",
path: "/favorites",
params: {
userId: userId,
},
})
// Get the list of favorites in storage.
const favoriteRestaurants = await getData<StoredFavorites>(
"favorite-restaurants",
)
// Create an Object with the restaurantId as the key and full Favorite as the value.
const favoriteRestaurantMap: { [key: string]: Favorite } = {}
if (favoriteRestaurants && favoriteRestaurants.favorites) {
favoriteRestaurants.favorites.forEach((storedFavorite) => {
favoriteRestaurantMap[storedFavorite.restaurantId] = storedFavorite
})
}
if (apiResponse && apiResponse.data) {
// Iterate through the list of favorites returned by the server:
const favoritesUpdatedOnServer = apiResponse.data.filter((apiFavorite) => {
const storedFavorite = favoriteRestaurantMap[apiFavorite.restaurantId]
// If the server datetimeUpdated is later than the datetimeUpdated in storage, or if the favorite is not in storage:
const serverDatetimeIsLaterThanStorage = storedFavorite
? apiFavorite.datetimeUpdated > storedFavorite.datetimeUpdated
: true
return serverDatetimeIsLaterThanStorage
})
const idsOfFavoritesUpdatedOnServer = favoritesUpdatedOnServer.map(
(apiFavorite) => {
if (favoriteRestaurantMap[apiFavorite.restaurantId]) {
// Update the object that came from storage.
favoriteRestaurantMap[apiFavorite.restaurantId]._id = apiFavorite._id
favoriteRestaurantMap[apiFavorite.restaurantId].datetimeUpdated =
apiFavorite.datetimeUpdated
favoriteRestaurantMap[apiFavorite.restaurantId].favorite =
apiFavorite.favorite
} else {
// Create the favorite in the map; this will be added to storage later.
favoriteRestaurantMap[apiFavorite.restaurantId] = apiFavorite
}
// Keep this in an array/set to reference below…
return apiFavorite._id
},
)
// Query storage for favorites updated since the lastSynced datetime:
const favoritesUpdatedWhileOffline =
favoriteRestaurants?.favorites.filter((storedFavorite) => {
const favoriteUpdatedSinceLastSync =
storedFavorite.datetimeUpdated > favoriteRestaurants?.lastSynced
return favoriteUpdatedSinceLastSync
}) || []
// If the favorite isn’t in the array/set created above
const favoritesToUpdateOnServer = favoritesUpdatedWhileOffline.filter(
(storedFavorite) => {
const storedFavoriteIsMoreRecentThanServer =
idsOfFavoritesUpdatedOnServer.includes(storedFavorite._id) === false
return storedFavoriteIsMoreRecentThanServer
},
)
// Call the API to update the favorite
favoritesToUpdateOnServer.map(async (storedFavorite) => {
const { data: updateFavoritesResponse } =
await apiRequest<FavoriteResponse>({
method: "POST",
path: "/favorites",
body: storedFavorite,
})
// Update the object that will be stored:
if (updateFavoritesResponse && updateFavoritesResponse.data) {
favoriteRestaurantMap[storedFavorite.restaurantId] =
updateFavoritesResponse.data
}
})
// Update the lastSynced datetime in storage
const updatedFavoriteRestaurants = {
favorites: [] as Favorite[],
lastSynced: Date.now(),
}
for (const restaurantId in favoriteRestaurantMap) {
const favoriteRestaurant = favoriteRestaurantMap[restaurantId]
updatedFavoriteRestaurants.favorites.push(favoriteRestaurant)
}
// Update the stored data.
await storeData<StoredFavorites>(
"favorite-restaurants",
updatedFavoriteRestaurants,
)
}
}
✏️ Update src/services/pmo/favorite/index.ts to be:
export { default } from "./favorite"
export * from "./hooks"
export * from "./sync"
Verify 3
Use the console
or debugger to check the syncWithServer
function is called when signing in and out.
Exercise 3
- Create a
FavoritesSync
component that does the following:- When the user is signed in and has a network connection, sync with the server.
- Add the
FavoritesSync
component to theApp
JSX.
Hint: Use all of the imports provided in the favorite.tsx
file.
Solution 3
Click to see the solution
✏️ Update src/services/pmo/favorite/favorite.tsx to be:
import { useNetInfo } from "@react-native-community/netinfo"
import { useEffect } from "react"
import { useUser } from "../../auth"
import { syncWithServer } from "./sync"
const FavoritesSync: React.FC = () => {
const { isConnected } = useNetInfo()
const user = useUser()
useEffect(() => {
async function syncData() {
if (isConnected && user) {
await syncWithServer(user.id)
}
}
syncData()
}, [isConnected, user])
return null
}
export default FavoritesSync
✏️ Update src/App.tsx to be:
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"
import { NavigationContainer } from "@react-navigation/native"
import { createStackNavigator } from "@react-navigation/stack"
import { Pressable } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import Icon from "react-native-vector-icons/Ionicons"
import Box from "./design/Box"
import ThemeProvider, { useTheme } from "./design/theme/ThemeProvider"
import Typography from "./design/Typography"
import CityList from "./screens/CityList"
import RestaurantDetails from "./screens/RestaurantDetails"
import RestaurantList from "./screens/RestaurantList"
import RestaurantOrder from "./screens/RestaurantOrder"
import Settings from "./screens/Settings"
import StateList from "./screens/StateList"
import AuthProvider from "./services/auth/AuthProvider"
import DataMigration from "./services/DataMigration"
import FavoritesSync from "./services/pmo/favorite"
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace ReactNavigation {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface RootParamList extends RestaurantsStackParamList {}
}
}
export type RestaurantsStackParamList = {
StateList: undefined
CityList: {
state: {
name: string
short: string
}
}
RestaurantList: {
state: {
name: string
short: string
}
city: {
name: string
state: string
}
}
RestaurantDetails: {
slug: string
}
RestaurantOrder: {
slug: string
}
}
const RestaurantsStack = createStackNavigator<RestaurantsStackParamList>()
const RestaurantsNavigator: React.FC = () => {
return (
<RestaurantsStack.Navigator
initialRouteName="StateList"
screenOptions={{
header: ({ route, navigation }) => {
if (!navigation.canGoBack()) return null
return (
<Pressable onPress={navigation.goBack}>
<Box
padding="m"
style={{ flexDirection: "row", gap: 8, alignItems: "center" }}
>
<Icon name="arrow-back" size={20} />
<Typography variant="heading">
{/* @ts-ignore */}
{[route.params?.city?.name, route.params?.state?.name]
.filter(Boolean)
.join(", ")}
</Typography>
</Box>
</Pressable>
)
},
}}
>
<RestaurantsStack.Screen name="StateList" component={StateList} />
<RestaurantsStack.Screen name="CityList" component={CityList} />
<RestaurantsStack.Screen
name="RestaurantList"
component={RestaurantList}
/>
<RestaurantsStack.Screen
name="RestaurantDetails"
component={RestaurantDetails}
/>
<RestaurantsStack.Screen
name="RestaurantOrder"
component={RestaurantOrder}
/>
</RestaurantsStack.Navigator>
)
}
const AppTabs = createBottomTabNavigator()
const AppNavigator: React.FC = () => {
const theme = useTheme()
return (
<AppTabs.Navigator
initialRouteName="RestaurantsStack"
screenOptions={({ route }) => ({
headerStyle: {
backgroundColor: theme.palette.screen.main,
},
headerTitleStyle: {
color: theme.palette.screen.contrast,
...theme.typography.title,
},
tabBarStyle: {
backgroundColor: theme.palette.screen.main,
},
tabBarActiveTintColor: theme.palette.primary.strong,
tabBarInactiveTintColor: theme.palette.screen.contrast,
tabBarIcon: ({ focused, color }) => {
let icon = "settings"
if (route.name === "Settings") {
icon = focused ? "settings" : "settings-outline"
} else if (route.name === "Restaurants") {
icon = focused ? "restaurant" : "restaurant-outline"
}
return <Icon name={icon} size={20} color={color} />
},
})}
>
<AppTabs.Screen
name="Restaurants"
component={RestaurantsNavigator}
options={{ title: "Place My Order" }}
/>
<AppTabs.Screen
name="Settings"
component={Settings}
options={{ title: "Settings" }}
/>
</AppTabs.Navigator>
)
}
const App: React.FC = () => {
return (
<SafeAreaView style={{ flex: 1 }}>
<ThemeProvider>
<AuthProvider>
<DataMigration>
<NavigationContainer>
<AppNavigator />
</NavigationContainer>
<FavoritesSync />
</DataMigration>
</AuthProvider>
</ThemeProvider>
</SafeAreaView>
)
}
export default App
Next steps
Next, we will learn about Integrating Maps to our application.