I'd like to share with you a new Vite plugin I've been working on and encourage you to give it a try. First though I'd like to tell a story on how this came about.
A brief history
Four years ago I published a Storybook addon for React Native Web that would apply the config you need for Storybook with Webpack to display React Native components.
I got the idea from an issue someone raised on the Storybook Github where they had managed to get a custom Webpack config working for React Native. Before this I hadn't really even considered this as a possibility so I was immediately intrigued.
You can practically hear the cogs turning in my head.
Over the next few weeks I started experimenting with different Webpack configurations to figure out how to get React Native Web working. At the time Expo was also using Webpack for their React Native Web solution and I ended up taking quite a bit of that code for my plugin. Eventually I got something working and I made an addon that essentially added an extra babel-loader to your Storybook config with all the React Native Web stuff you might need.
This worked great for a while and the package has reached a decent level of popularity. However it does have a few issues and webpack is getting left behind. If you have experience configuring webpack you'll also know the pain I've been through with it.
Clearly though we're here to talk about Vite, and why even make a Webpack addon? Well there wasn't initially a way to use other bundlers with Storybook, everything worked via Webpack. However this changed in Storybook 6 and you could now pick your bundler via builders.
Quite quickly the Vite builder became very popular and this led to Vite becoming the recommended and default bundler for Storybook. Today most of Storybook's new features are being built on top of Vite, such as component testing features built on top of Vitest. Since this started happening I've been trying to make Vite work for React Native Web, but without much experience with Vite or web bundlers in general this took me quite a while.
What makes React Native Web different
If you look into bundling react native code for the web you'll find that things look a bit different than code you might see on the web today. In React Native projects we use a bundler called Metro that specifically targets React Native. This means it has features more focused on targeting different platforms and bundling into a single file for the React Native Javascript engine Hermes. A lot of the differences you see in React Native code is a direct result of how Metro bundling works.
- Metro will transpile everything including node_modules.
- Metro will take all the code no matter what it is and turn it into what is essentially es5/es6 commonjs code for Hermes.
- Metro platform specific code in
.web/.ios/.android
- Metro doesn't have a plugin system and instead the bundling is extended via babel plugins
- React Native is written in flow
- Its common practice to mix ESM and commonjs
require
for images andimport
for modules
Since library developers assume Metro will bundle all your node_modules packages, many packages will ship unbundled. However when using other bundlers the node_modules folder is usually excluded for performance reasons.
So knowing these things heres what you need to consider when getting React Native Web to work.
- Alias
react-native
toreact-native-web
- Resolve .web.js before .js
- Add a bunch of global variables React Native and Expo expects
- Transpile code with babel including node_modules
- Strip flow syntax
- Treat .js as .jsx
- Handle mixed commonjs and esm code
This list is the result of a lot of trying and failing, honestly running React Native Web is a bit of a mystery from the outside. This might be largely because I'm often trying to run it outside of the 'normal' constraints. If you were using Expo for React Native Web its quite unlikely you'd run into any of these issues because they put in a lot of work to make it run well for metro.
Making Vite work
I had some early success with just the react Vite plugin and applying the same config I would for Webpack. After a few attempts I had something working and was getting excited, though as soon I tried to run a build everything stopped working again.
What would happen is Vite would automatically optimize the node_modules in dev mode but in production builds that doesn't happen. Even if you try to configure the React plugin to include node_modules nothing will change. Thats because the react plugin specifically bails out whenever a file comes from node_modules.
I was missing a way to transpile node_modules (#4 on our list). Luckily though there are some solutions. The first success I had with this was using the Babel Vite plugin together with the React Vite plugin. This actually was working quite well and powered the first versions of my Vite framework for Storybook. The problem is that you had to basically configure babel twice and surfacing that to the user in a sane way is pretty difficult.
I tried a few things after this but I essentially landed on making a custom Vite plugin based on the React plugin but with all the React Native configurations built in. It automatically includes modules from react-native
or expo
. This plugin became vite-plugin-rnw
and it now powers the React Native Web Storybook framework @storybook/react-native-web-vite
.
With this new plugin all you need is this to get started:
// vite.config.js
import { defineConfig } from "vite";
import { rnw } from "vite-plugin-rnw";
export default defineConfig({
plugins: [rnw()],
});
Then if you need to transpile some node_modules you can override the config and it will respect whatever you decide to include.
Heres an example of an excludes pattern you could use.
const modulesToTranspile = [
'react-native',
'@react-native',
'expo',
'@expo',
"other-module"
];
const exclude = new RegExp(`/node_modules/(?!${modulesToTranspile.join('|')})`)
export default defineConfig({
plugins: [rnw({ exclude })],
});
How does it work?
From here on I'm going to dive into the Vite config I ended up with to get this all working.
Looking back at the list:
- Alias
react-native
toreact-native-web
- Resolve .web.js before .js
- Add a bunch of global variables React Native and Expo expects
- transpile code with babel including node_modules
- Strip flow syntax
- Treat .js as .jsx
- Handle mixed commonjs and esm code
1. Handling aliases
This is easily achieved with some resolve configuration:
{
resolve: {
alias: {
"react-native": "react-native-web",
},
}
}
2. Resolve .web.js first
Similarly you can configure the supported file extensions quite easily.
const extensions = [
".web.js",
".web.ts",
".web.tsx",
".web.mjs",
".web.cjs",
".js",
".jsx",
".json",
".ts",
".tsx",
".mjs",
".cjs",
]
return {
resolve: {
extensions,
}
optimizeDeps: {
esbuildOptions: {
resolveExtensions: extensions,
}
}
}
3. Apply global variables
Next up is a list of frankly strange environment variables that I found to be necessary for various things. You could probably skip some of these and just apply them as you run into those edge cases.
The most important one is having __DEV__
since its widely used to determine if we're in dev mode.
const development = env.mode === "development";
return {
define: {
// yeah weird I know
global: "window",
DEV: JSON.stringify(development),
// don't even remember anymore
"global.__x": {},
// reanimated stuff
_frameTimestamp: undefined,
// reanimated stuff
_WORKLET: false,
// expected by most react native code
__DEV__: JSON.stringify(development),
"process.env.NODE_ENV": JSON.stringify(
process.env.NODE_ENV || env.mode
),
// expected by some expo libraries
EXPO_OS: JSON.stringify("web"),
"process.env.EXPO_OS": JSON.stringify("web"),
// something in reanimated seemed to need this
"global.Error": "Error",
},
}
4. Babel transforms
I won't go into the plugin version of this because it would be a bit too much to cover. However I'll give you the version that I had working before forking the React plugin.
To get your node_modules to transpile you can add vite-plugin-babel
and have it include specifically the node_modules you need.
import react from "@vitejs/plugin-react";
import babel from "vite-plugin-babel";
plugins: [
// handles user code
react({
jsxRuntime: 'automatic',
babel: {
babelrc: false,
configFile: false,
},
}),
// handles node modules code
babel({
include:[
//include the modules you want to transpile here
/node_modules\/(react-native|@react-native|expo|@expo)/,
],
babelConfig: {
babelrc: false,
configFile: false,
presets: [
[
'@babel/preset-react',
{
development: isDevelopment,
runtime: 'automatic',
},
],
],
plugins: [
[
// this is a fix for reanimated not working in production
'@babel/plugin-transform-modules-commonjs',
{
strict: false,
strictMode: false, // prevent "use strict" injections
allowTopLevelThis: true, // don't rewrite global `this` -> `undefined`
},
],
],
},
}),
]
5. strip flow syntax
This one was a bit weird, I had assumed I could just apply a babel parser or plugin and just ignore flow, however it turned out to be a bit more difficult. The thing that worked best for me was to use this plugin: @bunchtogether/vite-plugin-flow
.
import { esbuildFlowPlugin } from "@bunchtogether/vite-plugin-flow";
// ...
optimizeDeps: {
esbuildOptions: {
loader: {
".js": "jsx",
},
plugins: [
esbuildFlowPlugin(
new RegExp(/\.(flow|jsx?)$/),
(_path: string) => "jsx"
),
],
},
},
Then include this Vite plugin
import { flowPlugin } from "@bunchtogether/vite-plugin-flow";
// ...
plugins: [
flowPlugin({
// to include more packages add them here like
// `/\/node_modules\/(?!react-native|@react-native|other_package)/`
exclude:/\/node_modules\/(?!react-native|@react-native)/,
}),
]
6. Treat .js as .jsx
Honestly I expected this would be enough, but I've found that its often not.
return {
optimizeDeps: {
esbuildOptions: {
loader: {
".js": "jsx",
},
plugins: [
esbuildFlowPlugin(
new RegExp(/\.(flow|jsx?)$/),
(_path: string) => "jsx"
),
],
},
},
}
You can add a custom Vite plugin like this that will handle it if you run into issues with the approach above.
plugins: [
{
name: "treat-js-files-as-jsx",
async transform(code, id) {
if (!id.match(/\.js$/)) return null;
return vite.transformWithEsbuild(code, id, {
loader: "jsx",
jsx: "automatic",
});
},
},
]
7. Handle mixed commonjs and ESM code
This one is another case where its a bit awkward to solve because usually you can only have CJS or ESM in a file, but as we know thats not the case with most React Native projects.
What I've found is that this plugin works really well to solve this for most cases vite-plugin-commonjs
import commonjs from "vite-plugin-commonjs";
plugins: [
commonjs()
]
A working config file
So yeah thats a lot and I hope to keep finding simpler ways to do this. However you don't need to do that (unless you want to) since I've published the plugin for the purpose of simplifying the configuration
// vite.config.js
import { defineConfig } from "vite";
import { rnw } from "vite-plugin-rnw";
export default defineConfig({
plugins: [rnw()],
});
Or alternatively to find a version of the config discussed here all put together check out this repository:
https://github.com/dannyhw/rn-web-vite-example/blob/main/vite.config.ts
Wrapping up
If you want to help improve the plugin please checkout the repository here:
https://github.com/dannyhw/vite-plugin-rnw
Hopefully this helps someone out there, let me know if you try it out or have any suggestions.
You can contact me at https://x.com/Danny_H_W if you have questions.
Heres my Github if you want to follow my work: https://github.com/dannyhw.
Thanks to Shilman and others on the storybook core team for the pairing sessions as always and thanks to @anishamalde for reviewing this post.