I have a problem. The application is written in Angular, and the component library in React. Clone a library too expensive. So, you need to use React components in an Angular application with minimal cost. We figure out how to do it.
I am not a specialist at Angular at all. I tried the first version in 2017, then I looked at AngularDart a bit, and now I have encountered application support on a modern version of the framework. If it seems to you that the decisions are strange or "from another world", it does not seem to you.
The solution presented in the article has not yet been tested in real projects and is only a concept. Use it at your own risk.
I now support and develop a fairly large application on Angular 8. Plus, there are a couple of small applications on React and plans to build a dozen more (also on React). All applications are used for the internal needs of the company and should be in the same style. The only logical way is to create a component library on the basis of which you can quickly build any application.
But in the current situation, you can’t just take and write a library in React. You cannot use it in the largest application - it is written in Angular. I am not the first to encounter this problem. The first thing to google for "react in angular" is the Microsoft repository . Unfortunately, this solution has no documentation at all. Plus, in the readme of the project it is clearly said that the package is used internally by Microsoft, the team has no obvious plans for its development and it is only at your own risk to use it. I'm not ready to drag such an ambiguous package into production, so I decided to write my bike.
Official site @ angular-react / core
React is a library designed to solve one specific problem - managing the DOM tree of a document. We can put any React element into an arbitrary DOM node.
const Hello = () => <p>Hello, React!</p>; render(<Hello />, document.getElementById('#hello'));
Moreover, there are no restrictions on repeated calls to the render
function. That is, it is possible to render each component separately in the DOM. And this is exactly what will help to take the first step in integration.
First of all, it is important to understand that React by default does not require any special conditions during assembly. If you do not use JSX, but confine createElement
to calling createElement
, then you won’t have to take any steps, everything will just work out of the box.
But, of course, we are used to using JSX and do not want to lose it. Fortunately, Angular uses TypeScript by default, which can transform JSX into function calls. You just need to add the compiler flag --jsx=react
or in tsconfig.json
in the compilerOptions
section add the line "jsx": "react"
.
To get started, we need to make sure that the React components are displayed inside the Angular application. That is, so that the resulting DOM elements from the library work take up the right places in the element tree.
Each time you use the React component, thinking about calling the render
function correctly is too difficult. Plus, in the future we will need to integrate components at the data level and event handlers. In this case, it makes sense to create an Angular-component that will encapsulate in itself all the logic of creating and controlling the React-element.
// Hello.tsx export const Hello = () => <p>Hello, React!</p>; // hello.component.ts import { createElement } from 'react'; import { render } from 'react-dom'; import { Hello } from './Hello'; @Component({ selector: 'app-hello', template: `<div #react></div>`, }) export class HelloComponent implements OnInit { @ViewChild('react', { read: ElementRef, static: true }) ref: ElementRef; ngOnInit() { this.renderReactElement(); } private renderReactElement() { const props = {}; const reactElement = createElement(Hello, props); render(reactElement, this.ref.nativeElement); } }
The code of the Angular component is extremely simple. It itself renders only an empty container and gets a link to it. In this case, at the time of initialization, the render of the React element is called. It is created using the createElement
function and passed to the render
function, which places it in a DOM node created from Angular. You can use such a component like any other Angulat component, no special conditions.
Usually, when displaying interface elements, you need to transfer data to them. Everything here is also quite prosaic - when calling createElement
you can pass any data through the props to the component.
// Hello.tsx export const Hello = ({ name }) => <p>Hello, {name}!</p>; // hello.component.ts import { createElement } from 'react'; import { render } from 'react-dom'; import { Hello } from './Hello'; @Component({ selector: 'app-hello', template: `<div #react></div>`, }) export class HelloComponent implements OnInit { @ViewChild('react', { read: ElementRef, static: true }) ref: ElementRef; @Input() name: string; ngOnInit() { this.renderReactElement(); } private renderReactElement() { const props = { name: this.name, }; const reactElement = createElement(Hello, props); render(reactElement, this.ref.nativeElement); } }
Now you can pass the name
string to the Angular component, it will fall into the React component and will be rendered. But if the line changes due to some external reasons, React will not know about it and we will get an outdated display. Angular has a ngOnChanges
life cycle ngOnChanges
that allows you to track changes in component parameters and reactions to it. We implement the OnChanges
interface and add a method:
// ... ngOnChanges(_: SimpleChanges) { this.renderReactElement(); } // ...
It is enough to just call the render
function again with an element created from new props, and the library itself will figure out which parts of the tree should be rendered. The local state inside the component will also be preserved.
After these manipulations, the Angular-component can be used in the usual way and pass data to it.
<app-hello name="Angular"></app-hello> <app-hello [name]="name"></app-hello>
For finer work with updating components, you can look towards the change detection strategy . I will not consider this in detail.
Another problem remains - the reaction of the application to events inside the React-components. Let's @Output
decorator and pass the callback to the component through props.
// Hello.tsx export const Hello = ({ name, onClick }) => ( <div> <p>Hello, {name}!</p> <button onClick={onClick}>Say "hello"</button> </div> ); // hello.component.ts import { createElement } from 'react'; import { render } from 'react-dom'; import { Hello } from './Hello'; @Component({ selector: 'app-hello', template: `<div #react></div>`, }) export class HelloComponent implements OnInit { @ViewChild('react', { read: ElementRef, static: true }) ref: ElementRef; @Input() name: string; @Output() click = new EventEmitter<string>(); ngOnInit() { this.renderReactElement(); } private renderReactElement() { const props = { name: this.name, onClick: () => this.lick.emit(`Hello, ${this.name}!`), }; const reactElement = createElement(Hello, props); render(reactElement, this.ref.nativeElement); } }
Done. When using the component, you can register event handlers and respond to them.
<app-hello [name]="name" (click)="sayHello($event)"></app-hello>
The result is a fully functional wrapper for the React component for use inside an Angular application. You can transfer data to it and respond to events inside it.
For me, the most convenient thing in Angular is the Two-way Data Binding, ngModel
. It is convenient, simple, requires a very small amount of code. But in the current implementation, integration is not possible. It can be fixed. To be honest, I do not really understand how this mechanism works from the point of view of the internal device. Therefore, I admit that my solution is super-suboptimal and I will be glad if you write in the comments a more beautiful way to support ngModel
.
First of all, you need to implement the ControlValueAccessor
interface (from the @angular/forms
package and add a new provider to the component.
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; const REACT_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => HelloComponent), multi: true }; @Component({ selector: 'app-hello', template: `<div #react></div>`, providers: [PREACT_VALUE_ACCESSOR], }) export class PreactComponent implements OnInit, OnChanges, ControlValueAccessor { // ... }
This interface requires the implementation of the methods onBlur
, writeValue
, registerOnChange
, registerOnTouched
. All of them are well described in the documentation. We realize them.
// const noop = () => {}; @Component({ selector: 'app-hello', template: `<div #react></div>`, providers: [PREACT_VALUE_ACCESSOR], }) export class PreactComponent implements OnInit, OnChanges, ControlValueAccessor { // ... private innerValue: string; // ngModel private onTouchedCallback: Callback = noop; private onChangeCallback: Callback = noop; // // get value(): string { return this.innerValue; } set value(v: string) { if (v !== this.innerValue) { this.innerValue = v; // , this.onChangeCallback(v); } } writeValue(value: string) { if (value !== this.innerValue) { this.innerValue = value; // this.renderReactElement(); } } // registerOnChange(fn: Callback) { this.onChangeCallback = fn; } registerOnTouched(fn: Callback) { this.onTouchedCallback = fn; } // onBlur() { this.onTouchedCallback(); } }
After that, you need to ensure that all of this is passed to the React component. Unfortunately, React is not able to work with Two-way Data Binding, so we will give it a value and a callback to change it. renderReactElement
method.
// ... private renderReactElement() { const props = { name: this.name, onClick: () => this.lick.emit(`Hello, ${this.name}!`), model: { value: this.value, onChange: v => { this.value = v; } }, }; const reactElement = createElement(Hello, props); render(reactElement, this.ref.nativeElement); } // ...
And in the React component, we will use this value and the callback.
export const Hello = ({ name, onClick, model }) => ( <div> <p>Hello, {name}!</p> <button onClick={onClick}>Say "hello"</button> <input value={model.value} onChange={e => model.onChange(e.target.value)} /> </div> );
Now, we really integrated React into Angular. You can use the resulting component as you like.
<app-hello [name]="name" (click)="sayHello($event)" [(ngModel)]="name" ></app-hello>
React is a very simple library that is easy to integrate with anything. Using the approach shown, you can not only use any React-components inside Angular-applications on an ongoing basis, but also gradually migrate the entire application.
In this article, I did not touch on stylization issues at all. If you use classic CSS-in-JS solutions (styled-components, emotion, JSS), you don’t have to take any additional actions. But if the project requires more productive solutions (astroturf, linaria, CSS Modules), you will need to work on the webpack configuration. Feature story - Customize Webpack Configuration in Your Angular Application .
To fully migrate the application from Angular to React, you still need to solve the problem of implementing services in React-components. A simple way is to get the service in a wrapper component and pass it through props. The difficult way is to write a layer that will get services from the injector by token. Consideration of this issue beyond the scope of the article
It's important to understand that with this approach to 85KB of Angular, almost 40KB of react
and react-dom
code is react
. This can have a significant impact on application performance. I recommend considering using the miniature Preact, which weighs only 3KB. Its integration is almost no different.
tsconfig.json
"jsxFactory": "h"
h
import { h } from 'preact'
React.createElement
Preact.h
ReactDOM.render
Preact.render
Done! Read the instructions for migrating from React to Preact . There are practically no differences.
Thematic link from comments - Micro Frontends
Another thematic link from comments - Micro Frontends