npx create-single-spa or Webpack 5 Module Federation to orchestrate Angular/React coexistence during migration.@if, @for) maps more naturally to JSX. Angular 19 standalone-by-default is ideal.subscribe() calls require a dedicated RxJS simplification phase. Do not bridge RxJS Observables into React components — they break Suspense, Concurrent Mode, and Server Components.strict, noImplicitAny, strictNullChecks settings from Angular’s tsconfig to React’s tsconfig. Loosening strictness during migration introduces regressions.NgZone: 'noop' or isolate its zone to prevent unexpected React behavior.| Angular Pattern | React Equivalent | Example |
|---|---|---|
@Component({ template }) | Function component + JSX | function App() { return <div>Hello</div>; } |
@Injectable() class DataService | Custom hook or plain module | function useData() { ... } or export const dataService = { ... } |
constructor(private svc: DataService) (DI) | useContext() or direct import | const data = useContext(DataContext) |
this.http.get<T>(url) (HttpClient) | fetch / React Query / SWR | const { data } = useQuery({ queryKey: ['key'], queryFn: () => fetch(url) }) |
observable$.subscribe(val => ...) (RxJS) | useState + useEffect or React Query | const [val, setVal] = useState(null); useEffect(() => { ... }, []) |
signal() (Angular 16+) | useState / useSignal | const [count, setCount] = useState(0) |
@Input() name: string | Props | function Child({ name }: { name: string }) { ... } |
@Output() clicked = new EventEmitter() | Callback props | function Child({ onClick }: { onClick: () => void }) { ... } |
*ngIf="condition" / @if (condition) | Conditional rendering | {condition && <Component />} |
*ngFor="let item of items" / @for (item of items) | .map() in JSX | {items.map(item => <Item key={item.id} {...item} />)} |
[(ngModel)]="value" (two-way binding) | Controlled input | <input value={val} onChange={e => setVal(e.target.value)} /> |
RouterModule.forRoot(routes) | React Router <Routes> | <Routes><Route path="/" element={<Home />} /></Routes> |
canActivate: [AuthGuard] (route guard) | Wrapper component or loader | <ProtectedRoute><Dashboard /></ProtectedRoute> |
@Pipe({ name: 'currency' }) | Plain function or useMemo | const formatted = useMemo(() => formatCurrency(val), [val]) |
@NgModule({ imports, declarations }) | No equivalent — just ES imports | React has no module system; use ES modules and React.lazy() |
HttpInterceptor | Fetch wrapper or Axios interceptor | const api = axios.create(); api.interceptors.request.use(...) |
Reactive Forms (FormGroup, FormControl) | React Hook Form or useActionState (React 19) | const { register, handleSubmit } = useForm<FormData>() |
| async pipe | use() hook (React 19) or useQuery | const comments = use(commentsPromise) |
START
├── Is the Angular app < 10K LOC with few third-party Angular libraries?
│ ├── YES → Full rewrite in React (faster than incremental for small apps)
│ └── NO ↓
├── Does the team need to ship features during migration?
│ ├── YES → Strangler Fig: use Module Federation or single-spa for coexistence
│ └── NO ↓
├── Is the Angular app on version 17+ with standalone components?
│ ├── YES → Easier migration — standalone components map 1:1 to React. Migrate component-by-component.
│ └── NO ↓
├── Is the app heavily module-based with lazy-loaded Angular modules?
│ ├── YES → Migrate route-by-route: replace each Angular lazy module with a React micro-frontend
│ └── NO ↓
├── Does the app rely heavily on RxJS for complex async orchestration?
│ ├── YES → First refactor RxJS → simpler async patterns (promises, Angular signals), then migrate to React hooks
│ └── NO ↓
├── Is there heavy use of Angular Material or CDK?
│ ├── YES → Choose a React UI library (MUI, Radix, shadcn/ui) first, map components, then migrate
│ └── NO ↓
└── DEFAULT → Route-by-route incremental migration with shared state via a framework-agnostic store
Map all Angular modules, services, routes, and third-party dependencies. Categorize each module by complexity and business criticality. This determines migration order and timeline. [src3, src5]
# Count Angular components, services, pipes, directives, and modules
find src -name "*.component.ts" | wc -l
find src -name "*.service.ts" | wc -l
find src -name "*.pipe.ts" | wc -l
find src -name "*.module.ts" | wc -l
# Count RxJS usage (higher count = more migration effort)
grep -rn "subscribe\|Observable\|BehaviorSubject\|switchMap" --include="*.ts" src/ | wc -l
# Count Angular signals usage (Angular 16+, maps easily to React useState)
grep -rn "signal(\|computed(\|effect(" --include="*.ts" src/ | wc -l
Verify: You have a document listing every Angular module with component count, service count, RxJS complexity score, signals usage count, and third-party dependency list.
Install Module Federation (recommended) or single-spa to run Angular and React side by side. The Angular app becomes the “host” and React apps mount as remotes. [src5, src7]
# Option A: Module Federation (Webpack 5 — recommended for new migrations)
npm install @angular-architects/module-federation
ng add @angular-architects/module-federation --project main --port 4200
# Option B: single-spa (proven for complex multi-framework setups)
npx create-single-spa --framework react --moduleType app-parcel
npm install single-spa single-spa-react
Verify: Both Angular and React apps load in the same browser window. Navigate between routes without full page reloads.
Angular services and React hooks cannot directly share state. Create a framework-agnostic store that both can read/write. [src3, src6]
// shared/store.ts — framework-agnostic reactive store
type Listener = () => void;
export class SharedStore<T extends Record<string, unknown>> {
private state: T;
private listeners = new Set<Listener>();
constructor(initialState: T) { this.state = initialState; }
getState(): T { return this.state; }
setState(partial: Partial<T>): void {
this.state = { ...this.state, ...partial };
this.listeners.forEach(fn => fn());
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
Verify: Setting state from the Angular console triggers re-renders in React components and vice versa.
Convert Angular @Injectable services into React custom hooks (for stateful/lifecycle logic) or plain TypeScript modules (for pure functions). [src1, src2]
// BEFORE: Angular service
// @Injectable({ providedIn: 'root' })
// export class UserService {
// constructor(private http: HttpClient) {}
// getUsers(): Observable<User[]> { return this.http.get<User[]>('/api/users'); }
// }
// AFTER: React custom hook (with React Query)
import { useQuery } from '@tanstack/react-query';
export function useUsers() {
return useQuery<User[]>({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
});
}
Verify: useUsers() in a React component returns the same data as UserService.getUsers() in Angular.
Replace one Angular lazy-loaded route at a time with its React equivalent. The Module Federation host or single-spa router decides which framework renders each URL. [src3, src7]
// single-spa root config
import { registerApplication, start } from 'single-spa';
registerApplication({
name: 'angular-app',
app: () => import('./angular-main'),
activeWhen: ['/dashboard', '/settings'], // Not yet migrated
});
registerApplication({
name: 'react-app',
app: () => import('./react-main'),
activeWhen: ['/users', '/reports'], // Migrated to React
});
start();
Verify: Navigate to /users — React renders. Navigate to /dashboard — Angular renders. Back button and shared store work correctly.
Translate Angular template directives to React JSX patterns. This is the bulk of the migration work. [src1, src4]
// BEFORE: Angular template
// <li *ngFor="let user of users; trackBy: trackById"
// [class.active]="user.id === selectedId"
// (click)="selectUser(user)">
// {{ user.name | uppercase }}
// </li>
// AFTER: React JSX
function UserList() {
const { data: users } = useUsers();
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<ul>
{users?.map(user => (
<li
key={user.id}
className={user.id === selectedId ? 'active' : ''}
onClick={() => setSelectedId(user.id)}
>
{user.name.toUpperCase()}
</li>
))}
</ul>
);
}
Verify: Side-by-side comparison — both render identical HTML. Click behavior and styling match.
After all routes are migrated and production-tested, remove Angular, the coexistence shell, and the shared store bridge. [src3, src6]
# Remove Angular packages
npm uninstall @angular/core @angular/common @angular/router @angular/forms \
@angular/platform-browser @angular/compiler @angular/compiler-cli \
@ngrx/store @ngrx/effects rxjs zone.js single-spa single-spa-angular
# Verify no Angular references remain
grep -rn "@angular\|@Injectable\|@Component\|@NgModule" --include="*.ts" src/ | head -20
npm ls @angular/core
Verify: npm ls @angular/core returns “empty”. App loads, all routes work, bundle size decreased. CI pipeline passes.
Full script: typescript-react-converting-an-angular-service-wit.ts (66 lines)
// Input: Angular service using RxJS BehaviorSubject for real-time state
// Output: React custom hook with equivalent behavior using useSyncExternalStore
import { useSyncExternalStore, useCallback, useMemo } from 'react';
interface CartItem { id: string; name: string; price: number; quantity: number; }
// Module-level store (replaces @Injectable singleton)
let cartItems: CartItem[] = [];
const listeners = new Set<() => void>();
function emitChange() { listeners.forEach(fn => fn()); }
export const cartStore = {
subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot(): CartItem[] { return cartItems; },
addItem(item: CartItem): void {
cartItems = [...cartItems, item];
emitChange();
},
removeItem(id: string): void {
cartItems = cartItems.filter(i => i.id !== id);
emitChange();
},
};
export function useCart() {
const items = useSyncExternalStore(cartStore.subscribe, cartStore.getSnapshot);
const total = useMemo(() => items.reduce((sum, i) => sum + i.price * i.quantity, 0), [items]);
const addItem = useCallback((item: CartItem) => cartStore.addItem(item), []);
const removeItem = useCallback((id: string) => cartStore.removeItem(id), []);
return { items, total, addItem, removeItem };
}
Full script: typescript-react-converting-angular-reactive-forms.ts (87 lines)
// Input: Angular reactive form with validators and error messages
// Output: React Hook Form equivalent with identical validation
import { useForm } from 'react-hook-form';
interface SignupForm { email: string; password: string; confirmPassword: string; }
function SignupPage() {
const { register, handleSubmit, watch, formState: { errors, isSubmitting } } = useForm<SignupForm>();
const password = watch('password');
const onSubmit = async (data: SignupForm) => {
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: data.email, password: data.password }),
});
if (!res.ok) throw new Error('Signup failed');
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', {
required: 'Email is required',
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Invalid email' },
})} />
{errors.email && <span className="error">{errors.email.message}</span>}
<button type="submit" disabled={isSubmitting}>Sign Up</button>
</form>
);
}
Full script: typescript-framework-agnostic-bridge-for-angular-r.ts (51 lines)
// Input: Angular app that needs to share auth state with React micro-frontends
// Output: CustomEvent-based bridge that works across any framework boundary
interface AuthPayload { userId: string; token: string; roles: string[]; }
type EventMap = {
'auth:login': AuthPayload;
'auth:logout': undefined;
'theme:change': { theme: 'light' | 'dark' };
};
export function emit<K extends keyof EventMap>(event: K, detail: EventMap[K]): void {
window.dispatchEvent(new CustomEvent(event, { detail }));
}
export function on<K extends keyof EventMap>(
event: K, handler: (detail: EventMap[K]) => void
): () => void {
const wrapper = (e: Event) => handler((e as CustomEvent).detail);
window.addEventListener(event, wrapper);
return () => window.removeEventListener(event, wrapper);
}
// Input: Angular canActivate guard
// Output: React wrapper component with React Router v6
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <Outlet />;
}
// Usage in router:
// <Route element={<ProtectedRoute />}>
// <Route path="/dashboard" element={<Dashboard />} />
// <Route path="/settings" element={<Settings />} />
// </Route>
// ❌ BAD — Rebuilding Angular's DI in React
class DIContainer {
private registry = new Map<string, any>();
register(token: string, instance: any) { this.registry.set(token, instance); }
resolve<T>(token: string): T { return this.registry.get(token); }
}
const container = new DIContainer();
container.register('UserService', new UserService());
function UserList() {
const userService = container.resolve<UserService>('UserService');
// ...
}
// ✅ GOOD — React-idiomatic approach: custom hooks and plain imports
export function useUsers() {
return useQuery({ queryKey: ['users'], queryFn: fetchUsers });
}
export function formatUserName(user: User): string {
return `${user.firstName} ${user.lastName}`;
}
function UserList() {
const { data: users } = useUsers();
return <ul>{users?.map(u => <li key={u.id}>{formatUserName(u)}</li>)}</ul>;
}
// ❌ BAD — Manual RxJS subscription in React (memory leaks, no Suspense support)
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const sub = userService.getUser(userId).pipe(
switchMap(user => userService.getActivity(user.id)),
catchError(err => of(null))
).subscribe(data => setUser(data));
return () => sub.unsubscribe();
}, [userId]);
}
// ✅ GOOD — React Query handles caching, refetching, error states automatically
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {(error as Error).message}</div>;
return <div>{user?.name}</div>;
}
// ❌ BAD — Recreating Angular's NgModule as deeply nested providers
function SharedModule({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<AuthProvider>
<HttpProvider>
<ToastProvider>
{children}
</ToastProvider>
</HttpProvider>
</AuthProvider>
</ThemeProvider>
);
}
// ✅ GOOD — Only wrap what needs wrapping, keep providers flat
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
// Auth context only wraps routes that need it via layout routes
// ❌ BAD — Using refs to imperatively read form values (Angular [(ngModel)] muscle memory)
function EditProfile() {
const nameRef = useRef<HTMLInputElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
const name = nameRef.current!.value;
const email = emailRef.current!.value;
fetch('/api/profile', { method: 'PUT', body: JSON.stringify({ name, email }) });
};
// No validation, no re-render on input change
}
// ✅ GOOD — Controlled inputs: React state is the single source of truth
function EditProfile() {
const [name, setName] = useState('John');
const [email, setEmail] = useState('[email protected]');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await fetch('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email }),
});
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
<button type="submit">Save</button>
</form>
);
}
// ❌ BAD — Halting all feature development for 6+ months to rewrite
// "We'll rewrite the entire app in React over Q2-Q3, then launch."
// Result: scope creep, feature freeze, missed deadlines, regressions at go-live.
// ✅ GOOD — Incremental migration with continuous delivery
// Month 1: Set up single-spa shell, migrate /login to React
// Month 2: Migrate /users, keep shipping Angular features on /dashboard
// Month 3: Migrate /dashboard
// Month 4: Migrate /settings, remove Angular
// Each migration is tested and deployed to production independently.
useEffect. This creates dual reactivity systems, makes debugging harder, and prevents React Suspense from working. Fix: Replace Observable-based data fetching with React Query or SWR. [src4]strict: true. When creating the React project, ensure the same strictness. Fix: Copy strict, noImplicitAny, strictNullChecks settings to the React tsconfig. [src6]NgZone: 'noop' during coexistence or isolate its zone. [src5]canActivate guards and HttpInterceptor don’t have direct React equivalents. Fix: Implement guards as wrapper/layout route components; implement interceptors as Axios/fetch middleware. [src4]webpack-bundle-analyzer. [src7]signal(), computed(), effect()) are conceptually identical to React’s useState/useMemo/useEffect. Refactoring RxJS to signals first makes the final React migration almost mechanical. [src2]# Count Angular-specific patterns remaining in codebase
grep -rn "@Component\|@Injectable\|@NgModule\|@Pipe\|@Directive" --include="*.ts" src/ | wc -l
# Count RxJS usage (should trend toward zero)
grep -rn "subscribe(\|\.pipe(\|BehaviorSubject\|switchMap\|mergeMap" --include="*.ts" src/ | wc -l
# Count Angular signals (useful if pre-migrating RxJS to signals first)
grep -rn "signal(\|computed(\|effect(" --include="*.ts" src/ | wc -l
# Check total bundle size during coexistence
npx webpack-bundle-analyzer dist/stats.json
# Verify no zone.js interference in React components
grep -rn "zone.js\|NgZone" --include="*.ts" src/ | head -10
# List all Angular packages still installed
npm ls 2>/dev/null | grep @angular
# Check for duplicate dependencies
npm ls --all 2>/dev/null | grep -E "rxjs|zone.js|@angular" | sort -u
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Angular 19 (Nov 2024) | Current | Standalone components default, signals stable, @let syntax | Easiest to migrate — no NgModule boilerplate, signals map to useState |
| Angular 18 (May 2024) | Active | Zoneless change detection (experimental), stable signals | Good migration candidate — modern APIs reduce migration surface |
| Angular 17 (Nov 2023) | LTS | New control flow (@if, @for, @switch), deferrable views | Control flow closer to JSX, simplifies template migration |
| Angular 16 (May 2023) | Maintenance | Signals (developer preview), required inputs | Signals map more naturally to React state |
| Angular 14–15 (2022) | EOL/Maintenance | Standalone components (opt-in), typed forms | Standalone components can be migrated individually |
| React 19 (Dec 2024) | Current | use() hook, ref as prop, Actions, useActionState, useOptimistic | Use modern API — no forwardRef needed, better form handling |
| React 18 (Mar 2022) | LTS | createRoot, Concurrent features, useSyncExternalStore | Minimum recommended React version for new migrations |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Team is hiring React devs and Angular talent is scarce | Angular team is productive, no hiring issues | Stay on Angular, upgrade to latest version |
| App needs React ecosystem (Next.js, React Native, Remix) | SSR is the only need | Angular 17+ SSR or Angular Universal |
| Codebase has accumulated tech debt, rewrite is justified | App is well-maintained, meeting business needs | Upgrade Angular version instead |
| Company is standardizing on React across products | Angular app is isolated and self-contained | Maintain independently |
| Complex RxJS chains cause bugs and confusion | Team is proficient with RxJS | Simplify RxJS with Angular signals instead |
| UI library is limiting design flexibility | Current UI library meets design requirements | Swap UI library within Angular |
| Need to share code between web and mobile (React Native) | Only need responsive web design | Keep Angular, use responsive CSS |
NgZone: 'noop' or isolate its zone.Observable patterns do not compose well with React Suspense or React Server Components. Plan to fully replace RxJS with React-native patterns rather than bridging them.use() hook, useActionState, and useOptimistic provide first-class replacements for patterns Angular developers are accustomed to (async pipe, form state management, optimistic updates). Prefer these over older patterns when targeting React 19.