Home Blog CV Projects Patterns Notes Book Colophon Search

React Strict DOM

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.

Comments

Be the first to comment.

Add Comment





Copyright James Gardner 1996-2020 All Rights Reserved. Admin.