DEV Community

Cover image for Tutorial: How to share code between iOS, Android & Web using React Native, react-native-web and monorepo
Bruno Lemos
Bruno Lemos

Posted on • Edited on

Tutorial: How to share code between iOS, Android & Web using React Native, react-native-web and monorepo

Let's make our react-native app work in the browser, the right way.

This tutorial was made for react-native <= 0.61. If you are using a newer version, I recommend that you fork this repository instead: brunolemos/react-native-web-monorepo, I am keeping it updated 🙌


Why am I writing this?

Hi 👋 I'm Bruno Lemos. I recently launched a project called DevHub - TweetDeck for GitHub and one of the things that caught people's attention was the fact that it is an app made by a single developer and available on 6 platforms: Web (react-native-web), iOS (react native), Android (react native), macOS, Windows and Linux (electron, for now), with almost 100% code sharing between them. It even shares some code with the server! This is something that would require a team of 3+ until a couple years ago.

Since then, I've received dozens of tweets and private messages asking how to achieve the same and in this tutorial I'll walk you through it.

What's react-native-web?

If you are not familiar with react-native-web, it's a lib by Necolas (ex Twitter engineer) to make your React Native code render in the browser. Roughly speaking, you will write <View /> and it will render <div />, making sure all styles render the exact same thing. It does more than that, but let's keep it simple.

The new Twitter was created using this technology and it's awesome.

If you already know react-native, you don't need to learn any new syntax. It's the same API.


Summary

  • Starting a new React Native project
  • Turning our folder structure into a monorepo
  • Making react-native work in a monorepo
  • Sharing code between our monorepo packages
  • Creating a new web project using create-react-app and react-native-web
  • Making CRA work inside our monorepo with code sharing
  • ???
  • Profit

Step-by-step tutorial

Starting a new React Native project

  • $ react-native init myprojectname
  • $ cd myprojectname
  • $ git init && git add . -A && git commit -m "Initial commit"

Note: It's much easier to create a cross platform app from scratch than trying to port an existing mobile-only (or even harder: web-only) project, since they may be using lot's of platform specific dependencies.

EDIT: If you use expo, it seems they will soon have built in support for web!

Turning our folder structure into a monorepo

Monorepo means having multiple packages in a single repository so you can easily share code between them.  It's a bit less trivial than it sounds because both react-native and create-react-app require some work to support monorepo projects. But hey, at least it's possible!

We'll use a feature called Yarn Workspaces for that.
Requirements: Node.js, Yarn and React Native

  • Make sure you are at the project root folder
  • $ rm yarn.lock && rm -rf node_modules
  • $ mkdir -p packages/components/src packages/mobile packages/web
  • Move all the files (except .git) to the packages/mobile folder
  • Edit the name field on packages/mobile/package.json from packagename to mobile
  • Create this package.json at the root directory to enable Yarn Workspaces:
{
  "name": "myprojectname",
  "private": true,
  "workspaces": {
    "packages": [
      "packages/*"
    ],
    "nohoist": []
  }
  "dependencies": {
    "react-native": "0.61.3"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Create a .gitignore at the root directory:
.DS_Store
.vscode
node_modules/
yarn-error.log
Enter fullscreen mode Exit fullscreen mode
  • $ yarn

Making react-native work in a monorepo

  • Check where react-native got installed. If it was at /node_modules/react-native, all right. If it was at /packages/mobile/node_modules/react-native, something is wrong. Make sure you have the latest versions of node and yarn. Also make sure to use the exact same version of dependencies between the monorepo packages, e.g. "react": "16.11.0" on both mobile and components, not a different version between them.

  • Open your favorite editor and use the Search & Replace feature to replace all occurrences of node_modules/react-native/ with ../../node_modules/react-native/.

  • For react-native <= 0.59, open packages/mobile/package.json. Your start script currently ends in /cli.js start. Append this to the end: --projectRoot ../../.

  • Open packages./mobile/metro.config.js and set the projectRoot field on it as well so it looks like this:

const path = require('path')

module.exports = {
  projectRoot: path.resolve(__dirname, '../../'),
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: false,
      },
    }),
  },
}
Enter fullscreen mode Exit fullscreen mode
  • [Workaround] You currently need to add the react-native dependency to the root package.json to be able to bundle the JS:
  "dependencies": {
    "react-native": "0.61.3"
  },
Enter fullscreen mode Exit fullscreen mode

iOS changes

  • $ open packages/mobile/ios/myprojectname.xcodeproj/
  • Open AppDelegate.m, find jsBundleURLForBundleRoot:@"index" and replace index with packages/mobile/index
  • Still inside Xcode, click on your project name on the left, and then go to Build Phases > Bundle React Native code and Images. Replace its content with this:
export NODE_BINARY=node
export EXTRA_PACKAGER_ARGS="--entry-file packages/mobile/index.js"
../../../node_modules/react-native/scripts/react-native-xcode.sh
Enter fullscreen mode Exit fullscreen mode
  • $ yarn workspace mobile start

You can now run the iOS app! 💙 Choose one iPhone emulator and press the "Run" triangle button inside Xcode.

image

Android changes

  • $ studio packages/mobile/android/
  • Open packages/mobile/android/app/build.gradle. Search for the text project.ext.react = [...]. Edit it so it looks like this:
project.ext.react = [
    entryFile: "packages/mobile/index.js",
    root: "../../../../"
]
Enter fullscreen mode Exit fullscreen mode
  • Android Studio will show a Sync Now popup. Click on it.
  • Open packages/mobile/android/app/src/main/java/com/myprojectname/MainApplication.java. Search for the getJSMainModuleName method. Replace index with packages/mobile/index, so it looks like this:
@Override
protected String getJSMainModuleName() {
  return "packages/mobile/index";
}
Enter fullscreen mode Exit fullscreen mode

If you get the Cannot get property 'packageName' on null object error, try disabling auto linking

You can now run the Android app! 💙 Press the "Run" green triangle button inside Android Studio and choose the emulator or device.

image

Sharing code between our monorepo packages

We've created lots of folders in our monorepo, but only used mobile so far. Let's prepare our codebase for code sharing and then move some files to the components package, so it can be reused by mobile, web and any other platform we decide to support in the future (e.g.: desktop, server, etc.).

  • Create the file packages/components/package.json with the following contents:
{
  "name": "components",
  "version": "0.0.1",
  "private": true
}
Enter fullscreen mode Exit fullscreen mode
  • [optional] If you decide to support more platforms in the future, you'll do the same thing for them: Create a packages/core/package.json, packages/desktop/package.json, packages/server/package.json, etc. The name field must be unique for each one.

  • Open packages/mobile/package.json. Add all the monorepo packages that you are using as dependencies. In this tutorial, mobile is only using the components package:

"dependencies": {
  "components": "0.0.1",
  ...
}
Enter fullscreen mode Exit fullscreen mode
  • Stop the react-native packager if it's running
  • $ yarn
  • $ mv packages/mobile/App.js packages/components/src/
  • Open packages/mobile/index.js. Replace import App from './App' with import App from 'components/src/App'. This is the magic working right here. One package now have access to the others!
  • Edit packages/components/src/App.js, replace Welcome to React Native! with Welcome to React Native monorepo! so we know we are rendering the correct file.
  • $ yarn workspace mobile start

Yay! You can now refresh the running iOS/Android apps and see our screen that's coming from our shared components package. 🎉

  • $ git add . -A && git commit -m "Monorepo"

Web project

Note: You can reuse up to 100% of the code, but that doesn't mean you should. It's recommended to have some differences between platforms to make them feel more natural to the user. To do that, you can create platform-specific files ending with .web.js, .ios.js, .android.js or .native.js. See example.

Creating a new web project using CRA and react-native-web

  • $ cd packages/
  • $ npx create-react-app web
  • $ cd ./web (stay inside this folder for the next steps)
  • $ rm src/* (or manually delete all files inside packages/web/src)
  • Make sure the dependencies inside package.json are the exact same between all monorepo packages. For example, update the "react" version to "16.9.0" (or any other version) on both web and mobile packages.
  • $ yarn add react-native-web react-art
  • $ yarn add --dev babel-plugin-react-native-web
  • Create the file packages/web/src/index.js with the following contents:
import { AppRegistry } from 'react-native'

import App from 'components/src/App'

AppRegistry.registerComponent('myprojectname', () => App)
AppRegistry.runApplication('myprojectname', {
  rootTag: document.getElementById('root'),
})
Enter fullscreen mode Exit fullscreen mode

Note: when we import from react-native inside a create-react-app project, its webpack config automatically alias it to react-native-web for us.

  • Create the file packages/web/public/index.css with the following contents:
html,
body,
#root,
#root > div {
  width: 100%;
  height: 100%;
}

body {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
Enter fullscreen mode Exit fullscreen mode
  • Edit packages/web/public/index.html to include our CSS before closing the head tag:
...
<title>React App</title>
<link rel="stylesheet" href="%PUBLIC_URL%/index.css" />
</head>
Enter fullscreen mode Exit fullscreen mode

Making CRA work inside our monorepo with code sharing

CRA doesn't build files outside the src folder by default. We need to make it do it, so it can understand the code from our monorepo packages, which contains JSX and other non-pure-JS code.

  • Stay inside packages/web/ for the next steps
  • Create a .env file (packages/web/.env) with the following content:
SKIP_PREFLIGHT_CHECK=true
Enter fullscreen mode Exit fullscreen mode
  • $ yarn add --dev react-app-rewired
  • Replace the scripts inside packages/web/package.json with this:
"scripts": {
  "start": "react-app-rewired start",
  "build": "react-app-rewired build",
  "test": "react-app-rewired test",
  "eject": "react-app-rewired eject"
},
Enter fullscreen mode Exit fullscreen mode
  • Create the packages/web/config-overrides.js file with the following contents:  
const fs = require('fs')
const path = require('path')
const webpack = require('webpack')

const appDirectory = fs.realpathSync(process.cwd())
const resolveApp = relativePath => path.resolve(appDirectory, relativePath)

// our packages that will now be included in the CRA build step
const appIncludes = [
  resolveApp('src'),
  resolveApp('../components/src'),
]

module.exports = function override(config, env) {
  // allow importing from outside of src folder
  config.resolve.plugins = config.resolve.plugins.filter(
    plugin => plugin.constructor.name !== 'ModuleScopePlugin'
  )
  config.module.rules[0].include = appIncludes
  config.module.rules[1] = null
  config.module.rules[2].oneOf[1].include = appIncludes
  config.module.rules[2].oneOf[1].options.plugins = [
    require.resolve('babel-plugin-react-native-web'),
  ].concat(config.module.rules[2].oneOf[1].options.plugins)
  config.module.rules = config.module.rules.filter(Boolean)
  config.plugins.push(
    new webpack.DefinePlugin({ __DEV__: env !== 'production' })
  )

  return config
}
Enter fullscreen mode Exit fullscreen mode

The code above overrides some create-react-app's webpack config so it includes our monorepo packages in CRA's build step

  • $ git add . -A && git commit -m "Web project"

That's it! You can now run yarn start inside packages/web (or yarn workspace web start at the root directory) to start the web project, sharing code with our react-native mobile project! 🎉

image

Some gotchas

  • react-native-web supports most of the react-native API, but a few pieces are missing like Alert, Modal, RefreshControl and WebView;
  • If you come across a dependency that doesn't work well with the monorepo structure, you may add it to the nohoist list; But avoid that if possible, because it may cause other issues, specially with the metro bundler.

Some tips

  • Navigation may be a bit of a challenge; you can use something like react-navigation which recently added web support or you can try using two different navigators between and mobile, in case you want the best of both worlds by compromising some code sharing;
  • If you plan sharing code with the server, I recommend creating a core package that only contain logic and helper functions (no UI-related code);
  • For Next.js, you can check their official example with react-native-web
  • For native windows, you can try react-native-windows;
  • For native macOS, you can the new Apple Project Catalyst, but support for it is not 100% there yet (see my tweet);
  • To install new dependencies, use the command yarn workspace components add xxx from the root directory. To run a script from a package, run yarn workspace web start, for example; To run a script from all packages, run yarn workspaces run scriptname;

Thanks for reading! 💙

If you like react, consider following me here on Dev.to and on Twitter.


Links

Latest comments (158)

Collapse
 
musthashman profile image
mustHASHman

I wish there was a recent version of this repo.

Collapse
 
darylaranha profile image
Daryl Aranha

I am unable to run android app. When I run yarn android I am getting this error.

FAILURE: Build failed with an exception.

* Where:
Script '<Project Path>/monorepo/node_modules/react-native/react.gradle' line: 34

* What went wrong:
A problem occurred configuring project ':app'.
> Failed to notify project evaluation listener.
   > Couldn't determine CLI location. Please set `project.ext.react.cliPath` to the path of the react-native cli.js
Enter fullscreen mode Exit fullscreen mode

How to update project.ext.react.cliPath path?
Can anyone suggest what changes did I miss?

Collapse
 
darylaranha profile image
Daryl Aranha

I found it. Thank you. I needed to add cliPath inside project.ext.react in app/build.gradle

Collapse
 
aeid profile image
Ahmed Eid

how did you do that, could you please add a screenshot or something ?

Thread Thread
 
darylaranha profile image
Daryl Aranha

I apologies for the late response.
Basically what you need to do is go to mobile/android/app/build.gradle and update the file as show below.

...
project.ext.react = [
    ...
    root: "../../../../",
    cliPath: "../../../../node_modules/react-native"
    ...
]
...
Enter fullscreen mode Exit fullscreen mode

If you want to jump right into the code then you can check out my repo, I have updated the setup.

Collapse
 
shokoofaghods profile image
shokoofa-ghods

hi Bruno , hope you're doing well
I just tried to set up this tutorial step by step but after I added >>config-overrides.js file in packages/web and run yarn start inside packages/web I got this error:

yarn run v1.17.3
$ react-app-rewired start
Cannot read property 'oneOf' of undefined
error Command failed with exit code 1.

Collapse
 
varungupta85 profile image
Varun Gupta

I am able to run this setup in Dev mode on both ios and android but when I create a releaze, I get an error saying the shared package is not found because it is a symlink and metro bundler doesn't support symlink yet. Were you able to create release builds? If yes, were there any changes required?

Collapse
 
brunolemos profile image
Bruno Lemos

ios or android? which react-native version? what's your operating system? try using the latest react-native version and maybe also try using the yarn resolutions feature to force it use the latest version of @react-native-community/cli which as of today is 5.0.1-alpha.0.

Collapse
 
varungupta85 profile image
Varun Gupta

So, I have a mobile app in production on iOS and Android that we plan to provide over the browser also. To reuse the logic code in the web app, I am putting those in shared packages managed using the yarn workspaces. Initially, I was using react-native 0.63.3 which uses react 16.13.1 for the mobile app. When I created the web app, it was using react 17.0.1. There were a couple of invalid hook call errors which happen due to conflicting versions of react. I tried no-hoisting react and react-native specific packages but that resulted in some packages under root node modules and some under the package node_modules which becomes problematic during auto-linking. So, I ended up upgrading react-native to 0.64.0-rc.1 which uses react 17.0.1. I was able to build and run the app on both iOS and Android but when I created the release build, I got some errors mainly around not able to find the shared packages since they are symlinks and metro doesn't support symlinks. I read about some workaround by providing extraNodeModules and projectRoots in metro.config.js but I kept on getting one error or the other.

I wanted to ask if you made any special changes to create release builds for your app and what are those?

After banging my head for the last few days to convert the existing app to the mono-repo, I started out creating a brand new app in a mono-repo structure, have a dummy shared package and create release builds from it and then port my code over. I am getting the below error on iOS while creating an archive. I am able to run the app in dev mode:

error: Build input file cannot be found: '/Users/varungupta/Projects/galarm-combined/node_modules/react-native/Libraries/FBReactNativeSpec/FBReactNativeSpec/FBReactNativeSpec-generated.mm' (in target 'FBReactNativeSpec' from project 'Pods')

This error doesn't appear if I create a new project and create an archive without making it into a mono-repo.

One more thing I wanted to ask is do I need to change the paths in node_modules/react-native/scripts/react_native_pods.rb and react.gradle files as they contain some references to node_modules.

Thread Thread
 
brunolemos profile image
Bruno Lemos • Edited
  1. Do not use nohoist, this causes many problems like the issue with metro you mentioned
  2. Make sure you use the exact same version of the dependencies between the projects (e.g. react "17.0.1", without "^" or "~")
  3. Make sure that all your "node_modules" are empty inside the packages. The only one with dependencies should be the node_modules in the root folder. If they are not empty, you probably have dependencies with different versions. You can use yarn resolutions to fix that.
  4. Set projectRoot in the metro config and change the entry files to packages/mobile/index as mentioned in the article
  5. RN 0.64 is still in beta and has issues. Of the of the issues is this one about FBReactNativeSpec, you need to run "pod install" again every time after you run "yarn".
  6. Yes you need to change all references of node_modules/react-native. Including on podfile and creating a react-native.config.js file

See this repository: github.com/brunolemos/react-native...

Thread Thread
 
varungupta85 profile image
Varun Gupta

Dear Bruno,

Thanks for your response. I am already doing #2 as yarn seem to update the packages without updating package.json if there are minor upgrades available. I was not aware of the yarn resolutions but that is pretty cool I do have some node_modules in my web and mobile folder which I will fix using yarn resolutions.

For setting the project root in metro.config.js, I guess I just need to set it to the dirname like projectRoot: path.resolve(dirname, '.')

For the react-native.config.js file, could you please tell me where and how it is used. I created it based on your blog instructions but I am not sure how it is used.

Also, I checked the devhub repository (great resource btw), it doesn't have a react-native.config.js file or setting a projectRoot in metro.config.js file.

Thanks again for your valuable time.

Collapse
 
lunvjp profile image
Jack

I couldn't get this worked in 3 days so I'm gonna post some of my stuck here.

Collapse
 
sagarmh profile image
sagarmh

Hey Bruno how are you?

I am facing issue

src/index.tsx:3:21 - error TS2307: Cannot find module 'components/src/App'.

import { App } from 'components/src/App'

I have downloaded your code as it is and even tried the above process multiple time but still facing the same issue.

Collapse
 
thienthaitechnology profile image
Thien Thai Technology Co.,Ltd

root package.json should add "," to end of "workspaces":{...}
like this:
{
"name": "myprojectname",
"private": true,
"workspaces": {
"packages": [
"packages/*"
],
"nohoist": []
},
"dependencies": {
"react-native": "0.61.3"
}
}

Collapse
 
pra3t0r5 profile image
Pra3t0r5

Hi man, absolutely amazing tutorial, my dev team managed to get it up an running in no time.
However, we are facing two problems, one is related to babel plugins, no matter what i did, i could not add the @babel/plugin-transform-react-jsx, therefore the web package crashes:

../commons/pages/tabs/tabs.page.js
SyntaxError: /home/praetors/Projects/node/proyectoSembrar/packages/commons/pages/tabs/tabs.page.js: Support for the experimental syntax 'jsx' isn't currently enabled (16:9):

  14 | const dummyTab = () => {
  15 |     return (
> 16 |         <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
     |         ^
  17 |             <Text>Test</Text>
  18 |         </View>
  19 |     );

Add @babel/plugin-transform-react-jsx (https://git.io/vb4yd) to the 'plugins' section of your Babel config to enable transformation.
Enter fullscreen mode Exit fullscreen mode

The other problem comes with react-native-vector-icons: ^7.0.0, we added the:

apply from: "../../../../node_modules/react-native-vector-icons/fonts.gradle"
Enter fullscreen mode Exit fullscreen mode

but we could not get them to render in android, instead it shows the crossed box, here is how we implemented it:

import Icon from 'react-native-vector-icons/Ionicons';

export default function Tabs() {
    return (
        <Tab.Navigator
           //...
            screenOptions={({ route }) => ({
                tabBarIcon: ({
                    focused, color, size
                }) => {
                    let iconName;
                    switch (route.name) {
                        case 'Inicio':
                            iconName = focused ? 'ios-home' : 'ios-home-outline';
                            break;
                    //...
                    }
                    return <Icon name={iconName} size={size} color={color} />;
                }
            })}>
            <Tab.Screen name="Inicio" component={Home} />
             //...
        </Tab.Navigator>
    )
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
andreimelo profile image
Andrei Melo

try this, please see the following :
step

  1. npm install @babel/plugin-transform-react-jsx or yarn add @babel/plugin-transform-react-jsx

  2. go to config-overrides.js : Then add the package installed to plugins...
    please see below,

const fs = require('fs');
const path = require('path');
const webpack = require('webpack');

const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath);

// our packages that will now be included in the CRA build step
const appIncludes = [
resolveApp('src'),
resolveApp('../common/src'),
resolveApp('../../node_modules/@react-navigation'),
resolveApp('../../node_modules/react-navigation'),
resolveApp('../../node_modules/react-native-gesture-handler'),
resolveApp('../../node_modules/react-native-screens'),
];

module.exports = function override(config, env){
config.module.rules.push({
test : /.(js|mjs|tsx|ts)$/,
exclude : /@babel(?:\/|\{1,2})runtime/,
use : {
loader : 'babel-loader',
options : {
babelrc : false,
configFile : false,
compact : false,
// The configration for compilation
presets : [
[
'module:metro-react-native-babel-preset',
], // Add this line,
[
require.resolve('babel-preset-react-app/dependencies'),
{ helpers: true },
],
],
cacheDirectory : true,
plugins : [
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-transform-flow-strip-types',
'@babel/plugin-transform-react-jsx'
[
'module-resolver',
{
alias : {
'^react-native$' : 'react-native-web',
},
},
],
],
},
},
});
return config;
};

thanks

Collapse
 
niteshagarwal_ profile image
Nitesh Agarwal

This JS is not valid (check module-resolver)

Collapse
 
andreimelo profile image
Andrei Melo

paste it to *config-overrides.js

Collapse
 
js08 profile image
js

Hi,

I downloaded the repo, I am able to run the web app, but I have issue with ios
Can you tell me why I am getting the below issue, I am trying to do pod install inside my ios folder

aal003110384:ios reerer89989823$ pod install

[!] Invalid Podfile file: cannot load such file -- /Users/reerer89989823/Desktop/codebase/Shipment/react-native-web-monorepo-master/packages/mobile/node_modules/@react-native-community/cli-platform-ios/native_modules.

# from /Users/reerer89989823/Desktop/codebase/Shipment/react-native-web-monorepo-master/packages/mobile/ios/Podfile:2
# -------------------------------------------
# platform :ios, '9.0'

require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
#

# -------------------------------------------

Collapse
 
meprakashravi profile image
Prakash Ravichandran

Hi Bruno,

Great tutorial. Thanks for sharing.

Is it possible to maintain the each platform code in 3 different repo. Already I am having iOS and Android project. Now i would like to add some modules in react_native and integrate in iOS and Android. The thing is when the iOS and Android are inside the react-native root repo that is working fine. Linking also happening. But when I separate the iOS folder and when try to install the Pod i am getting issue as "cannot find module react_native/cli" in use_native_modules.

Kindly help me.