Summary:
There are certainly benefits to migrating your application from AngularJS to React (frankly to any other modern framework/library). If a full rewrite is not for you, in this article I will walk you through a simple way to introduce React into your AngularJS application. Even if you end up using an open source library for bridging AngularJS to React, this article will give you understanding of its underlying principles.
Want to skip all the explanations? Jump directly to the full working example.
So, you decided to switch your application from AngularJS to React. Good! Because frankly you should run from that no-longer-supported framework towards anything else. Any modern framework/library is more performant, easier to work with and has a larger community.
Reasons
At Awesense we have two use cases which are hard to implement with AngularJS but super easy with React:
Dynamic content. We wanted to give users the ability to customize their dashboard page. React elements and their properties are just JS classes, functions and objects, and you don’t need to do anything special to simply map user configuration to the correct UI.
Map overlays. The Awesense client application is map centric, and we need to render various UI elements from vanilla JavaScript. With React you can create root components whenever you want, whereas AngularJS was designed to be bootstrapped once and take care of everything in your app. Jumping in and out from the AngularJS universe is possible but definitely not as elegant as one line of code in React.
A full rewrite is rarely a good decision. Migrating gradually allowed us to spend more time on AngularJS tech debt during quieter periods and ramp up feature development to support business growth when it mattered, a good balance that everyone was happy with.
You can use libraries like ngReact, react2angular, angular2react, to help you with the migration, but it takes very little code to implement your own solution, and it’s good to fully understand how it works anyway. The Awesense solution was inspired by this Small Improvements blog post and their open source example.
Initial Steps
In order to make the transition smoother you should first prepare your AngularJS codebase with the following steps:
Define your controllers and component templates in the same file if you are not doing so already.
Start using AngularJS components instead of directives. Components provide lifecycle hooks. Although React and AngularJS lifecycle methods are called at different times in a component render cycle it’s beneficial to familiarize yourself with the concept.
Divide your components into container and presentational components. Such separation of concerns makes your code easier to manage and reuse.
Embrace unidirectional dataflow architecture: stop using the
=
two-way-binding, pass inputs to child components with<
binding instead. Treat your child components as pure functions that don’t mutate passed arguments. Instead, children should update parents’ state by calling callbacks passed down to them as outputs. This will give you better visibility into how the data flows through your application, where it’s updated, and who owns it.
Components
Our strategy is to start the migration from “leaf” presentational components, work your way up to stateful components, and ultimately to the top-level components which are rendered in routes. That way you don’t ever need to load AngularJS code inside a React component, and you don’t need to deal with routing until the very end.
Simple Component
First, you need a way to use React components inside your existing AngularJS code. I won’t cover how to use AngularJS components from inside React components since we don’t need that with our strategy, and our ultimate goal is to switch away from AngularJS anyway.
Create a simple React component:
import React from 'react';
export default function ReactExample() {
return <div>Hello world</div>;
};
An equivalent AngularJS component would look something like this:
angular
.module('myModule', [])
.component('reactExample', {
template: '<div>Hello world</div>',
controller: function() {
// component logic
}
});
So, we need a helper function which would wrap our React component into an AngularJS component that can be used from our old AngularJS codebase:
// ---- angular-react-helper.jsx ----
import ReactDOM from 'react-dom';
import React from 'react';
export function reactToAngularComponent(Component) {
return {
controller: /*@ngInject*/ function($element) {
this.$onInit = () => ReactDOM.render(<Component/>, $element[0]);
this.$onDestroy = () => ReactDOM.unmountComponentAtNode($element[0]);
}
};
}
// ---- angular component file ----
import { reactToAngularComponent } from '<path>/angular-react.helper.jsx';
import ReactExample from '<path>/react-example.component.jsx';
angular
.module('myModule', [])
.component('reactExampleBridge', reactToAngularComponent(ReactExample));
Here our helper function reactToAngularComponent
returns a simple AngularJS component config without a template. Instead this config accesses the underlying parent DOM element with $element[0]
and uses $onInit
and $onDestroy
AngularJS lifecycle methods to mount ReactExample
component on creation and unmount it on destruction of the reactExampleBridge
component.
Note the suffix "Bridge" in the reactExampleBridge
component name. In the middle of your migration this naming convention will make it easy to identify an AngularJS component that only has bridge component children remaining (which means that we can now rewrite the parent component in React and drop all the bridges).
Now we can use reactExampleBridge
inside another AngularJS component template:
angular
.module('myModule')
.component('anotherComponent', {
template: '<react-example-bridge></react-example-bridge>'
});
Passing Props
Let’s change the ReactExample
component so it accepts some props:
import React from 'react';
import { string } from 'prop-types';
export default function ReactExample(props) {
return <div>{props.exampleText}</div>;
};
ReactExample.propTypes = {
exampleText: string
};
We don’t need to make any changes in reactExampleBridge
component, but the reactToAngularComponent
helper function needs some tweaking:
// ---- angular-react-helper.jsx ----
import ReactDOM from 'react-dom';
import React from 'react';
function toBindings(propTypes) {
const bindings = {};
Object.keys(propTypes).forEach(key => bindings[key] = '<');
return bindings;
}
function toProps(propTypes, controller) {
const props = {};
Object.keys(propTypes).forEach(key => props[key] = controller[key]);
return props;
}
export function reactToAngularComponent(Component) {
const propTypes = Component.propTypes || {};
return {
bindings: toBindings(propTypes),
controller: /*@ngInject*/ function($element) {
this.$onChanges = () => {
const props = toProps(propTypes, this);
ReactDOM.render(<Component {...props} />, $element[0]);
};
this.$onDestroy = () => ReactDOM.unmountComponentAtNode($element[0]);
}
};
}
As you can see, we added two more helper functions:
toBindings
– generates an AngularJS component bindings object out of React componentpropTypes
. We need to use it only once, when registering the AngularJS wrapper component.toProps
– creates a React props object from AngularJS controller values. We need to use it every time the controller values change, which is why the$onInit
lifecycle hook was replaced with$onChanges
. Conveniently, the sameReactDOM
render
method can be used to mount the React element into the DOM for the first time as well as to efficiently update an already mounted React element with new props.
This imposes some limitations on how you can declare React components and use them in bridge components:
All props must be declared explicitly in the
propTypes
object. OurReactExample
component won’t receive any unspecified props. It’s good practice to havepropTypes
defined on all React components anyway for documentation purposes. It also makes debugging easier since React outputs warnings in the console when a prop of an unexpected type is passed to a component.All inputs passed to a bridge component must be immutable, otherwise the
$onChanges
lifecycle method will not be triggered, and theReactExample
component will not receive updated values.All inputs passed to
reactExampleBridge
must be expressions because thetoBindings
helper function uses only the<
type of binding.
Now we can pass example-text
input to our reactExampleBridge
component:
class AnotherComponentController {
/*@ngInject*/
constructor() {
this.value = 'exampleValue';
}
}
const anotherComponentConfig = {
controller: SomeComponentController,
template: `
<react-example-bridge
example-text=”$ctrl.value”
></react-example-bridge>
`
};
angular.module('myModule').component('anotherComponent', anotherComponentConfig);
Different Types of Bindings
Usually when defining an AngularJS component you would use three types of bindings: <
, @
and &
. A simple todo list AngularJS component would look like this:
// --- todo-list.js ---
const todoListComponentConfig = {
bindings: {
title: '@',
items: '<',
onSelect: '&',
},
template: '...'
};
angular.module('myModule').component('todoList', todoListComponentConfig);
// --- parent-component.js ---
class ParentComponentController {
/*@ngInject*/
constructor() {
this.todoItems = [ ... ];
}
selectItem(itemId, nextState) {
// update logic goes here
}
}
const parentComponentConfig = {
controller: ParentComponentController,
template: `
<todo-list
title="Tasks For Tomorrow"
items="$ctrl.todoItems"
on-select="$ctrl.selectItem(itemId, nextState)"
></todo-list>
`
};
angular.module('myModule').component('parentComponent', parentComponentConfig);
However, our reactToAngularComponent
helper only uses <
type of bindings. Let’s rewrite our todoList
AngularJS component as a React bridge to see how to pass different types of bindings to it.
// ---- todo-list.jsx ----
import React from 'react';
import { arrayOf, bool, func, shape, string } from 'prop-types';
function TodoList(props) {
return (
<div>
<h2>{props.title}</h2>
{props.items.map(item => (
<label key={item.id} style={{ display: 'block' }}>
<input
type='checkbox'
checked={item.isSelected}
onChange={() => props.onSelect(item.id, !item.isSelected)}
/>
{item.label}
</label>
))}
</div>
);
}
TodoList.propTypes = {
title: string,
items: arrayOf(shape({
id: string,
label: string,
isSelected: bool
})),
onSelect: func
};
// ---- todo-list-bridge.js ----
import { reactToAngularComponent } from '<path>/angular-react.helper.jsx';
import TodoList from '<path>/todo-list.jsx';
angular
.module('myModule')
.component('todoListBridge', reactToAngularComponent(TodoList));
// ---- app.js ----
class AppController {
constructor() {
this.todoItems = [
{ id: '1', isSelected: true, label: 'Wake up' },
{ id: '2', isSelected: false, label: 'Cook breakfast' },
{ id: '3', isSelected: false, label: 'Conquer the World' }
];
}
handleItemSelect(itemId, nextState) {
// update logic goes here
}
}
const appComponentConfig = {
controller: AppController,
template: `
<todo-list-bridge
title="'Tasks For Tomorrow'"
items="$ctrl.todoItems"
on-select="::$ctrl.handleItemSelect"
></todo-list-bridge>
`
};
angular.module('myModule').component('myApp', appComponentConfig);
The items
input was originally defined with the <
binding type, so we didn’t need to make any changes to it, but for title
and on-select
we had to make the following adjustments:
-
Originally
title
was defined with@
binding, so we could pass a string right away. Now fortodoListBridge
components AngularJS will evaluate the passedtitle
input as an expression, so we need to double quote the string:title="'Tasks For Tomorrow'"
-
Originally
on-select
was defined with&
binding and required us to specify what arguments the callback expects. Now we don’t need to do that since we pass the underlying function itself:on-select="::$ctrl.handleItemSelect"
Since the
handleItemSelect
function never changes we can optimise our parent component by using::
one-time-binding syntax that tells AngularJS not to watch forhandleItemSelect
changes.
Immutable Data
Let’s implement handleItemSelect
logic.
handleItemSelect(itemId, nextState) {
this.todoItems = this.todoItems.map(item => {
if (item.id === itemId) {
return Object.assign({}, item, { isSelected: nextState });
}
return item;
});
}
We are replacing the todoItems
array with its copy by using ES6 Array.prototype.map. The todoBridge
component’s $onChange
method won’t detect the change if you simply update a todo item in place. Therefore the underlying TodoList
React component won’t be re-rendered and UI will stay stale.
I strongly recommend getting used to not mutating your data, it makes reasoning about your application state much easier, and prevents many bugs. Having immutable data will also open a door into further optimizations with React via shouldComponentUpdate
and React.PureComponent
.
Callbacks
Since we’re passing the handleItemSelect
callback as an expression, when that function is called in the TodoList
component it won’t know that it was originally defined on AppController
. For this
keyword inside the callback to point to the controller, we can either bind the context to the function with the Function.prototype.bind() method or define the method with a fat arrow function as class instance fields, all of which will bind the right this
under the hood.
// binding in the constructor
constructor() {
// ...
this.handleItemSelect = this.handleItemSelect.bind(this);
}
// or defining the method with with a fat arrow as class instance field
handleItemSelect = (itemId, nextState) => {
// ...
};
For all outputs declared with &
binding, AngularJS will trigger a digest cycle whenever the callback is called. Now we need to do it manually, otherwise you’ll get rather peculiar behaviour: your UI would update only on the next digest cycle tick.
/*@ngInject*/
constructor($scope) {
this.$scope = $scope;
// ...
}
handleItemSelect(itemId, nextState) {
this.todoItems = this.todoItems.map(item => {
if (item.id === itemId) {
return Object.assign({}, item, { isSelected: nextState });
}
return item;
});
// Need to trigger digest cycle manually since we pass this function
// to a bridge component and changes to this.todoItems
// will happen from outside of the AngularJS framework.
this.$scope.$apply();
}
Services and Factories
AngularJS is a big framework which offers a lot of functionality out of the box. Your final goal is to find a replacement for all of the AngularJS services you use. But until that’s done, your React components need a way to access those services. For that we need another helper function:
function getAngularService(name) {
const injector = angular.element(document.body).injector();
return injector.get(name);
}
Add some sanity checks for easier debugging:
function getAngularService(name) {
const injector = angular.element(document.body).injector();
if (!injector || !injector.get) {
throw new Error(`Couldn't find angular injector to get "${name}" service`);
}
const service = injector.get(name);
if (!service) {
throw new Error(`Couldn't find "${name}" angular service`);
}
return service;
}
Let’s add a button to our React TodoList
component that scrolls to the top of the list, and use AngularJS $anchorScroll
service to perform that scroll:
class TodoList extends React.Component {
constructor(props) {
super(props);
this.$anchorScroll = getAngularService('$anchorScroll');
this.goToTop = this.goToTop.bind(this);
}
goToTop() {
this.$anchorScroll('title');
}
render() {
return (
<div>
<h2 id='title'>{this.props.title}</h2>
{this.props.items.map(item => (...))}
<a onClick={this.goToTop}>Go to Top</a>
</div>
);
}
}
A couple tips to make your migration a bit easier:
If a service doesn’t have any AngularJS dependencies, don’t register it on your app module. Import it directly to the files where you use it.
Hide each AngularJS service in a wrapper that only exposes the functionality that you need. This way you can switch out the underlying AngularJS service much more easily when it’s time to replace it.
Using a Service Outside of AngularJS
Pick an AngularJS service, for example $http
. Create a new myHttpService
class and get the AngularJS service with the getAngularService
helper function. Add only those methods of $http
which your application needs. Additionally you can isolate relevant logic that is reused often in your code, such as a custom server error handler in case of $http
wrapper.
Finally, instantiate your new service:
// --- http-service.js ---
class myHttpService {
constructor() {
this.$http = getAngularService('$http');
}
send() {
// your logic that uses Angular $http service
}
}
export default new myHttpService();
Such a wrapper can only be imported when the underlying AngularJS service is already registered with AngularJS. A safe way to do that is at component initialization time.
const dependencies = {
getMyHttpService: () => require('<path>/http-service.js').default
};
class MyReactComponent extends React.Component {
constructor(props) {
super(props);
this.myHttpService = dependencies.getMyHttpService();
}
// now you can use this.myHttpService in your React components,
}
// or import myHttpService the same way to some Angular component
class MyAngularController {
/*@ngInject*/
constructor() {
this.myHttpService = dependencies.getMyHttpService();
}
// now you can use this.myHttpService in your Angular component,
}
The benefit of this approach is that a wrapper is imported the same way to both React and AngularJS components.
Complete Code
Let’s recall. Here is a complete TODO list example code.
// ---- angular-react-helper.jsx ----
// 40 lines of code you need to start transforming your AngularJS app
// into a hybrid app.
import ReactDOM from 'react-dom';
import React from 'react';
function toBindings(propTypes) {
const bindings = {};
Object.keys(propTypes).forEach(key => bindings[key] = '<');
return bindings;
}
function toProps(propTypes, controller) {
const props = {};
Object.keys(propTypes).forEach(key => props[key] = controller[key]);
return props;
}
export function reactToAngularComponent(Component) {
const propTypes = Component.propTypes || {};
return {
bindings: toBindings(propTypes),
controller: /*@ngInject*/ function($element) {
this.$onChanges = () => {
const props = toProps(propTypes, this);
ReactDOM.render(<Component { ...props } />, $element[0]);
};
this.$onDestroy = () => ReactDOM.unmountComponentAtNode($element[0]);
}
};
}
export function getAngularService(name) {
const injector = angular.element(document.body).injector();
if (!injector || !injector.get) {
throw new Error(`Couldn't find angular injector to get "${name}" service`);
}
const service = injector.get(name);
if (!service) {
throw new Error(`Couldn't find "${name}" angular service`);
}
return service;
}
// ---- todo-list.jsx ----
import React from 'react';
import { arrayOf, bool, func, shape, string } from 'prop-types';
import { getAngularService } from '<path>/angular-react-helper.jsx';
class TodoList extends React.Component {
constructor(props) {
super(props);
// The way to get any AngularJS service from outside of the framework.
this.$anchorScroll = getAngularService('$anchorScroll');
this.goToTop = this.goToTop.bind(this);
}
goToTop() {
this.$anchorScroll('title');
}
render() {
return (
<div>
<h2 id='title'>{this.props.title}</h2>
{this.props.items.map(item => (
<label key={item.id} style={{ display: 'block' }}>
<input
type='checkbox'
checked={item.isSelected}
onChange={() => this.props.onSelect(item.id, !item.isSelected)}
/>
{item.label}
</label>
))}
<a onClick={this.goToTop}>Go to top</a>
</div>
);
}
}
// Must define all propTypes explicitly
// since they will be used to map angular inputs to react props.
TodoList.propTypes = {
title: string,
items: arrayOf(shape({
id: string,
label: string,
isSelected: bool
})),
onSelect: func
};
// ---- todo-list-bridge.js ----
// This is all the code you need to create a bridge component.
import { reactToAngularComponent } from '<path>/angular-react-helper.jsx';
import TodoList from '<path>/todo-list.jsx';
angular
.module('myModule')
.component('todoListBridge', reactToAngularComponent(TodoList));
// ---- app.js ----
// An example of how to use the bridge component
// inside another Angular component.
class AppController {
/*@ngInject*/
constructor($scope) {
this.$scope = $scope;
this.todoItems = [
{ id: '1', isSelected: true, label: 'Wake up' },
{ id: '2', isSelected: false, label: 'Cook breakfast' },
{ id: '3', isSelected: false, label: 'Conquer the World' }
];
// All inputs need to be passed as expression to bridge component,
// so we bind "this" context to the controller method,
// for the same reason we do it in React components.
this.handleItemSelect = this.handleItemSelect.bind(this);
}
handleItemSelect(itemId, nextState) {
// Controller properties passed to bridge component must be immutable,
// otherwise its "$onChanges" life cycle method won't be called
// and the underlying React component won't be updated.
this.todoItems = this.todoItems.map(item => {
if (item.id === itemId) {
return Object.assign({}, item, { isSelected: nextState });
}
return item;
});
// Need to trigger digest cycle manually
// since we changed todoItems from outside of the framework
this.$scope.$apply();
}
}
const appComponentConfig = {
controller: AppController,
// All inputs must be passed to the bridge component as expression.
template: `
<todo-list-bridge
title="'My TODO List'"
items="$ctrl.todoItems"
on-select="::$ctrl.handleItemSelect"
></todo-list-bridge>
`
};
angular.module('myModule').component('myApp', appComponentConfig);
At Awesense we follow simple rules to keep the migration going smoothly:
- All new functionality is written in React;
- If a developer touches old code, they rewrite it or part of it depending on the company’s business priorities at the time.
In the first year we switched 40% of our frontend code to React. After two years, more than two-thirds of our code base is now written in React.
I hope you feel more empowered knowing how AngularJS-React bridging works under the hood, and that the option to migrate to React does not look so daunting anymore.
Top comments (6)
Nice One
Hello, this was a good article. I do have a doubt on how to handle the Angular JS component transclude and require option.
I was wondering the same, did you solve it? Especially transclude
Yea, finally after a lot of try outs and failure.
Thanks for the reply!
I know about the children. but if I pass to react an angular component, will it work?
Great great article!