single-spa – Unable to resolve bare specifier ‘react-dom/client’

single-spa – Unable to resolve bare specifier ‘react-dom/client’

Our whole setup involves multiple micro-frontends powered by single-spa and React configured to share libraries via dynamic imports using systemjs import maps. After updating to React 18, we encountered the following error:

Error: Unable to resolve bare specifier 'react-dom/client' from '/path/to/file.js'

Some background

Our setup is quite complex but it is based around the recommended setup by single-spa. We have the following import map.

    "imports": {
        "react": "/js/vendors/react/18.2.0/umd/react.production.min.js",
        "react-dom": "/js/vendors/react-dom/18.2.0/umd/react-dom.production.min.js",
        "react-dom/server": "/js/vendors/react-dom/18.2.0/umd/react-dom-server.browser.production.min.js",

Then we configure Webpack to resolve these packages via dynamic import using the externals configuration.

"externals": [

The react-dom package changed the way they render components. They now use react-dom/client instead of react-dom to create and render the root component.

import React from 'react';
import ReactDOMClient from 'react-dom/client';
import MainApp from './MainApp';

const root = ReactDOMClient.createRoot(
  document.getElementById('root') as HTMLElement

  <MainApp />

No matter how I tried, I just can’t make it resolve to react-dom/client.

So I got my hands dirty.


I inspected the code in react-dom/client and I found out that it is just a wrapper for createRoot and hydrateRoot functions from react-dom. I just re-created the wrapper inside my src and use it to initialize single-spa.

// src/ReactDOMClient.ts
const ReactDom = require('react-dom');

let createRoot;
let hydrateRoot;

if (process.env.NODE_ENV === 'production') {
  createRoot = ReactDom.createRoot;
  hydrateRoot = ReactDom.hydrateRoot;
} else {
  createRoot = function(c, o) {
    i.usingClientEntryPoint = true;
    try {
      return ReactDom.createRoot(c, o);
    } finally {
      i.usingClientEntryPoint = false;
  hydrateRoot = function(c, h, o) {
    i.usingClientEntryPoint = true;
    try {
      return ReactDom.hydrateRoot(c, h, o);
    } finally {
      i.usingClientEntryPoint = false;

export {

This is now my new single-spa lifecycle scripts.

// src/lifecycle.ts
import React from 'react';
import singleSpaReact from 'single-spa-react';
import ErrorBoundary from "./components/ErrorBoundary";
import MainApp from './MainApp';

const ReactDOMClient = require('./ReactDomClient');

const reactLifecycles = singleSpaReact({
  rootComponent: MainApp,
  errorBoundary: ErrorBoundary

export const bootstrap = [

export const mount = [

export const unmount = [

And now it works! I just have to replicate the same fix to gazillion micro-frontends more (just 4 actually) and deploy them all!

Featured image by

Leave a reply

Your email address will not be published. Required fields are marked *