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>
  )
}

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 "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")

✏️ 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"

  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>
          />
        </View>
      </Card>
      {/* Exercise: Display the connection state in the Settings view. */}
    </Screen>
  )
}

Verify 1

✏️ Update src/screens/Settings/Settings.test.tsx to be:

    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:

  const { error, isPending, signIn, signOut } = useAuthentication()
  const user = useUser()
  const { mode, setMode } = useThemeMode()
  const { isConnected } = useNetInfo()

  return (
    <Screen>
          />
        </View>
      </Card>
      <Card>
        <Typography variant="title">
          Connection status: {isConnected ? "Online" : "Offline"}
        </Typography>
      </Card>
    </Screen>
  )
}

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 { Favorite, FavoriteResponse, StoredFavorites } from "./interfaces"

export const useFavorite = (
  userId?: string,
  restaurantId?: string,
): {
  error: Error | undefined
  isFavorite: boolean
  isPending: boolean

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:

  restaurantId?: string,
): {
  error: Error | undefined
  isFavorite: boolean
  isPending: boolean
  toggleFavorite: () => void
} => {
    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.

  return {
    error,
    isFavorite: (favoriteRestaurant && favoriteRestaurant.favorite) || false,
    isPending,
    toggleFavorite,
  }
}

Getting favorites from storage

The Hook gets all the favorite restaurants from Async Storage:

    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(

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:

  error: Error | undefined
  isFavorite: boolean
  isPending: boolean
  toggleFavorite: () => void
} => {
  const [error, setError] = useState<Error | undefined>()
  const [isPending, setIsPending] = useState<boolean>(false)
    (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)
    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:

      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.

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 { 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<
    }
  }

  return {
    error,
    isFavorite: (favoriteRestaurant && favoriteRestaurant.favorite) || false,
    isPending,
    toggleFavorite,
  }
}

✏️ Create src/services/pmo/favorite/hooks.test.ts and update it to be:

      jest.clearAllMocks()
    })

    it("should initialize with the correct default values", async () => {
      apiRequest.mockResolvedValue({
        data: { data: mockFavorites },
        error: undefined,
      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,

✏️ Create src/services/pmo/favorite/index.ts and update it to be:

export * from "./hooks"

✏️ Update src/screens/RestaurantDetails/RestaurantDetails.tsx to be:

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
  const navigation = useNavigation()
  const { data: restaurant, error, isPending } = useRestaurant({ slug })

  // Exercise: Add a button that uses the `toggleFavorite` helper.

  useEffect(() => {
    if (restaurant) {
  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={() => {

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"

    render(
      <NavigationContainer>
        <AuthProvider>
          {/* @ts-ignore */}
          <RestaurantDetails route={route} />
        </AuthProvider>
      </NavigationContainer>,
    )

    useRestaurant.mockReturnValue({ ...mockRestaurantData, data: undefined })
    render(
      <NavigationContainer>
        <AuthProvider>
          {/* @ts-ignore */}
          <RestaurantDetails route={route} />
        </AuthProvider>
      </NavigationContainer>,
    )

    expect(screen.getByText("")).toBeOnTheScreen()
  })

  it("renders loading state", () => {

    render(
      <NavigationContainer>
        <AuthProvider>
          {/* @ts-ignore */}
          <RestaurantDetails route={route} />
        </AuthProvider>
      </NavigationContainer>,
    )


    render(
      <NavigationContainer>
        <AuthProvider>
          {/* @ts-ignore */}
          <RestaurantDetails route={route} />
        </AuthProvider>
      </NavigationContainer>,
    )

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:

  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) {
      <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>
  )
}

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:

} 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>(

Updating favorites modified on the device

Next, the Hook will send any favorites that were modified while the device was offline to the API:

      },
    )

    // 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 = {

Updating storage with the changes

Last, the Hook will update Async Storage with all the changes that have accumulated through the sync process:

      }
    })

    // 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 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
  )
}

// Exercise: Add the `FavoritesSync` component to the `App` JSX.
const App: React.FC = () => {
  return (
    <SafeAreaView style={{ flex: 1 }}>

✏️ Create src/services/pmo/favorite/favorite.tsx and update it to be:

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
}

✏️ Create src/services/pmo/favorite/sync.ts and update it to be:

  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",

✏️ 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 { 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
}

✏️ Update src/App.tsx to be:

            <NavigationContainer>
              <AppNavigator />
            </NavigationContainer>
            <FavoritesSync />
          </DataMigration>
        </AuthProvider>
      </ThemeProvider>

Next steps

Next, we will learn about Integrating Maps to our application.

Previous Lesson: Security and AuthenticationNext Lesson: Integrating Maps