Lazy loading React Native components from a server
Sarath Kumar CM
Posted on March 11, 2021
TL;DR
What if we could add placeholders in our apps that can display content fetched from a server; and what if we're able to build and host those content at the server as react-native components?
This would mean we could push new, feature-rich content to our apps without pushing app store updates. We could create dynamic home pages that change over short time periods. We could change the look and feel of the whole app to match the ongoing festival mood. We could even launch new journeys and experiences in the app without waiting for app reviews and app store approvals.
Below is a method I put together to achieve this. The idea is rather simple and straightforward, and I hope you like it.
Foreword
If you have ever looked into implementing always changing home pages in mobile apps, you would have come across the term Server Driven UI or Server Driven Rendering. It's a mechanism to render the pages in apps using configuration stored in a server.
In simple words - we define basic building blocks of the UI in the app, create a JSON configuration object describing the layout and building blocks of the page to render, fetch the configuration from the app, and render the layout with the components corresponding to the configuration.
Most implementations use a JSON configuration, and some use HTML or JSX renderers to push new components that are not present in the app.
SDUI is great, and helps lots of apps deliver a great user experience, often tailored for the logged in user. However, the UI rendered using this technique can usually only have predefined behavior, and to change that we'll need to push app updates. It'll also need us to learn the semantics of creating the configuration, which very well could evolve into a complex language in case of advanced and more capable frameworks.
Here I will describe a way to write UI components using a language we already know - react native, fetch them on-demand from a server, and render them in a react native app. Using this method, we can provide dynamic experiences within the app using full-fledged react (native) components and without pushing app store updates.
There are services like Microsoft Codepush that provide Over The Air app updates without pushing app store updates. You're probably better off with those if you are just looking for a way to provide updates to your app.
What's about to follow is purely a hobby exercise, and not fine-tuned to be used in production just yet.
This post was published on my blog. If you wish, you can continue reading there ➔.
Step 1: React.lazy and Suspense
React has already provided us with components that help with lazy loading. React.lazy
and React.Suspense
.
React.lazy
takes in a promise that will resolve to a component function (or class) and returns a component that can be rendered within a <React.Suspense>
component. These components were introduced to support dynamic imports, as shown below.
import React, { Suspense } from 'react';
const Component = React.lazy(() => import('./HeavyComponent'));
function MyComponent() {
return (
<div>
<Suspense fallback={null}>
<Component />
</Suspense>
</div>
);
}
Even though React.lazy
is supposed to be used with dynamic imports, it supports just about any Promise that resolves to a react component. This fits perfectly with our needs. As our components are going to be stored in server, and fetching and parsing them should give us a promise that resolves to a react component.
Let's abstract fetching and parsing of remote components into a method called fetchComponent
and try to write a DynamicComponent
that renders it.
import React, { useMemo, Suspense } from 'react';
import { Text, View } from 'react-native';
const DynamicComponent = ({ __id, children, ...props }) => {
const Component = useMemo(() => {
return React.lazy(async () => fetchComponent(__id))
}, [__id]);
return (
<Suspense fallback={<View><Text>Loading...</Text></View>}>
<Component {...props}>{children}</Component>
</Suspense>
)
};
export default React.memo(DynamicComponent);
Here, I chose to name the __id prop with underscores to make sure they don't conflict with the properties of the actual component fetched from the server. We also memoize the component based on the value of __id because we don't want to re-fetch the component on every render from the server.
Step 2: Fetching and parsing the remote components
Luckily for us, JavaScript comes with eval
, so we don't have to write our own parser for parsing source code of remote components. People generally have reservations about using eval
, rightly so. However I see this as one of those occasions where it's fine to use it, and instead of using eval
, we'll use its cousin - the Function
constructor - to be safer from unforeseen bugs.
There are still hurdles though.
- Javascript doesn't understand JSX. Solution is to use a module bundler and babel to transform JSX to javascript code and bundle everything together. We'll do this in step 3.
- We'll need to use a module loader to evaluate the bundle and give us the exported component. We'll write our own basic
require
function. - There has to be exactly one instance of
React
in our application (and the same might be true for some of the other libraries we use), therefore we'll need to specify all packages in node_modules as external dependencies while building the remote components. Then we need a way to provide instances of these packages to the remote components from the App's code. Since we're going to write our own version ofrequire
, we'll write it in a way to make this possible.
Below is a version of require
function I found here, which I tweaked to our needs.
function getParsedModule(code, moduleName) {
const _this = Object.create({
"package-a": () => A // provide packages to be injected here.
});
function require(name) {
if (!(name in _this) && moduleName === name) {
let module = { exports: {} };
_this[name] = () => module;
let wrapper = Function("require, exports, module", code);
wrapper(require, module.exports, module);
} else if (!(name in _this)) {
throw `Module '${name}' not found`
}
return (_this[name]()).exports;
}
return require(moduleName);
}
We can inject packages from our app into the remote components by defining them as properties of _this
. Please note that each property of _this
is a function, since I did not want to load un-necessary modules which are not needed by the remote components.
Now in-order to inject the packages, we need to create a file called packages.js
in the app and write code as shown below.
import React from "react";
import ReactNative from "react-native";
import * as ReactRedux from "react-redux";
import * as ComponentsFromMyApp from "./components-from-my-app"
const Packages = {
"react": () => ({ exports: React }),
"react-native":() => ({ exports: ReactNative }),
"react-redux": () => ({ exports: ReactRedux }),
"components-from-my-app"; () => ({ exports: ComponentsFromMyApp }),
}
export default Packages
For convenience, I have included only few packages, but ideally this should contain all packages from the app's package.json dependencies, and this file should be auto-generated during the build step.
Notice that we have provided a components-from-my-app
, which are custom components from our app that we want to use in the remote components.
Now, we can tweak our getParsedModule
function to accept a packages argument, and pass the object export from packages.js
file.
function getParsedModule(code, moduleName, packages) {
const _this = Object.create(packages);
function require(name) {
if (!(name in _this) && moduleName === name) {
let module = { exports: {} };
_this[name] = () => module;
let wrapper = Function("require, exports, module", code);
wrapper(require, module.exports, module);
} else if (!(name in _this)) {
throw `Module '${name}' not found`
}
return (_this[name]()).exports;
}
return require(moduleName);
}
It's time to write our fetchComponent
function now, which is fairly straight-forward at this point. For convenience I'm going to hard-code the URL in the code itself. Since I'll be hosting the server in my laptop, I've used the host system's IP address when testing in android simulator. There is also a time query string added to the URL, to avoid caching of the remote components while developing.
import { Text } from "react-native";
import packages from "../packages";
export async function fetchComponent(id) {
try {
const text = await fetch(`http://10.0.2.2:8080/${id}.js?time=${Date.now()}`).then(a => {
if (!a.ok) {
throw new Error('Network response was not ok');
}
return a.text()
});
return { default: getParsedModule(text, id, packages ) };
} catch (error) {
console.log(error)
return { default() { return <Text>Failed to Render</Text> } }
}
}
It's time to setup and write the remote components now.
Step 3: Setting up the remote components project.
I chose rollup as the bundler. The directory structure of the remote components project is very simple, as follows.
. ├── components/
└── hello-world-component.js
├── babel.config.js
├── rollup.config.js
└── package.json
In rollup.config, we need to export an array of configs - one per each remote component bundle - otherwise rollup will extract common code into a common bundle. For our use case, we want everything the component refers in a single bundle file.
Here's my rollup.config.js file:
import babel from 'rollup-plugin-babel'
import commonjs from 'rollup-plugin-commonjs'
import resolve from 'rollup-plugin-node-resolve'
import { terser } from "rollup-plugin-terser";
const fs = require("fs");
const pkg = JSON.parse(require("fs")
.readFileSync(require("path")
.resolve('./package.json'), 'utf-8'));
const external = Object.keys(pkg.dependencies || {});
const allComponents = fs.readdirSync("./components");
const allFiles = allComponents
.filter(a => a.endsWith(".js"))
.map(a => `./components/${a}`)
const getConfig = (file) => ({
input: file,
output: [{ dir: "dist", format: 'cjs' }],
plugins: [
resolve(),
babel(),
commonjs(),
terser()
],
external
})
export default allFiles.map(getConfig)
Instead of defining configs for each entry file, we generate configs for each file in the components folder.
babel.config.js file looks like this:
const presets = [
[
"@babel/preset-env", { modules: false }],
"@babel/preset-react"
]
const plugins = []
plugins.push(["@babel/plugin-proposal-class-properties"])
module.exports = {
presets,
plugins
}
The package.json
file will contain all the packages needed to write the react native components. In fact we can copy the dependencies from our app's package.json
file, so that the remote components will have access to the same packages.
The file looks like this:
{
"name": "remote-components",
"scripts": {
"start": "http-server ./dist",
"build": "rollup --config ./rollup.config.js"
},
"dependencies": {
// copy dependencies from app
},
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.13.0",
"@babel/preset-env": "^7.13.9",
"@babel/preset-react": "^7.12.13",
"babel-core": "^6.26.3",
"babel-plugin-module-resolver": "^4.1.0",
"babel-preset-env": "^1.7.0",
"http-server": "^0.12.3",
"rollup": "^2.40.0",
"rollup-plugin-babel": "^4.4.0",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-terser": "^7.0.2"
}
}
We can now start writing our first remote component.
A counter component example is provided below:
import React, { useState } from 'react'
import { StyleSheet, Text, View, Button } from 'react-native'
const Counter = () => {
const [count, setCount] = useState(0)
return (
<View style={{ margin: 15 }}>
<Text>{count}</Text>
<Button onPress={() => setCount(count+1)} title="Click Me!"/>
</View>
)
}
export default Counter
The code is exactly how we would have written this component in the app, and we can use any libraries available in the app. In theory we can add even new libraries - we just need to tweak the build process and bundle it along with the component.
We can access redux store using either hooks or connect()
. We should be able to access contexts and navigation objects as well.
Once the components are written, we can build them using npm run build
command, and start a development server using npm start
. Once you are ready to deploy, the files in dist
folder can be deployed and served as static files.
Step 4: Add placeholders in the app for dynamic components to render
These components can be placed anywhere and can render anything from a small button on a page to entire pages, or even a stack of pages.
import React, { useState } from 'react';
import ReactNative, { Text, View, Button } from 'react-native';
import DynamicComponent from "./dynamic-component";
export default function App() {
const [show, setShow] = useState(false);
return (
<View className="App">
<Text>Press the button below to load the component</Text>
<Button onPress={() => setShow(!show)} title={show ? "Hide" : "Show"}></Button>
{show && <DynamicComponent __id="counter"/>}
</View>
);
}
Demo
Here's a demo of an App using lazy loaded remote-components. The source code for the demo app and the remote components are available at this github repo
Remarks
This has been a long article. If you are still reading this, I hope you find the article interesting and useful. I haven't done any performance tests on this yet, but I believe there shouldn't be any significant degradation apart from the network and parsing delay during the initial load of the components. Once parsed, we can cache the parsed components in-memory to avoid subsequent fetches and parses.
Let me know how you feel about this technique in the comments. Any suggestions to improve the code and any feedbacks or alternate approaches are always welcome. :-)
Posted on March 11, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 7, 2021