Sharing TypeScript Code Between Web and React Native

We’re building a hybrid mobile app out of an existing web app. The front end will run out of a webview, and we’re adapting the back end to run on-device in [React Native].

One of the challenges we encountered was how to share code between our existing [monorepo] built with [webpack] and our new React Native app (which bundles with [Metro]).

Though it’s all [TypeScript], this wasn’t super straightforward. In this post, I’ll describe how we handled it.

## Plan A
Let’s call the existing web app _web-monorepo_ and the new React Native app _offline-rn-app_.

We initially attempted to reference sources directly, so that from _offline-rn-app_, you could `import { foo } from “web-monorepo/bar”;`. We encountered a lot of friction with this approach. For example:

  • Though React Native now has TypeScript support out of the box (😃), it uses Babel’s built-in TypeScript handling, which has some known [limitations][babel typescript limitations], and it couldn’t handle some patterns in our existing code (😩).
  • Our application code uses a handful of webpack-specific features, like requiring .graphql or .yml files with [special][graphql-tag] [loaders][yaml-loader], and importing whole directories of files with [require.context].

With enough time and research, it would probably be possible to work around issues like these, but it felt like we were swimming upstream. As we struggled with the particulars of our webpack configuration, we thought, “Why not let webpack do its thing, and then consume its output?”

## Plan B
We already had two webpack [entry points] in the _web-monorepo_ project: one for the React front end, and one for the Express backend. Our idea was is to add a third, producing a library that can be consumed by the React Native app.

This way, we get the real TypeScript compiler plus all of the webpack loaders, and a bunch of dead code can get [shaken][tree shaking] out.

Spoiler: It worked.

## Details
Here are the important parts of the configuration we’ve settled on.

### package.json
The first step toward building an installable library out of our existing Node project was to drop a few additions in its package.json:


// package.json
"main": "lib/index.js",
"types": "lib/entry/mobile-server.d.ts",
"files": [
  "lib/*"
],
"scripts": {
  "build:library": "webpack --config ./webpack/library.config.js"
}

The “main” and “types” fields are used by the consuming app upon installing our package. The “files” globs [specify][package-json-files] what we want consuming apps to get when they install the library. Lastly, the “build:library” convenience script leads us to our new webpack config.

### Webpack and tsconfig
Once we declared the files we intend to distribute, it was time to build them. Here’s the new webpack config:


// webpack/library.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: {
    "mobile-server": "./entry/mobile-server.ts"
  },
  devtool: "source-map",
  output: {
    path: path.resolve(__dirname, "../lib"),
    filename: "index.js",
    library: "my-lib",
    libraryTarget: "umd"
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: [
          {
            loader: "ts-loader",
            options: {
              configFile: "tsconfig.library.json"
            }
          }
        ]
      }
    ]
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js"],
    modules: [path.resolve(__dirname, "../modules"), "node_modules"]
  },
  externals: ["cheerio", "config"]
};

From an input of `entry/mobile-server.ts`, this produces the `lib/index.js` that package.json’s _main_ is expecting and a source map to go with it.

While most third-party code is bundled in, we can add [externals] for packages that we want the consuming app to provide. (More on this later.)

We’ve also supplied a custom tsconfig:


// tsconfig.library.json
{
  "extends": "./tsconfig",
  "compilerOptions": {
    "module": "es6",
    "target": "es5",
    "allowJs": false,
    "noEmit": false,
    "declaration": true,
    "declarationMap": true,
    "lib": [],
    "outDir": "lib",
    "resolveJsonModule": true
  },
  "include": ["entry/mobile-server.ts"],
  "exclude": ["node_modules", "dist", "lib"]
}

There’s nothing too interesting here except for that empty [lib][tsconfig-lib] list, which prevents the library build from inadvertently using APIs that aren’t available in React Native, like browser DOM or Node.js filesystem access.

With these in place, we can now `yarn build:library` to produce `lib/`.

### Packaging
We don’t intend to publish our new library to a package repository, so we’ll need to reference it via one of the other patterns you can [yarn add], like a file path or a GitHub repo.

After a little experimentation, we settled on producing a tarball with `yarn pack`. This makes for a nice single artifact to share between jobs in our [CircleCI workflow].

### Consuming the library
From _offline-rn-app_, we reference the tarball like this:


// package.json
"my-lib": "../web-monorepo/my-lib-1.0.0.tgz"

On the React Native side, using this feels about like using any other third-party library.

Recall the “externals” specified in the webpack config above? Some of the code we’re sharing depends on libraries that aren’t quite compatible with React Native. We may eventually migrate away from them, but for now, we have a decent workaround.

On the library side, we externalize the problematic modules to make the consuming app deal with it.

In the React Native app, we deal with it by swapping in alternate implementations. To do this, we added [babel-plugin-module-resolver], which allows you to alias modules arbitrarily:


// babel.config.js
module.exports = {
  presets: [
    "module:metro-react-native-babel-preset",
    "@babel/preset-typescript"
  ],
  plugins: [
    [
      require.resolve("babel-plugin-module-resolver"),
      {
        alias: {
          cheerio: "react-native-cheerio",
          config: "./src/mobile-config.ts"
        }
      }
    ]
  ]
};

..and voila! We have code from our Express server running in React Native.

## Future Improvement: Editor Experience
One rough patch I hope to smooth out in the future is the editor experience when working in the monorepo. VS Code only knows about _one_ of our tsconfigs. So when I’m editing `foo.ts`, I’ll get squiggles according one of my build targets, but I may introduce errors that I won’t see until next time I compile the other target from the command line.

Another tradeoff we made with the move to Plan B is that we can no longer [F12] from _offline-rn-app_’s sources into _web-monorepo_’s; instead, when you _go to definition_ across the boundary, you land on the library’s type definitions. Could source maps improve on this?

## Conclusion
Our solution involves a couple of compromises and a fair amount of complexity, but overall, this approach is working well.

Have you shared code between browser, server, and mobile? How’d it go?

[target]: https://webpack.js.org/configuration/target/
[React Native]: https://facebook.github.io/react-native/
[webpack]: https://webpack.js.org/
[TypeScript]: https://www.typescriptlang.org/
[monorepo]: https://en.wikipedia.org/wiki/Monorepo
[Metro]: https://facebook.github.io/metro/
[entry points]: https://webpack.js.org/concepts/#entry
[F12]: https://code.visualstudio.com/docs/editor/editingevolved#_go-to-definition
[babel typescript limitations]: https://babeljs.io/docs/en/next/babel-plugin-transform-typescript.html#caveats
[graphql-tag]: https://github.com/apollographql/graphql-tag#webpack-preprocessing-with-graphql-tagloader
[yaml-loader]: https://github.com/okonet/yaml-loader
[require.context]: https://webpack.js.org/guides/dependency-management/#requirecontext
[tree shaking]: https://webpack.js.org/guides/tree-shaking/
[externals]: https://webpack.js.org/configuration/externals/
[ts-loader]: https://github.com/TypeStrong/ts-loader
[tsconfig-lib]: https://github.com/Microsoft/TypeScript/wiki/What’s-new-in-TypeScript#including-built-in-type-declarations-with—lib
[babel-plugin-module-resolver]: https://github.com/tleunen/babel-plugin-module-resolver
[rn-cheerio]: https://github.com/leon-4A6C/react-native-cheerio#readme
[cheerio]: https://github.com/cheeriojs/cheerio
[yarn add]: https://yarnpkg.com/lang/en/docs/cli/add/
[CircleCI workflow]: https://circleci.com/docs/2.0/workflows/
[package-json-files]: https://docs.npmjs.com/files/package.json#files

Conversation
  • Michal Murawski says:

    “instead, when you go to definition across the boundary, you land on the library’s type definitions. Could source maps improve on this?”

    Just a question: did you try yarn workspaces? Seems like it should do the job for that case. Good job ;)

  • Comments are closed.