Post contents
In our last blog post, we took a look at how to scaffold a React Native monorepo. We explained how some of the benefits were not only code sharing between native apps, but that it enabled us to have different types of applications share the same code:
Here, we showed a Windows, macOS, Android, and iOS app that all share from the same codebase in a monorepo.
What if I told you that this isn't where things stopped?
Let's look at how each of these platforms are supported in React Native:
- iOS (maintained from Meta)
- Android (maintained by Meta)
- Windows (maintained by Microsoft)
- macOS (maintained by Microsoft)
- Web (maintained by ecosystem)
Wait, what?! We can build web apps using React Native?!
It's true! While this might seem backwards at first, it's a superpower to get a React Native app ported to the web quickly.
So, how do we do this?
Creating a Vite Project
While there's more to the monorepo aspect of the monorepo, let's talk about how to set up a web project using Vite and React Native first without worrying about the monorepo parts too much.
Setting Up the Initial Vite Project
So, let's take the file structure from the last article and add a websites/admin-portal
folder:
This package.json
will include the basics to get a Vite site up-and-running:
{ "name": "@your-org/web-admin-portal", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", "typescript": "^5.2.2", "vite": "^5.2.0" }}
As well as a vite.config.ts
file:
import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'export default defineConfig({ plugins: [react()],})
This allows us to have our index.html
file act as our web app's entry point:
<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Your App Name Here</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body></html>
Finally, the <script>
tag allows us to run and import main.tsx
from src
to run React:
import React from 'react'import ReactDOM from 'react-dom/client'function App() { return <p>Hello, world!</p>}ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <App /> </React.StrictMode>,)
Adding React Native Web Support
Now, to run React Native in the Vite project, we'll add a few new packages:
yarn add react-native react-native-web
Then, we can tell Vite that "whenever you see react-native
, replace it with react-native-web
" by updating our vite.config.ts
file:
import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'export default defineConfig({ plugins: [react()], resolve: { alias: [ { find: /^react-native\/(.*)/, replacement: "react-native-web/$1" }, { find: /^react-native$/, replacement: "react-native-web", }, ] }})
Now we can use react-native
imports in our app:
import {Text} from "react-native";function App() { return <Text>Hello, world!</Text>}
Resolving Web Modules First
In React Native's default bundler, Metro, it's able to select which files should be imported based on which platform you're building for.
IE:
Will import from main.tsx
if you're not using React Native, main.ios.tsx
if you are and are building for iOS apps, and main.android.tsx
if you're targeting Android.
IE - Building for iOS will select the following file when importing main
:
This feature extends to React Native Web support as well. Many libraries rely on this functionality to resolve a .web.tsx
or .web.js
extension before other prefixed paths.
To add support for this into Vite, we'll need to add the following to our Vite config:
const defaultExtensions = [ ".mjs", ".js", ".mts", ".ts", ".jsx", ".tsx", ".json",];const allExtensions = [ ...defaultExtensions.map((ext) => ext.replace(/^\./, ".web.")), // For AWS ...defaultExtensions.map((ext) => ext.replace(/^\./, ".browser.")), ...defaultExtensions,];export default defineConfig({ // ... optimizeDeps: { esbuildOptions: { loader: { ".js": "jsx", ".ts": "tsx", }, mainFields: ["module", "main"], resolveExtensions: [".web.js", ".js", ".ts"], }, }, resolve: { extensions: allExtensions, // ... },});
Handle JSX in JS Files
Some packages do not bundle their JSX in .jsx
file extensions and instead have their JSX in .js
files. Vite does not support this and requires all JSX syntax to be in .jsx
or .tsx
files.
To sidestep this, we'll add a custom Vite plugin that transfoms
{ name: "load-js-files-as-jsx", async load(id) { if ( !id.match( /node_modules\/(?:react-native-reanimated|react-native-vector-icons)\/.*.js$/, ) ) { return; } const file = await fs.promises.readFile(id, "utf-8"); return esbuild.transformSync(file, { loader: "jsx" }); },}
This means that if you see an error like this:
[commonjs--resolver] Unexpected token (58:16) in /websites/web-portal/node_modules/react-native-elements/dist/input/Input.js
file: /websites/web-portal/node_modules/react-native-elements/dist/input/Input.js:58:16
56: });
57: const hideErrorMessage = !renderErrorMessage && !errorMessage;
58: return (<View style={StyleSheet.flatten([styles.container, containerStyle])}>
^
59: {renderText(label, Object.assign({ style: labelStyle }, labelProps), Object.assign({ fontSize: 16, color: (_a...
60: android: Object.assign({}, fonts.android.bold),
error during build:
SyntaxError: Unexpected token (58:16) in /websites/web-portal/node_modules/react-native-elements/dist/input/Input.js
You should add the dependency throwing the error (in this case, react-native-elements
) to the regex above.
Add Font Icons (react-native-vector-icons
)
Any good UI project comes with icons. In the React Native world, the most common set of icons comes from the react-native-vector-icons
package.
To add support for the package in a Vite project, you'll:
- Use the
rollup-plugin-copy
plugin to copy the font files to a staticpublic
folder - Add a stylesheet that references those fonts
You do the first by adding in the plugin to your vite.config.ts
file:
import { defineConfig } from "vite";import copy from "rollup-plugin-copy";export default defineConfig({ plugins: [ // ... { ...copy({ hook: "options", flatten: true, targets: [ { src: "node_modules/react-native-vector-icons/Fonts/*", dest: "public/fonts", }, ], }), enforce: "pre", }, ], resolve: { // You also need to alias the vector-icons packages to be web-centric! alias: [ { find: "react-native-vector-icons/MaterialIcons", replacement: "react-native-vector-icons/dist/MaterialIcons", }, { find: "react-native-vector-icons/MaterialCommunityIcons", replacement: "react-native-vector-icons/dist/MaterialCommunityIcons", }, ] } // ...});
Then add the following to your index.html
file:
<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Your Title</title> <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" /> <style> @font-face { src: url("/fonts/MaterialIcons.ttf"); font-family: MaterialIcons; } @font-face { src: url("/fonts/MaterialCommunityIcons.ttf"); font-family: MaterialCommunityIcons; } </style> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body></html>
Adding in Monorepo Support
Now that we have our Vite React Native project set up properly, let's configure the monorepo aspects so we can share code with our mobile apps quickly.
Deduplicating React Native Deps
Because all React Native projects rely on a single instance of React and React Native (as a singleton), we need to tell Vite that it should remove duplicate copies of React Native deps in our apps. We do this by using resolve.dedupe
in vite.config.ts
:
import { defineConfig } from "vite";export default defineConfig({ // ... resolve: { dedupe: [ "@react-native-async-storage/async-storage", "@react-native-picker/picker", "@reduxjs/toolkit", "@tanstack/react-query", "react", "react-dom", "react-native", "react-native-device-info", "react-native-elements", "react-native-maps", "react-redux", "react/jsx-runtime", "styled-components", "styled-components/native", ], // ... },});
This means that you'll want to add to this dedupe
list anytime you add a react
or react-native
dependency to your shared project's package.json
.
Now we can import our shared library into our web portal:
// websites/admin-portal/App.tsximport {HelloWorld} from "@your-org/shared-elements";export const App = () => { return <HelloWorld/>}
And it should render properly!
Web-Specific Code in Shared Elements
Now that we have shared code between our desktop and mobile apps, it's tempting to have all of our code generic enough to support both platforms in our shared elements.
However, it turns out that there's a lot of instances where it's highly useful to have platform specific code. Let's break out @your-org/shared-elements
package into two dedicated packages:
@your-org/shared-elements/mobile
@your-org/shared-elements/web
Where each platform is able to remove specific code from their respective bundles.
IE, we can do this:
// packages/shared-elements/src/hello.tsximport {Text} from "react-native";const HelloWeb = () => { return <p>I am a website</p>;}const HelloMobile = () => { return <Text>I am an app</Text>;}export HelloWorld = process.env.IS_WEB ? HelloWeb : HelloMobile;
And only having the following on the web import:
const HelloWorld = () => { return <p>I am a website</p>;}
And this on the mobile import:
import {Text} from "react-native";const HelloMobile = () => { return <Text>I am an app</Text>;}
To enable this, we'll need to add a custom Vite plugin to replace process.env.IS_WEB
with true
on web builds and false
on mobile builds.
// packages/shared-elements/vite/plugins.tsimport { PluginOption } from "vite";function removeMobileCodePlugin(): PluginOption { return { name: "define web", transform(code, _id, ssr) { if (!ssr && code.includes("process.env.IS_WEB")) { return code.replace(/process.env.IS_WEB/g, "true"); } return undefined; }, enforce: "pre", };}function removeWebCodePlugin(): PluginOption { return { name: "define web", transform(code, _id, ssr) { if (!ssr && code.includes("process.env.IS_WEB")) { return code.replace(/process.env.IS_WEB/g, "false"); } }, enforce: "pre", };}
Then, we'll need:
- A shared common Vite config file
- Two dedicated builds of web and mobile:
// packages/shared-elements/vite/base-config.tsimport { LibraryFormats, UserConfigExport } from "vite";import react from "@vitejs/plugin-react";export const commonFormats = ["es", "cjs"] as LibraryFormats[];export const baseOutDir = "./dist";export const getFileName = (prefix: string, format: string) => { switch (format) { case "es": case "esm": case "module": return `${prefix}.mjs`; case "cjs": case "commonjs": default: return `${prefix}.cjs`; }};export const baseConfig = { plugins: [react()], build: { rollupOptions: { external: [ "@react-native-async-storage/async-storage", "@react-native-async-storage/async-storage", "@react-native-community/netinfo", "@react-native-picker/picker", "@react-native-community/geolocation", "@reduxjs/toolkit", "@tanstack/react-query" ], output: { globals: { react: "React", "react/jsx-runtime": "jsxRuntime", "react-native": "ReactNative", "react-dom": "ReactDOM", }, }, }, },} satisfies UserConfigExport;
// packages/shared-elements/vite.config.web.tsimport { baseConfig, baseOutDir, commonFormats, getFileName } from "./base-config";import { removeMobileCodePlugin } from "./remove-mobile-code-plugin";import path from "node:path";import { resolve } from "path";import { defineConfig } from "vite";import dts from "vite-plugin-dts";export const getWebConfig = defineConfig(({ ...baseConfig, plugins: [ removeMobileCodePlugin(), ...baseConfig.plugins, dts({ entryRoot: path.resolve(__dirname, "../src"), outDir: resolve(__dirname, "..", baseOutDir, "web"), }), ], build: { ...baseConfig.build, outDir: resolve(__dirname, "..", baseOutDir, "web"), lib: { entry: resolve(__dirname, "../src/index.tsx"), name: "SharedElementsWeb", fileName: (format, entryName) => getFileName("web", format), formats: commonFormats, }, }, })
// packages/shared-elements/vite.config.mobile.tsimport { baseConfig, baseOutDir, commonFormats, getFileName } from "./base-config";import { removeMobileCodePlugin } from "./remove-mobile-code-plugin";import path from "node:path";import { resolve } from "path";import { defineConfig } from "vite";import dts from "vite-plugin-dts";export const getWebConfig = defineConfig(({ ...baseConfig, plugins: [ removeWebCodePlugin(), ...baseConfig.plugins, dts({ entryRoot: path.resolve(__dirname, "../src"), outDir: resolve(__dirname, "..", baseOutDir, "mobile"), }), ], build: { ...baseConfig.build, outDir: resolve(__dirname, "..", baseOutDir, "mobile"), lib: { entry: resolve(__dirname, "../src/index.tsx"), name: "SharedElementsMobile", fileName: (format, entryName) => getFileName("mobile", format), formats: commonFormats, }, }, })
Then, update your packages/shared-elements/package.json
file to build two different outputs:
{ "name": "@your-org/shared-elements", "scripts": { "build": "run-p \"build:*\"", "build:mobile": "vite build --config vite.config.mobile.ts", "build:web": "vite build --config vite.config.web.ts", }, "files": [ "assets", "dist", "src" ], "exports": { "./mobile": { "types": "./dist/mobile/index.d.ts", "import": "./dist/mobile/mobile.mjs", "require": "./dist/mobile/mobile.cjs", "default": "./dist/mobile/mobile.cjs" }, "./web": { "types": "./dist/web/index.d.ts", "import": "./dist/web/web.mjs", "require": "./dist/web/web.cjs", "default": "./dist/web/web.cjs" } }, "typesVersions": { "*": { "mobile": [ "./dist/mobile/index.d.ts" ], "web": [ "./dist/web/index.d.ts" ] } }}
This is a partial view of the
shared-elements
package.json
file
Now you'll import the code differently in your mobile and web projects. You'll import like this on your mobile project:
import {HelloWorld} from "@your-org/shared-elements/mobile"
And this in your web project:
import {HelloWorld} from "@your-org/shared-elements/web"
Adding in Styled Component Support
As we showcased before, you can mix-n-match HTML-elements and React Native elements in your shared-element
's JSX; but only so long as you edgecase the HTML elements within a process.env.IS_WEB
check.
Now what happens if we want to use styled(Text)
and styled.p
usage within the same file?
Well, unfortunately for us, the styled.p
syntax is not tree-shakable from the mobile build, even if you're not using it in your codebase.
This means that if we build our mobile build and run the following code:
import {Text} from "react-native";import styledWeb from "styled-components";import styled from "styled-components/native";const WebText = styledWeb.p` color: red;`const MobileText = styled(View)` color: red;`export const HelloWorld = () => { return <MobileText>Testing 123</MobileText>}
You'll get an error when running the mobile app:
ERROR TypeError: undefined is not a function, js engine: hermes
at loadModuleImplementation (http://localhost:8081/index.bundle//&platform=ios&dev=true&lazy=true&minify=false&inlineSourceMap=false&modulesOnly=false&runModule=true&app=com.your-org.AdminPanel:259:14)
at RCTView
at View (http://localhost:8081/index.bundle//&platform=ios&dev=true&lazy=true&minify=false&inlineSourceMap=false&modulesOnly=false&runModule=true&app=com.your-org.AdminPanel:88607:43)
at RCTView
at View (http://localhost:8081/index.bundle//&platform=ios&dev=true&lazy=true&minify=false&inlineSourceMap=false&modulesOnly=false&runModule=true&app=com.your-org.AdminPanel:88607:43)
at AppContainer (http://localhost:8081/index.bundle//&platform=ios&dev=true&lazy=true&minify=false&inlineSourceMap=false&modulesOnly=false&runModule=true&app=com.your-org.AdminPanel:88449:36)
at AdminPanel(RootComponent) (http://localhost:8081/index.bundle//&platform=ios&dev=true&lazy=true&minify=false&inlineSourceMap=false&modulesOnly=false&runModule=true&app=com.your-org.AdminPanel:139624:28)
To solve this error, we can write our own Babel plugin to be executed by Vite that replaces:
// ❌ Not tree-shakable, kept in mobile bundleconst WebText = styledWeb.p` color: red;`;
Into:
// ✅ Tree-shakable, removed from mobile bundleconst WebText = () => null;
To do this, we wrote a Babel plugin that runs in Vite:
import { PluginOption } from "vite";import { parse } from "@babel/parser";import generate from "@babel/generator";import traverse from "@babel/traverse";/** * This code is required to generate a mobile-only build, as: * - Styled components are eagerly imported * - This is because, despite dead code elimination, SC breaks with `styled.div` usage * @see https://github.com/styled-components/babel-plugin-styled-components/issues/245 * * So this plugin literally replaces: * `styled.div` with `() => null` */export function removeWebCodePlugin(): PluginOption { return { name: "define web", transform(code, _id, ssr) { if (!ssr && code.includes("process.env.IS_WEB")) { return code.replace(/process.env.IS_WEB/g, "false"); } if (code.includes("styled.")) { const ast = parse(code, { sourceType: "module", plugins: ["jsx", "typescript"], }); traverse(ast, { TaggedTemplateExpression(path) { const styledExpressionNode = path.node.tag; /** * styled.div`...` */ const isStyledIdentifier = styledExpressionNode.type === "MemberExpression" && styledExpressionNode.object && styledExpressionNode.object.type === "Identifier" && styledExpressionNode.object.name === "styled"; /** * styled.div.attrs(...)`...` */ const isStyledCallExpression = styledExpressionNode.type === "CallExpression" && styledExpressionNode.callee && styledExpressionNode.callee.type === "Identifier" && styledExpressionNode.callee.name === "styled"; // Replace `styled.div` with `() => null` (AKA empty functional React component) if (isStyledIdentifier || isStyledCallExpression) { const replacementAst = parse("() => null"); // This statement is `true`, now try telling TypeScript that. if ( replacementAst.type === "File" && path.parent.type === "VariableDeclarator" ) { path.parent.init = replacementAst.program.body[0] as never; } } }, }); const output = generate(ast); return output.code; } return undefined; }, enforce: "pre", };}
While explaining how this Babel plugin is complex, let's go over it quickly:
-
We first check if we should be transforming the code in the first place within Babel by seeing if the code in the file contains
styled.
in its string represtentation of the code:if (code.includes("styled.")) { // ...}
-
We then parse the plain text source code into a Babel AST as JSX TypeScript code:
const ast = parse(code, { sourceType: "module", plugins: ["jsx", "typescript"],});
-
We then step through every node of the AST looking for a
TaggedTemplateExpression
, namely a named tag:traverse(ast, { TaggedTemplateExpression(path) { // ... }}
-
const isStyledIdentifier = styledExpressionNode.type === "MemberExpression" && styledExpressionNode.object && styledExpressionNode.object.type === "Identifier" && styledExpressionNode.object.name === "styled";
Finds the
styled.div
API -
const isStyledCallExpression = styledExpressionNode.type === "CallExpression" && styledExpressionNode.callee && styledExpressionNode.callee.type === "Identifier" && styledExpressionNode.callee.name === "styled";
Finds the extended
styled.div.attrs()
API -
Finally, we can simplify the last line to show:
if (isStyledIdentifier || isStyledCallExpression) { const replacementAst = parse("() => null"); path.parent.init = replacementAst.program.body[0] as never;}
Replacing the parent assignment
=
to() => null
Add this plugin where you had the previous removeWebCodePlugin
plugin in Vite, and you're off to the races!
This plugin has edgecases it won't handle. IE, if you rename
styled.p
towebStyled.p
.To handle these kinds of edgecases would require a much larger Babel plugin. Please let us know if you end up extending this or writing one from scratch!