Migrating from AngularJS to React

The AngularJS long-term support period is coming to an end, so now is a good time to consider migrating to a more modern web framework, such as React. That’s what we’ve been planning at work since the start of the year, and I’d like to share our high-level approach.

Two frameworks, one web app

It would be infeasible to migrate our entire app all at once, so first we need a way of running both Angular and React code at the same time.

To start with, we need to decide either:

  1. Migrate pages across and run them standalone.
  2. Migrate individual components and run them in the Angular-controlled pages, until there are enough components to make a full page.

We went with option 1. While option 2 would allow us to get more React code into production faster, I wanted to make sure we could create acceptable alternatives to parts of the framework Angular offered that React does not.

In Angular, these built-in tools are things like dependency injection, services, and URL routing. I was pretty confident with the team’s ability to create individual React components - the risk comes from having to put it all together to create pages.

By writing code with the goal of creating pages from the start, we avoid more “gotchas”, and weren’t going to end up with a bunch of components that don’t fit together with React running the show instead of Angular.

Running Angular and React side-by-side

Next is deciding how we’re going to run these pages at the same time. We investigated as much as we could to help with this problem. The app would be served statically from a file system — no server-side rendering. The two viable options we found were loading React or Angular bundles depending on the route, and the single-spa library.

The first idea worked like this: React pages would have their own index.html files inside the paths that were relevant to them. This HTML would contain a script tag that would load the React JS bundle. The Angular bundle would then be served up on all other routes like a normal SPA with native history (no hash-prefixed paths in the URL).

For example:

index.html
angular.bundle.js
react.bundle.js
orders/
└── index.html

If you visit example.com/customers then fall back to loading /index.html with the Angular script tag. If you visit example.com/orders it will find /orders/index.html which will have the React script tag.

The main problems with this approach included requiring separate index.html files in every path that used React. We were letting the file system decide which paths did what, therefore limiting which parts of the app we could make React. It also created difficult problems with client-side routing. We found it tricky getting the correct app to load when the user navigates around.

I wasn’t too happy with single-spa either. It was a bit too complicated for our use case. We were only interested in moving one app over to another, not the vaguely-defined trend of micro-front-ends. It seemed to run both frameworks side-by-side in the same page, which has the smell of unforeseen consequences.

I also wasn’t keen on relying heavily on a large codebase we haven’t written for something like this. Something as important as bootstrapping the app should be reasonably simple and well understood by the team, in case it went wrong in production.

The solution we found took ideas from both. Instead of having an index.html for each route, we have one, containing a “bootstrap” JS file that picks the React/Angular JS bundle client-side, and appends it to the document.

Here is a basic example of that:

(function() {
  const useReact = window.location.pathname.indexOf('/react') === 0;
  const scriptSrc = useReact ? 'react.bundle.js' : 'angular.bundle.js';
  const script = document.createElement('script');
  script.setAttribute('src', scriptSrc);
  document.body.appendChild(script);
})();

It’s surprisingly simple. We can set the useReact boolean how we want, although we still have it based on the route (“/react” is just an example here). It also works for selecting the right stylesheet, you can append the <link> for that here too.

To get around client-side routing, we created Link components in each framework that would reload the page when used. The browser is able to detect whether it was a full page load or a history.push() so back and forward works too.

In testing, we found no real-world performance issues loading the page. The only thing a user might notice is the full page load between frameworks. As this is usual for a non-SPA (Single Page App) website, it shouldn’t be an issue. Hopefully this approach continues to work out going forward!🤞

Code structure

We decided to make this project a monorepo, rather than split Angular and React into separate git repos. It becomes considerably easier to create code changes that touch both sides of the app this way.

We looked at tools that offer ways of managing multiple JS projects in one repo. Lerna does this with features to run multiple dependency installs and JS scripts, but it is mainly suited for managing multiple versions of publishable libraries.

Yarn offers workspaces, a way of splitting your dependencies, making them easier to manage. But we didn’t want to move from npm for package management. We tried in the past and the setup didn’t work for our project. Maybe npm workspaces in v7 is a good move in the future.

We did, however, like the idea of “packages” (aka… folders) as a way of separating the code between frameworks. So this is the format we went for:

tsconfig.json
package.json
packages/
├── angular/
│   ├── scripts/
│   ├── styles/
├── react/
│   ├── scripts/
│   ├── styles/
├── shared/
│   ├── scripts/
│   ├── styles/
└── assets/
    └── images/
        (etc.)

A global package.json and TypeScript config, and separate folders for framework specific code. The ‘shared’ folder contains code that is used by pages in both frameworks. We try to share and reuse as much as possible. This is made easier using TypeScript path mapping.

To generate Angular and React JS bundles, we extended our Webpack config like this:

entry: {
  angular: 'packages/angular/scripts/app.module.ts',
  react: 'packages/react/scripts/app.tsx',
},
output: {
  path: 'dist',
  filename: '[name].bundle.js',
}

That produces two files, angular.bundle.js and react.bundle.js. Nice!

Here are some more tips we’ve tried to work by:

  • Migrate small or less-trafficked pages to start with before working up to the big ones.
  • Don’t leave out tests. If they were written for the old code, you should migrate them too.
  • Don’t feature creep. It’s much easier to review the UI and behaviour if you can compare it directly to the old code.
  • Try to write your own solutions, it will eliminate more unknowns about the code you and your users are running.

I hope some of the approaches I’ve written about here are of help if you ever need to migrate web frameworks too.