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. 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 | 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) |
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. 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
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 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.
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 Angular and React apps load in the same browser window. Navigate between 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);
}
}
Verify: Setting state from the Angular console triggers re-renders in React components and vice versa.
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'); }
// }
// 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.
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
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.
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 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.
7. Decommission Angular and clean up
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.
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
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
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());
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
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>;
}
Wrong: Converting RxJS observables 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]);
}
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 (provider hell)
// ❌ 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>
);
}
Correct: Use flat composition with co-located providers
// ✅ 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
Wrong: Keeping two-way binding mentality with uncontrolled inputs
// ❌ 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
}
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 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>
);
}
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, missed deadlines, regressions at 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 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.
Common Pitfalls
- Trying to find 1:1 React equivalents for every Angular concept: Angular has DI, modules, decorators, 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 Suspense from working. Fix: Replace Observable-based data fetching with React Query or SWR. [src4] - Losing TypeScript strictness during migration: Angular projects typically use
strict: true. 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.js for change detection. It patches all async APIs (setTimeout, Promise, fetch), causing unexpected behavior in React. Fix: Configure Angular to run with
NgZone: 'noop'during coexistence or isolate its zone. [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 uses ViewEncapsulation (emulated Shadow DOM). React uses global CSS, CSS modules, or CSS-in-JS. Fix: Use CSS Modules or shared design tokens (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. 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)
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 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 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 |
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 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 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.