How to Migrate from Angular to React

How do I migrate from Angular to React?

TL;DR

Constraints

Quick Reference

Angular PatternReact EquivalentExample
@Component({ template })Function component + JSXfunction App() { return <div>Hello</div>; }
@Injectable() class DataServiceCustom hook or plain modulefunction useData() { ... } or export const dataService = { ... }
constructor(private svc: DataService) (DI)useContext() or direct importconst data = useContext(DataContext)
this.http.get<T>(url) (HttpClient)fetch / React Query / SWRconst { data } = useQuery({ queryKey: ['key'], queryFn: () => fetch(url) })
observable$.subscribe(val => ...) (RxJS)useState + useEffect or React Queryconst [val, setVal] = useState(null); useEffect(() => { ... }, [])
signal() (Angular 16+)useState / useSignal (Preact Signals)const [count, setCount] = useState(0)
@Input() name: stringPropsfunction Child({ name }: { name: string }) { ... }
@Output() clicked = new EventEmitter()Callback propsfunction 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 useMemoconst formatted = useMemo(() => formatCurrency(val), [val])
@NgModule({ imports, declarations })No equivalent — just ES importsReact has no module system; use ES modules and React.lazy()
HttpInterceptorFetch wrapper or Axios interceptorconst api = axios.create(); api.interceptors.request.use(...)
Reactive Forms (FormGroup, FormControl)React Hook Form or useActionState (React 19)const { register, handleSubmit } = useForm<FormData>()
| async pipeuse() hook (React 19) or useQueryconst comments = use(commentsPromise)
Meta / Title servicesDirect 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

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

VersionStatusBreaking ChangesMigration Notes
Angular 22 (~Jun 2026)UpcomingSignal-first era — zoneless change detection is the default for new projectsClosest yet to React’s model; signals and zoneless remove most of the conceptual gap
Angular 21 (Nov 2025)Current LTSContinued signal/zoneless polish, Material 3 alignmentModern 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 hydrationRecommended upgrade target before migrating — signals map 1:1 to React hooks
Angular 19 (Nov 2024)MaintenanceStandalone components default, @let syntaxEasy to migrate — no NgModule boilerplate
Angular 17-18 (2023-24)EOL/MaintenanceNew 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/MaintenanceStandalone (opt-in), typed forms, signals (preview)Standalone components migrate individually; refactor RxJS to signals first
React 19.2 (Oct 2025)CurrentDocument metadata in components, refined Suspense/Activity, type hardeningLatest stable (19.2.x). Use with the React Compiler
React Compiler v1.0 (Oct 2025)StableAutomatic memoizationDrop most manual useMemo/useCallback; opt in via the babel/swc plugin + eslint-plugin-react-hooks v7
React 19 (Dec 2024)Activeuse() hook, ref as prop, Actions, useActionState, useOptimistic; forwardRef deprecatedUse modern API — pass ref as a prop, prefer Actions for forms
React 18 (Mar 2022)LTScreateRoot, Concurrent features, useSyncExternalStoreMinimum recommended React version for new migrations

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Team is hiring React developers and Angular talent is scarceAngular team is productive and no hiring issuesStay on Angular, upgrade to latest version
App needs React ecosystem libraries (Next.js, React Native, Remix)Server-side rendering is the only needAngular 17+ SSR or Angular Universal
Codebase has accumulated technical debt and a rewrite is justifiedApp is well-maintained and meeting business needsUpgrade Angular version instead
Company is standardizing on React across multiple productsAngular app is isolated and self-containedMaintain independently
Complex RxJS chains are causing bugs and developer confusionTeam is proficient with RxJS and reactive patternsSimplify RxJS with Angular signals instead
UI library (Angular Material) is limiting design flexibilityCurrent UI library meets design requirementsSwap UI library within Angular
Need to share code between web and mobile (React Native)Only need responsive web designKeep Angular, use responsive CSS

Important Caveats