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. 🚀