Apr. 17, 2017

Using AngularJS components & directives inside React

Why

We’ve seen some informative articles on how to use React components inside an Angular app, but guides for “using Angular components inside React” were scarce.

The GDC project is in the process of migrating from AngularJS (Angular 1) to React. For quite a while, the React-in-Angular pattern was used to perform a strangler migration one component at a time. As React ate through more and more of the project, we reached a tipping point where it could become the root app, but we were still short a few pieces of somewhat complex Angular directives that prevented us from reaching feature parity.

Being able to reuse those Angular directives as-is allowed us to continue the migration without compromising existing features or our delivery timeline.

Bootstrapping Angular

This part is actually incredibly simple.

Assuming the name of the root module for your legacy Angular app is ngApp, and the name of the Angular component you want to wrap as a react component is widget


class AngularWidget extends React.Component {
  componentDidMount() {
    angular.bootstrap(this.container, ['ngApp']);
  }
  render = () => (
    <div
      ref={c => { this.container = c; }}
      dangerouslySetInnerHTML={{ __html: '<widget></widget>' }}
    />
  )
}

Here’s a contrived example using angular-bootstrap-datetimepicker

See the Pen Angular Bootstrap DateTimePicker in React by Chang Wang (@cheapsteak) on CodePen.

This would suffice for completely self-contained components/directives, but what if there are buttons or links in the Angular component that are supposed to take the user to the now react app?

Hijacking ui-router

Assuming ui-router is being used for routing, we only have to intercept calls to $state.go

componentDidMount() {
    angular.module('ngApp')
      .run(($state) => {
        $state.go = (state, params, options) => {
          console.log('hijacked ui router');
        }
      })
    angular.bootstrap(this.container, ['ngApp']);
}

You can then choose how the app should handle those requests

Here’s an example that passes it off to react-router v4

See the Pen Angular Hijack ui-router in React by Chang Wang (@cheapsteak) on CodePen.

If the old routes and new routes don’t match 1:1, you could insert a mapping function that maps old routes to new routes. An example of that can be found in our codebase here

Neutralize Angular’s location tampering

A problem we ran into was that after initializing and then navigating away from the Angular component, the browser’s address bar would become “stuck”, always resetting to the route the angular component was on.

Keep an eye on the address bar:

It turns out Angular’s digest cycle checks the browser’s location, and when it notices that the browser’s url is different from what it thinks it should be, it sets the browser’s url to the “correct” value. Since the url is now being handled outside of Angular, we need to disable this behaviour.

Looking at Angular’s source, we see it sets the url via the (🕵️private) $browser service

We need to neutralize the setter without breaking the app. Here’s how:

angular
  .module('ngApp')
  .run(($browser) => {
    $browser.url = function (url) {
      if (url) {
        // setter is now a noop
        return $browser;
      } else {
        // getter always returns ''
        return '';
      }
    };
  })

Note that the above would result in $browser.url() when used as a getter to always return an empty string. That could be replaced with an actual path if the component needs a specific route or query param (e.g. return 'http://localhost/route/subroute/123?q=4' instead of return ''), or even the location from react-router.  

Cleanup

We’re nearly done, just need to clean up the bootstrapped angular app when the React component unmounts.

Here’s a minimal example (you can replace ngApp with the name of your angular module).


class AngularWidget extends React.Component {
  componentDidMount() {
    this.$rootScope = angular.injector(['ng', 'ngApp']).get('$rootScope');
    angular.bootstrap(this.container, ['ngApp']);
  }
  componentWillUnmount() {
    this.$rootScope.$destroy();
  }
  render = () => (
    <div
      ref={c => { this.container = c; }}
      dangerouslySetInnerHTML={{ __html: '<widget></widget>' }}
    />
  )
}

Summary

It’s always a difficult decision on whether, when, and how to migrate from a framework. For those that have started or are about to begin, I hope this article will help ease and complete that transition.

If you’ve read this far, consider following me on Medium or tweet at me @CheapSteak

You might also be interested in my article on Migrating a legacy frontend build system to Webpack and Quick and Dirty tricks for debugging Javascript, or perhaps Animations with ReactTransitionGroup

Thanks to Ben Hare, Dusan Andric, Jeffrey Burt and Francois Gerthoffert for proofreading and feedback 😄

Chang Wang, Frontend Architect
JavaScript Junkie • Pragmatic Perfectionist • Discount-steak Devotee