Understanding React-to-Angular Migration
Migrating from React to Angular is the process of transforming a front-end application originally built with React (a JavaScript library for building user interfaces) into one powered by Angular (a full-fledged TypeScript framework). This is not a simple "search and replace" operation. React and Angular have fundamentally different philosophies: React gives you a library of primitives and leaves architectural decisions largely up to you and the ecosystem, while Angular provides an opinionated, batteries-included framework with strong conventions around modules, dependency injection, and component lifecycle management. A migration therefore involves rethinking component trees, state handling, routing, and data fetching in Angular's terms.
Why Migration Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Organizations choose to migrate for several strategic reasons:
- Enterprise scalability: Angular's strict, modular architecture and first-class TypeScript support make large codebases easier to maintain and onboard new developers into.
- Built-in solutions: Angular ships with its own HTTP client, form system (reactive and template-driven), routing module, and testing utilities, reducing dependency sprawl.
- Long-term support: Angular follows a predictable, semantic versioning release schedule with clear deprecation paths and long-term support windows.
- Full-stack alignment: Teams using .NET, Java, or NestJS on the back end often find Angular's decorator-based, OOP-flavored style a natural fit with their server-side code.
- Performance characteristics: Angular's change detection (based on zone.js and hierarchical injectors) can be tuned for specific performance profiles that differ from React's virtual DOM reconciliation.
Before committing to a migration, validate that the benefits outweigh the effort. A phased, component-by-component approach (sometimes called a "strangler pattern") is almost always preferable to a big-bang rewrite.
Key Architectural Differences
Understanding these differences is crucial before writing any migration code:
Component Model
React components are functions (or classes) that return JSX. Angular components are classes decorated with @Component, with a separate HTML template and styling. React uses one-way data flow via props; Angular uses @Input() and @Output() decorators for the same purpose but also supports two-way binding with [(ngModel)] syntax.
State Management
React relies on hooks like useState, useReducer, or external libraries such as Redux/Zustand. Angular manages state through class fields, services with RxJS observables, or dedicated state libraries like NgRx. Angular's async pipe in templates automatically subscribes and unsubscribes from observables, reducing boilerplate.
Routing
React Router uses component-based route definitions (<Route path="/" element={...}/>). Angular uses a centralized route configuration object where paths map to component classes, with guards, resolvers, and lazy-loaded modules defined declaratively.
Dependency Injection
React has no built-in DI; context or props are used to pass dependencies. Angular has a full hierarchical dependency injection system where services are provided in modules, components, or at the root level and injected via constructor parameters.
Forms
React forms are typically uncontrolled or controlled via component state. Angular offers template-driven forms (simple, declarative) and reactive forms (powerful, testable, with explicit FormGroup and FormControl instances).
Step-by-Step Migration Guide
We will walk through a practical migration of a small React application—a task management dashboard—into its Angular equivalent. The approach is to rebuild the feature set incrementally while keeping the React app running in production.
Step 1: Initialize the Angular Workspace
Create a new Angular project alongside your React codebase. Use the Angular CLI to generate a workspace:
npm install -g @angular/cli
ng new task-dashboard-angular --routing --style scss
cd task-dashboard-angular
This creates a project with routing enabled and SCSS for styling. The source code lives under src/app. The main module (app.module.ts) is the root of the application, analogous to where you might wrap providers in React's root component.
Step 2: Create the Core Data Model and Service
In React, you might fetch data inside a component using useEffect and store it in useState. In Angular, data fetching logic belongs in an injectable service. Let's define a Task interface and a TaskService:
// src/app/models/task.model.ts
export interface Task {
id: number;
title: string;
completed: boolean;
dueDate?: Date;
}
// src/app/services/task.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { Task } from '../models/task.model';
@Injectable({
providedIn: 'root' // available application-wide, no need to register in providers[]
})
export class TaskService {
private apiUrl = 'https://api.example.com/tasks'; // replace with your actual endpoint
constructor(private http: HttpClient) {}
getTasks(): Observable<Task[]> {
return this.http.get<Task[]>(this.apiUrl).pipe(
map(tasks => tasks.map(task => ({ ...task, dueDate: task.dueDate ? new Date(task.dueDate) : undefined }))),
catchError(this.handleError)
);
}
createTask(task: Partial<Task>): Observable<Task> {
return this.http.post<Task>(this.apiUrl, task).pipe(
catchError(this.handleError)
);
}
updateTask(task: Task): Observable<Task> {
return this.http.put<Task>(`${this.apiUrl}/${task.id}`, task).pipe(
catchError(this.handleError)
);
}
deleteTask(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(
catchError(this.handleError)
);
}
private handleError(error: any): Observable<never> {
console.error('TaskService error:', error);
return throwError(() => new Error(error.message || 'Server error'));
}
}
This service mirrors what a custom hook like useTasks() might encapsulate in React—fetching, caching, and error handling—but centralized and injectable anywhere via Angular's DI.
Step 3: Migrate the Task List Component
Assume the React version has a TaskList component that receives tasks as props and emits an onToggle callback. Here is how it maps to Angular:
// React version (for reference)
/*
function TaskList({ tasks, onToggle }) {
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
{task.title}
</li>
))}
</ul>
);
}
*/
Angular equivalent — TypeScript class:
// src/app/components/task-list/task-list.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Task } from '../../models/task.model';
@Component({
selector: 'app-task-list',
templateUrl: './task-list.component.html',
styleUrls: ['./task-list.component.scss']
})
export class TaskListComponent {
@Input() tasks: Task[] = [];
@Output() toggleComplete = new EventEmitter<number>();
onCheckboxChange(taskId: number): void {
this.toggleComplete.emit(taskId);
}
}
Angular template (task-list.component.html):
<ul class="task-list">
<li *ngFor="let task of tasks">
<input
type="checkbox"
[checked]="task.completed"
(change)="onCheckboxChange(task.id)"
/>
<span [class.completed]="task.completed">{{ task.title }}</span>
<span class="due-date" *ngIf="task.dueDate">
Due: {{ task.dueDate | date:'mediumDate' }}
</span>
</li>
</ul>
Key mappings to notice:
props→@Input()decorated class fields- Callback props like
onToggle→@Output()combined withEventEmitter - JSX map →
*ngForstructural directive - Conditional rendering →
*ngIfstructural directive - Date formatting →
datepipe (Angular pipes are analogous to formatting functions in JSX)
Step 4: Migrate the Parent Container Component
The React container might use useState and useEffect to load tasks and handle toggling:
// React container (for reference)
/*
function TaskDashboard() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchTasks()
.then(data => { setTasks(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, []);
const handleToggle = (id) => {
const updated = tasks.map(t => t.id === id ? { ...t, completed: !t.completed } : t);
setTasks(updated);
// also persist to backend...
};
if (loading) return ;
if (error) return ;
return (
Task Dashboard
);
}
*/
Angular equivalent component class:
// src/app/components/dashboard/dashboard.component.ts
import { Component, OnInit } from '@angular/core';
import { Task } from '../../models/task.model';
import { TaskService } from '../../services/task.service';
import { finalize } from 'rxjs/operators';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit {
tasks: Task[] = [];
loading = true;
error: string | null = null;
constructor(private taskService: TaskService) {}
ngOnInit(): void {
this.loadTasks();
}
loadTasks(): void {
this.loading = true;
this.error = null;
this.taskService.getTasks()
.pipe(finalize(() => this.loading = false))
.subscribe({
next: (tasks) => this.tasks = tasks,
error: (err) => this.error = err.message || 'Failed to load tasks'
});
}
handleToggle(taskId: number): void {
const task = this.tasks.find(t => t.id === taskId);
if (!task) return;
// Optimistic local update
const updatedTask = { ...task, completed: !task.completed };
this.tasks = this.tasks.map(t => t.id === taskId ? updatedTask : t);
// Persist to backend
this.taskService.updateTask(updatedTask).subscribe({
error: (err) => {
// Rollback on failure
this.tasks = this.tasks.map(t => t.id === taskId ? task : t);
this.error = 'Failed to update task. Reverted.';
}
});
}
}
Dashboard template:
<div class="dashboard">
<h1>Task Dashboard</h1>
<app-spinner *ngIf="loading"></app-spinner>
<app-error-banner
*ngIf="error"
[message]="error"
(dismiss)="error = null"
></app-error-banner>
<app-task-list
*ngIf="!loading && !error"
[tasks]="tasks"
(toggleComplete)="handleToggle($event)"
></app-task-list>
</div>
Angular's lifecycle method ngOnInit replaces the useEffect with empty dependency array. The subscribe call replaces .then() promise chains (though Angular also works fine with async/await if you convert observables to promises). Error state is managed as a plain class field, which templates can bind to directly.
Step 5: Migrate Routing
Suppose the React app has two pages: a dashboard at / and a task detail page at /task/:id. Here is the React Router setup for reference:
// React Router (for reference)
/*
} />
} />
*/
In Angular, routing is configured as a route array passed to RouterModule.forRoot(). Here is how to define the equivalent:
// src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DashboardComponent } from './components/dashboard/dashboard.component';
import { TaskDetailComponent } from './components/task-detail/task-detail.component';
const routes: Routes = [
{ path: '', component: DashboardComponent, pathMatch: 'full' },
{ path: 'task/:id', component: TaskDetailComponent },
{ path: '**', redirectTo: '' } // wildcard redirect to dashboard
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}
And ensure this module is imported in app.module.ts:
// src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { DashboardComponent } from './components/dashboard/dashboard.component';
import { TaskListComponent } from './components/task-list/task-list.component';
import { TaskDetailComponent } from './components/task-detail/task-detail.component';
import { SpinnerComponent } from './components/spinner/spinner.component';
import { ErrorBannerComponent } from './components/error-banner/error-banner.component';
@NgModule({
declarations: [
AppComponent,
DashboardComponent,
TaskListComponent,
TaskDetailComponent,
SpinnerComponent,
ErrorBannerComponent
],
imports: [
BrowserModule,
HttpClientModule,
AppRoutingModule
],
providers: [], // TaskService is already providedIn: 'root'
bootstrap: [AppComponent]
})
export class AppModule {}
The root component template uses <router-outlet> instead of React's <Outlet /> or manually rendered route elements:
<!-- src/app/app.component.html -->
<nav>
<a routerLink="/" routerLinkActive="active">Dashboard</a>
<a routerLink="/task/1" routerLinkActive="active">Sample Task</a>
</nav>
<router-outlet></router-outlet>
Step 6: Build the Task Detail Component
This page reads the route parameter :id and fetches a single task. In React, you might use useParams(). In Angular, inject ActivatedRoute:
// src/app/components/task-detail/task-detail.component.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { TaskService } from '../../services/task.service';
import { Task } from '../../models/task.model';
import { switchMap } from 'rxjs/operators';
import { Observable } from 'rxjs';
@Component({
selector: 'app-task-detail',
templateUrl: './task-detail.component.html',
styleUrls: ['./task-detail.component.scss']
})
export class TaskDetailComponent implements OnInit {
task$!: Observable<Task>;
taskId!: number;
constructor(
private route: ActivatedRoute,
private taskService: TaskService,
private router: Router
) {}
ngOnInit(): void {
this.task$ = this.route.paramMap.pipe(
switchMap(params => {
const id = Number(params.get('id'));
this.taskId = id;
return this.taskService.getTaskById(id);
})
);
}
deleteTask(): void {
if (confirm('Delete this task?')) {
this.taskService.deleteTask(this.taskId).subscribe(() => {
this.router.navigate(['/']);
});
}
}
}
Template using the async pipe to subscribe automatically:
<div *ngIf="task$ | async as task; else loading">
<h2>{{ task.title }}</h2>
<p>Status: {{ task.completed ? 'Completed' : 'Pending' }}</p>
<p *ngIf="task.dueDate">Due: {{ task.dueDate | date:'fullDate' }}</p>
<button (click)="deleteTask()">Delete</button>
<a routerLink="/">Back to Dashboard</a>
</div>
<ng-template #loading>
<app-spinner></app-spinner>
</ng-template>
The async pipe is a powerful Angular feature that has no direct React equivalent—it subscribes to an observable and returns its latest value, automatically marking the view for change detection when new values arrive, and unsubscribing when the component is destroyed.
Step 7: Migrate Forms (Add Task Feature)
Suppose the React app has a simple controlled form for adding a new task. We will implement the same feature using Angular's reactive forms:
// src/app/components/add-task/add-task.component.ts
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TaskService } from '../../services/task.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-add-task',
templateUrl: './add-task.component.html',
styleUrls: ['./add-task.component.scss']
})
export class AddTaskComponent {
taskForm: FormGroup;
submitting = false;
serverError: string | null = null;
constructor(
private fb: FormBuilder,
private taskService: TaskService,
private router: Router
) {
this.taskForm = this.fb.group({
title: ['', [Validators.required, Validators.minLength(3)]],
dueDate: [null]
});
}
get title() { return this.taskForm.get('title'); }
onSubmit(): void {
if (this.taskForm.invalid) return;
this.submitting = true;
this.serverError = null;
this.taskService.createTask(this.taskForm.value).subscribe({
next: () => {
this.submitting = false;
this.router.navigate(['/']);
},
error: (err) => {
this.submitting = false;
this.serverError = err.message || 'Creation failed';
}
});
}
}
Template:
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
<label>
Title:
<input type="text" formControlName="title" />
</label>
<div *ngIf="title?.invalid && (title?.dirty || title?.touched)" class="error">
<span *ngIf="title?.errors?.['required']">Title is required.</span>
<span *ngIf="title?.errors?.['minlength']">Title must be at least 3 characters.</span>
</div>
<label>
Due Date:
<input type="date" formControlName="dueDate" />
</label>
<div *ngIf="serverError" class="error">{{ serverError }}</div>
<button type="submit" [disabled]="taskForm.invalid || submitting">
{{ submitting ? 'Saving...' : 'Add Task' }}
</button>
</form>
Angular reactive forms provide explicit FormGroup and FormControl tracking, built-in validators, and fine-grained status properties (dirty, touched, errors). This reduces the boilerplate of manually wiring up validation logic that you would write in a React controlled form handler.
Step 8: Testing Migration
Angular uses Jasmine and Karma by default (or Jest with configuration). Components are tested via TestBed, which creates a testing module mirroring your NgModule setup. Here is how a React Testing Library test translates to an Angular component test:
// React Testing Library (for reference)
/*
test('renders tasks', () => {
render( );
expect(screen.getByText('Test')).toBeInTheDocument();
});
*/
Angular TestBed test:
// src/app/components/task-list/task-list.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskListComponent } from './task-list.component';
import { Task } from '../../models/task.model';
import { By } from '@angular/platform-browser';
describe('TaskListComponent', () => {
let component: TaskListComponent;
let fixture: ComponentFixture<TaskListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [TaskListComponent]
}).compileComponents();
fixture = TestBed.createComponent(TaskListComponent);
component = fixture.componentInstance;
});
it('should display task titles', () => {
const tasks: Task[] = [{ id: 1, title: 'Test Task', completed: false }];
component.tasks = tasks;
fixture.detectChanges();
const listItem = fixture.debugElement.query(By.css('li span'));
expect(listItem.nativeElement.textContent).toContain('Test Task');
});
it('should emit toggleComplete on checkbox change', () => {
const tasks: Task[] = [{ id: 1, title: 'Test', completed: false }];
component.tasks = tasks;
spyOn(component.toggleComplete, 'emit');
fixture.detectChanges();
const checkbox = fixture.debugElement.query(By.css('input[type="checkbox"]'));
checkbox.nativeElement.dispatchEvent(new Event('change'));
fixture.detectChanges();
expect(component.toggleComplete.emit).toHaveBeenCalledWith(1);
});
});
Angular's testing paradigm relies on fixture.detectChanges() to trigger change detection manually, and DebugElement queries to inspect the rendered DOM. Service-level tests are straightforward: instantiate the service in a test module, mock HttpClient, and assert observable streams.
Best Practices During Migration
- Strangler pattern first: Do not attempt a full rewrite overnight. Run both frameworks concurrently under the same domain using reverse proxy configuration, and migrate route by route. Angular elements (custom elements compiled from Angular components) can even embed Angular inside React pages as a transitional step.
- Establish a design system: Before migrating components, extract common UI primitives (buttons, inputs, modals) into framework-agnostic HTML/CSS or a shared component library. This prevents duplicating styling effort.
- Map state carefully: Document where each piece of state lives in the React app—component state, context, Redux store, URL parameters—and decide the Angular equivalent upfront (component field, service with BehaviorSubject, NgRx store, route data).
- Leverage TypeScript gradually: Angular is TypeScript-first. If your React project is plain JavaScript, introduce TypeScript into the React codebase first so types can be shared across the boundary during the migration phase.
- Use environment variables consistently: Both frameworks support environment-specific builds. Align your
.envand Angular environment files so API endpoints and feature flags match during the overlap period. - Plan for bundle size: Angular's initial bundle is larger than React's. Use lazy-loaded feature modules (
loadChildren) from day one to split code by route and avoid a monolithic main bundle. - Keep the React app as documentation: The existing React code is the most accurate specification of how the system should behave. Write Angular tests against the same acceptance criteria, and refer back to React component edge cases when writing Angular equivalents.
- Train the team on RxJS: Observables are central to Angular's HTTP, routing, and reactive forms. Invest time in understanding operators like
switchMap,debounceTime, anddistinctUntilChangedto avoid subtle bugs.
Conclusion
Migrating from React to Angular is a significant undertaking that touches architecture, state management, routing, testing, and team workflows. By understanding the fundamental paradigm shifts—from functional components and hooks to class-based components with decorators, from manual DI to Angular's injector hierarchy, from JSX to Angular template syntax—you can approach the migration methodically. The step-by-step examples in this guide demonstrate the concrete mappings for components, services, routing, forms, and tests. Following the strangler pattern, investing in shared types and design tokens, and leveraging Angular's built-in capabilities will help you deliver a maintainable, scalable Angular application without sacrificing the functionality your users rely on.