21 May, 2025
React Strict DOM peer dependencies are currently a bit of a mess. Rather than using --legacy-peer-deps
this post describes how I'm getting around it and creating a setup of shared React components running on iOS and Android (using Expo) and web (using Vite although since we are then using Babel, we don't get all the benfits of esbuild).
For background, React Strict DOM and StyleX work together to provide an HTML and CSS subset that works in browsers and react native. It looks like it will replace React Native Web according to that project's author: https://github.com/necolas/react-native-web/discussions/2646.
OK, let's get started:
For web run these commands one at a time:
npm create vite@latest web -- --template react-ts
cd web
npm i --save 'react-strict-dom' 'react-native@0.77' 'react@18.2' 'react-dom@18.2' postcss-react-strict-dom '@types/react@18.2' '@types/react-dom@18.2' vite-plugin-babel @babel/preset-react
Then create some files based on https://github.com/facebook/react-strict-dom/issues/265#issuecomment-2660129604:
cat << 'EOF' > babel.config.cjs
module.exports = {
presets: [
['@babel/preset-react', { runtime: 'automatic' }],
'react-strict-dom/babel-preset'
]
};
EOF
cat << 'EOF' > postcss.config.js
export default {
plugins: {
'postcss-react-strict-dom': {
include: ['src/**/*.{js,jsx,ts,tsx}']
}
}
};
EOF
cat << 'EOF' > vite.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import babel from 'vite-plugin-babel';
// https://vite.dev/config/
export default defineConfig({
plugins: [
react({
babel: {
presets: ['react-strict-dom/babel-preset']
}
}),
babel({
include: /node_modules\/react-strict-dom(-svg)?/
})
]
});
EOF
cat << 'EOF' > src/stylex.css
/* This directive is used by the react-strict-dom postcss plugin. */
/* It is automatically replaced with generated CSS during builds. */
/* Keep it under src to make sure styles reload correctly */
@stylex;
EOF
Add the css import at the top level:
cat << 'EOF' > top
// Required for CSS to work.
import './stylex.css';
EOF
cat src/main.tsx >> top
mv top src/main.tsx
All done:
cd ../
For native, run these one at a time:
npx create-expo-app@latest mobile --template default@52
cd mobile
npm i --save react-strict-dom
Then create some files based on https://facebook.github.io/react-strict-dom/learn/setup/:
cat << 'EOF' > babel.config.js
const reactStrictPreset = require('react-strict-dom/babel-preset');
function getPlatform(caller) {
// This information is populated by Expo
return caller && caller.platform;
}
function getIsDev(caller) {
// This information is populated by Expo
if (caller?.isDev != null) return caller.isDev;
// https://babeljs.io/docs/options#envname
return (
process.env.BABEL_ENV === 'development' ||
process.env.NODE_ENV === 'development'
);
}
module.exports = function (api) {
// If not using Expo, set these values manually or by other means
const platform = api.caller(getPlatform);
const dev = api.caller(getIsDev);
return {
plugins: [],
presets: [
// Expo's babel preset
'babel-preset-expo',
// React Strict DOM's babel preset
[reactStrictPreset, {
debug: dev,
dev,
platform
}]
]
};
};
EOF
cat << 'EOF' > metro.config.js
// Learn more https://docs.expo.dev/guides/monorepos
const { getDefaultConfig } = require('expo/metro-config');
// Find the project and workspace directories
const projectRoot = __dirname;
const config = getDefaultConfig(projectRoot);
// 1. Enable Metro support for symlinks and package exports
config.resolver.unstable_enablePackageExports = true;
// 2. Only for npm monorepos: force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
// config.resolver.disableHierarchicalLookup = true;
module.exports = config;
EOF
cat << 'EOF' > postcss.config.js
module.exports = {
plugins: {
'postcss-react-strict-dom': {
include: [
// Include source files to watch for style changes
'src/**/*.{js,jsx,mjs,ts,tsx}',
// List any installed node_modules that include UI built with React Strict DOM
'node_modules/<package-name>/*.js'
]
},
autoprefixer: {}
}
};
EOF
cat << 'EOF' > stylex.css
/* This directive is used by the react-strict-dom postcss plugin. */
/* It is automatically replaced with generated CSS during builds. */
@stylex;
EOF
You'll also need to add some imports at the top level:
cat << 'EOF' > top
// Required for CSS to work on Expo Web.
import '.././stylex.css';
// Required for Fast Refresh to work on Expo Web
import '@expo/metro-runtime';
EOF
cat app/_layout.tsx >> top
mv top app/_layout.tsx
All done:
cd ../
Now, we can share some React Strict DOM code between the two projects. Now, if you try and create a symlink directory within the metro project it won't work. In fact it was the first issue on the project #1. There are workarounds in that thread, but they aren't ideal:
// Don't do this:
// config['watchFolders'].push(__dirname + '/../../shared');
// config['resolver']['unstable_enableSymlinks'] = true;
// config['resolver']['extraNodeModules'] = {
// // This makes sure Metro resolves @ as if it's from project root
// '@': __dirname,
// };
Luckily the vite web version has no such issues, so let's create the shared components in the mobile
project folder and then symlink them into the web
folder.
Create shared
:
mkdir mobile/shared
Here's a very simple Hello
component to share in the two projects from the shared
directory:
cat << 'EOF' > mobile/shared/Hello.tsx
import { css } from 'react-strict-dom';
import { html } from 'react-strict-dom';
const styles = css.create({
base: {
fontSize: 16,
lineHeight: 1.5,
color: 'rgb(250,0,0)',
},
});
export function Hello() {
return (
<html.p style={styles.base}>Hello World!</html.p>
);
}
EOF
To install this in the web
folder:
cd web/src
ln -s ../../mobile/shared shared
cd ../../
If you just put it in web
instead of web/src
then the styles hot-reloading won't work.
Then update the default files:
cat << 'EOF' > mobile/app/\(tabs\)/index.tsx
import { Hello } from '@/shared/Hello';
export default function HomeScreen() {
return (
<Hello />
);
}
EOF
cat << 'EOF' > web/src/App.tsx
import './App.css'
import { Hello } from './shared/Hello';
function App() {
return (
<Hello />
)
}
export default App
EOF
Then run all three projects:
cd mobile
npm run start
Press i
for iOS then a
for Android.
You should see the iOS, Android versions running in their respective emulator or connected device.
Then in another terminal:
cd web
npm run dev
Then if you visit http://localhost:5173 in a browser you'll see the web version.
As you change files in mobile/shared
all three versions will update!
Combine this with preact signals and SWR and I think you have quite a nice setup. It is one I plan to explore in a bit more detail anyway.
Hopefully as React Strict DOM matures, it will keep up with the latest React and Expo versions so we don't have to use old ones so much.
Tip: You can also do:
cd mobile
npm run web
Which will create a version of the mobile app codebase for web, but I find mobile look and feel on a desktop-sized browser doesn't work too well, hence the separated projects approach.
And for a production web build you need to change src/main.tsx
to:
cat << 'EOF' > web/src/main.tsx
import './stylex.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
const rootElement = document.getElementById('root');
if (rootElement) {
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
);
} else {
console.error('Unable to find root element with id "root".');
}
EOF
Then:
cd web
npm run build
Which gives something like this:
vite v6.3.5 building for production...
✓ 32 modules transformed.
dist/index.html 0.46 kB │ gzip: 0.30 kB
dist/assets/index-DZtMsOz4.css 2.69 kB │ gzip: 1.16 kB
dist/assets/index-B_iIhIXb.js 145.90 kB │ gzip: 47.21 kB
✓ built in 465ms
So the production site is less that 50KB.
Be the first to comment.
Copyright James Gardner 1996-2020 All Rights Reserved. Admin.