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.

Screenshot of the application settings page with the 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.

Screenshot of the application's Restaurant Details screen displaying the add favorite button. Screenshot of the application's Restaurant Details screen displaying the remove favorite button.

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 the App 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.