Building a Standalone React Widget with UMD and Dynamically Loading It in Another React App Using Single-SPA

Building a Standalone React Widget with UMD and Dynamically Loading It in Another React App Using Single-SPA

Introduction to Microfrontends and Single-SPA

As web applications grow in complexity, maintaining a monolithic frontend can become challenging. Microfrontends solve this by breaking down a large frontend application into smaller, manageable parts that can be developed and deployed independently.

Single-SPA is a powerful JavaScript framework that enables this architecture by dynamically loading and mounting frontend applications or components (parcels) at runtime. It allows different teams to develop features in different frameworks (React, Vue, Angular) while keeping them seamlessly integrated in one application.


Why Use Single-SPA?

  • Scalability: Microfrontends enable large teams to work independently without conflicts.

  • Tech Stack Independence: Different frameworks can be used within the same application.

  • Incremental Upgrades: Individual microfrontends can be updated without affecting the entire system.

  • Code Reusability: Modules can be shared across multiple applications without duplication.


Setting Up the Host Application

The host app will be responsible for embedding the microfrontend parcel.

Step 1: Initialize the Host App

Run the following commands to set up a React-based host application using Vite:

mkdir host-app && cd host-app
yarn create vite . --template react
yarn install

Step 2: Install Dependencies

yarn add single-spa single-spa-react

Step 3: Expose React and ReactDOM

Modify src/main.jsx to ensure React and ReactDOM are available globally:

import React from "react";
import * as ReactDOM from "react-dom/client"; // ✅ Use react-dom/client for React 18+
import { StrictMode } from "react";
import App from "./App.jsx";

// ✅ Expose the correct version of ReactDOM (from react-dom/client)
window.React = React;
window.ReactDOM = ReactDOM;

console.log("React is now exposed on window:", window.React);
console.log("ReactDOM is now exposed on window:", window.ReactDOM);

ReactDOM.createRoot(document.getElementById("root")).render(
  <StrictMode>
    <App />
  </StrictMode>
);

Step 4: Add SystemJS to the HTML File

Modify index.html to include SystemJS for loading the parcel dynamically:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Host Application</title>
    <script src="https://unpkg.com/systemjs/dist/system.js"></script>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Step 5: Load the Parcel in App.jsx

Modify src/App.jsx to embed the microfrontend parcel:

import React from "react";
import { mountRootParcel } from "single-spa";
import Parcel from 'single-spa-react/parcel'; 

function App() {
  return (
    <div>
      <h1>Host Application</h1>
      <Parcel
        mountParcel={mountRootParcel}
        config={() => window.System.import('http://localhost:3000/parcel.js')}
        wrapWith="div"
        wrapStyle={{ width: "100%", height: "400px" }}
      />
      <hr />
    </div>
  );
}

export default App;

Setting Up the Microfrontend Parcel

Step 1: Initialize the Parcel App

mkdir carousel-widget && cd carousel-widget
yarn create vite . --template react
yarn install

Step 2: Install Dependencies

yarn add webpack webpack-cli babel-loader @babel/preset-react @babel/preset-env style-loader css-loader single-spa-react

Step 3: Configure Webpack for UMD Build

Modify webpack.config.cjs:

const path = require('path');

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "parcel.js",
    path: path.resolve(process.cwd(), "dist"),
    library: "parcel",
    libraryTarget: "umd", // ✅ Must be "umd"
    globalObject: "window",
    publicPath: "/",
  },
  mode: "production",
  module: {
    rules: [
      {
        test: /\.jsx?$/, // Ensures both .js and .jsx files are processed
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env", "@babel/preset-react"],
          },
        },
      },
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
  resolve: {
    extensions: [".js", ".jsx"],
  },
  externals: {
    react: "React",
    "react-dom": "ReactDOM",
  },
};

Step 4: Implement the Parcel Component

Modify src/index.js:

import React from "react";
import Carousel from "./Carousel";
import "./carousel.css";

const roots = new Map();

export function mount(props) {
  return new Promise((resolve, reject) => {
    try {
      const { domElement } = props;
      let root = roots.get(domElement);
      if (!root) {
        root = window.ReactDOM.createRoot(domElement);
        roots.set(domElement, root);
      }
      root.render(<Carousel />);
      resolve();
    } catch (error) {
      reject(error);
    }
  });
}

export function unmount(props) {
  return new Promise((resolve, reject) => {
    try {
      const { domElement } = props;
      if (roots.has(domElement)) {
        roots.get(domElement).unmount();
        roots.delete(domElement);
      }
      resolve();
    } catch (error) {
      reject(error);
    }
  });
}

Create src/Carousel.js:

import React, { useState } from "react";
import "./carousel.css";

const images = [
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTz5_pFXLFlros8tRZoOHLVZVI30KJEU411IQ&s",
  "https://kinsta.com/wp-content/uploads/2023/04/react-must-be-in-scope-when-using-jsx.jpg",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS58TIBqANB1PTufYIQTmZJBVAj4oN1KLVJFjM-0IOWVYMof6KNE6zhRjrUgHnH5CaWnwo&usqp=CAU",
];

const Carousel = () => {
  const [currentIndex, setCurrentIndex] = useState(0);

  const prevSlide = () => {
    setCurrentIndex((prevIndex) => (prevIndex === 0 ? images.length - 1 : prevIndex - 1));
  };

  const nextSlide = () => {
    setCurrentIndex((prevIndex) => (prevIndex === images.length - 1 ? 0 : prevIndex + 1));
  };

  return (
    <div className="carousel-container">
      <button onClick={prevSlide} className="carousel-btn left"></button>
      <img src={images[currentIndex]} alt={`Slide ${currentIndex + 1}`} className="carousel-image"/>
      <button onClick={nextSlide} className="carousel-btn right"></button>
    </div>
  );
};

export default Carousel;

Create src/carousel.css

.carousel-container {
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  width: 100%;
  height: 100%;
  background-color: #f0f0f0;
}

.carousel-image {
  width: 400px;
  height: 300px;
  object-fit: cover;
  border-radius: 10px;
}

.carousel-btn {
  position: absolute;
  background: rgba(0, 0, 0, 0.5);
  color: white;
  border: none;
  padding: 10px;
  font-size: 20px;
  cursor: pointer;
}

.left {
  left: 10px;
}

.right {
  right: 10px;
}

.carousel-btn:hover {
  background: rgba(0, 0, 0, 0.8);
}

Step 5: Update the package.json scripts

  "scripts": {
    "serve": "serve -s dist --cors",
    "build": "webpack --config webpack.config.cjs"
  },

Step 6: Build and Serve the Parcel Locally

yarn add serve --dev
yarn build
yarn serve

Now, the parcel is accessible at.http://localhost:3000/parcel.js


Final Steps: Run the Host and Parcel Apps

1️⃣ Start the Parcel App

cd carousel-widget
yarn serve

2️⃣ Start the Host App

cd host-app
yarn dev

Now open http://localhost:5173/ (or your Vite dev server URL) to see the host app dynamically loading the parcel! 🚀


Conclusion

This guide covered setting up a Single-SPA microfrontend with React, exposing React versions properly, and ensuring smooth mounting/unmounting of parcels. By following this, you can create scalable and modular frontend architectures. 🚀


Github

https://github.com/adeeshsharma/react-single-spa-widget

Did you find this article valuable?

Support Adeesh's Software Engineering Insights by becoming a sponsor. Any amount is appreciated!