React Native: Targetting Android, iOS, Windows, MacOS, Linux, and the Web using React-Native-Web and Electron

Michael Rooplall
16 min readMar 31, 2022

--

A React Native Positron project running on an Android emulator, the web, and Electron.
Our React Native Web Electron Project

JavaScript has become an incredibly versatile language, allowing us to build web applications with react, cross-platform desktop applications with Electron, and native mobile applications with React Native.

What if we could combine all three of these projects? Imagine being able to build multiple applications from one platform agnostic codebase — targeting Android, iOS, Windows, MacOS, Linux, and the web — and still maintain the ability to use native platform modules when necessary.

Having one codebase allows you to keep projects smaller and maintainable and offers less friction when implementing new features. Using frameworks like React-Native-Web and Electron and tools like Webpack, TypeScript, and Babel, we can accomplish just this.

The completed GitHub repository for this project can be found here, but you’re encouraged to go through the steps and build it yourself to familiarize yourself with how all these different libraries and framework work together.

Pre-requirements

This guide requires that you already have npm (or yarn) installed along with a React Native developer environment set up.

You should also have an android or iOS device or emulator set up and ready to go to view the native builds of your application.

This guide also assumes you have an understanding of React or React Native methodology. We will also be using TypeScript to simplify any long-term development.

This guide does not depend on Expo, but you can start with an Expo project and obtain the same results.

This guide will not touch on the following:

  • State management and frameworks like Redux
  • Routing with React Navigation
  • Getting your Android or iOS emulator up and running
  • Building and distributing your final Android, iOS, web, and Electron applications

1. Initializing a React-Native Project with TypeScript Support

Our first step is to generate a base React Native project using the react-native CLI.

The react-native init command takes a --template flag that allows us to add TypeScript support for our project right at the start.

Go ahead and run the following command to generate our React Native project with TypeScript:

npx react-native init ReactNativePositron --template react-native-template-typescript

React Native's project generator only accepts alpha-numeric characters for the project name, so I had to use ReactNativePositron rather than the more standard react-native-positron.

ℹ If you’re interested in using Expo and not the React Native CLI, you can first follow this guide to get a React Native with Expo project generated.

If you have an Android or iOS emulator already set up, you can go ahead and run npm run android (or npm run ios) in your project directory to verify that your base React Native project is working. This should start the Metro Bundler, responsible for bundling and serving your application for Android and iOS. After the app finishes building, you should see the ‘Welcome to React Native’ screen.

Screenshot of an android emulator open to the Welcome to React Native entry screen.
Welcome to React Native!

2. TypeScript Configuration

A tsconfig.json file allows us to specify a list of built-in API declaration to include in our project. We want the latest feature sets in JavaScript, so we’ll use esnext, and with the intention of building for the web we will also need to include dom for web support.

If your project does not include a tsconfig.json file for whatever reason, you can run npx tsc --init to generate one. Ensure that it matches the full tsconfig.json gist provided below.

Find the tsconfig.json file in your project root directory and make the following changes under compilerOptions:

  • Change lib from ["es2017"] to ["esnext, dom"]
  • Uncomment baseURL and ensure that it’s set to "."
  • Uncomment paths, and add the following aliases:
    - "@src/*": ["src/*"]
    - "@assets/*" : ["src/assets/*"]

The Aliases section properly covers how to setup and use aliases across TypeScript, Babel, and Webpack in our project.

tsconfig.json

3. Configuring Webpack and Babel

Next we’ll install Webpack and Babel along with their various plugins and loaders. The same way the Metro Bundler handles packaging and serving your application bundles for React Native, Webpack and Babel handle packaging and serving your application bundles for the web and Electron.

ℹ️ Webpack also comes with a development server that allows for hot-reloading in the browser as you develop.

We‘ll also be using minimist to parse npm script arguments. This will help webpack determine if it is building for production or development, and check if the build target is for the web or electron.

Ensure you are in your project directory and install webpack, webpack loaders, minimist, and Babel related dependencies:

cd ReactNativePositron
npm install --save-dev webpack webpack-cli webpack-dev-server babel-loader babel-plugin-module-resolver url-loader css-loader style-loader html-webpack-plugin minimist babel-plugin-react-native-web

Babel Configuration

Find thebabel.config.js file in your project’s root directory, or create one if it doesn’t exist. This snippet includes a plugins section with the following module-resolver configuration, which will help us set up path aliasing in our project:

babel.config.js

You may also notice the extensions section with platform-specific prefixes like .android.tsx, .ios.tsx, and .native.tsx. When we build our applications, they will “smartly” compile only the files we specify are for each particular platform. We’ll cover how this works more in-depth under the Writing Platform Specific Code section.

Webpack Configuration

Webpack is responsible for running our live development server, adding hot-module-reloading, and bundling our application for the web and Electron.

In the root directory of our project, create a new file called webpack.config.js :

# Windows
echo . > webpack.config.js
# MacOS/Linux
touch webpack.config.js

ℹ If you encounter any Invalid or unexpected token or “Unexpected character errors, ensure that ALL your files created from the cli are saved with UTF-8 encoding. Visual Studio Code tends to open echo files as UTF-16, and Node does not like that.

webpack.config.js

When building your application for production, see ELECTRON_OUTPUT and WEB_OUTPUT for their bundle outputs. Webpack output bundles use the name found in app.json. This is done so that Electron can find the bundles easily, without any work on your part.

According to the React-Native-Web docs, many React Native packages are not compiled to ES5 before being published. If you depend on any uncompiled packages, they can cause webpack build errors. To fix this, you can add your node modules to the provided COMPILE_NODEMODULES array and Webpack and Babel will handle transpiling it.

4. npm scripts

Before we setup React-Native-Web and Electron, we need to add some scripts to our package.json. These will be responsible for starting the Metro Bundler, launching our Android and iOS applications, running our hot-reloadable web application, and launching our Electron application.

npm scripts in package.json

5. Targeting the Web with React-Native-Web

Next we’re going to install and setup react-native-web. React-native-web, or React Native for Web, is responsible for adding web support (and subsequently Electron support) to our application.

React Native for Web” is a project by Nicolas Gallagher that makes it possible to run React Native components and APIs on the web using React DOM. It is currently used in apps by companies including Twitter, Flipkart, and Uber. Software engineers from Facebook, Twitter, and Expo continue to contribute design and patches to the project.

Setting up React-Native-Web

In your project directory, install react-native-web and react-dom:

npm install react-native-web react-dom

We’ll also create a /src folder and a /src/assets subfolder. This is where we’ll put our component files and other project assets like images:

mkdir src
mkdir src/assets

Open your index.js file, and overwrite it with the following:

index.js

We add webpack’s hot-reloading feature so any changes we make will automatically re-render. We also set a reference to a rootTag: this element will be the parent of the component returned by App.tsx.

Create anindex.html file in your projects root directory. This will be the entry point for our web application.

# Windows
echo . > index.html
# MacOS/Linux
touch index.html
index.html

TypeScript Support for React-Native-Web

React-Native-Web uses Flow for type checking rather than TypeScript. Efforts to add TypeScript support to React-Native-Web are currently ongoing, and can be tracked here.

Adding Types for React-Native-Web

For the time being, create a/types folder and a /types/react-native-web-overrides.ts file to monkey-patch TypeScript support in as you continue to build your project and run into issues. An example of such a file can be found here.

# Windows
mkdir ./src/types
echo . > ./src/types/react-native-web-overrides.ts
# MacOS/Linux
mkdir ./src/types
touch ./src/types/react-native-web-overrides.ts

Creating a Platform Agnostic App.tsx

As it stands, the current App.tsx file provided by the React-Native template uses a native library uncompilable for the web.

I’ve provided the following App.tsx file that uses basic components supported out-of-the-box by React-Native-Web and showcases importing platform-specific variants of custom components.

App.tsx

In our /src folder, create a new folder named /PlatformComponent and add three files: PlatformComponent.native.tsx PlatformComponent.electron.tsx and PlatformComponent.tsx.

# Windows
mkdir ./src/PlatformComponent
echo . > ./src/PlatformComponent/PlatformComponent.native.tsx
echo . > ./src/PlatformComponent/PlatformComponent.electron.tsx
echo . > ./src/PlatformComponent/PlatformComponent.tsx
# MacOS/Linux
mkdir ./src/PlatformComponent
touch ./src/PlatformComponent/PlatformComponent.native.tsx
touch ./src/PlatformComponent/PlatformComponent.electron.tsx
touch ./src/PlatformComponent/PlatformComponent.tsx
src/PlatformComponent/PlatformComponent.electron.tsx, src/PlatformComponent/PlatformComponent.native.tsx, and src/PlatformComponent/PlatformComponent.tsx

We’ll also cover how using platform-specific components work in-depth under the Writing Platform Specific Code section.

Running our Web Application

With npm scripts already defined in our package.json, we can go ahead and run npm run run:web to see our app running in the browser.

You can also run the metro bundler with npm run start and deploy your app to your Android or iOS device or emulator using npm run run:android (or npm run run:ios) to ensure that your React Native mobile builds are still working.

Our RNWE running on the browser and an Android emulator
Our RNWE running on the browser and an Android emulator

6. Targeting the Desktop with Electron

Next, we’ll install and setup Electron.

Electron is a framework that allows you to essentially bundle web technologies into desktop executables. It combines the Chromium rendering engine with the Node.js runtime, and was built with the mentality that “If you can build a website, you can build a desktop app.”

With that mentality in mind —

If you can build a react-native application for iOS and Android— then you can build an application for Windows, MacOS, Linux, and the web as well.

ℹ️ As of 1.6.10, Electron comes with built in TypeScript support, so there’s no need to install the type definitions separately.

The Electron Process Model

There are three types of Electron files we’ll be dealing with: a file for the main process, a file for a renderer process, and preload scripts.

The main process
Each Electron app has a single main process, which acts as the application’s entry point. The main process runs in a Node.js environment, meaning it has the ability to require modules and use all of Node.js APIs.

The renderer processes
Each Electron app spawns a separate renderer process for each open BrowserWindow. A renderer is responsible for rendering web content. For all intents and purposes, code ran in renderer processes should behave according to web standards.

Preload scripts
Preload scripts contain code that executes in a renderer process before its web content begins loading. These scripts run within the renderer context, but are granted more privileges by having access to Node.js APIs.

ℹ️ You can read up more on Electron’s process model here.

Setting up our Electron application

Install electron so we can use it within our npm scripts and add proper type-resolving:

npm install electron --save-dev

For the sake of keeping the project tree easier to navigate, we’ll create an /electron-app folder in our project directory. This will house our main entry file responsible for spawning our renderer process.

In /electron-app, create two files: electron-main.js and electron-preload.ts.

ℹ️ Note that electron-main.js is a JavaScript file and electron-preload.ts is a TypeScript file. If you would like your electron-main.js file to also be a TypeScript file, you must pass it through webpack and ensure that in package.json, electron is called on the generated JavaScript bundle.

# Windows
mkdir ./electron-app
echo . > ./electron-app/electron-main.js
echo . > ./electron-app/electron-preload.ts
# MacOS/Linux
mkdir ./electron-app
touch ./electron-app/electron-main.js
touch ./electron-app/electron-preload.ts
electron-app/electron-main.js
electron-app/electron-preload.js

Entry index.electron.html file for the Electron Renderer

We will also need a separate index.electron.html file, as Electron requires a small script for remapping global and has other security requirements.

I placed my index.electron.html file under the project’s root directory. Alternatively, you can place this file inside of the/electron-app folder — just be sure to reflect this change in the webpack configuration.

# Create an index.electron.html file in the project directory
# Windows
echo . > index.electron.html
# MacOS/Linux
touch index.electron.html
index.electron.html

Electron requires a main section in our package.json. This points to the entry file responsible for Electron’s main process, electron-app/electron-main.js. When we call electron . from our package.json script or our CLI, it runs this main file.

// package.json
{
/* ... */
"main": "electron-app/electron-main.js",
/* ... */
}

Security, Node Integration, and Context Isolation

In Node 12 and later, Electron disables Node Integration and enables Context Isolation by default.

Context Isolation is a feature that ensures that both your preload scripts and Electron’s internal logic run in a separate context to the website you load. This is important for security purposes as it helps prevent the website from accessing Electron internals or the powerful APIs your preload script has access to.

ℹ️ If your plan to load and execute code from untrusted sources, (e.g. the internet or from user input), then it is strongly suggested that you either keep Node Integration off or control access through Context Isolation.

ℹ️ This project supports Context Isolation out of the box — and you can read up more on how to use this feature here.

Communicating With the Electron Main Process

With Context Isolation enabled and Node Integration disabled, you cannot directly access node modules from the renderer side of your application. Instead, our electron-preload.ts script pollutes the global window object with anapi object. This api object is developed-defined and can access to the ipcRenderer module, which in turn is responsible for passing messages between the react front-end and Electron main process.

For example, if you wanted to send a message to the Electron main process from the react renderer front-end, it would look like this:

window.api.send("toMain", "hello!");

You can think of the electron-preload as a middleman responsible for filtering exactly what the end user can do with your node modules.

ℹ️ Ensure that you are only accessing the window.api object from an electron-specific file. It does not exist on Android, iOS, or the browser.

Types for the exposed Electron API

Our electron-preload.ts script has an example api for communicating between the main and renderer processes. To properly use our api in the renderer process, we must include type definitions for this api.

To add type definitions, create an electron-api.ts file in /src/types:

# Windows
echo . > ./src/types/electron-api.ts
# MacOS/Linux
touch ./src/types/electron-api.ts

As your API gets more complex, you may want to have it dynamically export your type definitions so you don’t need to constantly update the electron-api.ts file.

src/types/electron-api.ts

Hot Reloading in Electron

Implementing a hot reloading feature in Electron has proven to be complex and outside the scope of this guide. In the future, I may make a separate guide detailing this further. For now, we can proceed without hot reloading.

By default, the webpack development server bundles are served from memory. To launch Electron, our main process requires that the renderer and preload bundles be exposed to the file system.

To solve this, our webpack configuration for Electron will automatically create a /__webpack-dev-server__ folder in /electron-app. This will house our temporary bundle files for development.

This folder should also be added to your .gitignore.

If you would like your /__webpack-dev-server__ folder to automatically clean up when you exit Electron, you can add the following npm script to your package.json:

# Windows
"postrun:electron" : "rmdir /s /q .\\electron-app\\__webpack-dev-server__"
# MacOS/Linux
"postrun:electron" : "rm -r .\\electron-app\\__webpack-dev-server__"

The __webpack-dev-server__ folder will automatically delete itself when you quit the Electron application.

Running our Electron Application

You can now run npm run run:electron and verify that your Electron application launches without issue.

Our React Native Positron project running in Electron

7. Writing Platform Specific Code

One of the biggest benefits of this project is how easy it is to separate platform-specific variants of a component.

There are two ways to write platform specific code: you can import per-platform files depending on the file extension or alternatively use the Platform module detect the running platform.

Option 1: Using Platform-specific Files

Using the following naming format, you can use different variants of a component depending on the platform:

<file-name>.<target-platform>.<file-extension>

Valid file extensions:
.js, .ts, .jsx, .tsx

Valid target platforms:
android Targets only android devices
ios Targets only iOS devices
native Targets both android and iOS devices
electron Targets only electron applications
web Targets only web applications

Example
MyComponent.android.tsx
HomeWidget.electron.jsx
MobileLayout.native.tsx

If a platform-specific target is not provided, the resolver will fallback to using a default file, so you should ensure that a fallback file is available if needed.

Separating components into platform-specific files allows you to easily use different native libraries across platforms. For example, an .android.tsx file can make calls to native Android libraries, free from throwing errors on an iOS device or web browser.

In our App.tsx file, there is an import for a components under /src/PlatformComponent. App.tsx will smartly use only that platform’s component variant when being built.

import PlatformComponent from '@src/PlatformComponent/PlatformComponent';

Notice that when importing our component, we didn’t have to specify .android.tsx, .ios.tsx, .tsx.

Option 2: Using the Platform Module

With React-Native-Web, you can use the Platform module to check whether the application is running on Android, iOS, or the web.

import { Platform } from 'react-native';//console.log(Platform.OS); // --> 'android', 'ios', or 'web'.if (Platform.OS == 'android'){
console.log("This is an android device!");
} else if (Platform.OS == 'ios'){
console.log("This is an iOS device!");
} else if (Platform.OS == 'web'){
console.log("This application is running in a browser environment- it could be on the web or bundled with Electron and running on Windows, MacOS, or Linux!");
}

Note that when Platform.OS returns ‘web’, it does not specify whether the application is running in a web browser or on Electron. At the moment, it is up to you, the developer, to detect when the app is running in an Electron renderer process. There are many discussions with regards to this topic here.

ℹ️ It may be practical to build your own custom Platform detection module, which can internally use Platform.OS and other identifiers for distinguishing between Electron and the browser. You may also wish to identify whether the Electron application is running on Windows, MacOS, or Linux to further use desktop OS specific libraries.

8. Aliasing

As your project grows in complexity, you may need to refer to specific static directories from deep in the project structure. This can lead to a lot of confusing up-pathing, for example: ../../../../../../../../assets/app-banner.png .

We can alias the /src/assets folder to @assets/, allowing us to reference directories from anywhere in our codebase like this: @assets/app-banner.png.

Here’s an example of how aliases are included across our tsconfig.js, babel.config.js, and webpack.config.js files. Pay special attention to how they are formatted differently across each file.

🎉 With that, our React-Native-Web-Electron project is set up!

You can now open multiple terminals (or terminal tabs) and run the following npm scripts separately to launch your native, electron, and web applications:

# 1. Start the metro bundler
npm run start
# 2. Deploy our Android or iOS application## If you have an Android device or emulator
npm run run:android
## If you have an iOS device or emulator
npm run run:ios
# 3. Start our webpack development server
npm run run:web
# ... and visit the application at http://localhost:8080/
# 4. Bundle and launch the Electron application
npm run run:electron
A React Native Positron project running on an Android emulator, the web, and Electron.
Our React Native Positron Project running on the web, on Electron, and on an Android Emulator

Now you can create your own cross-platform applications for Android, iOS, Windows, MacOS, Linux, and the web — all from one codebase.

You can check out the project repository for this writeup here: https://github.com/DeveloperBlue/react-native-positron-quickstart

What’s Next

Ultimately, this is just a barebones project template. It does not include build scripts, full integration with testing suites like Jest, or other little things like svg support for Android and iOS devices.

There are also inconsistencies when using viewport dimensions like ‘100vh’ between the desktop browsers and mobile browsers.

As it stands, applications are currently single-page. React Router has support for native, the web, and Electron, allowing you to add multi-paging, routing, and more.

It’s also up to you the developer to set up building and distributing your Android APKs, iOS IPAs, Electron executables, and web bundles.

Remember to read up and follow the best security practices for React Native, the web, and Electron.

For the sake of brevity, I did not cover all of these topics in this article. If you are interested in any of these things, please let me know!

Final Thoughts

Needless to say, being able to build cross-platform applications in one integrated project with a reusable codebase is a major benefit your development workflow.

Having one codebase allows you keep your projects smaller and maintainable and offers less friction for implementing new features — all with the added benefit of having a shorter time-to-market.

The frameworks used to make this possible are also open source, widely used, and heavily supported. React-Native and Electron have large communities and extensive documentation. Along with React-Native-Web, these frameworks are also industry backed and used by companies like Twitter, Flipkart, and Uber. This means you can trust that these frameworks will be around and maintained for a long time to come.

It should be noted that Electron is not the only way to run web applications as Desktop executables. If you are coming from a C++/UWP background, I would suggest taking a look at React Native for Windows, a project by Microsoft for building native applications for Windows and MacOS. Alternatively, you may look into using Web Assembly to execute native modules from Electron at near native speeds.

Ultimately, nothing beats a native desktop application — but I strongly believe that with the right profiling and tuning in addition to using technologies like Web Assembly, you can create highly performant applications from a React-Native-Web-Electron project.

About the Author

Michael Rooplall is a full-stack software developer. He loves to make games, mentor his high school Robotics Team, and build applications and services across a variety of languages for fun.

Connect with Michael on LinkedIn and Twitter and check out his portfolio on GitHub and his Website.

--

--