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 / useSignalconst [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)

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

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

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 devs and Angular talent is scarceAngular team is productive, no hiring issuesStay on Angular, upgrade to latest version
App needs React ecosystem (Next.js, React Native, Remix)SSR is the only needAngular 17+ SSR or Angular Universal
Codebase has accumulated tech debt, rewrite is justifiedApp is well-maintained, meeting business needsUpgrade Angular version instead
Company is standardizing on React across productsAngular app is isolated and self-containedMaintain independently
Complex RxJS chains cause bugs and confusionTeam is proficient with RxJSSimplify RxJS with Angular signals instead
UI library 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