Geeks Invention

Engineering

Angular and React in One App: What Actually Breaks

Angular and React in One App: What Actually Breaks

Most front-end rewrites die in a spreadsheet. Someone totals up eighteen months of engineering capacity, a feature freeze nobody in the business will sign, and one irreversible switch-over date, and the project quietly slides another quarter. Meanwhile the AngularJS application that went end-of-life years ago is still in production, and still impossible to hire for.

The way out is to stop treating it as a switch. Run both frameworks in the same page, in production, and move the boundary between them one screen at a time. There is no launch day because there is nothing to launch — the old stack just gets smaller every sprint.

The architecture case for that is straightforward and we have written it up on our micro-frontend coexistence page. This post is the other half: the code that actually mounts the boundary, and the four things that break once you do. None of the four are exotic. All of them will cost you a day if you meet them for the first time in production.

The boundary is a custom element

You need one integration primitive that both frameworks already understand, and the browser provides it. Custom elements are a platform feature, not a framework feature, which means neither side has to know what the other is built with.

On the Angular side, createCustomElement wraps a component into a standard HTMLElement:

import { createCustomElement } from '@angular/elements';
import { Injector } from '@angular/core';

export class AppModule {
  constructor(private injector: Injector) {}

  ngDoBootstrap() {
    const el = createCustomElement(LegacyOrderTable, { injector: this.injector });
    customElements.define('legacy-order-table', el);
  }
}

On the React side it is now just a tag. No adapter library, no bridge, no shared build:

function OrdersScreen({ customerId }) {
  return <legacy-order-table customer-id={customerId} />;
}

That is the entire happy path, and it genuinely does work on the first try. Which is exactly why the next four sections exist — everything that goes wrong from here looks like it has nothing to do with the boundary you just created.

1. Your props arrive as strings

The example above passes customerId, a number. Depending on your React version, the component may receive the number 42 — or the string "42". Pass an object and you may get the string "[object Object]".

The reason is the split between DOM attributes, which are always strings, and DOM properties, which can hold any value. Angular Elements defines both. React 18 and earlier set unknown props on custom elements as attributes, so anything non-primitive gets stringified on the way across. React 19 sets them as properties when the element defines one, which fixes the common case silently — and means the same code behaves differently on either side of an upgrade.

Don't rely on the version. Set properties explicitly through a ref and the behaviour is the same everywhere:

function OrdersScreen({ customer }) {
  const ref = useRef(null);

  useEffect(() => {
    // a property assignment, not an attribute — objects survive
    ref.current.customer = customer;
  }, [customer]);

  return <legacy-order-table ref={ref} />;
}

The same asymmetry applies coming back the other way. Custom elements communicate outward by dispatching CustomEvent, and React's synthetic event system has no declarative binding for arbitrary event names, so onOrderSelected will never fire. You attach it by hand with addEventListener in the same effect, and you remove it on cleanup.

2. CSS reaches across the boundary and nothing warns you

This is the one that costs the most time, because it produces layout bugs in components you did not touch, in a sprint where you changed something unrelated.

Legacy stylesheets accumulate bare-element rules over the years — header { position: fixed }, nav { float: right }, li { list-style: none }. Those match on tag name, so they apply to every matching element in the document, including markup rendered by the new framework that has never heard of them. Nothing errors. The rule simply applies, and the bug looks like it belongs to whatever you were working on.

Shadow DOM is the real fix where it fits, because it is genuine isolation rather than a naming convention:

@Component({
  selector: 'legacy-order-table',
  encapsulation: ViewEncapsulation.ShadowDom,
  // ...
})

It does not always fit. Shadow DOM complicates global theming, some component libraries assume they can query the whole document, and form participation inside a shadow root needs deliberate handling. Where it is wrong, the fallback is scoped class prefixes on both sides plus a hard rule that neither side ships a bare-element selector.

Either way, audit the legacy stylesheet before the first screen moves, not after. Grep it for selectors that match on tag alone. That list is your actual risk register, and it is usually longer than anyone expects.

3. Two sources of truth for the session

The tempting mistake is to let each side keep its own copy of the auth token and reconcile them. It works until a token refresh happens in one framework and the other keeps using the stale one for the rest of the session, producing intermittent 401s that nobody can reproduce.

The host application owns the session. It holds the token, it runs the refresh cycle, and it exposes the current user through a narrow read-only interface. The guest reads through that interface and never caches. For shared application state, move data across as events or through a small framework-agnostic store both sides subscribe to.

Two sources of truth across a framework boundary is the failure mode that makes teams abandon this approach and go back to planning the big-bang rewrite. It is worth being dogmatic about.

4. Change detection stops running

An Angular guest updates its view when Angular knows something happened, and Angular knows because Zone.js patched the async APIs it was listening to. An event originating in React reaches the custom element from outside that zone, so the input lands on the component and the template never re-renders.

The symptom is a component holding correct data and displaying stale data. The fix is to re-enter the zone at the boundary:

constructor(private zone: NgZone) {}

@Input() set customer(value: Customer) {
  this.zone.run(() => {
    this._customer = value;
  });
}

If the guest runs zoneless with signals, this class of bug disappears — the signal write schedules the update regardless of which zone the caller was in. That is a reasonable argument for moving guests to signals early in the migration rather than late.

What it costs, stated plainly

Two frameworks in one page is not free, and anything claiming otherwise is selling something. A React runtime is roughly 45KB gzipped and an Angular runtime is larger. While both are loaded, you are carrying weight you would not otherwise carry.

What makes it acceptable is that the cost is bounded and it points downward. Routing decides what mounts, so most screens load one framework only. Where a screen genuinely needs both, the second loads lazily. And the legacy bundle shrinks as screens move across, so the overhead curve bends down over the life of the migration instead of up.

Track the legacy bundle size as a first-class metric and report it per screen. It should fall every sprint. A migration that quietly doubles time-to-interactive is not one anyone should accept, and the number is the only thing that will tell you honestly which way it is going.

The order that keeps this boring

  • Audit the legacy stylesheet and the global scripts first. These produce the surprises.
  • Stand up the shell and move one low-traffic screen. Prove the boundary in production before committing to a plan.
  • Move by feature area, not by whichever screen looks easiest, so the team holds two mental models in one part of the product at a time rather than across all of it.
  • Keep every step independently deployable and independently revertible.
  • Watch the legacy bundle every sprint.

There is a real cost this list does not capture: your team is holding two mental models for as long as the migration runs. That is an argument for finishing rather than settling into the middle state indefinitely — coexistence is a route, not a destination.

Why this beats waiting

The rewrite gets cancelled because it asks for everything up front and returns nothing until the end. Coexistence inverts that. Every screen you move is shipped, reversible and in production, and the decision to keep going gets made with evidence rather than a business case.

If you are looking at an end-of-life front end and a roadmap nobody will pause, that is the shape of the way out. We have written more about how we sequence and run these migrations here, and if you want to talk through a specific codebase, a technical conversation is the place to start.

Keep reading

More from the blog

Back to Blogs