DEV Community

Cover image for Moving from Angular to React: A Practical Guide
Emmanuel R for CobuildX AI

Posted on Originally published at cobuildx.ai

Moving from Angular to React: A Practical Guide

If you've spent time in Angular, you're used to a framework that makes most of the decisions for you. Templates, dependency injection, routing and forms all come in the box. React gives you much less: it's a library for building components, and routing, shared state and how you structure the app are left to you. That difference is why moving between them takes more than rewriting syntax. We ported large applications from Angular to React, and this is what we learned along the way: how to pick a strategy, how Angular's ideas map onto React's, and how to make the move without breaking things for your users.

Most Angular-to-React migrations don't fail on a tricky hook or a missing library. They fail on a decision made in the first week: how the move should happen at all.

So before we touched a single component, we had to answer a risk question rather than a technical one. Rewrite the whole thing in one go, or move it across in pieces while both versions keep running? Most of what follows depends on that answer.

Source code: the examples here come from a small todo app we built twice, once in Angular 14 and once in React 19, with the same features, styling and tests. Both versions are on GitHub at cobuild-tech/migratex-examples [1]. The app is small enough that we did it as a full rewrite. The incremental advice below is what we follow for bigger applications.

Choosing the Strategic Approach

The full rewrite

In a full rewrite you leave the old application alone and build a new one next to it, feature by feature. On a chosen day, traffic switches over and the old app is retired.

For a small, low-stakes app, that's fine. It's what we did with the todo app: two components, one service, no real users depending on it. For anything larger it hides a nasty risk. The team can spend months writing code that no real user ever touches, and the feedback only arrives at the end, which is the most expensive moment to find out you misunderstood something. Joel Spolsky made the classic case against big rewrites back in 2000 [3], and most of it still holds.

The business doesn't pause while you do this, either. Feature requests keep coming, so each one gets built twice (once in the old app to keep it alive, once in the new one), or it waits until after the cutover. Nobody enjoys being the one to explain that to a product manager.

The incremental approach

An incremental migration takes the opposite view. The old app is never frozen and never swapped out in one go. It gets replaced piece by piece, with both versions serving real users at the same time.

The industry name for this is the Strangler Fig pattern [2], after a vine that starts small on a host tree and slowly grows around it, until the tree isn't needed any more and only the fig is left standing.

In software, the vine is a thin routing layer. For each route (or even each request) it decides whether the old system or the new one answers. More routes move across over time, until the old system has nothing left to serve and you can switch it off.

Strangler Fig stages: Angular serves 100%, then 50%, then 0% of routes while React grows from 0% to 50% to 100%

For almost anything with real users, this is the approach we'd pick, because every step is small and you can undo it. A route that moves gets tried by real people within days, not months. If it breaks, the router sends that route back to the old app while you fix it. The old app doesn't vanish overnight. It hands over one route at a time, until there's nothing left for it to do.

How to choose

The questions that decide it turn out to be about the organisation more than the code:

  • Do real users depend on the app today? If they do, a rewrite puts them at risk for the whole length of the project.
  • How big is it, and how many teams touch it? One team and a handful of screens can survive a rewrite. Several teams, dozens of screens and a steady stream of feature requests make incremental far more practical.
  • Can the business live with a feature freeze? A rewrite almost always needs one, because anything added to the old app mid-rewrite has to be built again in the new one.
  • Can both frameworks run side by side? Incremental depends on it, usually through a micro frontend shell [4].

Our rule of thumb: rewrite if the app is small, low-risk and owned by one team. Otherwise go incremental, and let the old system keep serving users while the new one gradually takes over.

Mapping What We Already Knew

What surprised us once we started was how much Angular and React agree on. They solve mostly the same problems. They just use different mechanisms to do it.

So the work wasn't learning React from scratch. It was matching each Angular mechanism to its React counterpart, and resisting the urge to rebuild Angular's machinery inside React. Here's the mapping we ended up with. The official Angular [7] and React [13] docs cover each mechanism in more depth.

Migration workflow in six steps: understand, choose a path, map concepts, move bottom-up, verify, deprecate

Templates

Open an Angular component next to a React one and the first thing you notice is the file count: two files against one. It's tempting to call that a formatting difference and move on. It's more than that.

Same screen, two ways of writing it

To keep the comparison honest, we built the same todo app twice (source on GitHub [1]), once in each framework, with identical behaviour and identical styling. Here's the single todo item from each.

In Angular, the logic lives in a TypeScript class, and a decorator points it at a separate HTML file:

@Component({
  selector: 'todo-item',
  templateUrl: './todo-item.component.html',
})
export class TodoItemComponent {
  @Input() title = '';
  @Input() isDone = false;
  @Output() onComplete = new EventEmitter<string>();
}
Enter fullscreen mode Exit fullscreen mode
<li class="todo-item {{ type }} {{ isDone ? 'done' : '' }}">
  <div class="checkbox" (click)="handleClickCheck()"></div>
  <p>{{ title }}</p>
</li>
Enter fullscreen mode Exit fullscreen mode

In React, it's one function that returns the markup:

export function TodoItem({ title, type, isDone, id, onComplete }) {
  return (
    <li className={`todo-item ${type} ${isDone ? 'done' : ''}`}>
      <div className="checkbox" onClick={handleClickCheck} />
      <p>{title}</p>
    </li>
  );
}
Enter fullscreen mode Exit fullscreen mode

Both render exactly the same <li>, and both are answering the same question: given this data, what should appear on the screen?

Angular joins a class file and a template file with the @Component decorator and compiles the template; React's TodoItem is one function whose JSX is plain JavaScript. Both answer: given this data, what should appear on the screen?

A template is a language. JSX is a value.

That's the difference that explains nearly everything else. An Angular template looks like HTML, but it's really Angular's own small language [8], which the compiler reads and turns into code. It has its own vocabulary for everything a screen needs:

What you need Angular template React JSX
Show a value {{ title }} {title}
Pass data to a child [title]="item.title" title={item.title}
React to an event (onDelete)="handleItemDelete($event)" onDelete={handleItemDelete}
Loop over a list *ngFor="let item of todoList" todoList.map(item => ...)
Show conditionally *ngIf="completeList.length" completeList.length > 0 && ...
Keep an input in sync [(ngModel)]="title" value={title} + onChange

Look at the right-hand column. React has no special syntax for loops or conditions, because JSX is just JavaScript [14]. <p>{title}</p> compiles to an ordinary function call that returns an object describing the UI. A loop is .map(), a condition is &&, and a variable is a variable.

A few practical things fall out of that. Scope works differently: an Angular template sees the fields of its class, linked by name across two files, while JSX sees whatever is in scope a few lines up, so TypeScript flags a misspelled name straight away.

Components are found differently, too. <todo-item> only works in Angular because TodoItemComponent is listed in the module's declarations. <TodoItem> works in React because it was imported at the top of the file. There's no registry, just imports.

Talking back to the parent gets simpler. Angular needs @Output(), an EventEmitter and .emit(). In React, onComplete is just a prop that happens to be a function, and the child calls it.

And because JSX is a value, you can keep markup in a variable, return it early or pass it to another component, which you can't do with a template file. The smaller renames follow from the same idea: class becomes className, image paths become imports, and trackBy [11] becomes key [15]. Our Angular trackById method simply disappeared. In React it's key={item.id} on the list item.

Which do we prefer?

For this kind of work, we lean towards JSX: one file, one language, and TypeScript checking the markup and the logic together. But Angular's constraint deserves credit. Its templates make it hard for business logic to creep into the view, and nothing in JSX stops a render function from filling up with logic. In React, keeping components clean is a team habit, not a framework rule.

What this means for a migration

Porting a template isn't a line-by-line translation of directives. It's closer to rewriting a small program in another language: *ngFor becomes a map, *ngIf becomes an expression, @Output becomes a callback. The screen that comes out should be identical, and in our todo app it is.

The more useful question isn't "what's React's version of this directive?" It's "what is this template trying to show, and how would I say that in plain JavaScript?"

Two-way binding

Angular two-way binding, where ngModel lets the input and the class field both write title, compared with React one-way flow from state to input to setTitle

In Angular, [(ngModel)] [9] keeps a form field and a piece of data in sync in both directions. Type in the input and the class field updates; change the field in code and the input updates. In the todo app, the title box is one line:

<input name="title" [(ngModel)]="title" />
Enter fullscreen mode Exit fullscreen mode

That "banana in a box" syntax is really two bindings folded together. [ngModel] pushes the value down into the input, and (ngModelChange) pushes every change back up into the class.

React only gives you the first half. Data flows one way, from state down to the screen, and you write the way back yourself with a controlled input [16]:

<input value={title} onChange={e => setTitle(e.target.value)} />
Enter fullscreen mode Exit fullscreen mode

Yes, it's more typing. What you get for it is traceability. In Angular, title can change from the template, from the class, or from anything else bound to that field, and none of those paths are visible from the input. In React, the only way title changes is through setTitle. Search for it and you've found every place the data can change.

Who is allowed to change the data?

That's really the whole difference. With two-way binding, both sides can: the class writes title, the input writes title, and the framework quietly keeps them in sync. It's like a shared document that two people are editing at once. Convenient, until something looks wrong and you have to work out who changed it.

With one-way data flow, only the state can. The screen is just a picture of that state. The input never changes title itself; it reports what the user typed through onChange, and the state decides what to do with it. One person owns the document, and everyone else sends suggestions.

Question Two-way binding (Angular) One-way data flow (React)
Who owns the data Shared by the view and the class The state, and only the state
How the view changes data Writes to it directly Asks, through an event handler
Where to look when a value is wrong The template, the class, and anything bound to them Every call to the setter
Best fit Simple forms with a few fields Screens where inputs depend on each other

For a simple form with a few fields, two-way binding is genuinely nicer to write. Once inputs start depending on each other, though, we'd take one-way flow every time. React makes that choice for you, and a migration is a good moment to get used to it.

Dependency injection

Angular hands services to components through the constructor [10]. The DI container creates the service, decides how long it lives, and gives the same instance to everyone who asks for it. In the todo app, the component just declares what it needs:

constructor(private localStorageService: LocalStorageService<ToDo>) {}
Enter fullscreen mode Exit fullscreen mode

React has no container like that, so what happens to the service?

When we opened LocalStorageService, the answer was a bit of an anticlimax. It was a generic class with an empty constructor and two methods, getItem and setItem. The class existed mainly so the DI system had something to inject. In React, those became two exported functions in a nine-line file:

import { getItem, setItem } from '../services/localStorageService';
Enter fullscreen mode Exit fullscreen mode

Nothing to register, no lifetime to manage. Where logic also needs to hold state or run side effects, it becomes a custom hook instead [19]. That's where the todo state went: everything AppComponent used to hold now lives in useTodos(), which App calls like any other function.

Angular's injector creates one shared LocalStorageService and injects it through constructors; in React, localStorageService.ts exports functions that the useTodos() hook imports and App calls

Don't rebuild Angular's architecture in React just because the old app had it. Work out what the service was actually doing, and move only that.

State management

In Angular, state usually lives in one of two places: inside a component, or in a shared service that DI hands to whoever needs it. Because DI is built in, sharing state is almost free, and a lot of Angular apps put most of their state into services from day one.

React starts from the opposite default. State begins local, inside the component, with useState. It only moves when other components need it: first up to the nearest parent they share [17], then into React Context [18] if they're far apart in the tree. Only when that stops being enough do you reach for a library such as Redux [24] or Zustand [25].

The progression looks like this:

React state progression: local useState, lifted to a shared parent, Context, then a library such as Redux or Zustand

Why Angular reaches for services

Angular was built around dependency injection from the start, and a service marked providedIn: 'root' is a single shared instance for the whole app. That makes it a natural home for shared state. Our todo app keeps its state in AppComponent, but a small shared store in Angular 14 typically looks like this:

@Injectable({ providedIn: 'root' })
export class TodoStore {
  private todos = new BehaviorSubject<ToDo[]>([]);
  readonly todos$ = this.todos.asObservable();

  add(todo: ToDo) {
    this.todos.next([...this.todos.value, todo]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Any component that asks for TodoStore in its constructor gets the same instance, and so the same list. Sharing takes one line.

Why React didn't follow

React started from a different idea: the UI is a function of state. A component is just a function that runs again whenever its state or props change. There's no container holding shared instances, so state lives where it's rendered, and sharing it is something you do on purpose.

In our React version, all the todo state lives in the useTodos() hook. App calls it once, and each TodoItem gets only what it needs through props. If two components far apart in the tree needed the same data, Context would let them share it without passing props through every level. The todo app is too small to need it, but it would look like this:

const TodosContext = createContext(null);

function App() {
  const todos = useTodos();
  return (
    <TodosContext.Provider value={todos}>
      <Page />
    </TodosContext.Provider>
  );
}

function CompletedCount() {
  const { completeList } = useContext(TodosContext);
  return <span>{completeList.length} done</span>;
}
Enter fullscreen mode Exit fullscreen mode

Anything bigger than that, React leaves to libraries such as Redux or Zustand. That's deliberate: the core stays small, and each team picks the tool that fits.

Watch out for in-place updates

One porting detail is easy to miss. Angular's handleSubmit adds a todo with this.todoList.push(...) and then saves this.todoList to localStorage. Angular's change detection is happy with an array being changed in place. React isn't. Push onto an array held in state and nothing re-renders [20], and reading a state variable straight after calling its setter still gives you the old value [21].

So every handler in useTodos() builds the next list first ([...todoList, newTodo] rather than push), passes it to the setter, and saves that same list to localStorage. It's a small change, but it's the kind that passes a quick review and then shows up as a list that doesn't update.

Pros and cons

Trade-off Angular: shared services React: local-first
Pros Sharing is built in and takes one line. There is one obvious place for shared data. Every team follows the same pattern. State sits next to the code that uses it. Data flow is easy to trace. Nothing gets shared by accident.
Cons It is easy to share state that never needed sharing. Any component that injects the service can change it. It is hard to see which components depend on a service. Passing props through many levels ("prop drilling") gets tedious. You have to choose a tool for app-wide state. Different teams may choose differently.

Coming from Angular, the temptation is to reach for the biggest tool first and set up a global store on day one. Resist it. Use the smallest tool that solves today's problem, and move up only when you actually feel the pain of not having something bigger. The todo app never needed more than useState.

Routing

Angular ships its router as part of the framework [12]. React doesn't ship one at all. You add a library such as React Router [22], or pick a framework like Next.js [23] if you also want server rendering.

It sounds like a bigger difference than it is. In both, a URL should resolve to a screen; what changes is the configuration and the tooling around it, not how you think about navigation. (The todo app has no routes at all, so this was the one part of the mapping we didn't need.)

Routing in both frameworks: a URL such as /dashboard goes to a router that matches it to a screen. Angular builds the router in; React adds React Router or Next.js

How the Migration Should Actually Happen, Step by Step

With the strategy chosen and the concepts mapped, the migration itself followed a fixed order:

Four migration steps in order: set up the tooling, let old and new coexist, migrate bottom-up, verify constantly

The order matters more than it looks. Most of the risk in a migration comes from skipping a step, or doing them out of sequence.

If the steps look familiar, it's because they're the Strangler Fig pattern in practice. Step 2 builds the routing layer, the "vine" that decides who answers each route. Step 3 moves routes across one at a time. Step 4, plus the rollback plan further down, makes sure every move can be undone. That combination is where the reliability comes from:

  • a mistake affects one route, not the whole application
  • every migrated piece meets real users within days, not at the end of a months-long rewrite
  • the router can send a route back to the Angular version in minutes if something goes wrong
  • new work carries on in whichever framework currently owns that route, so there's no feature freeze

1. Set up the tooling

Before a single feature moves, the new React project should build, test and deploy, even if all it contains is one empty page. For the todo app, that meant Vite [26], React 19 and TypeScript, with three scripts in package.json that every change goes through: npm run lint, npm test and npm run build. Not much, but it meant the React side was checked the same way from the first commit.

For a production app, you'd want the full set in place before any real code lands:

  • Linting and formatting. A linter (we used oxlint [29]; ESLint is the common alternative) plus a formatter like Prettier, so style never has to come up in review.
  • Type checking. tsc runs as part of the build, so a wrong prop or a missing field fails straight away instead of in production.
  • Tests. Vitest [27] and Testing Library [28] for unit and component tests. For the side-by-side check in step 4, an end-to-end tool such as Playwright [30] can run the same user journey against both versions.
  • CI. Lint, type checks, tests and a build on every pull request, deployed somewhere real, ideally with a preview URL for each branch.
  • Observability. Error tracking (Sentry, for example), Core Web Vitals and logs, all tagged by framework, so you can tell which side a problem came from.
  • Feature flags. The switch [6] that later lets you send a route back to Angular. Far easier to add now than in the middle of an incident.

This pipeline is the safety net everything later depends on. Retrofitting it once the real code exists is harder, and riskier.

2. Decide how old and new will live together

In an incremental migration, Angular pages and React pages have to live on the same site at the same time, sometimes for months. So how do users move between them without noticing?

This is where a micro frontend architecture earns its keep. A thin shell application owns the routing decision and hands each route to whichever framework currently owns it [4]. Frameworks such as single-spa [5] exist to do exactly this wiring.

A shell app owns the URL and delegates each route to an Angular remote (/invoices, /settings) or a React remote (/dashboard, /reports); routes move from Angular to React over time

Not every project needs this. A small app can do without it, and a full rewrite (like our todo app) skips this step entirely. It pays off in larger applications, with many teams and many screens, where the migration has to happen without stopping feature work.

3. Migrate from the bottom up

Start with the smallest, most isolated pieces (a button, a form field, a single list item) and only move up once they're proven. In a real codebase, "smallest" isn't always obvious, and that's where a dependency graph helps.

Build a dependency graph (a DAG) first

Before moving anything, map which pieces depend on which. Every component and service is a node, with an arrow from A to B whenever A uses B. Code shouldn't depend on itself in a loop, so the result is a directed acyclic graph, or DAG, and the migration order falls straight out of it: start with the nodes that depend on nothing, and only move a node once everything it depends on has moved.

Ours was small enough to draw by hand:

AppComponent
 ├── uses → TodoItemComponent     (depends on nothing)
 └── uses → LocalStorageService   (depends on nothing)
Enter fullscreen mode Exit fullscreen mode

TodoItemComponent and LocalStorageService depend on nothing, so they went first and became TodoItem and localStorageService.ts. AppComponent depends on both, so it went last, once there was nothing left underneath it.

For a larger app you won't draw this by hand. Tools such as madge [31] or dependency-cruiser [32] generate the graph from source, for example:

npx madge --image graph.svg --extensions ts src/app
Enter fullscreen mode Exit fullscreen mode

Two things to watch for. If the tool finds a cycle, where A uses B and B also uses A, break it before migrating, usually by pulling the shared part into its own small module. And keep the graph up to date as you go. It doubles as a progress chart, with migrated nodes marked as done.

Going top-down usually means touching everything at once, which throws away most of the safety you chose an incremental migration for.

4. Verify constantly, not only at the end

After each piece moved, we asked two questions: does it behave the same, and does it look the same?

Tests answer the first. We ported every Angular spec (Jasmine and Karma) to Vitest and React Testing Library, scenario for scenario, so coverage never dropped, and the React version finishes with all 13 tests passing. The second question needs a person. We ran both apps side by side in a browser, added, prioritised, completed and deleted todos in each, and compared screenshots.

One small decision made that easier: both versions use the same localStorage keys (todoList and completeList) and the same data shape, so either app can read the other's todos in the same browser. It's a cheap trick, and worth copying.

Don't save either check for the end. A problem found one piece later is cheap to fix. Fifty pieces later, it isn't.

Deprecation and Rollback

Life of each piece: migrated to React, verified in production, old code kept but unused, old code removed, with a rollback path back to Angular until removal

When can the old code go? Not when the new code compiles, and not when the tests pass. For a production app, only after the React version has run under real traffic for a while without surprises.

Each piece goes through the same four stages: migrated to React, verified in production, old code kept but unused, and finally old code removed. Until that last stage, keep a way back, whether that's a feature flag [6] or a route-level toggle that sends users to the Angular version if something slips past the tests.

Keeping that path costs effort, and once everything looks fine it's tempting to rip it out early. Don't. The point of a rollback is that you don't know when you'll need it, and the one time you do, it pays for itself.

Looking Back

The migration was never really about turning Angular syntax into React syntax. The harder and more useful part was understanding what the existing app actually did, finding the idea behind each Angular mechanism, and deciding how that idea should live in React.

It got much easier once we stopped thinking of it as one big rewrite and started treating it as a series of small decisions. Understand what you have before changing anything. Map the concepts deliberately instead of copying the old architecture. Move in small pieces, and check each one before the next. And let go slowly: keep a way back until the new system has earned the trust to stand on its own.

About MigrateX

MigrateX

MigrateX is our software and services platform for moving software, data and infrastructure from one technology to another. It brings automation and experienced engineers together, so the repetitive work gets done quickly and the tricky decisions get the human attention they need.

Everything in this guide comes from how we run Angular to React projects with MigrateX. Here's what that looks like in practice:

  1. Assessment. We scan your Angular app and map what it really relies on: components, services and the DI tree, two-way bindings, NgRx or service-based state, routes and guards, and third-party modules. You get a clear picture of where the effort really sits before anyone commits to a timeline.
  2. Migration plan. Together with your team, we choose between a full rewrite and an incremental move, decide what each Angular mechanism becomes in React, and build the dependency graph that sets the order screens move in.
  3. Step-by-step migration. Automation handles the repetitive parts, like converting templates to JSX, porting Jasmine specs to Vitest and setting up the tooling. Our engineers handle the parts that need judgement, like state design, replacing services and the shell that lets Angular and React share one site.
  4. Proof that it works. Every ported test passes, and your most important user journeys run as Playwright tests against both apps. A screen only counts as migrated when it passes on both, so progress is something you can see, not just something you're told.
  5. Handover and clean-up. We remove the shell routing and the last Angular packages, keep a way back until traffic is quiet, and leave your team with a React codebase they know and own.

What you get along the way:

  • No big-bang release. Your app keeps running and shipping features while the migration happens in the background.
  • Less risk. Shared tests, feature flags and a way back mean users don't notice the move, and you can pause at any point.
  • A team that's ready. We pair with your developers throughout, so they're comfortable with React long before the last Angular screen is gone.
  • More than front ends. The same approach covers other frameworks, like Ember and Backbone, as well as data and infrastructure migrations.

To see the code behind this guide, visit github.com/cobuild-tech/migratex-examples. Planning a move from Angular, or already partway through? We'd love to hear about it. Tell us a little about your app through Contact Us, and we'll start with a free conversation about where the effort is likely to be.

References

Source code

Migration strategy

Angular documentation (v17)

React documentation

Libraries and tooling


Originally published on the CobuildX AI blog.

Top comments (0)