← Back to DevBytes

Migrating from React to Angular: Step-by-Step Guide

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:

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:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles