Code Sharing with React Native & Webpack

by Łukasz Nawrocki

Time is a crucial factor in modern software development – clients need features delivered in a flash. Time – we could always do with more of it. As software engineers, though, we are often faced with such situations and have developed strategies to better use time. We work to reduce the amount we spend on unnecessary tasks. On repetitive work. On duplication of effort.

One target of such efforts is code duplication – something of particular relevance for multi-platform applications.

Code sharing is a technique that enables applications to reuse the same code across desktop, web, and mobile platforms. When done successfully, an application does not sacrifice its native look and feel on each platform. As it’s a large topic, I will limit the discussion in this post to sharing a single component across multiple platforms. Also, in reality, it’s a more likely scenario – one where we want to add a shareable component to a long-running project with an established codebase.

A conservative approach to code sharing would be to share business logic, for example, a set of JavaScript functions in a common Node module, and develop the UI of the application separately. However, this approach would not fulfil our primary objective. We would still need to implement the UI from scratch, using two different technologies, making one developer create the same feature twice, or even worse – engaging two developers for the same job.

Instead, I’ll go with a strategy based on the React Native framework that has more code sharing potential. It enables developers to create mobile applications in JavaScript and deliver them as native applications with a native look and feel.

But the best feature of React Native is yet to be fully utilised in the programming world. It is the package called React Native Web that allows web developers to use React Native components and run on the web using React DOM. React Native Web is widely used in the React Native community and makes it easy to reuse components used to create mobile React Native applications in ReactJS web apps. Let’s create an example component and see what it takes to “learn once, write everywhere”.

Project Structure

In this post we’ll only be talking about a single component. In the real world, it would likely be part of a larger component library. We’ll be using TypeScript, but there’s nothing preventing you from using JavaScript should you wish – obviously you’d need to change the configuration and webpack loaders though. Apart from including typings and creating a need for tsconfig.json file, this assumption also matters when it comes to choosing appropriate webpack loaders.

Let’s create the main structure of our project:

-src/
-web/
-package.json
-tsconfig.json
-webpack.config.js

The UI and login of our shareable component will sit in the src directory. We create a simple round button which will accept onPress callback and imageSource as properties:

src/components/Button.tsx:

import React from "react";
import { TouchableOpacity, StyleSheet, ImageSourcePropType } from "react-native";

Interface ButtonProps {
  onPress: () => void
  imageSource: ImageSourcePropType
}

export default function Button({ onPress, imageSource }: ButtonProps) {
  return (
    <TouchableOpacity style={styles.button} onPress={onPress}>
      <Image
        style={styles.image}
        source={imageSource}
      />
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  container: {
    width: 56,
    height: 56,
    borderRadius: 28,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "#fff",
  },
  image: {
    width: 28,
    height: 28,
  },
});

We will keep all the components in the src directory. This way, every time someone installs the package, the code will appear in node_modules of the mobile React Native app. The other thing we could do would be to build it into a separate lib directory, for example with the tsc command, but we’re not going to do that here today.

The other important directory in the project tree is web. This is a place where we will keep the JavaScript component bundle. This is important because we can’t rely on React Native and React Native Web being installed in the React app, so in order to use our component in a web application we should have a pure React version of it prepared. How to make this bundle is discussed in the next section – for now it is important to note we will have a separate directory for the web.

We should also have a way to differentiate between the web, bundled version and React Native version of the component when importing from our library. We will do it by differentiating import paths. For web applications, we will be using /web directory to import, as presented below. For the React Native we will use the root directory represented by the name of the library. We should not have one entry point for it, because that will bring a risk of importing the bundle in React Native or importing React Native version in the React app. This is why inside the web directory we will add a new package.json file:

web/package.json:

{
  "main": "build/bundle.web.js",
  "types": "build/src/index.d.ts"
}

bundle.web.js file is the file with React bundle and the entry point to our components’ library from within the web application. Because we have put package.json inside the web directory, if we want to use the Button component in the React web application we will import it like:

/**
* importing from build/bundle.web.js under the hood
*/

import { Button } from 'our-components-library/web'

While in React Native app it will simply be:

import { Button } from 'our-components-library'

Except from src and web directories, in the project root we also defined three files:

  1. package.json as the main configuration file of the whole library, containing dependencies, scripts, entry point for React Native etc.
  2. tsconfig.json with definitions of typescript rules we use in the app
  3. webpack.config.js with settings for bundling the code for web usage.

We will now take a closer look at the last file as bundling for the web is one of the focal points of the whole process of creating sharable components.

Bundling React Native for web

To bundle the code I will use the most common solution by far – Webpack. In order to use it we first need to install a couple of modules. Depending on preference you could use Yarn or NPM, I will use Yarn in the examples. We install to dev dependencies using command:

yarn add --dev webpack webpack-cli

Because we have installed the webpack-cli package, we can now use the webpack command to bundle our code. Let’s do it and also let’s add a script to package.json and name it accordingly so other developers can easily identify the purpose of it.

package.json:

"scripts": {
   "build-react": "webpack"
 },

Now, the most important part of the bundling process – we need to define configuration file, so the Webpack knows how to translate our React Native code into a React bundle. Let’s first take a look at the whole config and go through it section-by-section later.

webpack.config.js:

const path = require("path");
 
module.exports = {
 entry: path.join(__dirname, "src/index.ts"),
 output: {
   filename: "bundle.web.js",
   path: path.join(__dirname, "/web/build"),
   library: "our-components-library",
   libraryTarget: "umd",
   umdNamedDefine: true,
 },
 module: {
   rules: [
     {
       test: /\.(ts|tsx)?$/,
       include: path.resolve(__dirname, "src"),
       exclude: /node_modules/,
       use: [
         {
           loader: "babel-loader",
           options: {
             cacheDirectory: true,
             presets: ["module:metro-react-native-babel-preset"],
             plugins: ["react-native-web"],
           },
         },
         {
           loader: "awesome-typescript-loader",
           options: {
             configFileName: "tsconfig.json",
           },
         },
       ],
     },
     {
       test: /\.(gif|jpe?g|png|svg)$/,
       use: {
         loader: "url-loader",
         options: {
           name: "[name].[ext]",
           esModule: false,
         },
       },
     },
   ],
 },
 resolve: {
   extensions: [".ts", ".tsx", ".js", ".jsx"],
   alias: {
     "react-native$": "react-native-web",
   },
 },
 externals: {
   react: {
     commonjs: "react",
     commonjs2: "react",
     amd: "React",
     root: "React",
   },
   "react-dom": {
     commonjs: "react-dom",
     commonjs2: "react-dom",
     amd: "ReactDOM",
     root: "ReactDOM",
   },
 },
};

First thing we need to define for Webpack is the input file. This is where we export all the components in our library, for example:

src/index.ts:

import Button from "./components/Button";
 
export { Button };

In the next section, we define what should be the result of the bundling process:

output: {
   filename: "bundle.web.js",
   path: path.join(__dirname, "/web/build"),
   library: "our-components-library",
   libraryTarget: "umd",
   umdNamedDefine: true,
 },

Filename and path are self–explanatory, but it’s worth remembering that the path to the final bundle and its name must be the same as what’s defined in web/package.json, otherwise we won’t be able to import components of the library correctly as they won’t be found. Setting library name and libraryTarget to “umd” and umdNamedDefine allows us to import components from the library individually.

In the module section one needs to define all the loaders used to build particular file types. While awesome-typescript-loader and url-loader probably sound familiar as they are often used with React applications, I’d like to focus on configuration for babel-loader. Two settings are worth mentioning here – we need to use metro-react-native-babel-presets to obtain Babel presets for our React Native components, and we also add react-native-web Babel plugin. The plugin basically rewrites paths to import only the modules needed by the app. That allows us to vastly reduce the size of the result bundle.

Size concern is also the purpose for externals part of the config:

react: {
     commonjs: "react",
     commonjs2: "react",
     amd: "React",
     root: "React",
   },
   "react-dom": {
     commonjs: "react-dom",
     commonjs2: "react-dom",
     amd: "ReactDOM",
     root: "ReactDOM",
   },
 },

Thanks to this configuration, libraries mentioned are not bundled into the final file. Without it, we could have the whole React framework bundled and passed between packages. Because React and ReactDOM have to be installed in the parent project of our components library anyway, it wouldn’t make any sense to have it in the bundle.

Finally, we instruct webpack to use React Native Web library whenever we import something from react-native. This is obtained with alias configuration:

resolve: {
   extensions: [".ts", ".tsx", ".js", ".jsx"],
   alias: {
     "react-native$": "react-native-web",
   },
 },

Full list of React Native modules supported by React Native Web is available in it’s documentation.

After running the build-react script, we obtain a nicely compressed bundle of about 80kB. Thanks to web/package.json configuration, it is enough to import from the bundle like described before:

import { Button } from 'our-components-library/web'

Summary

Module bundlers allow us to develop React Native Web and React Native component libraries that can be shared across web and native mobile applications. In the example presented, we used the webpack bundler, but could have used an alternative such as rollup.

It’s also worth noting the way presented above is still a bit complex because it requires a lot of manual setup. A vast effort has been recently put into development of React Native and React Native Web libraries, together with an awesome React Native dev environment called Expo, which makes building React Native apps even simpler. Taking that into account, we can expect even more examples of cross-platform development in the upcoming years.