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:
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.
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.
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.
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.
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
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
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” } }
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.
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
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.
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:
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:
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
Shared with Dropbox
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.
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 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 — try LogRocket for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowCompare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.