Imagine finding yourself in a situation where you join a project tasked with developing a massive legacy application created five years ago using the first version of Angular. This application resembles a forgotten treasure, the functioning of which no one in the company knows anymore. Hidden within its depths is an authentication system and numerous modules managing business logic, all intertwined into a single structure with the help of an outdated build tool – Gulp. As if that wasn't enough, the application's dependencies still require loading through Bower, adding another layer of outdated technologies. In this context, your task acquires not only a technical but almost an archaeological aspect – you're faced with restoring the functionality of this digital relic and updating it without losing the value of the knowledge and experience encoded within.
Now, consider the parallel reality of this project, where alongside the legacy Angular system, the development team has embarked on creating a new application, this time choosing the modern and popular React. The project is built using Webpack, which is the de facto standard for modern web applications. The new application implements its internal routing mechanism and integrates modern libraries, significantly differentiating its technological base from the old Angular application.
However, despite the external attractiveness and modernity of the React application, a serious problem of duplicating business logic between the two systems arises. Such key interface elements as the main sidebar, despite having an identical appearance, must be rewritten from scratch for the new system. This increases the workload and leads to the need to maintain and update code in two different codebases whenever business requirements change. The lack of code reuse between the two applications becomes a significant drawback, increasing labor costs and the likelihood of errors when synchronizing changes.
The ideal solution in such a situation would be gradually integrating new technologies into the legacy application, with the possibility of selectively replacing its parts with modern alternatives. Imagine how the scenario would change if we could, step by step, replace outdated pages with new ones developed using React while preserving and reusing the common business logic and utilities, such as the authentication system. This would reduce code duplication and facilitate the transition to modern technologies, minimizing the risks and costs associated with a complete overhaul of the application.
Throughout my professional career, I have encountered situations twice where a company had an outdated AngularJS application and, alongside it, was developing a new application on React. Finding ways to integrate and create a symbiosis between the old and new code becomes a critically important task. There are various methods to create the illusion of a single whole from two different applications, including using an iframe or setting up routing through Nginx.
Today, I want to share not so much a step-by-step guide but an introduction to an interesting idea that could help developers with the task of integrating Angular and React applications. My focus today is on using Module Federation to address this issue. While it may not be considered an perfect solution, it nonetheless works and could be the very impetus someone is looking for in their quest to unite these two worlds.
We will touch on micro-frontends, explore the peculiarities of building such applications, and discuss how to organize interaction between Angular and React code. This idea is intended to show how modern approaches can be used to solve highly relevant tasks in development and offer a path to pursue in search of solutions.
Let's consider the stack:
In traditional frontend development, work is usually carried out within a single project, which contains a single package.json
file, uses one bundler, and has a set of modules that can freely import each other to exchange functionality and data. In this approach, all project elements are closely integrated and work in a unified context.
However, "micro-frontends" implies a significant change in this paradigm. The essence of the micro-frontend approach is that several frontend projects (or modules) are launched within a single application, which are loosely coupled with each other. This means that each of these micro-frontends can be developed, tested, built, and deployed independently of the others while together, they form a single application.
The micro-frontend architecture offers an interesting and innovative approach to web application development; however, like any technology, it has pros and cons.
Pros:
Cons:
Micro-frontends represent a powerful tool for developing complex web applications, but they require careful planning and a considered approach to their implementation and maintenance.
If you already have two independently functioning applications, transitioning to a micro-frontend architecture may be a relatively simple, especially if a unified system like Webpack is used for project assembly.
However, it's worth noting that many AngularJS projects are traditionally built using Gulp, which may become an obstacle to integration, given the differences between Gulp and Webpack in project assembly approaches. Gulp is often used for automating tasks, such as file minification and compiling SCSS to CSS, whereas Webpack provides more comprehensive capabilities for modular application assembly.
In this article, we will explore overcoming this limitation and integrating a React application using Webpack with an AngularJS application built using Gulp.
As we mentioned earlier, there are several approaches to creating a project where micro-frontends on React and Angular can coexist successfully:
postMessage
, which requires the development of a special mechanism for message exchange.http://site.com/angular/…
, and the React application at http://site.com/react/…
. Among the cons:
localStorage
or URL parameters. This challenge involves managing and synchronizing the state across different applications, which can become complex and error-prone.
Share in the comments if you know other effective methods for implementing a micro-frontend architecture!
In our project, we organize two types of builds: dev and production. The Angular application uses Gulp, while the React application is built through Webpack.
For production builds: We use Docker to run the build processes in parallel, after which we serve the results — static files for Angular and React — via Nginx. The React application is built using the ModuleFederation plugin.
For dev builds: A webpack-dev-server
is run on the local machine, serving Angular as static files and React in normal mode. In this article, we will not delve into the details of this build, as it represents a fairly standard configuration without the use of Module Federation.
Angular is the foundation for our application, into which we integrate React components. This allows us to retain most of the logic within the Angular application and gradually transition to React, adding new fragments as needed. This approach allows smooth integration of the new frontend without radical changes in the existing codebase.
In the article, we will refer to the approximate structure of the project, indicating which files and sections are affected in the process of presenting the material:
- Dockerfile
- package.json
- packages // microfrontends
- react-app
- package.json
- src
- angularIntegration.ts // entry to react app for angular app
- angularContext.tsx // pass global props from angular
- index.tsx
- angular-app
- package.json
- app
- index.html
- scripts
- react-integration.js // load react API and add that to `window`
- routes.js
- components
- sidebar
- sidebar-d.js // example of component
Let's start with the concept of a monorepository. One of the advantages of micro-frontend architecture is the ability to separate large applications from each other effectively. However, when it comes to development, it is especially convenient when two or more applications are located in the same repository. This applies to both dev and production builds.
The main advantage of a monorepo is that if an error occurs in one of the projects, it becomes obvious to the entire team, and further deployment to production is blocked. This significantly simplifies tool management, especially when you have not two but many small applications: it's easier to manage their dependencies and the build process. Moreover, a monorepo allows quick changes to be made simultaneously to Angular and React parts of the project, ensuring rapid task switching and feedback.
To focus on the key points and not complicate the article with details of managing a monorepo, we will not delve into the tools for working with monorepos. Instead, let's assume that we already have separate scripts set up for running production and dev builds directly from the root package.json
: react-app:build
, react-app:dev
, angular-app:build
, angular-app:dev
.
For the production build, we adopt an approach where the Angular and React applications are built separately in Docker. After the build process is completed, the results — the static files of both applications — are placed in the app/www
directory for further distribution via Nginx. One of this process's key features is using the ModuleFederation plugin in the React application build, which generates a remoteEntry.js
file.
This remoteEntry.js
file plays a central role in the Module Federation mechanism, allowing different frontend applications to dynamically load and use each other's code as dependencies at runtime. This provides flexibility and modularity, allowing, for example, an Angular application to integrate and use React components without the need to statically include the entire application in the build. Thus, both builds can remain independent, while ModuleFederation facilitates efficient interaction at runtime.
In the context of AngularJS, built through Gulp, and React, using Webpack and Module Federation, we directly interact with remoteEntry.js
from Angular. This file, created for React, automatically manages the loading of the necessary parts of the application. By connecting remoteEntry.js
to Angular, we gain access to React components and functions, easing integration between different parts of the project without complex configurations.
The Docker configuration for our project is organized into several stages. In the first stage, package.json
both applications are copied, and their dependencies are installed. Then, the process continues with the parallel building of the production versions of the React and Angular applications. The final step includes copying Nginx settings and the necessary static files.
An approximate configuration looks like this:
#
# Stage 1.1 - APP: Install modules
#
FROM node:20.3.0 AS app_modules_installer
WORKDIR /app
COPY package.json yarn.lock /app/
COPY packages/angular-app/package.json /app/packages/angular-app/
COPY packages/react-app/package.json /app/packages/react-app/
RUN yarn install --frozen-lockfile
#
# Stage 2.1 - APP: Build Angular app
#
FROM app_modules_installer AS app_angular_builder
WORKDIR /app
COPY packages/angular-app /app/packages/angular-app
RUN yarn angular-app:build
#
# Stage 2.2 - APP: Build React app
#
FROM app_modules_installer AS app_react_builder
WORKDIR /app
COPY packages/react-app /app/packages/react-app
RUN yarn react-app:build
#
# Stage 3.1 - NGINX: Build
#
FROM nginx:1.25-alpine3.18 AS nginx_builder
COPY deploy/nginx.conf /etc/nginx/nginx.conf
#
# Stage 3.2 - NGINX: Add assets
#
FROM nginx_builder
COPY --from=app_angular_builder /app/packages/angular-app/www /app/www
COPY --from=app_react_builder /app/packages/react-app/build /app/www/react-app
The project incorporates the Module Federation plugin, which you can learn more about on the official Webpack website. The essence is that our application is identified as react-app, integration is done using the remoteEntry.js file, and we export a module named angularIntegration.
new ModuleFederationPlugin({
name: 'react-app',
library: { type: 'global', name: 'react-app' },
filename: 'remoteEntry.js',
exposes: {
angularIntegration: './src/angularIntegration.ts',
},
}),
To complete the integration, it's necessary to add the following line to the index.html of the Angular application: <script src="scripts/react-integration.js"></script>. This script will be discussed in detail later, but its inclusion already prepares the platform for integration between Angular and React.
In a nutshell, we don't use Module Federation for the dev build but rely on webpack-dev-server
, which provides static file serving and HMR (Hot Module Replacement) for React. Angular is built and serves as static files for Webpack.
P.S.: Honestly, the first version of the article had a lot of details about this process, but due to the routine of setting up file paths, I decided not to complicate things. Just like that!
From the integration of React and Angular, we expect the following:
These requirements form the basis for effective and flexible integration between the two frameworks, allowing developers to leverage the advantages of both technologies within a single application.
The technical approaches for using an individual component or page are similar. The general scheme of integration looks as follows:
For successful integration of a React component into an Angular application, the following steps need to be executed:
remoteEntry.js
: This file, provided by Module Federation, contains the necessary information for loading and executing React modules in Angular.window
: This allows globally accessing the React component's render function within the Angular application.ReactDOM.createRoot
, a root, a React element is created based on the specified container.root.render
method.unmount
function for cleaning up the Virtual DOM when the component is no longer needed, for example, when changing pages.
Next, we will detail each of these steps, starting with the production build, as it represents a more complex case, and then briefly discuss how a similar process is implemented in dev mode.
Step 1: On the Angular side, the script react-integration.js is directly included in the index.html file, as is customary with any other script. This script is responsible for dynamically loading the remoteEntry.js file onto the page. The remoteEntry.js file is a special artifact created using the Module Federation plugin and provides an API for interacting with the React application. In our case, this API is named react-app:
// react-integration.js
(function () {
// #1
if (
["localhost", "some-dev-domen.com"].some((host) =>
window.location.host.includes(host)
)
) {
return; // dev mode
}
const createScript = (src) => {
const script = document.createElement("script");
script.src = src;
script.type = "module";
document.head.appendChild(script);
};
// #2
createScript(
`react-app/remoteEntry.js?hash=${window.REMOTE_ENTRY_HASH}`
);
// #3
const reactAppName = "react-app";
const moduleName = "angularIntegration";
// #4
const getApi = () => {
return window[reactAppName]
.get(moduleName)
.then((mod) => mod())
.then((mod) => mod.default);
};
// #5
window.renderReactSidebar = (container, globalAngularProps, props) => {
return getApi().then((render) =>
render(container, "sidebar", globalAngularProps, props)
);
};
window.renderReactPage = (container, globalAngularProps, props) => {
return getApi().then((render) =>
render(container, "page", globalAngularProps, props)
);
};
})();
Let's take a closer look at the process of working with the react-integration.js
script:
webpack-dev-server
is run.remoteEntry.js
Script: The remoteEntry.js
script is dynamically added to the page, including a hash in its address to prevent browser caching. This is especially important for updates to ensure the browser loads the current version. The hash can be generated during the Angular build process via Gulp, for example, using the current time (Date.now()
).renderReactSidebar
and renderReactPage
are added to the window
object, which will be called from Angular, in controllers or directives, to render React components.
Step 2: Instead of adding functions such as renderReactSidebar
to the Angular Dependency Injection (DI) system, a simpler approach was chosen by directly including these functions in the window
object. This facilitates their accessibility and invocation from different parts of the Angular application. When these functions are called, a container from Angular, into which the React component will be embedded, global settings such as handlers for ui-router
, and initial props
for the components to be passed. This method allows for flexible integration and management of React components within the Angular application, providing all necessary data and context for their correct operation.
Step 3: Next, using an Angular directive, the process of rendering the React component can be initiated. This is done by calling the corresponding function added to window
(for example, renderReactSidebar
), directly from the directive. At this point, the container (DOM element) into which the React component should be embedded, as well as the necessary props
and any global settings, are passed as arguments to the function.
// sidebar-d.js
(function () {
'use strict';
// # 1
angular.module('front').directive('frontSidebar', sidebar);
/* @ngInject */
function sidebar() {
return {
controller: sidebarCtrl,
templateUrl: 'scripts/components/sidebar/sidebar-d.html',
};
}
/* @ngInject */
async function sidebarCtrl(
$scope,
$state,
DynamicService,
) {
// #2
const globalAngularProps = {
goToRoute: (state, payload) => {
$state.go(state, payload, { reload: false });
},
};
// #3
const sidebarNode = document.getElementById('front-sidebar');
// #4
const { observer, unmount } = await window.renderReactSidebar(sidebarNode, globalAngularProps, {
someProp: 'initial state'
});
// #5
DynamicService.subscribe().then(function (count) {
observer.updateProps({ someProp: 'updated state' });
});
// #6
$scope.$on('$destroy', () => {
if (unmount) {
unmount();
}
})
}
})();
The process of integrating a React component into an Angular application through a directive includes the following steps:
<front-sidebar></front-sidebar>
in an Angular application.renderReactSidebar
, the React component is rendered in the found container. This process returns an observer
for managing props and an unmount
function to remove the component information from memory (Virtual DOM).observer
to update the React component's props when the data received from the service changes.unmount
function must be called for the React component to clean up the Virtual DOM and prevent memory leaks correctly.
The action plan for integrating React pages into an Angular application is similar to that used for components but requires additional routing configuration. This is particularly important if the Angular application uses ui-router, as Angular needs to be informed about routing changes occurring on the React side.
// routes.js
.state('dashboard.test', {
url: '/test',
template: '<react-page></react-page>',
})
Configuring routing between Angular and React can indeed be a challenging task, especially when ui-router
for Angular and react-router-dom
for React are used. Both routers aim to control state changes and navigation within the application, which can lead to conflicts if they are not properly configured to work together.
If readers are interested in learning more about ways to resolve these conflicts and how to configure both routers for harmonious coexistence, I can delve into this topic in a future article, describing possible approaches and best practices for integrating routing in micro-frontend architectures.
Moving to the work on the React side, we use the angularIntegration.js script as the entry point for our application. This script serves as a key element for integrating React components into an Angular application, allowing for the pinpoint insertion of React elements directly from Angular.
// angularIntegration.ts
import { renderPage, renderSidebar } from './index';
import { GlobalPropsFromAngular } from './angularContext';
export default function angularIntegration(
container: HTMLElement | null,
content: 'page' | 'sidebar',
globalPropsFromAngular: GlobalPropsFromAngular,
props: Record<string, any>,
) {
switch (content) {
case 'page':
return renderPage(container, globalPropsFromAngular);
case 'sidebar':
return renderSidebar(container, globalPropsFromAngular, props as any);
}
}
The angularIntegration
function, exported from the React application, was previously declared for the Module Federation plugin. It is used for injecting the React tree into a specified Angular application container, determining which specific components will be displayed.
The essence of any rendering function is similar. Let's look at the detailed process of rendering a sidebar:
// index.tsx
// #1
export const renderSidebar = (
container: HTMLElement | null,
globalAngularProps: GlobalPropsFromAngular,
props: SideMenuBarProps,
) => {
// #2
const { Component, observer } = withPropsObserver(SideMenuBar, props);
// #3
const unmount = render(container, globalAngularProps, <Component {...props} />, 'sidebar');
// #4
return { observer, unmount };
};
function withPropsObserver<T extends object>(Component: FC<T>, initialProps: T) {
let lazyUpdatedProps: T | null = null;
// #2.a
const observer: {
updateProps: (props: T) => void;
} = {
updateProps: (props) => {
lazyUpdatedProps = lazyUpdatedProps ? { ...lazyUpdatedProps, ...props } : props;
},
};
// #2.b
const Wrapper: FC<T> = () => {
const [props, setProps] = useState<T>(initialProps);
// #2.c
useEffect(() => {
if (lazyUpdatedProps) {
setProps(lazyUpdatedProps);
lazyUpdatedProps = null;
}
observer.updateProps = (newProps) => {
setProps((prev) => ({ ...prev, ...newProps }));
};
}, []);
return <Component {...props} />;
};
return {
Component: Wrapper,
observer,
};
}
const render = (
container: HTMLElement | null,
globalAngularProps: GlobalPropsFromAngular,
content: ReactNode,
identifierPrefix: string,
) => {
if (!container) {
throw Error('Root element not found!');
}
// #3.a
const root = ReactDOM.createRoot(container, {
identifierPrefix,
});
// #3.b
root.render(
<AngularContextProvider {...globalAngularProps}>{content}</AngularContextProvider>,
);
// #3.c
return () => root.unmount();
};
The renderSidebar
function initiates the rendering process of the sidebar, represented by a React component.
We want to have the ability to pass props to our component, so we wrap it in withPropsObserver
. Here, the Higher-Order Component pattern is used:
observer
object with an updateProps
function is created. This function allows for updating the component's props from Angular. Initially, updateProps
acts as a placeholder to collect lazyUpdatedProps
— props that have been updated prior to the component's initialization.Wrapper
— a wrapper that extends the component with additional capabilities for storing and managing props received from Angular.Wrapper
uses lazyUpdatedProps
to initialize the local state and updates observer.updateProps
so that this function can change the component's state.Now, we start rendering the component, for this, we call the render
function.
ReactDOM.createRoot
, the component is embedded into the provided Angular application container, creating a new React tree.AngularContextProvider
is included to provide access to global data and Angular handlers, for example, to support routing.unmount
function is returned along with the rendering result to allow subsequent removal of the Virtual DOM.After successful rendering, the observer
object and unmount
function are passed back to the Angular application. This gives Angular the ability to manage the state and lifecycle of the React component, as well as the component tree.
For development, we choose webpack-dev-server
because of its advantage in Hot Module Replacement compared to Gulp, which speeds up working with React code. Angular's static file serves as the index.html
template for Webpack, and React render functions are specified in window
through index.tsx
, simplifying integration and avoiding the use of react-integration.js
.
// index.tsx
if (process.env.NODE_ENV === 'development') {
window.renderReactPage = (container, globalAngularProps) => {
return renderPage(container, globalAngularProps);
};
window.renderReactSidebar = (container, globalAngularProps, props) => {
return renderSidebar(container, globalAngularProps, props);
};
}
We have explored the build setup and code interaction for integrating React and AngularJS, highlighting some nuances along the way. Despite potential technical complexities, this approach facilitates an efficient transition from an outdated stack to a modern one, opening new opportunities for development.
If you're interested in the topic, I'm ready to share solutions to common challenges, such as setting up routing, authentication, and using feature flags. Wishing you a pleasant day!