Hans Otto Wirtz
Posted on May 30, 2022
While Angular is a great framework, the engineering effort put in by the React team and community is unmatched in the frontend world. So much so, that we have recently started transitioning from Angular to React.
Some differences are really important to us:
- The latest and greatest stuff is happening in React world
- More community components available
- More developer-friendly API
- Change detection is much easier to wrap your head around
- Smaller API surface, meaning less confusion
- Better choice of primitives (components as variables vs templates with
ng-template
andng-content
) - Clear path towards SSR
- Integrating React Three Fiber
However, we have a perfectly good website that we don't want to throw away. That's why we decided to write a library that allows us to use React in Angular and Angular in React.
That may sound like quite a challenge, but we've figured out a way to do it rather easily (basically by just using ReactDOM.render
).
I'll share a few extracts from our source code so you can see how we're doing it. If you want to dive right in, check out the library at github.com/bubblydoo/angular-react.
The basic idea is that there are wrapper components:
<!-- Using a React component in Angular: -->
<react-wrapper
[component]="Button"
[props]="{ children: 'Hello world!' }">
</react-wrapper>
// Using an Angular component in React:
function Text(props) {
return (
<AngularWrapper
component={TextComponent}
inputs={{ text: props.text }}>
)
}
These wrappers should then render the components using the other framework.
React wrapper
The react-wrapper is basically implemented as follows:
@Component({
selector: "react-wrapper",
template: `<div #wrapper></div>`,
})
export class ReactWrapperComponent implements OnChanges, OnDestroy {
@ViewChild("wrapper") containerRef: ElementRef;
@Input()
props: any = {};
@Input()
component: React.ElementType;
ngAfterViewInit() {
this.render();
}
ngOnChanges() {
this.render();
}
private render() {
ReactDOM.render(
<this.component {...this.props} />,
this.containerRef.nativeElement
);
}
}
You can find its full source code here.
Any time the component or the props change, React will rerender the component. Pretty simple!
This is possible because in React, components are very lightweight, we can easily pass components around and render them. There is no need for any global setup. On the other hand, in Angular, there is a need for a global context. What if we want to pass Angular children into a React component?
Angular wrapper
Mounting an Angular component inside an arbitrary DOM element is also possible, although not so elegant. We need to have the global NgModuleRef
and an Angular Component to do so.
Using a callback ref, we mount the Angular Component into a React component.
We pass a div element to the Angular Component as its children, and then use React.createPortal
to mount React children into it.
function AngularWrapper(props) {
const ngComponent = props.component;
const ngModuleRef = useContext(AngularModuleContext);
const hasChildren = !!props.children
const ngContentContainerEl = useMemo<HTMLDivElement | null>(() => {
if (hasChildren) return document.createElement('div');
return null;
}, [hasChildren]);
const ref = useCallback<(node: HTMLElement) => void>(
(node) => {
const projectableNodes = ngContentContainerEl ? [[ngContentContainerEl]] : [];
const componentFactory = ngModuleRef.componentFactoryResolver.resolveComponentFactory(ngComponent);
const componentRef = componentFactory.create(ngModuleRef.injector, projectableNodes, node);
},
[ngComponent, ngModuleRef]
);
return (
<>
{React.createElement(componentName, { ref })}
{ngContentContainerEl && ReactDOM.createPortal(<>{children}</>, ngContentContainerEl)}
</>
);
}
There's much more to this file, such as:
- event handling
- inputs & outputs handling
- passing the
React.forwardRef
correctly
You can find the source code here
Result
We can use Angular and React components interchangeably now!
@Component({
selector: 'inner-angular',
template: `<div style="border: 1px solid; padding: 5px">this is inner Angular</div>`,
})
class InnerAngularComponent {}
@Component({
template: `
<div style="border: 1px solid; padding: 5px">
<div>this is outer Angular</div>
<react-wrapper [component]="ReactComponent"></react-wrapper>
</div>
`,
})
class OuterAngularComponent {
ReactComponent = ReactComponent;
}
function ReactComponent(props: { children: any }) {
return (
<div style={{ border: '1px solid', padding: '5px' }}>
<div>this is React</div>
<div>
<AngularWrapper component={InnerAngularComponent} />
{props.children}
</div>
</div>
);
}
This renders correctly, as follows:
Using Angular services in React
Ever wondered how the dependency injection in Angular works? Well, it just keeps a simple key-value map internally. This key-value map is the injector.
In the react wrapper, we actually also provide the injector on the context:
ReactDOM.render(
<InjectorContext value={this.injector}>
<this.component {...this.props} />
</InjectorContext>,
this.containerRef.nativeElement
)
This way, we can get services as follows in React:
const injector = useContext(InjectorContext)
const authService = injector.get(AuthService)
We added a shorthand hook to the library to make this even shorter:
import { useInjected } from '@bubblydoo/react-angular'
const authService = useInjected(AuthService)
This way, it's easy to use Angular injectables in React components!
Using Observables in React
While there are more feature-complete solutions like observable-hooks, consuming RxJS Observables is so common in Angular that we also added a hook for it.
import { useObservable } from '@bubblydoo/react-angular'
const [value, error, completed] = useObservable(authService.isAuthenticated$)
if (error) return <>Something went wrong!<>
return <>{value ? "Logged in!" : "Not logged in"}</>
Example component: Link
(inspired by Next.js)
This component makes it possible to use the Angular Router in React components.
interface Props {
link?: string[];
children: any;
}
export default function Link(props: Props) {
const { link, children } = props;
const router = useInjected(Router);
const zone = useInjected(NgZone);
const activatedRoute = useInjected(ActivatedRoute);
const locationStrategy = useInjected(LocationStrategy);
const onClick = (e?: any) => {
e?.preventDefault();
zone.run(() => {
router.navigate(link);
});
};
const urlTree = router.createUrlTree(link, { relativeTo: activatedRoute });
const href = locationStrategy.prepareExternalUrl(router.serializeUrl(urlTree));
const childProps = { onClick, href };
return React.cloneElement(children, childProps);
}
Usage:
<Link link={['dashboard']}>
<a>Go to dashboard</a>
</Link>
Where we can improve
i18n
Angular i18n is not handled very well yet. $localize
is not working in .tsx
files. It's also not possible to add i18n
attributes to React components, because extract-i18n
only looks at Angular templates.
NgZone
Working with NgZone: sometimes events coming from a React element should be handled with ngZone.run(() => ...)
, in case you want to use Angular services and the events are not being tracked by the Zone. See the Link example component above.
Getting a reference to the Angular component in React
This is not yet implemented either, but we could add a componentRef
prop to the AngularWrapper
to get a reference to the Angular component.
Passing Angular children into react-wrapper
Right now this is not possible:
<react-wrapper [component]="Button">
<div>Angular Text</div>
</react-wrapper>
The workaround:
@Component({
template: `<div>Angular Text</div>`
})
class TextComponent {}
@Component({
template: `
<react-wrapper [component]="Button" [props]="{{children}}"></react-wrapper>
`
})
class AppComponent {
children = React.createElement(
AngularWrapper,
{ component: TextComponent }
)
}
Adding this would make using the components interchangeably a lot easier.
Using the AngularWrapper outside of an Angular component
Right now, this library is meant to be used inside Angular projects. More testing should be done to make sure it also works inside Next.js and Create React App projects.
One of the problems is that you would need to create and provide an NgModule:
import { AngularModuleContext, AngularWrapper } from '@bubblydoo/angular-react'
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
const ngModuleRef = await platformBrowserDynamic()
.bootstrapModule(AppModule)
<AngularModuleContext.Provider value={ngModuleRef}>
<div>
<AngularWrapper component={Text} inputs={{ text: 'Hi!'}}/>
</div>
</AngularModuleContext.Provider>
Another problem is the build system: Next.js and CRA projects aren't configured to read and bundle Angular code, which needs special compilers. This configuration is usually provided by @angular/cli
, but it would be hard integrating that with the existing Next.js/CRA build system.
- You would need to setup a separate Angular library project to build the Angular components into normal Javascript code.
- You would need to use
@angular/compiler-cli/linker/babel
as ababel-loader
plugin in your Webpack config. - You can then import the Angular components into your React project.
We're considering refining this approach in order to move to Next.js, so we can finally leverage SSG/SSR. We're experimenting with Turborepo in order to manage the separate projects.
Credits
Although they're very different projects, I mostly took inspiration from @angular/elements, and also from microsoft/angular-react (this is a legacy project, that doesn't work for newer Angular versions anymore).
Posted on May 30, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.