Transitioning from Angular to React, without starting from scratch

hansottowirtz

Hans Otto Wirtz

Posted on May 30, 2022

Transitioning from Angular to React, without starting from scratch

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 and ng-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>
Enter fullscreen mode Exit fullscreen mode
// Using an Angular component in React:
function Text(props) {
  return (
    <AngularWrapper
      component={TextComponent}
      inputs={{ text: props.text }}>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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)}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

This renders correctly, as follows:

React in Angular and Angular in React demo

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
)
Enter fullscreen mode Exit fullscreen mode

This way, we can get services as follows in React:

const injector = useContext(InjectorContext)
const authService = injector.get(AuthService)
Enter fullscreen mode Exit fullscreen mode

We added a shorthand hook to the library to make this even shorter:

import { useInjected } from '@bubblydoo/react-angular'

const authService = useInjected(AuthService)
Enter fullscreen mode Exit fullscreen mode

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"}</>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<Link link={['dashboard']}>
  <a>Go to dashboard</a>
</Link>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 a babel-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).

💖 💪 🙅 🚩
hansottowirtz
Hans Otto Wirtz

Posted on May 30, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related