Using Async Storage page

Store data locally on device with Async Storage and run data migrations with new versions of your application.

Overview

In this section, you will:

  • Store data on device.
  • Use React Native Async Storage to store items, get items, and more.
  • Implement a local cache first (cache-then-network) strategy.
  • Migrate data with a new version of the application.

Objective 1: Cache API responses

Now that the core functionality of our app is working, let’s improve its performance by caching API responses and using them for GET requests.

Storing data on device

In mobile applications, persisting data locally is crucial for enabling offline usage and improving app responsiveness by reducing the need for network requests. React Native provides several options for local storage, ranging from simple key-value storage to more complex solutions like SQLite or Realm. For most use cases, especially when starting out, a simpler and effective solution suffices — such as @react-native-async-storage/async-storage.

Using React Native Async Storage

The @react-native-async-storage/async-storage package is a local storage system that is ideal for storing small pieces of data. It operates asynchronously and stores data in key-value pairs, making it a good choice for lightweight persistence without the need for setting up a database.

Its API includes a lot of functionality, but we’ll focus on using:

  • setItem
  • getItem
  • getAllKeys
  • clear

Storing items

Use the setItem method to store data using AsyncStorage. Since it only supports storing strings, complex data such as objects or arrays must be serialized using JSON.stringify before storage. This also means that classes and functions can't be serialized for storage.

Here is an example:

import AsyncStorage from "@react-native-async-storage/async-storage"

export async function saveProfileData(profile) {
  try {
    const serializedData = JSON.stringify(profile)
    await AsyncStorage.setItem("profileData", serializedData)
  } catch (error) {
    console.error("Failed to save data", error)
  }
}

This function takes a restaurant object, converts it into a string, and saves it under the key "restaurantData".

Getting items

Retrieving data from AsyncStorage is done using the getItem method. The retrieved data, being in string format, often needs to be converted back into JSON format using JSON.parse.

Consider this example:

import AsyncStorage from "@react-native-async-storage/async-storage"

export async function loadProfileData() {
  try {
    const serializedData = await AsyncStorage.getItem("profileData")
    return serializedData ? JSON.parse(serializedData) : null
  } catch (error) {
    console.error("Failed to load data", error)
  }
}

This function fetches the data stored under the "restaurantData" key and converts it from a string back into an object.

Getting all keys

Sometimes, it is necessary to know all the keys under which data is stored.

The getAllKeys method provides this capability:

import AsyncStorage from "@react-native-async-storage/async-storage"

export async function listAllKeys() {
  try {
    const keys = await AsyncStorage.getAllKeys()
    console.info("Stored keys:", keys)
  } catch (error) {
    console.error("Failed to fetch keys", error)
  }
}

Continuing from our examples above, the keys array would be: ["restaurantData"]

Clearing all storage

Use the clear method to remove all data stored in AsyncStorage:

import AsyncStorage from "@react-native-async-storage/async-storage"

export async function clearStorage() {
  try {
    await AsyncStorage.clear()
    console.info("Storage successfully cleared!")
  } catch (error) {
    console.error("Failed to clear the storage", error)
  }
}

After calling clear(), the getAllKeys method would return an empty array, and any getItem calls would no longer return data.

Caching strategies

In applications that make network requests, caching responses can improve performance while decreasing network data usage.

There are many different types of caching strategies for network requests:

  • No cache strategy: Every request is sent to the server without any attempt to cache data. This is suitable when data updates frequently or when exact real-time data is crucial.

  • Local cache first (cache-then-network): The app first tries to load data from the local cache. If the data is not available or is stale, it fetches data from the network and updates the cache. This is useful for reducing network requests and speeding up data retrieval.

  • Cache only: The app relies entirely on the cached data and does not perform any network requests. This is ideal for offline modes where no network connection is available.

  • Cache with network update (stale-while-revalidate): The app initially serves data from the cache for quick access, but simultaneously fetches the latest data from the network to update the cache. This way, users see data quickly and also get updates as they come.

  • Network with cache fallback: The primary approach is to fetch data from the network, but if the network is unavailable or the request fails, data is loaded from the cache. This is useful for maintaining functionality when offline or in poor network conditions.

  • Conditional cache strategy: Using conditional requests with cache tags (like ETag) or timestamps, the app can minimize data transfer by asking the server if there are updates since the last fetch. If the data hasn’t changed, the server returns a not-modified status, allowing the app to use cached data.

  • Incremental cache update: Suitable for data that can be partitioned or incremental updates (like paginated queries). Instead of refreshing the entire cache, only new or updated data chunks are fetched and cached.

  • Intelligent cache invalidation: A more complex strategy where the app decides when to invalidate cache based on specific rules or expiry times, often used in conjunction with other strategies to optimize data freshness versus retrieval time.

Each of these strategies can be implemented depending on the specific needs of the application, data sensitivity, user experience requirements, and network conditions. Choosing the right caching strategy is critical for balancing between data freshness, responsiveness, and minimizing network usage.

Local cache first (cache-then-network)

For our application, we will implement the “local cache first” (cache-then-network) strategy.

Here are some pointers on implementing this strategy:

  • Only cache GET requests. Most APIs make it so HEAD and GET requests are idempotent (they can be repeated over and over to the same effect), so they are safe to cache and reuse.
  • Store API requests by the full URL. Relative URLs can be a pain to deal with if you’re working with multiple servers.
  • Use a prefix for the storage key. A prefix before the URL can make it easier to find the same type of cached data, and separate it from other things that might be in storage.
  • Store the response data with metadata in the cache. Metadata can give you more info about the cached item, e.g. the date and time of when the item was cached could be stored as a string after calling new Date().

Setup 1

✏️ Install the new dependency:

npm install @react-native-async-storage/async-storage@1

✏️ Terminate the existing dev server and start it again:

npm run start

✏️ Update jest-setup.ts to be:

import "@testing-library/react-native/extend-expect"

import "react-native-gesture-handler/jestSetup"

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

✏️ Create src/services/storage/storage.ts and update it to be:

import AsyncStorage from "@react-native-async-storage/async-storage"

// Exercise: Implement the four `AsyncStorage` helpers.
export const getData = async <T>(key: string): Promise<T | undefined> => {}

export const getAllKeys = (): Promise<readonly string[]> => {}

export const storeData = <T>(key: string, value: T): Promise<void> => {}

export const clearStorage = (): Promise<void> => {}

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

export * from "./storage"

✏️ Update src/services/pmo/api/api.ts to be:

import { getData, storeData } from "../../storage"

const baseUrl = process.env.PMO_API

export interface CachedResponse<T> {
  data: T
  dateTime: string
}

export const keyPrefix = "apiRequest-"

export async function apiRequest<
  Data = never,
  Params = unknown,
  Body = unknown,
>({
  method,
  params,
  path,
  body,
}: {
  method: string
  params?: Params
  path: string
  body?: Body
}): Promise<{ data: Data | undefined; error: Error | undefined }> {
  try {
    const query = params ? stringifyQuery(params) : ""
    const requestUrl = `${baseUrl}${path}?${query}`

    const response = await fetch(requestUrl, {
      method,
      headers: {
        "Content-Type": "application/json",
      },
      body: body ? JSON.stringify(body) : undefined,
    })

    const data = await response.json()

    if (!response.ok) {
      const error = new Error(`${response.status} (${response.statusText})`)
      return { data: data, error: error }
    }

    return {
      data: data,
      error: undefined,
    }
  } catch (error) {
    return {
      data: undefined,
      error:
        error instanceof Error ? error : new Error("An unknown error occurred"),
    }
  }
}

export function stringifyQuery(
  input: Record<string, string | undefined | undefined>,
): string {
  const output: string[] = []

  for (const [key, value] of Object.entries(input)) {
    if (typeof value !== "undefined" && value !== null) {
      output.push(`${key}=${value}`)
    }
  }

  return output.join("&")
}

Verify 1

✏️ Create src/services/storage/storage.mock.ts and update it to be:

export const getData = async <T>(key: string): Promise<T | undefined> => {
  return undefined
}

export const getAllKeys = async (): Promise<readonly string[]> => {
  return []
}

export const storeData = async <T>(key: string, value: T): Promise<void> => {
  return undefined
}

export const clearStorage = async (): Promise<void> => {
  return undefined
}

✏️ Create src/services/storage/storage.test.ts and update it to be:

import AsyncStorage from "@react-native-async-storage/async-storage"

import { getData, getAllKeys, storeData, clearStorage } from "./storage" // Import your functions here

let mockStorageGetData: jest.SpyInstance<
  ReturnType<typeof AsyncStorage.getItem>
>
let mockStorageStoreData: jest.SpyInstance<
  ReturnType<typeof AsyncStorage.setItem>
>
let mockStorageGetKeys: jest.SpyInstance<
  ReturnType<typeof AsyncStorage.getAllKeys>
>
let mockStorageClear: jest.SpyInstance<ReturnType<typeof AsyncStorage.clear>>

jest.unmock("./storage")

describe("Services/Storage", () => {
  beforeEach(() => {
    jest.clearAllMocks()
    mockStorageGetData = jest.spyOn(AsyncStorage, "getItem")
    mockStorageGetKeys = jest.spyOn(AsyncStorage, "getAllKeys")
    mockStorageStoreData = jest.spyOn(AsyncStorage, "setItem")
    mockStorageClear = jest.spyOn(AsyncStorage, "clear")
  })

  describe("getData", () => {
    it("returns the parsed value from AsyncStorage", async () => {
      mockStorageGetData.mockResolvedValueOnce(
        JSON.stringify({ example: "data" }),
      )

      const data = await getData("testKey")

      expect(data).toEqual({ example: "data" })
      expect(AsyncStorage.getItem).toHaveBeenCalledWith("testKey")
    })

    it("returns undefined if key does not exist", async () => {
      mockStorageGetData.mockResolvedValueOnce(JSON.stringify(undefined))

      const data = await getData("nonExistingKey")

      expect(data).toBeUndefined()
      expect(mockStorageGetData).toHaveBeenCalledWith("nonExistingKey")
    })
  })

  describe("getAllKeys", () => {
    it("returns keys from AsyncStorage", async () => {
      const keys = ["key1", "key2", "key3"]
      mockStorageGetKeys.mockResolvedValueOnce(keys)

      const result = await getAllKeys()

      expect(result).toEqual(keys)
      expect(mockStorageGetKeys).toHaveBeenCalled()
    })
  })

  describe("storeData", () => {
    it("stores data in AsyncStorage", async () => {
      const data = { example: "data" }
      const key = "testKey"

      await storeData(key, data)

      expect(mockStorageStoreData).toHaveBeenCalledWith(
        key,
        JSON.stringify(data),
      )
    })
  })

  describe("clearStorage", () => {
    it("clears AsyncStorage", async () => {
      await clearStorage()

      expect(mockStorageClear).toHaveBeenCalled()
    })
  })
})

Exercise 1

For this exercise, let’s implement the storage APIs and use them to cache network responses:

  • Implement the four AsyncStorage helpers in storage.ts.
  • When a GET response is received, cache the response (and the current datetime) with the storage helpers.
  • When a GET request is made, check the cache first; if it’s been less than a minute since cached, return the cached value, otherwise make the request.

You can verify that this is working correctly by using DevTools to:

  • View the requests (or lack thereof) being made.
  • Inspect the stored data.

You can terminate the api server to check that uncached requests show an error.

Hint: Use new Date().toJSON() to get the current datetime as a string.

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/services/pmo/api/api.ts to be:

import { getData, storeData } from "../../storage"

const baseUrl = process.env.PMO_API

export interface CachedResponse<T> {
  data: T
  dateTime: string
}

export const keyPrefix = "apiRequest-"

export async function apiRequest<
  Data = never,
  Params = unknown,
  Body = unknown,
>({
  method,
  params,
  path,
  body,
}: {
  method: string
  params?: Params
  path: string
  body?: Body
}): Promise<{ data: Data | undefined; error: Error | undefined }> {
  try {
    const query = params ? stringifyQuery(params) : ""
    const requestUrl = `${baseUrl}${path}?${query}`

    if (method === "GET") {
      try {
        const cachedResponse = await getData<CachedResponse<Data>>(
          keyPrefix + requestUrl,
        )

        if (cachedResponse) {
          const diff =
            new Date().valueOf() - new Date(cachedResponse.dateTime).valueOf()
          // Return cached data if it’s younger than one minute
          if (diff < 60000) {
            return {
              data: cachedResponse.data,
              error: undefined,
            }
          }
        }
      } catch (error) {
        console.error("Failed to get cached value:", error)
      }
    }

    const response = await fetch(requestUrl, {
      method,
      headers: {
        "Content-Type": "application/json",
      },
      body: body ? JSON.stringify(body) : undefined,
    })

    const data = await response.json()

    if (method === "GET" && response.ok) {
      await storeData<CachedResponse<Data>>(keyPrefix + requestUrl, {
        data: data,
        dateTime: new Date().toJSON(),
      })
    }

    if (!response.ok) {
      const error = new Error(`${response.status} (${response.statusText})`)
      return { data: data, error: error }
    }

    return {
      data: data,
      error: undefined,
    }
  } catch (error) {
    return {
      data: undefined,
      error:
        error instanceof Error ? error : new Error("An unknown error occurred"),
    }
  }
}

export function stringifyQuery(
  input: Record<string, string | undefined | undefined>,
): string {
  const output: string[] = []

  for (const [key, value] of Object.entries(input)) {
    if (typeof value !== "undefined" && value !== null) {
      output.push(`${key}=${value}`)
    }
  }

  return output.join("&")
}

✏️ Update src/services/storage/storage.ts to be:

import AsyncStorage from "@react-native-async-storage/async-storage"

export const getData = async <T>(key: string): Promise<T | undefined> => {
  const value = await AsyncStorage.getItem(key)
  const jsonValue = value ? JSON.parse(value) : undefined
  return jsonValue
}

export const getAllKeys = (): Promise<readonly string[]> => {
  return AsyncStorage.getAllKeys()
}

export const storeData = <T>(key: string, value: T): Promise<void> => {
  const jsonValue = JSON.stringify(value)
  return AsyncStorage.setItem(key, jsonValue)
}

export const clearStorage = (): Promise<void> => {
  return AsyncStorage.clear()
}

Objective 2: Migrate data between versions

Our overall approach to caching the network responses seems great, although there is an improvement that we could make.

Right now, we’re storing the datetime as a string in Async Storage, which means we have to parse it every time we check the cached response:

import { getData, storeData } from "../../storage"

const baseUrl = process.env.PMO_API

export interface CachedResponse<T> {
  data: T
  dateTime: string
}

export const keyPrefix = "apiRequest-"

export async function apiRequest<
  Data = never,
  Params = unknown,
  Body = unknown,
>({
  method,
  params,
  path,
  body,
}: {
  method: string
  params?: Params
  path: string
  body?: Body
}): Promise<{ data: Data | undefined; error: Error | undefined }> {
  try {
    const query = params ? stringifyQuery(params) : ""
    const requestUrl = `${baseUrl}${path}?${query}`

    if (method === "GET") {
      try {
        const cachedResponse = await getData<CachedResponse<Data>>(
          keyPrefix + requestUrl,
        )

        if (cachedResponse) {
          const diff =
            new Date().valueOf() - new Date(cachedResponse.dateTime).valueOf()
          // Return cached data if it’s younger than one minute
          if (diff < 60000) {
            return {
              data: cachedResponse.data,
              error: undefined,
            }
          }
        }
      } catch (error) {
        console.error("Failed to get cached value:", error)
      }
    }

    const response = await fetch(requestUrl, {
      method,
      headers: {
        "Content-Type": "application/json",
      },
      body: body ? JSON.stringify(body) : undefined,
    })

    const data = await response.json()

    if (method === "GET" && response.ok) {
      await storeData<CachedResponse<Data>>(keyPrefix + requestUrl, {
        data: data,
        dateTime: new Date().toJSON(),
      })
    }

    if (!response.ok) {
      const error = new Error(`${response.status} (${response.statusText})`)
      return { data: data, error: error }
    }

    return {
      data: data,
      error: undefined,
    }
  } catch (error) {
    return {
      data: undefined,
      error:
        error instanceof Error ? error : new Error("An unknown error occurred"),
    }
  }
}

export function stringifyQuery(
  input: Record<string, string | undefined | undefined>,
): string {
  const output: string[] = []

  for (const [key, value] of Object.entries(input)) {
    if (typeof value !== "undefined" && value !== null) {
      output.push(`${key}=${value}`)
    }
  }

  return output.join("&")
}

We could improve this by storing the datetime as a number instead, so we don’t have to parse it when fetching from the cache:

import { getData, storeData } from "../../storage"

const baseUrl = process.env.PMO_API

export interface CachedResponse<T> {
  data: T
  dateTime: number
}

export const keyPrefix = "apiRequest-"

export async function apiRequest<
  Data = never,
  Params = unknown,
  Body = unknown,
>({
  method,
  params,
  path,
  body,
}: {
  method: string
  params?: Params
  path: string
  body?: Body
}): Promise<{ data: Data | undefined; error: Error | undefined }> {
  try {
    const query = params ? stringifyQuery(params) : ""
    const requestUrl = `${baseUrl}${path}?${query}`

    if (method === "GET") {
      try {
        const cachedResponse = await getData<CachedResponse<Data>>(
          keyPrefix + requestUrl,
        )

        if (cachedResponse) {
          const diff = Date.now() - cachedResponse.dateTime
          // Return cached data if it’s younger than one minute
          if (diff < 60000) {
            return {
              data: cachedResponse.data,
              error: undefined,
            }
          }
        }
      } catch (error) {
        console.error("Failed to get cached value:", error)
      }
    }

    const response = await fetch(requestUrl, {
      method,
      headers: {
        "Content-Type": "application/json",
      },
      body: body ? JSON.stringify(body) : undefined,
    })

    const data = await response.json()

    if (method === "GET" && response.ok) {
      await storeData<CachedResponse<Data>>(keyPrefix + requestUrl, {
        data: data,
        dateTime: Date.now(),
      })
    }

    if (!response.ok) {
      const error = new Error(`${response.status} (${response.statusText})`)
      return { data: data, error: error }
    }

    return {
      data: data,
      error: undefined,
    }
  } catch (error) {
    return {
      data: undefined,
      error:
        error instanceof Error ? error : new Error("An unknown error occurred"),
    }
  }
}

export function stringifyQuery(
  input: Record<string, string | undefined | undefined>,
): string {
  const output: string[] = []

  for (const [key, value] of Object.entries(input)) {
    if (typeof value !== "undefined" && value !== null) {
      output.push(`${key}=${value}`)
    }
  }

  return output.join("&")
}

If we make this change, it would be great if we could migrate the old cached data (with strings) to the new format (with numbers). Let’s dig into how we can add data migration to the application.

Migrating data

Here’s a brief overview of what you can do to build a migration process with the application:

  • Make changes to the apiRequest helper to use numbers instead of strings.
  • Add a component to the App JSX to block rendering the child components until the migration has been run.
  • While the migration is running, show a loading screen.
  • Check a version number with the data to determine whether the migration should run.
  • Run the migration to convert the old string values to numbers.
  • Store an updated version number with the data so future launches of the app know that the data has been updated.
  • Render the child components after the migration has finished.

We’ve already covered the apiRequest helper changes we want for using numbers instead of strings, so we’ll start with the component we’ll add to the App.

Blocking rendering child components

When updating an application, it’s essential to ensure the existing data is compatible with the new version. This often involves data migration, which should complete before the app continues to render its components. To achieve this, a component can be used in the App JSX to block the rendering of child components until the migration is complete.

import { ReactNode, useState } from "react"
import { Text } from "react-native"

const MigrationLoader: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [isMigrating, setIsMigrating] = useState(true)

  if (isMigrating) {
    return <Text>Loading…</Text>
  }

  return children
}

function App() {
  return (
    <MigrationLoader>
      <Text>This will render after the migration is done.</Text>
    </MigrationLoader>
  )
}

export default App

In this example, the “Loading…” text is shown when isMigrating is true, blocking the rendering of the rest of the application until the migration is completed.

Running the migration

Next, we need to run the actual migration!

The migration will inevitably include a call to an async function, so include a useEffect for this logic:

import { ReactNode, useEffect, useState } from "react"
import { Text } from "react-native"

const MigrationLoader: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [isMigrating, setIsMigrating] = useState(true)

  useEffect(() => {
    async function runMigration() {
      // Migration logic here
      setIsMigrating(false)
    }

    runMigration()
  }, [])

  if (isMigrating) {
    return <Text>Loading…</Text>
  }

  return children
}

function App() {
  return (
    <MigrationLoader>
      <Text>This will render after the migration is done.</Text>
    </MigrationLoader>
  )
}

export default App

Versioning the data

The code will currently run the migration every time the app launches, but it should only run the migration when it’s necessary.

To accomplish this, we can store a version number and fetch it before running the migration:

import { ReactNode, useEffect, useState } from "react"
import { Text } from "react-native"

async function getDataVersion() {
  // Get the data’s version number
}

async function storeDataVersion() {
  // Store the data’s version number
}

const MigrationLoader: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [isMigrating, setIsMigrating] = useState(true)

  useEffect(() => {
    async function runMigration() {
      const appVersion = (await getDataVersion()) || 1
      if (appVersion < 2) {
        // Migration logic here
        await storeDataVersion(2)
      }
      setIsMigrating(false)
    }

    runMigration()
  }, [])

  if (isMigrating) {
    return <Text>Loading…</Text>
  }

  return children
}

function App() {
  return (
    <MigrationLoader>
      <Text>This will render after the migration is done.</Text>
    </MigrationLoader>
  )
}

export default App

Ideally we would have added a version number when we first started storing the data, but that’s ok! We’ll assume that the data is version 1 if we haven’t stored the version number, and then we’ll call our new data version 2.

Setup 2

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

import { useEffect, useState } from "react"

import Loading from "../../components/Loading"
import { CachedResponse, keyPrefix } from "../pmo/api"
import { getData, getAllKeys, storeData, clearStorage } from "../storage"

interface CachedResponseV1 {
  data: unknown
  dateTime: string
}

const migrateDataFromV1toV2 = async (): Promise<void> => {
  // Exercise: Implement the data migration logic here.
}

export interface DataMigrationProps {
  children: React.ReactNode
}

const DataMigration: React.FC<DataMigrationProps> = ({ children }) => {
  // Exercise: Run the migration in a `useEffect`.
  // Exercise: While the migration is running, show a `Loading` component.
  // Exercise: When the migration is all done, render the `children`.
}

export default DataMigration

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

export { default } from "./DataMigration"
export * from "./DataMigration"

✏️ Update src/services/pmo/api/api.ts to be:

import { getData, storeData } from "../../storage"

const baseUrl = process.env.PMO_API

export interface CachedResponse<T> {
  data: T
  dateTime: number
}

export const keyPrefix = "apiRequest-"

export async function apiRequest<
  Data = never,
  Params = unknown,
  Body = unknown,
>({
  method,
  params,
  path,
  body,
}: {
  method: string
  params?: Params
  path: string
  body?: Body
}): Promise<{ data: Data | undefined; error: Error | undefined }> {
  try {
    const query = params ? stringifyQuery(params) : ""
    const requestUrl = `${baseUrl}${path}?${query}`

    if (method === "GET") {
      try {
        const cachedResponse = await getData<CachedResponse<Data>>(
          keyPrefix + requestUrl,
        )

        if (cachedResponse) {
          const diff = Date.now() - cachedResponse.dateTime
          // Return cached data if it’s younger than one minute
          if (diff < 60000) {
            return {
              data: cachedResponse.data,
              error: undefined,
            }
          }
        }
      } catch (error) {
        console.error("Failed to get cached value:", error)
      }
    }

    const response = await fetch(requestUrl, {
      method,
      headers: {
        "Content-Type": "application/json",
      },
      body: body ? JSON.stringify(body) : undefined,
    })

    const data = await response.json()

    if (method === "GET" && response.ok) {
      await storeData<CachedResponse<Data>>(keyPrefix + requestUrl, {
        data: data,
        dateTime: Date.now(),
      })
    }

    if (!response.ok) {
      const error = new Error(`${response.status} (${response.statusText})`)
      return { data: data, error: error }
    }

    return {
      data: data,
      error: undefined,
    }
  } catch (error) {
    return {
      data: undefined,
      error:
        error instanceof Error ? error : new Error("An unknown error occurred"),
    }
  }
}

export function stringifyQuery(
  input: Record<string, string | undefined | undefined>,
): string {
  const output: string[] = []

  for (const [key, value] of Object.entries(input)) {
    if (typeof value !== "undefined" && value !== null) {
      output.push(`${key}=${value}`)
    }
  }

  return output.join("&")
}

Verify 2

✏️ Create src/services/DataMigration/DataMigration.test.tsx and update it to be:

import { render, screen } from "@testing-library/react-native"

import Typography from "../../design/Typography"
import * as storage from "../storage/storage"

import DataMigration from "./DataMigration"

// Mocking the global fetch function
let mockStorageGetData: jest.SpyInstance<ReturnType<typeof storage.getData>>
let mockStorageStoreData: jest.SpyInstance<ReturnType<typeof storage.storeData>>
let mockStorageGetKeys: jest.SpyInstance<ReturnType<typeof storage.getAllKeys>>
let mockStorageClear: jest.SpyInstance<ReturnType<typeof storage.clearStorage>>

beforeEach(() => {
  mockStorageGetData = jest.spyOn(storage, "getData")
  mockStorageGetKeys = jest.spyOn(storage, "getAllKeys")
  mockStorageStoreData = jest.spyOn(storage, "storeData")
  mockStorageClear = jest.spyOn(storage, "clearStorage")

  mockStorageGetData.mockResolvedValue(undefined)
  mockStorageGetKeys.mockResolvedValue([
    "apiRequest-numberone",
    "otherkey-numbertwo",
    "apiRequest-numberthree",
  ])
})

afterEach(() => {
  jest.resetAllMocks()
})

describe("Services/DataMigration", () => {
  it("renders loading", async () => {
    mockStorageGetData.mockResolvedValueOnce(2)

    render(
      <DataMigration>
        <Typography>Hello!</Typography>
      </DataMigration>,
    )

    expect(screen.getByText(/Loading…/)).toBeOnTheScreen()
  })

  it("renders children after loading", async () => {
    mockStorageGetData.mockResolvedValueOnce(2)

    render(
      <DataMigration>
        <Typography>Hello!</Typography>
      </DataMigration>,
    )

    expect(await screen.findByText(/Hello!/)).toBeOnTheScreen()
  })

  it("updates data if version is less than 2", async () => {
    mockStorageGetData
      .mockResolvedValueOnce(1)
      .mockResolvedValueOnce({
        data: { text: "Not Important" },
        dateTime: new Date(),
      })
      .mockResolvedValueOnce({
        data: { text: "Still Not Important" },
        dateTime: new Date(),
      })

    render(
      <DataMigration>
        <Typography>Hello!</Typography>
      </DataMigration>,
    )

    expect(await screen.findByText(/Hello!/)).toBeOnTheScreen()

    /*
      With the mock data as it is, getKeys should be called once to get all the necessary keys.
      getData will return three times, once for retrieving the version number, and twice for the two keys that need to be data migrated
      storeData will return three times, twice for the two keys that need to be data migrated, and once more to update the version number
      storageClear will return zero times, it’s only called in the migration function if the migration function fails
    */
    expect(mockStorageGetKeys).toHaveReturnedTimes(1)
    expect(mockStorageGetData).toHaveReturnedTimes(3)
    expect(mockStorageStoreData).toHaveReturnedTimes(3)
    expect(mockStorageClear).toHaveReturnedTimes(0)
  })

  it("ignores local storage if version is 2 or more", async () => {
    mockStorageGetData.mockResolvedValueOnce(5)

    render(
      <DataMigration>
        <Typography>Hello!</Typography>
      </DataMigration>,
    )

    expect(await screen.findByText(/Hello!/)).toBeOnTheScreen()

    // if the local storage doesn't run for data migration, GetData will only run once to check the version number
    expect(mockStorageGetKeys).toHaveReturnedTimes(0)
    expect(mockStorageGetData).toHaveReturnedTimes(1)
    expect(mockStorageStoreData).toHaveReturnedTimes(0)
    expect(mockStorageClear).toHaveReturnedTimes(0)
  })
})

Exercise 2

Now let’s implement a data migration! We’ve already given you the changes to the apiRequest helper, so now you’ll need to write the logic for migrating the old data to the new format.

Here are the requirements for this exercise:

  • Add the DataMigration component to the App JSX so that it blocks the NavigationContainer component (and its children) from rendering until the migration is complete.
  • Update the DataMigration component to do the following:
    • Run the migration in a useEffect:
      • It will fetch the data version (and assume version 1 if not stored).
      • If the data version is less than 2, then run the migration and update the stored version number.
    • While the migration is running, show a Loading component.
    • When the migration is all done, render the children.
  • Here’s what we need to do for the actual migration logic:
    • Get all the keys in Async Storage.
    • For all the keys that start with our keyPrefix:
      • Fetch the old data.
      • Translate it to the new format.
      • Store the new data.
    • If there are any errors during the migration process, since this is just a network cache, delete everything in Async Storage.

Solution 2

If you’ve implemented the solution correctly, the tests will pass when you run npm run test!

Click to see the solution

✏️ Update src/services/DataMigration/DataMigration.tsx to be:

import { useEffect, useState } from "react"

import Loading from "../../components/Loading"
import { CachedResponse, keyPrefix } from "../pmo/api"
import { getData, getAllKeys, storeData, clearStorage } from "../storage"

interface CachedResponseV1 {
  data: unknown
  dateTime: string
}

const migrateDataFromV1toV2 = async (): Promise<void> => {
  const keys = await getAllKeys()
  try {
    for (const key of keys) {
      if (key.startsWith(keyPrefix)) {
        const oldData = (await getData(key)) as CachedResponseV1
        await storeData<CachedResponse<unknown>>(key, {
          ...oldData,
          dateTime: new Date(oldData.dateTime).valueOf(),
        })
      }
    }
  } catch (error) {
    console.error("'migrateDataFromV1toV2' failed with error:", error)
    await clearStorage()
  }
}

export interface DataMigrationProps {
  children: React.ReactNode
}

const DataMigration: React.FC<DataMigrationProps> = ({ children }) => {
  const [isMigrating, setIsMigrating] = useState(true)

  useEffect(() => {
    const checkMigration = async () => {
      const appVersion = (await getData<number>("version")) || 1
      if (appVersion < 2) {
        await migrateDataFromV1toV2()
        await storeData<number>("version", 2)
      }

      setIsMigrating(false)
    }

    checkMigration()
  }, [])

  if (isMigrating) {
    return <Loading />
  }

  return <>{children}</>
}

export default DataMigration

✏️ 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 DataMigration from "./services/DataMigration"

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>
        <DataMigration>
          <NavigationContainer>
            <AppNavigator />
          </NavigationContainer>
        </DataMigration>
      </ThemeProvider>
    </SafeAreaView>
  )
}

export default App

Next steps

Next, we will learn Security and Authentication.