Zain Sajjad Head of Product Experience at Peekaboo Guru. In love with mobile machine learning, React, React Native, and UI designing.

Using Re.pack for large-scale React Native projects

7 min read 2180

Re.pack For Large-Scale React Native Projects

Re.pack, a webpack-based toolkit to build your React Native application with full support of the webpack ecosystem, has been around for a while and is solving a big problem for large-scale apps by bringing code splitting, module federation, and multi-bundle support to React Native.

In this article, we will take a look at what Re.pack has to offer for React Native apps and also conduct a small experiment of its features.

Jump ahead:

Bundling JavaScript and React Native

Bundlers serve as a cornerstone technology for all JavaScript apps. So is the case for React Native apps; as we developed more sophisticated apps in React Native, ideas began to emerge about efficiently bundling our tons of JavaScript.

Facebook has recently invested in the Metro bundler and achieved significant results with the community’s help. But for large-scale apps serving tens of modules with lots of features, there is a need for a more dynamic and feature-rich bundler. Where else is there to look if you have webpack? 🙂

Re.pack brings all the good of webpack that the community has developed across the decade to React Native, which means we can now leverage modern ideas, like module federation and dynamic code splitting, to our React Native apps.

Re.pack features

Better code splitting

Code splitting has been the talk of the town since the emergence of SPAs when we started shipping MBs of JavaScript to render even the tiniest of UI. For React Native apps, it’s a little different; here, it is about loading only the required JS on the main thread to make our apps faster to boot and optimized in memory consumption. Though React Suspense code splitting is now very simple for React apps, it’s still a hassle for RN apps.

Re.pack allows the creation of suspense-based chunks of your application, with each chunk only loaded on the main thread when required. We will explore a detailed implementation of this feature later in this article.

Module federation

Several patterns have recently emerged in frontend engineering, and one of the most prominent is module federation — here is how webpack defines the concept:

Multiple separate builds should form a single application. These separate builds act like containers and can expose and consume code between builds, creating a unified application.

This enables extensive code re-usability across apps without handling the hassle of package management. Re.pack allows RN apps to use module federation with local and remote chunks, making it super easy to ship bug fixes and upgrades to several apps simultaneously.

Plugin ecosystem

Over the past decade, the community has developed tons of plugins for webpack, bringing some really cool ideas to the bundling arena. Enabling webpack for React Native bundling means we can leverage those plugins and create more optimized and efficient JS bundles.

Let’s create a small demo app to test the features we discussed above.

Building our demo React Native app

Initializing our React Native app

We will begin by setting up a React Native app via CLI with the TypeScript template:

npx react-native init RepackDemo --template react-native-template-typescript

Adding dependencies

Once the project is up and running, add Re.pack-related dependencies to our app using either of the following commands:

npm i -D @callstack/repack babel-loader swc-loader terser-webpack-plugin webpack @types/react

Or:

yarn add -D @callstack/repack babel-loader swc-loader terser-webpack-plugin webpack @types/react

Configuring commands

Using Re.pack means we are moving away from the default metro bundler of React Native, hence the default react-native start command is not useful anymore. Instead we will use react-native webpack-start to run our JS server.

To make this command available in React Native, we have to update our react-native.config.js with the following:

module.exports = {
 commands: require('@callstack/repack/commands'),
};

This will hook Re.pack commands react-native webpack-start and react-native webpack-bundle with React Native seamlessly. Now, to make our development faster, we will update our start script in package.json to react-native webpack-start:

{
  “scripts”: {
    “start”: “react-native webpack-start”
  }
}

Configuring webpack

To use our new start command, we need a webpack config file. For demo purposes, we are using off-the-shelf configs. You can always dive deeper and update configs according to your requirements. You can copy these configs from our demo project, and place it in webpack.config.mjs at the root of the project.

Configure iOS Native

For release build, our JavaScript is bundled using Xcode’s build phase tasks. To enable Re.pack there, we will update the build phase. Add the following line to the Bundle React Native code and images task:

export BUNDLE_COMMAND=webpack-bundle

The Bundle React Native Code And Images Task In Xcode

Configure Android Native

Just like in an iOS app, we will update buildCommand to build a command for an Android app.



Update the following lines in app/build.gradle in our project:

project.ext.react = [
    enableHermes: true,  // clean and rebuild if changing
    bundleCommand: "webpack-bundle",
]

🥳 We are now ready to start leveraging Re.pack for JavaScript bundling in React Native.

How code splitting works with Re.pack

Let’s start exploring how code splitting works with Re.pack. As we previously discussed, Re.pack offers both local and remote chunks. To reiterate, local chunks are shipped with the app while remote chunks are served over the network and are not part of the app’s build. For our example, we will create both local and remote chunks to get a better understanding.

Here is what our app’s repo looks like:

Apps' Repo

We created two modules: LocalModule and Remote Module.

Here is our module wrapper to encapsulate dynamic loading:

/**
 * path: src/modules/RemoteModule/index.tsx
 * description:
 *    This is a remote module that is loaded asynchronously.
 *    This file encapsulate React.lazy and React.Suspense over the remote module.
 */
import React from 'react';
import { Text } from '../../components/Text';
const Component = React.lazy(() => import(/* webpackChunkName: "remoteModule" */ './RemoteModule'));
export default () => (
  <React.Suspense fallback={<Text>Loading Remote Module...</Text>}>
    <Component />
  </React.Suspense>
);

Here is what the module’s code looks like:

/**
z * path: src/modules/RemoteModule/RemoteModule.tsx
 * description:
 *   This is a remote module that is loaded asynchronously.
 *   This file is the actual module that is loaded asynchronously.
 */
import React from 'react';
import { useColorScheme, View } from 'react-native';
import { Colors } from 'react-native/Libraries/NewAppScreen';
import { Section } from '../../components/Section';
import { Text } from '../../components/Text';
function RemoteModule() {
  const isDarkMode = useColorScheme() === 'dark';
  return (
    <View
      style={{
        backgroundColor: isDarkMode ? Colors.black : Colors.white,
      }}
    >
      <Section title="Remote Module">
        <Text>
          This module is loading dynamically for execution and is not shipped with the app. It is a remote module.
        </Text>
      </Section>
      <Section title="Details">
        <Text>
          This will not be part of app's initial bundle size. This will be loaded in app after consuming network
          bandwidth.
        </Text>
      </Section>
    </View>
  );
}
export default RemoteModule;

Once our modules are ready, we will set up our webpack configs, app configs, and root files to enable dynamic loading of these modules.

On the app configs side, we will define modules that we want to load dynamically. For our example we will define the following modules:

// app.json
{
  "localChunks": ["src_modules_LocalModule_LocalModule_tsx"],
  "remoteChunks": ["src_modules_RemoteModule_RemoteModule_tsx"],
}

Here, localChunks are the modules that are shipped with the app and remoteChunks are the modules that are loaded dynamically. These modules are passed to webpack configs to enable dynamic loading of these modules.

On the webpack configs side we will define entry points for our app. For our example we will define the following entry points:

// webpack.config.mjs#222
new Repack.RepackPlugin({
  ...
  extraChunks: [
    {
      include: appJson.localChunks,
      type: 'local',
    },
    {
      include: appJson.remoteChunks,
      type: 'remote',
      outputPath: path.join('build/output', platform, 'remote'),
    },
  ],
  ...
}),

Here we have defined two extra chunks beside the main bundle — one for local module and one for remote module. We have also defined an output path for remote chunks. This is where remote chunks will be saved at the end of the build process.

On the root file side, we will define how we want to load these modules. For our example, let’s define the following root file:

// index.js
import { AppRegistry, Platform } from 'react-native';
import { ScriptManager, Script } from '@callstack/repack/client';
import App from './src/App';
import { name as appName, localChunks, remoteChunkUrl, remoteChunkPort } from './app.json';

/**
 * We need to set storage for the ScriptManager to enable caching. This enables us to avoid downloading the same script multiple times.
 */
import AsyncStorage from '@react-native-async-storage/async-storage';
ScriptManager.shared.setStorage(AsyncStorage);

/**
 * We need to set a resolver for the ScriptManager to enable loading scripts from the remote server.
 * The resolver is a function that takes a scriptId and returns a promise that resolves to a script object.
 * The script object has the following shape:
 */
ScriptManager.shared.addResolver(async (scriptId) => {
  // For development we want to load scripts from the dev server.
  if (__DEV__) {
    return {
      url: Script.getDevServerURL(scriptId),
      cache: false,
    };
  }

  // For production we want to load local chunks from from the file system.
  if (localChunks.includes(scriptId)) {
    return {
      url: Script.getFileSystemURL(scriptId),
    };
  }

  /**
   * For production we want to load remote chunks from the remote server.
   *
   * We have create a small http server that serves the remote chunks.
   * The server is started by the `start:remote` script. It serves the chunks from the `build/output` directory.
   * For customizing server see `./serve-remote-bundles.js`
   */
  const scriptUrl = Platform.select({
    ios: `http://localhost:${remoteChunkPort}/build/output/ios/remote/${scriptId}`,
    android: `${remoteChunkUrl}:${remoteChunkPort}/build/output/android/remote/${scriptId}`,
  });

  return {
    url: Script.getRemoteURL(scriptUrl),
  };
});

/**
 * We can also add a listener to the ScriptManager to get notified about the loading process. This is useful for debugging.
 *
 * This is optional and can be removed.
 */
ScriptManager.shared.on('resolving', (...args) => {
  console.log('DEBUG/resolving', ...args);
});

ScriptManager.shared.on('resolved', (...args) => {
  console.log('DEBUG/resolved', ...args);
});

ScriptManager.shared.on('prefetching', (...args) => {
  console.log('DEBUG/prefetching', ...args);
});

ScriptManager.shared.on('loading', (...args) => {
  console.log('DEBUG/loading', ...args);
});

ScriptManager.shared.on('loaded', (...args) => {
  console.log('DEBUG/loaded', ...args);
});

ScriptManager.shared.on('error', (...args) => {
  console.log('DEBUG/error', ...args);
});

/**
 * We need to register the root component of the app with the AppRegistry.
 * Just like in the default React Native setup.
 */
AppRegistry.registerComponent(appName, () => App);

This makes our app ready to load remote modules. We can now run our app and see the results. Because modules are loaded from the dev server in debug mode, it’s not much different from the default setup. But in production mode, we can see that remote modules are created beside the main bundle and are loaded dynamically.

For a better understanding, we created a release APK and placed it under the APK analysis tool in Android Studio. We can see that the local module is not part of the main bundle while the remote module is nowhere inside the APK; rather, it’s created in the build/output/android/remote directory in our app’s repo:

Bundle Files Created Under Assets Directory

Remote Bundles Are Generated In The Build Folder

We started bundle serving the HTTP server for testing purposes and tested our app in production mode. We can see that the remote module is loaded from the HTTP server:

WhatsApp Video 2022-12-26 at 3.27.11 PM.mp4

Dropbox is a free service that lets you bring your photos, docs, and videos anywhere and share them easily. Never email yourself a file again!

Multiple entry points

One of the main advantages of webpack is that it allows us to create multiple entry points for our app. This is useful for large-scale and Brownfield apps where we want to split our app into multiple bundles.

Especially for Brownfield apps where React Native is powering certain features, each feature can be treated as a separate bundle with certain native dependencies shared across them. This section will show how we can use Re.pack to create multiple entry points for our app.

In our app, we have created a smaller and simpler entry point, Bitsy; it’s loaded from a different entry point, /bitsy.js.

We updated the webpack config as follows:

    // webpack.config.js#L70
    entry: [
      ...
      path.join(dirname, 'bitsy.js'),
      ...
    ]

To launch Bitsy from the native side, you can update MainActivity.java as follows:

    // MainActivity.java#L15
    @Override
    protected String getMainComponentName() {
        return "RepackBitsy";
    }

Or AppDelegate.m as follows:

    // AppDelegate.m#L47
    UIView *rootView = RCTAppSetupDefaultRootView(bridge, @"RepackBitsy", initProps);

Running the app will launch the Bitsy module instead of the RepackDemo app.

Conclusion

webpack has been around in frontend development since the start of the past decade. We have seen many plugins developed around it to solve complex problems in bundling and optimization areas for large-scale apps. Bringing all that power to React Native will help us easily maintain large-scale mobile apps. This can also help RN apps become more secure and performant.

Check out this sample project with Re.pack. You might have to tweak the webpack configs for your projects to ensure you get the optimal results. Please refer to the comments in the configs file and webpack docs for details on each option.

LogRocket: Instantly recreate issues in your React Native apps.

LogRocket is a React Native monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your React Native apps.

LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.

Start proactively monitoring your React Native apps — .

Zain Sajjad Head of Product Experience at Peekaboo Guru. In love with mobile machine learning, React, React Native, and UI designing.

Leave a Reply