React Native at Wix — The Architecture II (Deep dive)
Multi-Module Architecture
This blog post is part of a series of blog posts, which aims to shed some light and share from our experience working with React Native at Wix.
In my last post, I wrote about the high-level architecture of our Wix application. I explained how we adjust it to fit Wix organization structure (by using multi-module architecture, improving velocity, and shifting our environment to be Native agnostic), and how that helps us to achieve independent development and enhance the deployment experience for each group of developers.
Armed with this information, we can now dive deep into the code itself and see a practical implementation of such an architecture. We’ve previously outlined a few terms (Modules & Engine), which are used to describe the various pieces in the architecture. This post aims to illustrate some of them and points to relevant examples of code.
To better explain such a complex design, I chose to describe it from the bottom-up. First, we will jump into the Engine implementation, then build an example of a Module, and only afterwards review the main repository of the application.
For the sake of this example, I chose to build a simple blog application based on our react-native-crash-course by Dror Biran (recommended!). The example app will display a list of posts with the ability to view, add, and delete them.
The Engine
My example of an Engine implementation is called react-native-wix-engine
, you can find the full source code here:
https://github.com/wix-incubator/react-native-wix-engine
It’s important to note that this is not the same Engine that’s used in the Wix application and it may not be the Engine you want to use — it’s just an example to help you better understand how to create your own.
To begin our journey, let’s create a new react-native project using react-native init
.
Adding Native Libraries
As we discussed before, the Engine of your application should include all of the native implementation and the dependencies to 3rd party native libraries. Most likely things will differ from one application to another.
This is the point where you need to choose the libraries that will be the foundation of your application. When it comes to mobile app development using React Native, one of the most important steps is choosing the right navigation library for your project. Navigation serves as the backbone of an app and has a big impact on user experience. In my example, I chose react-native-navigation by Wix to achieve a fully native navigation experience. Another important library I decided to use is react-native-ui-lib — as a UI tool-set & components library.
You can add any libraries you want to the Engine, both JS and Native, and they will be available to be used by all the Modules in your application. If you install a Native library, remember to link it to native projects.
Pre-Built Binaries
The exciting part of the Engine is the pre-built binaries that are published to NPM together with the JS codebase of the Engine, and which are then used by Modules.
First, let’s see which files need to be built and published:
Android:
.APK
file with debug symbols.APK
file for release
iOS:
.APP
file with debug symbols — built for Simulator (x86).APP
file with debug symbols — built for a real iOS device (ARM).APP
file for release — built for a real iOS device (ARM)
In order to allow developers to debug applications with simulator\device in both release and debug mode, and to publish to the App Store and Google Play, we basically need to build our project 5 times. It means for every new Engine release, we should build all of these variations and publish them as an NPM package (here I chose to save the binaries in the same Engine NPM package, but it can be more efficient to save them in separate packages, and download only when required).
To build our Engine:
npm run build
You can review the full implementation of the publish script.
Remember that the pre-built binaries only include the native part, and we should also inject the JS bundle file, which brings us to the next section.
Engine Command Line Interface (CLI)
Among the goals we set was for our architecture and developers’ environment is to be native-agnostic.
Engine CLI is a command-line interface. The main purpose of our CLI is to replace react-native CLI (considering part of it is not relevant anymore) and to bypass the complexity of the Engine structure via a friendly interface for developers.
One example of Engine CLI usage is to install the pre-built on your device\simulator — which is a preferred and much faster option than using Xcode, Android Studio, or call react-native CLI command: react-native run-ios
to build iOS\Android apps from scratch.
rn-wix-engine -run-ios
\rn-wix-engine -run-android
— install the pre-built ios\android binary on all open simulators\devices, open the app, and run a local packager so the app fetches the bundle from it.
By using rn-wix-engine
you can also:
- Install\uninstall pre-built apps
- Run the packager
- Inject bundle file into pre-built release binaries (before publishing the app to store)
- Other utils function from react-native CLI
You can basically add any command you want to the Engine CLI to improve the velocity of developers of Modules.
Project Structure
As you may notice, the structure of this project is a little bit different:
react-native-wix-engine
├── ...
├── inner_folder
│ └── react-native-wix-engine
│ ├── ios
│ ├── android
│ ├── src
│ ├── bin
│ ├── app_builds
│ └── ...
└── ...
The Engine holds the native projects (and binaries), and run locally from the library root folder, but at the same time it is also used by other projects as an NPM package, and so, the project structure can’t stay the same. One of the reasons for that is the location of the node_modules
folder, which is installed by NPM in the root of the project which affect relative path in the code.
My Project
├── ...
├── node_modules
│ └── react-native-wix-engine
│ ├── ios
│ ├── android
│ ├── src
│ ├── bin
│ ├── app_builds
│ └── ...
└── package.json
For example, you can find the build.gradle
file in your Android project which contains the relative path to the react-native package in your node_modules
folder:
apply from: "../../node_modules/react-native/react.gradle"
Since we want to build the native project from both inside and outside the Engine repository, all relative paths to node_modules
folder will fail, because from an external point of view, node_modules
as a relative path moved two levels up, to be:
apply from: "../../../../node_modules/react-native/react.gradle"
We should simulate the same environment and restructure the Engine project structure. There are several ways to handle this challenge, I went with a simple trick and moved the Engine src
folder codebase 2 levels inside (and add symlink to package.json
to the root folder, for convenience)
Now, from either points of view: react-native-wix-engine
root folder and any consumer of this library (installed in node_modules) the source code is the same.
To complete this restructure, we need to fix the relative path of node_modules in the native projects and remember to publish the Engine from the inner folder and not from the root folder.
Module Manager & Module Registry
Communication is basically the heart of your Engine — all traffic between Modules passes through the Engine, and that’s actually the best part here.
Module Manager is responsible for loading all the Modules, making sure their contract is valid, registering all Module APIs into the Module Registry, which holds all of the information about each Module and its contact.
Communication between Modules can be done in 4 ways: Method, Component, Broadcast, and Service. Later on, we’ll see an example of, and will talk more about each of them.
Running the Engine
If we run the Engine without a Module you should see this screen:
npm run start-empty-engine
Next, let’s make our app more interesting and implement a new module.
The Module
You can find the full source code of the Module here: https://github.com/wix-incubator/react-native-wix-engine-playground/tree/main/module-blog
Using the Engine
The Engine provides the infrastructure for your module, it includes all of the native code, so your Module will only contain JS code. All we have to do to bring the Engine into our project is to add it as a dependency to the package.json
file.
Add react-native-wix-engine
to devDependencies
Unlike the main application repo, we want to add the Engine under devDependencies because packages under devDependencies are brought in only for local development and testing, and not for production. If every Module places the Engine under its dependencies, the Engine code will be loaded several times (and maybe in different versions), so we want to avoid these duplications and still be able to work with the Engine locally.
But don’t worry, in our real production environment the main application repo will be the one responsible for bringing in the Engine [read here more about NPM dependencies].
2. Add react-native-wix-engine
to peerDependencies
Having the Engine as a peer dependency means that your package (the Module) needs a certain dependency that the consuming project is responsible for bringing. For example, package X needs react-native code as a dependency, but it doesn’t want to decide for the whole project what react-native version to use. So the consuming project using library X will bring react-native, and in package X — we will define what versions of react-native this package supports under peerDependencies.
Ask the engine to load our module
3. Register as a module inengineConfig.json
For the Engine to load your module you need to create a file called engineConfig.js
. This file should include all the names of the Modules you want to run. In this case, we’re running in the local environment of a Module, so you would probably want to just add your Module and run it alone, with no other modules.
{
"engineConfig": {
"modules": [
"my-module-a"
]
}
}
The name of your Module in this file is the name of your npm package.
In case you have an integration with another module, you can add it as a devDependencies to your
package.json
and also add it to theengineConfig.js
. All the Module API will be available for you in your local environment.
4. Define scripts to run the Engine
Use Engine CLI commands to run your Module environment easily.
Our package.json
is ready:
{
"name": "demo-module-a",
"scripts": {
"android": "rn-wix-engine -p engineConfig.json -a",
"ios": "rn-wix-engine -p engineConfig.json -i"
},
"devDependencies": {
"react-native-wix-engine": "^0.0.1"
},
"peerDependencies": {
"react-native-wix-engine": "*"
}}
Bravo! So far we have loaded the Engine code and we’re ready to run the application. Although it will still be empty.
5. Implement your contract with the Engine
As I mentioned in the previous part, each Module needs to implement an interface as a contract with the Engine and other Modules, or in other words — implement an interface. In order to do so, we will create a file called module.js
.
First, we should set this file as the entry point for the Module by adding this line to your package.json
.
"main": "module.js"
Now, let’s take a look at module.js
for example:
export default class ModuleExampleA { prefix() { return 'module-example-a' } init() { // any initialization code for your module } methods() {
return [
{
id: 'module-example-a.some-method',
generator: () => () => { // do something },
},
];
} components() {
return [
{
id: 'module-example-a.homeScreen',
generator: () => require('./src/homeScreen').HomeScreen,
},
];
} listeners() {
return [
{
id: 'notification-module.newNotificationReceived',
callback: (notification) => {
// handle new notification
}
}]
} registerBroadcasts(register) {
this.sendEventAboutSomethingFunc =
register('module-example-a.sendEventAboutSomething');
} tabs() {
return [
{
id: 'moduleExampleA',
label: 'Home',
screen: 'module-example-a.homeScreen',
icon: require('./home.png'),
selectedIcon: require('./home_selected.png'),
},
];
}
}
Module.js includes various functions, let’s explain some of them.
Methods
Methods are the usual way to expose some functionality for other Modules. Used to pass data, receive data, or for any other reason. Registering a method of a Module is done by implementing a function called Methods()
with name and function generator.
Other Modules can call this method via ModuleRegistry, for example:
const result = engine.moduleRegistry.invoke('some.method.id', param1, param2);const result2 = await engine.moduleRegistry.invoke('some.async.method.id', param1, param2);
engine.moduleRegistry
is a singleton object, used for communication between modules.
Components
Same as methods, a Module can also expose components to be used by other modules.
Getting a component provided by another Module is also done via moduleRegistry
and that’s used in your JSX code.
const otherModuleComponent = engine.moduleRegistry. component('other.component.id');return (
<OtherModuleComponent someProp={'something'}/>
)
Broadcasts
Another way for communication is to publish and subscribe to events. Here, Modules can register to broadcasts, and get information about important updates from other areas of the application.
First, in you want to be a “sender” you need to declare your broadcast in registerBroadcasts
function:
registerBroadcasts(register) {
// register new broadcast
const sendNewNotificationBroadcast =
register('newNotificationRecieved',
'sends a broadcast to listen for notification revceived');// save broadcast function in your module
this.sendNewNotificationBroadcast = sendNewNotificationBroadcast;}
Then, you can call it whenever you want in your flow:
this.sendNewNotificationBroadcast({notificationId: 123'});
For a Module that needs to listen to another Module’s broadcast, you should use moduleRegistry
:
engine.moduleRegistry.registerListener(‘some.module.broadcast, ()=>{// do something});
Tabs
I chose my app to be tab-based, and in order to know which Tabs to add, I need this API to be able to ask Modules if they want to expose any Tabs when the app is opened.
You can add other APIs into your interface, and Modules would then be able to implement via their module.js according to your app requirements.
3. Let’s add some code
Congrats! We are done with all the boilerplate stuff and ready to implement our Module UI and business logic. Your module.js
is only a lobby on the path to your Module code — you can add your code into the src
folder.
Remember, Module can use any JS library, just add it to your dependencies in your
package.json
As a Module developer, you may want to use some native library that is not part of the Engine dependencies. In this case, you should crearta a PR with the new library to the Engine repo for the team to accept, and it will be available in the next Engine release. This method promises double eyes on each 3rd library added to the application, prevents usage of multiple libraries having the same code etc.
4. Run your module
As we defined earlier, we can run the engine with these commands:
npm run ios
npm run android
You can see that our application only includes just the blog Module with a single tab.
Main Application
You can find the full source code of an application example using react-native-wix-engine
here: https://github.com/wix-incubator/react-native-wix-engine-playground/tree/main/app-example
The main application repository contains only 2 files:
package.json
— includes a dependency to your Engine (react-native-wix-engine
in our case) and dependencies for all of your Modules.engineConfig.json
— a list of names of Modules you want the Engine to load. This file is loaded by the Engine and tells it which Modules to load:
package.json:
"dependencies": {
"react-native-wix-engine": "^0.0.2"
"my-module-a": "^0.0.1",
"my-module-b": "^0.0.1"
}
engineConfig.json
{
"engineConfig": {
"modules": [
"my-module-a",
"my-module-b"
]
}
}
That’s all.
You can now install and run the app on iOS Simulator\Android emulator with these simple commands (you can add them to your package.json
):
"scripts": {
"android": "rn-wix-engine -p engineConfig.json -a",
"ios": "rn-wix-engine -p engineConfig.json -i"
}
Finally, with all the Modules included in our application, you can see 2 tabs of 2 modules that communicate with each other.
Remember, you can separate your codebase into Modules in any way you like — by teams, by features, etc.
Let’s briefly examine what’s happening behind the scenes.
rn-wix-engine
CLI searches for the engineConfig.json
file in your root folder, installs the pre-built application (APK\App) on the device\simulator, and starts the application with the given list of Modules.
The main application repository shouldn’t be touched besides adding\removing Modules. All the magic is done by the Engine.
To release your application to the store, you can create a simple script using Engine CLI to inject the bundle into the pre-built binary and send it to App Store and Google Play.
Wrap Up
As you probably now realize, the architecture of your app can seem complex at first glance, but with a deeper understanding, you’ll find it’s not that scary and has its benefits, also allowing you to have a lot of advanced features:
- Modules can run independently and release new versions according to their release process.
- All teams are able to work on and with the same infrastructure.
- Module developers aren’t dealing with native code.
In retrospect, we can’t think of any other way 100+ developers would have been able to work comfortably without breaking each other’s code every day.
Thanks to the great team at Wix Engineering that contributed to this blog post, and for their sharp minds, this couldn’t have been done without them!
If you’d like to get more updates, you’re welcome to follow me on Twitter.
In this series of blog posts, I outline our experience with React Native, share our best practices, and what’s next for us.
Part 3 — The Architecture II (Deep Dive)
For more engineering updates and insights:
- Follow us on: Twitter | Facebook | LinkedIn
- Read our blog
- Visit us on GitHub
- Subscribe to our monthly newsletter
- Subscribe to our YouTube channel
- Follow our Medium publication
- Listen to our podcast on Apple, Spotify or Google