TL;DR combination of react-native and react-native-web can be used on one project to build native and web apps with a common rendering codebase. It doesn’t quite work “out of the box” but it is possible to get there with a handful of tweaks.
I’m looking for a way of delivering native mobile apps for Android, iOS as well as a mobile web client with minimal code duplication.
One candidate was a hybrid application on each platform and most of the functionality delivered in a Webview. I rejected that as it is hard to make the webview feel native without infeasible amounts of wheel re-inventing or putting all of the navigation into the native app so that it becomes huge and defeats the object.
Flutter, a new cross platform mobile project from Google which is still in alpha looks very promising but had to be ruled out because it has no web target currently and in general it is so new that it doesn’t really have much of an ecosystem (read: way too much stuff is currently missing for my needs).
Next try was react-native. Reusable React components could target native mobile using react-native and then build a web based React app for the mobile web target. Researching further I found react-native-web and the picture is complete (well sort of, more on that later!).
So the objective is now to build an app in react-native, using vanilla react-navigation so that it can also be targeted at mobile web via react-native-web. There are of course lots of limitations as react-native-web doesn’t have direct support for many components that exist within react-native, but through the technique of aliasing react-native-web to react-native in a webpack configuration, it looked at least minimally possible.
I did get there, but there are some hurdles along the way. I’ve written this up as I didn’t find any really accessible solutions elsewhere to some of problems that I encountered so figured I may as well seed Google with some answers.
Hopefully the principles will help somebody but the detail of the whole post will quickly become out of date as the modules get revised and issues get fixed so I should declare the environment it applies to:
react-native@0.53.0
react-native-web@0.4.0
react-navigation@1.0.3
react@16.2.0
At this stage I’m simply trying to prove to myself that this is a viable approach before investing any more time in it. I could submit pull requests to the various projects involved (there are several), but I’m a React neophyte, and I doubt that I have come up with the right solutions so I probably wouldn’t be taken seriously.
Getting Started
Yarn seems to work better than npm for really big dependency trees so to install react-native:
yarn global add react-native-cli
react-native init native_plus_web
cd native_plus_web
yarn install
I’m also using react-navigation, and a modal pop-out selector so:
yarn add react-navigation
yarn add react-native-modal-selector
The react-native init command installs a demo App.js in the root of the project. If you have xcode, or Android studio installed and want to check everything is working OK, you could just do the following at this point.
react-native run-ios
react-native run-android
Code
All of the code for the test project is on github at
https://github.com/rjp44/native_plus_web
I’m using a pattern for this application where all of my code sits in an app subdirectory, so I remove App.js from the top level and replace it with an index.jswhich just does an import of the top level App component from app/index.js
The index.js top level handles navigation using a StackNavigator instance from the react-navigation module. Screens and components sit in sub-directories and common config like the stylesheet sits in config (maybe that should be something different).
So far, so good, I have a working iOS and Android build of my application:
Adding React Native Web
This is where things start getting a little harder, we already have a react-native project, so the create-app setup script won’t work for us.
I went with the Web packaging for existing React Native apps section of getting started which suggests this (the babel-polyfill was an addition I spotted I needed later):
yarn add react react-dom react-native-web
yarn add --dev babel-loader babel-polyfill url-loader webpack webpack-dev-server
There is a suggested webpack.config.js which was a good starting point but did need a bit of work. This is noted in the instructions, so you have to be prepared to understand the webpack process and put some effort in to go down this route!
Basically any modules that make use of react-native are transpiled to instead alias this to the equivalent Component of the react-native-web module by way of a babel module alias. The babel react-native-web plugin loaded in webpack.config.js takes care of these for core react modules, but it doesn’t know about additional module dependencies that I created by importing react-navigation so these need to be spelled out.
Running the webpack dev server:
./node_modules/.bin/webpack-dev-server -d --config
./web/webpack.config.js --inline --hot --colors
gets a bunch of errors like:
ERROR in ./node_modules/react-native/Libraries/react-native/react-native-implementation.js
Module not found: Error: Can't resolve 'AccessibilityInfo' in '.../node_modules/react-native/Libraries/react-native'
@ ./node_modules/react-native/Libraries/react-native/react-native-implementation.js 19:35-63
@ ./node_modules/react-native-drawer-layout/dist/DrawerLayout.js
@ ./node_modules/react-native-drawer-layout-polyfill/dist/index.js
@ ./node_modules/react-navigation/src/views/Drawer/DrawerView.js
@ ./node_modules/react-navigation/src/react-navigation.js
@ ./app/index.js
@ ./app/index.web.js
@ multi (webpack)-dev-server/client?http://0.0.0.0:9000 webpack/hot/dev-server babel-polyfill ./app/index.web.js
The key marker is that the modules are trying to pull in source files from node_modules/react-native… which should have been aliased to react-native-web if they had been included the loader pattern.
The fix is to add the module doing the import (in the above case ./node_modules/react-navigation) into the include array in the webpack config.
I ended up with:
const path = require('path');
const webpack = require('webpack');
const rootDirectory = path.resolve(__dirname, '../');
const appDirectory = path.resolve(__dirname, '../app');
// This is needed for webpack to compile JavaScript.
// Many OSS React Native packages are not compiled to ES5 before being
// published. If you depend on uncompiled packages they may cause webpack build
// errors. To fix this webpack can be configured to compile to the necessary
// `node_module`.
const babelLoaderConfiguration = {
test: /\.js$/,
// Add every directory that needs to be compiled by Babel during the build.
include: [
path.resolve(appDirectory, 'index.web.js'),
path.resolve(rootDirectory, 'app'),
path.resolve(rootDirectory, 'node_modules/react-native-uncompiled'),
path.resolve(rootDirectory, 'node_modules/react-native-vector-icons'),
path.resolve(rootDirectory, 'node_modules/react-navigation'),
path.resolve(rootDirectory, 'node_modules/react-native-drawer-layout'),
path.resolve(rootDirectory, 'node_modules/react-native-dismiss-keyboard'),
path.resolve(rootDirectory, 'node_modules/react-native-safe-area-view'),
path.resolve(rootDirectory, 'node_modules/react-native-tab-view')
],
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
// Babel configuration (or use .babelrc)
// This aliases 'react-native' to 'react-native-web' and includes only
// the modules needed by the app.
plugins: [
// This is needed to polyfill ES6 async code in some of the above modules
'babel-polyfill',
// This aliases 'react-native' to 'react-native-web' to fool modules that only know
// about the former into some kind of compatibility.
'react-native-web'
],
// The 'react-native' preset is recommended to match React Native's packager
presets: ['react-native']
}
}
};
// This is needed for webpack to import static images in JavaScript files.
const imageLoaderConfiguration = {
test: /\.(gif|jpe?g|png|svg)$/,
use: {
loader: 'url-loader',
options: {
name: '[name].[ext]'
}
}
};
module.exports = {
// your web-specific entry file
entry: [
// Need babel polyfills before we include the bundle
"babel-polyfill",
// Bundle has to come second
path.resolve(appDirectory, 'index.web.js')
],
// configures where the build ends up
output: {
filename: 'bundle.web.js',
path: path.resolve('./', 'public')
},
// ...the rest of your config
module: {
rules: [
babelLoaderConfiguration,
imageLoaderConfiguration
]
},
plugins: [
// `process.env.NODE_ENV === 'production'` must be `true` for production
// builds to eliminate development checks and reduce build size. You may
// wish to include additional optimizations.
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
__DEV__: process.env.NODE_ENV === 'production' || true
})
],
resolve: {
// If you're working on a multi-platform React Native app, web-specific
// module implementations should be written in files using the extension
// `.web.js`.
extensions: ['.web.js', '.js']
},
// Want my devserver to bind to an interface IP so I can browse
// it on my phone from the LAN
devServer: {
contentBase: path.join(__dirname, "../public"),
compress: false,
port: 9000,
host: "0.0.0.0"
}
}
Even after doing this, I still ended up with a couple of persistent packaging errors:
ERROR in ./node_modules/react-native-safe-area-view/index.js
Module not found: Error: Can't resolve 'react-native-web/dist/exports/DeviceInfo' in '/Users/rob/code/native_plus_web/node_modules/react-native-safe-area-view'
and
ERROR in ./node_modules/react-native-tab-view/src/TabViewPagerAndroid.js
Module not found: Error: Can't resolve 'react-native-web/dist/exports/ViewPagerAndroid' in '/Users/rob/code/native_plus_web/node_modules/react-native-tab-view/src'
Google found me https://github.com/necolas/react-native-web/issues/801which describes the reason for these. Unfortunately the fix should be in the underlying modules rather than react-native-web so these remain until those modules change their code.
Monkey Patching
For now, I’m going to rely on working around and monkey patching the modules needed to make this work. I chose to use patch-package:
yarn add --dev patch-package postinstall-prepare
I’ve patched my local copy of react-native-safe-area-view and react-native-tab-view to fix these two issues, details are on github here. If you are building your own project then just copy my patches directory to your own project and:
yarn patch-package
These patches fix the outstanding packaging issues so now we have a clean webpack build, so lets go to http://localhost:9000/
But does it run
Nope.
Uncaught TypeError: Cannot read property 'animationType' of undefined
at eval (index.js?cf30:1)
at Object.<anonymous> (bundle.web.js:8593)
at __webpack_require__ (bundle.web.js:679)
at fn (bundle.web.js:89)
at eval (NightMode.js?cced:1)
at Object.<anonymous> (bundle.web.js:8581)
at __webpack_require__ (bundle.web.js:679)
at fn (bundle.web.js:89)
at eval (NightModeScreen.js?52fe:1)
at Object.<anonymous> (bundle.web.js:8570)
This comes from the dependency of a module (react-native-modal-selector), on the Modal component of react-native, which is not present in react-native-web. I’m not using it in the web rendering but it is imported so I split the file out to remove it. NightMode.js within my app got a NightMode.web.js cousin which uses a vanilla <Picker> and crucially doesn’t import the Modal dependency.
Next:
Uncaught ReferenceError: regeneratorRuntime is not defined
at Transitioner._callee (Transitioner.js?3342:1)
at commitCallbacks (react-dom.development.js?cada:6163)
at commitLifeCycles (react-dom.development.js?cada:8784)
at commitAllLifeCycles (react-dom.development.js?cada:9946)
at HTMLUnknownElement.callCallback (react-dom.development.js?cada:542)
at Object.invokeGuardedCallbackDev (react-dom.development.js?cada:581)
at invokeGuardedCallback (react-dom.development.js?cada:438)
at commitRoot (react-dom.development.js?cada:10050)
at performWorkOnRoot (react-dom.development.js?cada:11017)
at performWork (react-dom.development.js?cada:10967)
Webpack.config.js needs the babel-polyfill runtime loading before the rest of the bundle.
entry: [
// Need babel polyfills before we include the bundleType
"babel-polyfill",
// Bundle has to come second
path.resolve(appDirectory, 'index.web.js')
],
Bingo:
Now to do something useful with it!