How to Migrate from Angular to React
How do I migrate from Angular to React?
TL;DR
- Bottom line: Use an incremental Strangler Fig migration — run Angular and React side by side via Module Federation or single-spa, migrate route by route, and decommission Angular modules only after their React replacements are production-tested.
- Key tool/command:
npx create-single-spaor Webpack 5 Module Federation to orchestrate Angular/React coexistence during migration. - Watch out for: Porting Angular’s dependency injection (DI) system 1:1 into React — React has no DI container; services become custom hooks, context providers, or plain modules.
- Works with: Angular 14–20 (20 recommended; 21 LTS, 22 signal-first), React 18–19.2 + React Compiler v1.0, TypeScript 5.x+, single-spa 6+, Webpack 5 / Rspack Module Federation.
Constraints
- Angular 14+ required: Standalone component support is needed for incremental migration. Angular 17+ with new control flow (
@if,@for) maps more naturally to JSX and simplifies conversion. Angular 20+ (signals stable, zoneless stable as of 20.2) is ideal — its signal-first model is conceptually closest to React. - RxJS must be refactored before migration: Codebases with >200
subscribe()calls require a dedicated RxJS simplification phase. Do not bridge RxJS Observables into React components — they break Suspense, Concurrent Mode, and Server Components. - TypeScript strictness must be preserved: Copy
strict,noImplicitAny,strictNullCheckssettings from Angular’s tsconfig to React’s tsconfig. Loosening strictness during migration introduces regressions. - zone.js must be isolated during coexistence: Angular’s zone.js patches global async APIs (setTimeout, Promise, fetch). Configure Angular with
NgZone: 'noop'or isolate its zone to prevent unexpected React behavior. - No big-bang rewrites for apps >10K LOC: Use Strangler Fig pattern. Feature freezes longer than 2–3 months cause scope creep, missed deadlines, and regressions discovered only at go-live.
Quick Reference
| 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 (Preact Signals) | 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) |
Meta / Title services | Direct metadata in JSX (React 19) | <title>{post.title}</title> in component return |
Decision Tree
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 components. 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 (Zustand, custom event bus)
Decision Logic
Agent-facing if/then rules for recommending a concrete migration path. Each rule is independent; evaluate top-down and stop at the first match.
If the Angular app is < 10K LOC with few third-party Angular libraries
→ Full rewrite in React 19.2 (Vite + React Compiler v1.0) — for small apps a clean rewrite finishes faster than standing up a coexistence shell. [src3, src5]
If the team must keep shipping features during the migration
→ Use the Strangler Fig pattern with Module Federation (Webpack 5 / Rspack) or single-spa 6+ — never freeze feature work for a big-bang rewrite of an app over 10K LOC. [src3, src7]
If the Angular app is on v14-16 (pre-signals, NgModule-heavy)
→ Upgrade to Angular 20 first (signals stable, zoneless stable in 20.2), refactor RxJS to signals, then migrate — signals map almost 1:1 to React useState/useMemo/useEffect, making the final port mechanical. [src2, src9]
If the codebase has > 200 subscribe() calls or complex RxJS orchestration
→ Run a dedicated RxJS-to-signals (or React Query) refactor phase before touching React; do not bridge Observables into React components — they break Suspense and Server Components. [src4, src5]
If the app leans heavily on Angular Material or CDK
→ Pick the React UI library first (MUI, Radix, shadcn/ui), build a shared component map, then migrate route-by-route so styling stays consistent across the coexistence period. [src3, src6]
If you are targeting React 19+ for form-heavy or async-heavy screens
→ Replace Angular reactive forms and the async pipe with React 19 useActionState, useOptimistic, and the use() hook rather than older forwardRef/manual-subscription patterns (forwardRef is deprecated in React 19). [src8, src10]
If the app is a large enterprise monolith (> 50K LOC) with multiple team domains
→ Lazy-load independent React micro-frontends via Module Federation while Angular serves the shell, so teams ship migrations without coordinating a single bundle; budget 6-18 months. [src7]
Step-by-Step Guide
1. Audit the Angular codebase
Map all Angular modules, services, routes, and third-party dependencies. Categorize each module by complexity (simple, moderate, complex) 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 "*.directive.ts" | wc -l
find src -name "*.module.ts" | wc -l
# Count RxJS usage (higher count = more migration effort)
grep -rn "subscribe\|Observable\|BehaviorSubject\|switchMap\|mergeMap" --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
# List all third-party Angular-specific packages
cat package.json | grep -E "@angular|@ngrx|ngx-" | wc -l
Verify: You have a spreadsheet/document listing every Angular module with: component count, service count, RxJS complexity score, signals usage count, and third-party dependency list.
2. Set up the coexistence shell
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 frameworks load
npm run start # Angular host on :4200
npm run start:react # React remote on :3000
Verify: Both Angular and React apps load in the same browser window. Navigate between Angular routes and React routes without full page reloads.
3. Create a shared state layer
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);
}
}
// Create a global instance
export const appStore = new SharedStore({
user: null as { id: string; name: string } | null,
theme: 'light' as 'light' | 'dark',
});
Verify: From the Angular console: appStore.setState({ theme: 'dark' }) — React components reading the store re-render instantly.
4. Migrate services to hooks and modules
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');
}
getUserById(id: string): Observable<User> {
return this.http.get<User>(`/api/users/${id}`);
}
}
// AFTER: React custom hook (using React Query for server state)
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
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();
},
});
}
export function useUser(id: string) {
return useQuery<User>({
queryKey: ['users', id],
queryFn: async () => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
enabled: !!id,
});
}
Verify: useUsers() in a React component returns the same data as UserService.getUsers() in the Angular component. Compare network requests side by side.
5. Migrate routes incrementally
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: register Angular and React apps by route
import { registerApplication, start } from 'single-spa';
// Angular handles /dashboard and /settings (not yet migrated)
registerApplication({
name: 'angular-app',
app: () => import('./angular-main'),
activeWhen: ['/dashboard', '/settings'],
});
// React handles /users (migrated) and /reports (migrated)
registerApplication({
name: 'react-app',
app: () => import('./react-main'),
activeWhen: ['/users', '/reports'],
});
start();
Verify: Navigate to /users — React renders. Navigate to /dashboard — Angular renders. Back button works correctly. Shared store stays in sync.
6. Convert template syntax and patterns
Translate Angular template directives to React JSX patterns. This is the bulk of the migration work. [src1, src4]
// BEFORE: Angular component with template directives
@Component({
selector: 'app-user-list',
template: `
<div *ngIf="loading">Loading...</div>
<ul *ngIf="!loading">
<li *ngFor="let user of users; trackBy: trackById"
[class.active]="user.id === selectedId"
(click)="selectUser(user)">
{{ user.name | uppercase }}
</li>
</ul>
`
})
export class UserListComponent implements OnInit {
users: User[] = [];
loading = true;
selectedId: string | null = null;
constructor(private userService: UserService) {}
ngOnInit(): void {
this.userService.getUsers().subscribe(users => {
this.users = users;
this.loading = false;
});
}
selectUser(user: User): void {
this.selectedId = user.id;
}
trackById(index: number, user: User): string {
return user.id;
}
}
// AFTER: React equivalent
import { useState } from 'react';
import { useUsers } from '../hooks/useUsers';
function UserList() {
const { data: users, isLoading } = useUsers();
const [selectedId, setSelectedId] = useState<string | null>(null);
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{users?.map(user => (
<li
key={user.id}
className={user.id === selectedId ? 'active' : ''}
onClick={() => setSelectedId(user.id)}
>
{user.name.toUpperCase()}
</li>
))}
</ul>
);
}
export default UserList;
Verify: Side-by-side comparison — both render identical HTML. Click behavior and styling match. Check accessibility (keyboard navigation, ARIA attributes).
7. Decommission Angular and clean up
After all routes are migrated and production-tested, remove the Angular framework, coexistence shell, and shared store bridge. [src3, src6]
# Remove Angular packages
npm uninstall @angular/core @angular/common @angular/router @angular/forms \
@angular/platform-browser @angular/platform-browser-dynamic \
@angular/compiler @angular/compiler-cli @angular-devkit/build-angular \
@ngrx/store @ngrx/effects rxjs zone.js single-spa single-spa-angular
# Remove Angular config files
rm -f angular.json tsconfig.app.json tsconfig.spec.json
# Search for any remaining Angular references
grep -rn "@angular\|@Injectable\|@Component\|@NgModule\|subscribe(" --include="*.ts" src/ | head -20
# Replace shared store with React-native state (Zustand, Context, etc.)
# Verify no window.appStore references remain
grep -rn "appStore\|SharedStore" --include="*.ts" --include="*.tsx" src/ | head -20
Verify: npm ls @angular/core returns “empty”. App loads and all routes work without Angular. Bundle size decreased. CI pipeline passes all tests.
Code Examples
TypeScript/React: Converting an Angular service with RxJS to a React hook
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 };
}
TypeScript/React: Converting Angular Reactive Forms to React Hook Form
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>
);
}
TypeScript: Framework-agnostic bridge for Angular-React coexistence
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
// shared/event-bridge.ts — publish/subscribe via CustomEvent (DOM-native, zero deps)
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);
}
TypeScript/React: Converting Angular route guards to React protected routes
// Input: Angular canActivate guard
// Output: React wrapper component with React Router v6
// BEFORE: Angular guard
// @Injectable({ providedIn: 'root' })
// export class AuthGuard implements CanActivate {
// constructor(private authService: AuthService, private router: Router) {}
// canActivate(): boolean {
// if (this.authService.isAuthenticated()) return true;
// this.router.navigate(['/login']);
// return false;
// }
// }
// AFTER: React protected route component
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>
Anti-Patterns
Wrong: Porting Angular DI system into React with a custom DI container
// ❌ 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());
// In React component:
function UserList() {
const userService = container.resolve<UserService>('UserService');
// ...
}
Correct: Use React's built-in patterns — hooks for state, imports for logic
// ✅ GOOD — React-idiomatic approach: custom hooks and plain imports
// For stateful logic: custom hook
export function useUsers() {
return useQuery({ queryKey: ['users'], queryFn: fetchUsers });
}
// For stateless logic: plain module
export function formatUserName(user: User): string {
return `${user.firstName} ${user.lastName}`;
}
// In component:
function UserList() {
const { data: users } = useUsers();
return <ul>{users?.map(u => <li key={u.id}>{formatUserName(u)}</li>)}</ul>;
}
Wrong: Converting RxJS observables to React by subscribing inside useEffect
// ❌ 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]);
return <div>{user?.name}</div>;
}
Correct: Replace RxJS with React Query or native async patterns
// ✅ 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>;
}
Wrong: Migrating NgModules to React “module” wrappers
// ❌ BAD — Trying to recreate Angular's NgModule concept in React
// shared.module.tsx
export function SharedModule({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<AuthProvider>
<HttpProvider>
<ToastProvider>
{children}
</ToastProvider>
</HttpProvider>
</AuthProvider>
</ThemeProvider>
);
}
// This leads to deeply nested provider trees ("provider hell")
Correct: Use flat composition with co-located providers
// ✅ GOOD — Only wrap what needs wrapping, keep providers flat
// app/providers.tsx
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:
// <Route element={<AuthGuard />}>
// <Route path="/dashboard" element={<Dashboard />} />
// </Route>
Wrong: Keeping two-way data binding mentality with uncontrolled inputs
// ❌ BAD — Using refs to imperatively read/write 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; // Imperative read
const email = emailRef.current!.value; // No validation, no re-render
fetch('/api/profile', { method: 'PUT', body: JSON.stringify({ name, email }) });
};
return (
<form>
<input ref={nameRef} defaultValue="John" />
<input ref={emailRef} defaultValue="[email protected]" />
<button type="button" onClick={handleSubmit}>Save</button>
</form>
);
}
Correct: Use controlled components with state
// ✅ 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 [saving, setSaving] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
await fetch('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email }),
});
setSaving(false);
};
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" disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
</form>
);
}
Wrong: Big-bang rewrite of the entire Angular app at once
// ❌ 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 frustration, missed deadlines,
// regressions discovered only after go-live.
Correct: Strangler Fig — migrate route by route while shipping features
// ✅ GOOD — Incremental migration with continuous delivery
// Month 1: Set up single-spa shell, migrate /login route to React
// Month 2: Migrate /users route, keep shipping Angular features on /dashboard
// Month 3: Migrate /dashboard
// Month 4: Migrate /settings, remove Angular
// Each migration is tested independently and deployed to production.
Common Pitfalls
- Trying to find 1:1 React equivalents for every Angular concept: Angular has DI, modules, decorators, lifecycle hooks, pipes, guards, interceptors, and resolvers. React has components, hooks, and context. Don’t port the architecture — port the behavior. [src3]
- Keeping RxJS alongside React hooks: Developers often keep RxJS “because it works” and subscribe inside
useEffect. This creates dual reactivity systems, makes debugging harder, and prevents React’s Suspense/Concurrent features from working. Fix: ReplaceObservable-based data fetching with React Query or SWR; replaceBehaviorSubjectstate withuseState/useReducer/Zustand. [src4] - Losing TypeScript strictness during migration: Angular projects typically use
strict: truein tsconfig. When creating the React project, ensure the same strictness. Fix: Copystrict,noImplicitAny,strictNullCheckssettings to the React tsconfig. [src6] - Not handling zone.js removal properly: Angular relies on
zone.jsfor change detection. When running both frameworks, zone.js patches all async APIs (setTimeout, Promise, fetch), which can cause unexpected behavior in React. Fix: Configure Angular to run withNgZone: 'noop'during coexistence, or isolate Angular’s zone to its micro-frontend. [src5] - Forgetting to migrate route guards and interceptors: Angular’s
canActivateguards andHttpInterceptordon’t have direct React equivalents. Fix: Implement guards as wrapper/layout route components; implement interceptors as Axios/fetch middleware. [src4] - Sharing styles without a strategy: Angular components use ViewEncapsulation (emulated Shadow DOM). React components use global CSS, CSS modules, or CSS-in-JS. Mix them carelessly and styles bleed across frameworks. Fix: Use CSS Modules or a shared design token system (CSS custom properties) for both. [src3, src6]
- Not measuring bundle size during coexistence: Running both Angular (~150 KB gzipped) and React (~45 KB gzipped) simultaneously inflates the initial bundle. Fix: Use lazy loading aggressively — only load Angular for legacy routes and React for migrated routes. Monitor with
webpack-bundle-analyzer. [src7] - Ignoring Angular signals as a migration stepping stone: Angular 16+ signals (
signal(),computed(),effect()) are conceptually identical to React’suseState/useMemo/useEffect. Refactoring RxJS to signals first makes the final React migration almost mechanical. [src2]
Diagnostic Commands
# 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 as migration progresses)
grep -rn "subscribe(\|\.pipe(\|BehaviorSubject\|switchMap\|mergeMap\|combineLatest" --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 (Angular + React both pulling in rxjs, etc.)
npm ls --all 2>/dev/null | grep -E "rxjs|zone.js|@angular" | sort -u
# Verify React routes are loading (browser console)
# window.__SINGLE_SPA_DEVTOOLS && console.log(window.__SINGLE_SPA_DEVTOOLS.exposedMethods.getAppNames())
Version History & Compatibility
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Angular 22 (~Jun 2026) | Upcoming | Signal-first era — zoneless change detection is the default for new projects | Closest yet to React’s model; signals and zoneless remove most of the conceptual gap |
| Angular 21 (Nov 2025) | Current LTS | Continued signal/zoneless polish, Material 3 alignment | Modern baseline; minimal NgModule surface, signals everywhere |
| Angular 20 (May 2025) | LTS (security to Nov 2026) | effect, linkedSignal, toSignal stable; zoneless stable as of 20.2; incremental hydration | Recommended upgrade target before migrating — signals map 1:1 to React hooks |
| Angular 19 (Nov 2024) | Maintenance | Standalone components default, @let syntax | Easy to migrate — no NgModule boilerplate |
| Angular 17-18 (2023-24) | EOL/Maintenance | New control flow (@if, @for, @switch), deferrable views, zoneless (experimental) | Control flow closer to JSX; upgrade to 20+ first if time allows |
| Angular 14-16 (2022-23) | EOL/Maintenance | Standalone (opt-in), typed forms, signals (preview) | Standalone components migrate individually; refactor RxJS to signals first |
| React 19.2 (Oct 2025) | Current | Document metadata in components, refined Suspense/Activity, type hardening | Latest stable (19.2.x). Use with the React Compiler |
| React Compiler v1.0 (Oct 2025) | Stable | Automatic memoization | Drop most manual useMemo/useCallback; opt in via the babel/swc plugin + eslint-plugin-react-hooks v7 |
| React 19 (Dec 2024) | Active | use() hook, ref as prop, Actions, useActionState, useOptimistic; forwardRef deprecated | Use modern API — pass ref as a prop, prefer Actions for forms |
| React 18 (Mar 2022) | LTS | createRoot, Concurrent features, useSyncExternalStore | Minimum recommended React version for new migrations |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Team is hiring React developers and Angular talent is scarce | Angular team is productive and no hiring issues | Stay on Angular, upgrade to latest version |
| App needs React ecosystem libraries (Next.js, React Native, Remix) | Server-side rendering is the only need | Angular 17+ SSR or Angular Universal |
| Codebase has accumulated technical debt and a rewrite is justified | App is well-maintained and meeting business needs | Upgrade Angular version instead |
| Company is standardizing on React across multiple products | Angular app is isolated and self-contained | Maintain independently |
| Complex RxJS chains are causing bugs and developer confusion | Team is proficient with RxJS and reactive patterns | Simplify RxJS with Angular signals instead |
| UI library (Angular Material) 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 |
Important Caveats
- Angular and React have fundamentally different mental models: Angular is opinionated (DI, decorators, modules, RxJS), React is flexible (hooks, JSX, pick-your-own-libraries). Resist the urge to recreate Angular’s architecture in React — embrace React’s patterns.
- During coexistence (single-spa / Module Federation), expect a 150–200 KB increase in initial bundle size. Use aggressive code splitting and lazy loading to keep perceived performance acceptable.
- zone.js (Angular’s change detection mechanism) patches global async APIs. If React components exhibit unexpected re-renders or timing issues during coexistence, configure Angular with
NgZone: 'noop'or isolate its zone. - RxJS
Observablepatterns do not compose well with React Suspense or React Server Components. Plan to fully replace RxJS with React-native patterns (React Query, hooks,useSyncExternalStore) rather than bridging them. - Angular 20+ (signals stable, zoneless stable as of 20.2) and the upcoming Angular 22 “signal-first” release are architecturally much closer to React. If your Angular app is on v14-19, upgrading to v20+ first and refactoring RxJS to signals usually shrinks the eventual React migration, because signals map almost 1:1 to React hooks.
- React 19’s
use()hook,useActionState, anduseOptimisticprovide 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.2. - The React Compiler reached v1.0 (Oct 2025) and provides automatic memoization. Adopt it on the React side of the migration to avoid hand-writing
useMemo/useCallback— pair it witheslint-plugin-react-hooksv7 for compiler-aware linting. NoteforwardRefis deprecated in React 19; passrefas a regular prop in any plugin/component wrappers. - Timeline estimates vary significantly: small apps (<10K LOC) take 1–3 months; medium apps (10K–50K LOC) take 3–6 months; large enterprise apps (>50K LOC) can take 6–18 months with the Strangler Fig approach.