Performance and Optimization page

Improve the application’s launch time by implementing lazy loading.

Overview

In this section, you will:

  • Make smaller JavaScript bundles with code splitting.
  • Use React’s lazy and Suspense APIs to implement lazy loading.
  • Learn when it’s best to use dynamic imports.

Objective 1: Lazy load the Map view

In a previous section, we added a Map view that has a large dependency: the react-native-maps package. Large packages have a negative impact on the application’s startup time, but there’s a solution: lazy loading!

Let’s lazy load the Map view in our app so it launches faster.

JavaScript bundle size

A “bundle” (file) is created with all of the JavaScript code when the application is built, along with any additional assets (like images). As more code is added to the application, the size of this JavaScript bundle will increase.

The larger bundle can lead to longer startup times for the application because the bundle must be loaded, parsed, and ran before the app can be used. This longer launch time translates into a worse user experience as more code is added. Our app should improve as we add features without sacrificing launch time!

Making smaller JavaScript bundles

Currently, our existing imports are “static” import declarations that are defined at the top of the file:

import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"
import { NavigationContainer } from "@react-navigation/native"
import { SafeAreaView } from "react-native"

import Analytics from "./screens/Analytics"
import Home from "./screens/Home"

const AppTabs = createBottomTabNavigator()

const App: React.FC = () => {
  return (
    <SafeAreaView style={{ height: "100%", width: "100%" }}>
      <NavigationContainer>
        <AppTabs.Navigator initialRouteName="Home">
          <AppTabs.Screen component={Home} name="Home" />
          <AppTabs.Screen component={Analytics} name="Analytics" />
        </AppTabs.Navigator>
      </NavigationContainer>
    </SafeAreaView>
  )
}

export default App

We can split our code into multiple bundles by using the dynamic import() syntax from JavaScript.

import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"
import { NavigationContainer } from "@react-navigation/native"
import { useEffect, useState } from "react"
import { SafeAreaView } from "react-native"

import Home from "./screens/Home"

const Analytics: React.FC = () => {
  const [analyticsView, setAnalyticsView] = useState(null)

  useEffect(() => {
    async function loadView() {
      const analyticsModule = await import("./screens/Analytics")
      if (analyticsModule.default) {
        setAnalyticsView(analyticsModule.default)
      }
    }
    loadView()
  }, [])

  if (!analyticsView) {
    return <Loading />
  }

  return analyticsView
}

const AppTabs = createBottomTabNavigator()

const App: React.FC = () => {
  return (
    <SafeAreaView style={{ height: "100%", width: "100%" }}>
      <NavigationContainer>
        <AppTabs.Navigator initialRouteName="Home">
          <AppTabs.Screen component={Home} name="Home" />
          <AppTabs.Screen component={Analytics} name="Analytics" />
        </AppTabs.Navigator>
      </NavigationContainer>
    </SafeAreaView>
  )
}

export default App

Now with the dynamic import in place, the Analytics view in the code above will be split into a separate JavaScript bundle. This means that any of its code (including the code it imports) will be in a separate bundle. This will keep the main app bundle smaller over time as the Analytics view grows in size.

Using React’s lazy and Suspense APIs

This dynamic import() code takes a lot of lines to implement this one improvement:

import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"
import { NavigationContainer } from "@react-navigation/native"
import { useEffect, useState } from "react"
import { SafeAreaView } from "react-native"

import Home from "./screens/Home"

const Analytics: React.FC = () => {
  const [analyticsView, setAnalyticsView] = useState(null)

  useEffect(() => {
    async function loadView() {
      const analyticsModule = await import("./screens/Analytics")
      if (analyticsModule.default) {
        setAnalyticsView(analyticsModule.default)
      }
    }
    loadView()
  }, [])

  if (!analyticsView) {
    return <Loading />
  }

  return analyticsView
}

const AppTabs = createBottomTabNavigator()

const App: React.FC = () => {
  return (
    <SafeAreaView style={{ height: "100%", width: "100%" }}>
      <NavigationContainer>
        <AppTabs.Navigator initialRouteName="Home">
          <AppTabs.Screen component={Home} name="Home" />
          <AppTabs.Screen component={Analytics} name="Analytics" />
        </AppTabs.Navigator>
      </NavigationContainer>
    </SafeAreaView>
  )
}

export default App

If we had to write this for every single component that we wanted to import dynamically, we would have a lot of boilerplate repeated over and over.

Thankfully, React provides two APIs to simplify dynamic imports:

import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"
import { NavigationContainer } from "@react-navigation/native"
import { Suspense, lazy } from "react"
import { SafeAreaView } from "react-native"

import Loading from "./components/Loading"
import Home from "./screens/Home"

const Analytics = lazy(() => import("./screens/Analytics"))

const AppTabs = createBottomTabNavigator()

const AnalyticsLazyLoaded: React.FC = () => {
  return (
    <Suspense fallback={<Loading />}>
      <Analytics />
    </Suspense>
  )
}

const App: React.FC = () => {
  return (
    <SafeAreaView style={{ height: "100%", width: "100%" }}>
      <NavigationContainer>
        <AppTabs.Navigator initialRouteName="Home">
          <AppTabs.Screen component={Home} name="Home" />
          <AppTabs.Screen component={AnalyticsLazyLoaded} name="Analytics" />
        </AppTabs.Navigator>
      </NavigationContainer>
    </SafeAreaView>
  )
}

export default App

In the code above, we’ve replaced the static import with a dynamic import() within lazy.

When the Analytics tab is tapped on, React Native will load the component passed to Screen. Then, the <Suspense> component will render <Loading> while the Analytics module is being imported. When the module has loaded, the <Analytics> component will be displayed.

Selectively using dynamic imports

You might wonder if it’s a good idea to use dynamic import() statements everywhere.

Surprisingly, the answer is no! The benefit of dynamic imports is that the bundle is split up into separate files, but this comes with a small cost.

Each time a bundle is loaded, the JavaScript has to be parsed and ran. This takes a little bit of time for each bundle, so it’s best to only split your bundle in a few key places in your application. This can be in views where there is a large dependency (like our Maps view), or between tabs in the app (like we’ve shown above).

Setup 1

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

import { StackScreenProps } from "@react-navigation/stack"
import { Suspense, lazy, useState } from "react"

import { RestaurantsStackParamList } from "../../App"
import Loading from "../../components/Loading"
import Tabs from "../../components/Tabs"
import Box from "../../design/Box"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurants } from "../../services/pmo/restaurant"

import List from "./components/List"
import Map from "./components/Map"
// Exercise: Change the static Map `import` statement to a dynamic `import()`.

export interface RestaurantListProps
  extends StackScreenProps<RestaurantsStackParamList, "RestaurantList"> {}

const RestaurantList: React.FC<RestaurantListProps> = ({ route }) => {
  const { state, city } = route.params
  const {
    data: restaurants,
    error,
    isPending,
  } = useRestaurants({ state: state.short, city: city.name })

  const [tab, setTab] = useState<string>("list")

  if (error) {
    return (
      <Screen>
        <Box padding="m">
          <Typography variant="heading">Error loading restaurants: </Typography>
          <Typography variant="body">{error.message}</Typography>
        </Box>
      </Screen>
    )
  }

  if (isPending) {
    return <Loading />
  }

  return (
    <>
      <Tabs
        options={[
          { label: "List", value: "list" },
          { label: "Map", value: "map" },
        ]}
        onChange={setTab}
        value={tab}
      />

      <Screen noScroll>
        {tab === "list" && restaurants && <List restaurants={restaurants} />}
        {/* Exercise: Use `<Suspense>` to load the Map tab on the screen. */}
        {tab === "map" && restaurants && <Map restaurants={restaurants} />}
      </Screen>
    </>
  )
}

export default RestaurantList

Verify 1

Watch the output of the npm start command while it’s running.

When you’ve completed this exercise, you’ll notice that there’s a new bundle loaded when you go to the Map view:

BUILD SUCCESSFUL in 7s
199 actionable tasks: 15 executed, 184 up-to-date
info Connecting to the development server...
info Starting the app on "emulator-5554"...
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.placemyorder/.MainActivity }
 BUNDLE  ./index.js

 LOG  Running "PlaceMyOrder" with {"rootTag":11}
 BUNDLE  src/screens/RestaurantList/components/Map/index.ts

Exercise 1

Inside of RestaurantList.tsx:

  • Change the static Map import statement to a dynamic import().
  • Use <Suspense> to load the Map tab on the screen.

Solution 1

If you’ve implemented the solution correctly, you will see the new BUNDLE line logged while the server is running.

Click to see the solution

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

import { StackScreenProps } from "@react-navigation/stack"
import { Suspense, lazy, useState } from "react"

import { RestaurantsStackParamList } from "../../App"
import Loading from "../../components/Loading"
import Tabs from "../../components/Tabs"
import Box from "../../design/Box"
import Screen from "../../design/Screen"
import Typography from "../../design/Typography"
import { useRestaurants } from "../../services/pmo/restaurant"

import List from "./components/List"

const Map = lazy(() => import("./components/Map"))

export interface RestaurantListProps
  extends StackScreenProps<RestaurantsStackParamList, "RestaurantList"> {}

const RestaurantList: React.FC<RestaurantListProps> = ({ route }) => {
  const { state, city } = route.params
  const {
    data: restaurants,
    error,
    isPending,
  } = useRestaurants({ state: state.short, city: city.name })

  const [tab, setTab] = useState<string>("list")

  if (error) {
    return (
      <Screen>
        <Box padding="m">
          <Typography variant="heading">Error loading restaurants: </Typography>
          <Typography variant="body">{error.message}</Typography>
        </Box>
      </Screen>
    )
  }

  if (isPending) {
    return <Loading />
  }

  return (
    <>
      <Tabs
        options={[
          { label: "List", value: "list" },
          { label: "Map", value: "map" },
        ]}
        onChange={setTab}
        value={tab}
      />

      <Screen noScroll>
        {tab === "list" && restaurants && <List restaurants={restaurants} />}
        {tab === "map" && restaurants && (
          <Suspense fallback={<Loading />}>
            <Map restaurants={restaurants} />
          </Suspense>
        )}
      </Screen>
    </>
  )
}

export default RestaurantList

Next steps

Now we’ve got a complete and performant application. Let’s finish out our work by learning about Building React Native Apps.