Plenty of teams still run a production UI on Angular 8. It works, but it has had no security patches since November 2020, hiring for it is getting harder, and every dependency upgrade turns into a fight. Sooner or later the question comes up: upgrade Angular, or move to React?
This guide is written for Angular developers with two to eight years of experience who are moving an application, and themselves, to React. It does not start from zero: it explains React through the Angular concepts you already know, points out the habits that do not carry over, and gives a migration process that does not stop the business while it happens. The code uses the current stack as of October 2026: React 19.3, Vite 8, TypeScript, React Router 8 and TanStack Query 5. The plain React examples in Part 1 were run in a browser against React 19 to check that they behave as described.
How to read it
- Part 1, React for Angular developers: the mindset shifts, the React fundamentals mapped to Angular, a side-by-side cheat sheet, and the mistakes Angular developers typically make in React.
- Part 2, The migration process: assessment, running both apps side by side, the new project, the shared foundations, migrating a screen, testing and cutover.
- Part 3, Bringing the team along: a learning path, practice exercises, team conventions and a review checklist.
If you have two to four years of experience, read Part 1 fully and do the exercises in Part 3 before touching production code. If you have five or more years or lead the team, skim Part 1 for the cheat sheet and the common mistakes, then focus on Parts 2 and 3: your main job will be setting the foundations and conventions everyone else builds on.
Before you start: is React the right move?
Be honest about the alternative. Angular itself is now on version 22, and ng update can take an app forward one major version at a time. Upgrading Angular keeps your code, your team’s knowledge and your architecture. Moving to React is a rewrite of the UI layer, done gradually.
| Choose an Angular upgrade when | Choose React when |
|---|---|
| The team knows Angular well and is happy with it | The team, or the people you can hire, know React better |
| The app is large and the business logic sits in the components | The UI needs a redesign anyway, so screens will change regardless |
| You need a fast, low-risk result | You want React’s ecosystem (component libraries, React Native, frameworks) |
| The app is heavily built on Angular-specific libraries | Other products in the company already use React and could share components |
If React wins, read on. If the decision is unclear, run a two-week spike: rebuild one real screen in React with the approach below and compare effort and result.
Part 1: React for Angular developers
Six mindset shifts
Most of the difficulty for an experienced Angular developer is not new syntax. It is unlearning a few deep habits. Keep these six in mind and the rest of React falls into place.
- The template is JavaScript. There is no template language. Conditions are
ifand? :, loops are.map(), and anything you can do in TypeScript you can do in the markup. Fewer things to learn, but also no guard rails. - Data flows one way. There is no
[(ngModel)]. A parent passes data down as props, and a child asks the parent to change it by calling a function the parent passed down. When two components need the same data, you move the state up to their common parent (“lifting state up”). - There is no dependency injection container. Sharing happens through plain imports, props, context and hooks. You will not miss
providersarrays once you see how little ceremony this needs, but you do need to decide deliberately where each piece of state lives. - Promises, not Observables. HTTP calls return promises, and a query library handles caching and cancellation. RxJS is still allowed, but only where you have real streams (WebSockets, live feeds); it is no longer the default for everything.
- Rendering means calling your function again. There is no zone.js and no change detection cycle. When state changes, React calls the component function again and updates the DOM where the output changed. Everything in the function body runs on every render, so side effects never go there.
- You choose the libraries, so the team must agree on them. React has no official router, HTTP client or forms package. Part 2 recommends a set that covers what Angular gave you out of the box. Standardise on it early, or every developer will bring a different one.
What React is (and is not)
Angular is a framework: router, HTTP client, forms, dependency injection, testing tools and a CLI all come in the box. React is a library for building user interfaces. It renders components and keeps the screen in sync with your data. The core idea is simple:
UI = f(state). A component is a function that receives data and returns what the screen should look like. When the data changes, React calls the function again and updates only the parts of the page that changed.
A component re-renders when its own state changes, when its parent re-renders, or when a context it reads changes.
JSX: templates inside TypeScript
Angular keeps the template in HTML with its own syntax (*ngIf, [prop], (event)). React uses JSX, an HTML-like syntax inside TypeScript files (.tsx).
function Welcome() {
const user = { name: 'Asha', unread: 3 };
return (
<div className="welcome">
<h1>Hello, {user.name}</h1>
<p>You have {user.unread} new messages.</p>
<label htmlFor="q">Search</label>
<input id="q" type="search" />
</div>
);
}
Rules worth learning on day one:
- Anything inside
{ }is a JavaScript expression: variables, function calls, ternaries. classbecomesclassName,forbecomeshtmlFor, and event names are camelCase (onClick,onChange).- A component returns one root element. Use a fragment
<>...</>(Angular’s<ng-container>) to group elements without adding adiv. - Every tag must be closed:
<input />,<br />. - Component names start with a capital letter (
<UserCard />); lowercase tags are plain HTML.
Components and props
A React component is a function. Its inputs are props, the equivalent of @Input(). Outputs are simply callback props: there is no EventEmitter.
type UserCardProps = {
name: string;
email: string;
onSelect: (email: string) => void; // replaces @Output() selected = new EventEmitter()
};
export function UserCard({ name, email, onSelect }: UserCardProps) {
return (
<article className="card">
<h3>{name}</h3>
<p>{email}</p>
<button onClick={() => onSelect(email)}>Select</button>
</article>
);
}
// Using it:
<UserCard name="Asha" email="asha@example.com" onSelect={(e) => console.log(e)} />
Props are read-only. A component never changes its own props, in the same way an Angular component should not write to its inputs. By convention, callback props are named onSomething and the functions that handle them handleSomething.
Content projection. Angular’s <ng-content> is the children prop in React:
import type { ReactNode } from 'react';
function Panel({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="panel">
<h2>{title}</h2>
{children}
</section>
);
}
<Panel title="Orders"><OrderTable /></Panel>
The one-way flow looks like this, and it is the picture to keep in your head:
flowchart TB
P[OrdersPage<br/>owns state and loads data] ==>|filter| F[FilterBar]
P ==>|orders| T[OrderTable]
F -.->|onFilterChange| P
T -.->|onSelect| P
T ==>|order| R[OrderRow]
classDef core fill:#0f766e,stroke:#064e4a,stroke-width:2px,color:#ffffff
classDef data fill:#e3f2fd,stroke:#1565c0,stroke-width:1.5px,color:#1d1c1a
class P core
class F,T,R data
Thick arrows are props flowing down; dashed arrows are callbacks the children call to ask the parent for a change.
State with useState
State is data that belongs to a component and changes over time. In Angular it is a class field; in React you declare it with the useState hook.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((c) => c + 1)}>
Clicked {count} times
</button>
);
}
Two things trip up Angular developers:
- Never mutate state; replace it.
items.push(x)does nothing visible. WritesetItems([...items, x])orsetUser({ ...user, name: 'Ravi' }). React compares the old and new values by reference to decide what to update. - Updates are not instant. After
setCount(count + 1)the variablecountstill holds the old value until the next render. When the new value depends on the old one, use the function formsetCount((c) => c + 1).
Keep state minimal. If a value can be calculated from props or other state, calculate it during render instead of storing it:
const visible = todos.filter((t) => !t.done); // derived, not stored
Events, conditionals and lists
These replace (click), *ngIf and *ngFor:
function TodoList({ todos, onToggle }: { todos: Todo[]; onToggle: (id: string) => void }) {
if (todos.length === 0) return <p>Nothing to do.</p>; // *ngIf with an else
return (
<ul>
{todos.map((t) => ( // *ngFor
<li key={t.id}> {/* trackBy */}
<label>
<input type="checkbox" checked={t.done} onChange={() => onToggle(t.id)} />
{t.title}
</label>
{t.overdue && <span className="badge">Overdue</span>} {/* *ngIf */}
</li>
))}
</ul>
);
}
The key prop is React’s trackBy. It must be stable and unique among siblings; use an id from your data, not the array index, when the list can be reordered or filtered.
Effects: syncing with the outside world
useEffect runs code after React has updated the screen. Use it to synchronise with something outside React: a WebSocket, a timer, a third-party widget, a browser event.
import { useEffect, useState } from 'react';
function OnlineStatus() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => { // cleanup = ngOnDestroy
window.removeEventListener('online', on);
window.removeEventListener('offline', off);
};
}, []); // [] = run once after first render
return <span>{online ? 'Online' : 'Offline'}</span>;
}
The array at the end is the dependency list. The effect runs after the first render and again whenever one of the listed values changes. List every value from the component that the effect uses; the ESLint plugin for hooks checks this for you.
| Angular lifecycle | React equivalent |
|---|---|
constructor / field initialisers |
useState(initialValue) |
ngOnInit |
useEffect(fn, []), but for data loading use a query library (Part 2) |
ngOnChanges |
Calculate from props during render, or an effect with those props as dependencies |
ngAfterViewInit |
useEffect together with a ref to the element |
ngOnDestroy |
The cleanup function returned from useEffect |
ChangeDetectionStrategy.OnPush |
The default behaviour, plus the React Compiler’s automatic memoisation |
Do not reach for effects first. The most common mistake is using an effect to copy props into state, or to react to a button click. Event logic belongs in the event handler, and derived values belong in render. The React documentation has an excellent page called You Might Not Need an Effect; every developer on the team should read it once.
StrictMode runs effects twice in development. A new project wraps the app in <StrictMode>, which mounts, unmounts and mounts every component again in development to expose missing cleanups. If you see two API calls in development, that is why. Fix the cleanup rather than removing StrictMode.
Refs: reaching the DOM
useRef is the equivalent of @ViewChild. It holds a mutable value that does not cause a re-render, most often a DOM element.
import { useRef } from 'react';
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<input ref={inputRef} />
<button onClick={() => inputRef.current?.focus()}>Focus</button>
</>
);
}
In React 19, ref is a normal prop on your own components too, so the old forwardRef wrapper is no longer needed.
Context: replacing dependency injection for shared values
Angular services provided at the root are often used to share the current user, theme or settings. React’s context passes a value to every component below a provider without threading props through each level. A context provider placed around part of the tree is also the equivalent of providers: [...] on an Angular component: everything below it gets that instance.
import { createContext, useContext, useState, type ReactNode } from 'react';
type Theme = 'light' | 'dark';
const ThemeContext = createContext<{ theme: Theme; toggle: () => void } | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const toggle = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'));
return <ThemeContext value={{ theme, toggle }}>{children}</ThemeContext>; // React 19: context as provider
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
return ctx;
}
Context is for values that many components need and that change rarely. It is not a replacement for a data-fetching or state library: every component reading a context re-renders when its value changes.
Custom hooks: where service logic goes
A custom hook is a function whose name starts with use and that calls other hooks. It is how you package reusable stateful logic, much like a small Angular service, but scoped to the component that uses it. Each component that calls a hook gets its own copy of the state inside it.
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(t);
}, [value, delayMs]);
return debounced;
}
Stateless logic (formatting, validation, calculations) does not need a hook at all; a plain exported function is best. So a typical Angular service splits three ways: HTTP calls become query hooks, shared state goes to a store or context, and pure logic becomes plain functions.
The rules of hooks. Call hooks only at the top level of a component or custom hook, never inside conditions, loops or after an early return, and only from React functions. React identifies hooks by their call order, so the order must be the same on every render.
Forms, the React 19 way
A controlled input keeps its value in state. This is how you replace [(ngModel)]:
const [email, setEmail] = useState('');
<input value={email} onChange={(e) => setEmail(e.target.value)} />
React 19 adds Actions: a form can call an async function directly, and useActionState tracks the pending state and the result.
import { useActionState } from 'react';
type Result = { error?: string; ok?: boolean };
async function subscribe(_prev: Result, formData: FormData): Promise<Result> {
const email = String(formData.get('email') ?? '');
if (!email.includes('@')) return { error: 'Enter a valid email' };
const res = await fetch('/api/subscribe', { method: 'POST', body: formData });
return res.ok ? { ok: true } : { error: 'Something went wrong' };
}
export function NewsletterForm() {
const [state, formAction, isPending] = useActionState(subscribe, {});
return (
<form action={formAction}>
<input name="email" type="email" required />
<button disabled={isPending}>{isPending ? 'Saving…' : 'Subscribe'}</button>
{state.error && <p role="alert">{state.error}</p>}
{state.ok && <p>Subscribed.</p>}
</form>
);
}
For large forms with many fields and rules, a form library is still the better tool (see Part 2).
Using RxJS where it still makes sense
Senior Angular developers often have valuable RxJS pipelines for live data: price tickers, notifications, WebSocket feeds. You can keep them. Wrap the subscription in a small hook:
import { useEffect, useState } from 'react';
import type { Observable } from 'rxjs';
export function useObservable<T>(source$: Observable<T>, initial: T): T {
const [value, setValue] = useState(initial);
useEffect(() => {
const sub = source$.subscribe({ next: setValue, error: (e) => console.error(e) });
return () => sub.unsubscribe(); // what the async pipe did for you
}, [source$]);
return value;
}
// prices$ is created once, outside the component (or in a module), so it stays the same object
const price = useObservable(prices$, 0);
The observable must be the same object on every render. If you build it inside the component (interval(1000).pipe(...)), React sees a new dependency each time and resubscribes on every render. Create it in a module, or memoise it with useMemo.
What is new in React 19.x, briefly
- Actions,
useActionState,useOptimisticanduseFormStatusfor forms and mutations with built-in pending and error states. use()reads a promise or a context, and can be called conditionally.refas a prop, and context as a provider (<ThemeContext value={...}>).- Document metadata: render
<title>and<meta>inside any component and React moves them to the document head. - React Compiler 1.0 (October 2025) memoises components and values automatically at build time, so most hand-written
useMemo,useCallbackandmemobecome unnecessary in new code. - 19.2 added
<Activity>to hide parts of the UI while keeping their state, anduseEffectEventfor reading the latest props inside effects without re-running them. - 19.3 (September 2026) made
<ViewTransition>for animated transitions and refs on fragments stable.
Cheat sheet: Angular template to React
Keep this table open for the first few weeks.
| Angular | React |
|---|---|
{{ user.name }} |
{user.name} |
[disabled]="busy" |
disabled={busy} |
(click)="save()" |
onClick={save} or onClick={() => save(id)} |
[(ngModel)]="name" |
value={name} onChange={(e) => setName(e.target.value)} |
*ngIf="a; else b" |
{a ? <A /> : <B />} |
*ngIf="items.length" |
{items.length > 0 && <List />} (not {items.length && ...}, which renders 0) |
*ngFor="let x of xs; trackBy: byId" |
{xs.map((x) => <Row key={x.id} item={x} />)} |
[ngSwitch] |
An object lookup or a switch in a small helper component |
[ngClass]="{ active: isActive }" |
className={`tab ${isActive ? 'active' : ''}`} or the clsx package |
[ngStyle]="{ width: w + 'px' }" |
style={{ width: w }} (numbers become pixels) |
<ng-container> |
<>...</> |
<ng-content> |
{children} |
<ng-template> with ngTemplateOutlet |
A prop that takes JSX or a function returning JSX |
@ViewChild('box') |
const box = useRef(null) and ref={box} |
@HostListener('window:resize') |
useEffect that adds and removes the listener |
@Input() / @Output() |
Props / callback props |
| async |
useQuery for server data, use() for a promise |
| date: 'mediumDate' |
new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(d) |
| currency: 'INR' |
new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(n) |
[innerHTML]="html" |
dangerouslySetInnerHTML={{ __html: clean }}, after sanitising with DOMPurify |
providers: [Service] on a component |
A context provider wrapped around that part of the tree |
BehaviorSubject in a root service |
A Zustand store, or context with state |
Two of these deserve a warning. {count && <Badge />} renders the number 0 when count is zero, so compare explicitly (count > 0 &&). And Angular sanitises [innerHTML] for you; React does not. Treat every use of dangerouslySetInnerHTML as a security review item and sanitise the HTML first.
The <ng-template> case surprises people, so here it is in full. A table that lets each screen decide how to render a cell:
import type { ReactNode } from 'react';
type Column<T> = { header: string; cell: (row: T) => ReactNode };
function DataTable<T extends { id: string }>({ rows, columns }: { rows: T[]; columns: Column<T>[] }) {
return (
<table>
<thead><tr>{columns.map((c) => <th key={c.header}>{c.header}</th>)}</tr></thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>{columns.map((c) => <td key={c.header}>{c.cell(r)}</td>)}</tr>
))}
</tbody>
</table>
);
}
<DataTable
rows={orders}
columns={[
{ header: 'Order', cell: (o) => <a href={`/orders/${o.id}`}>{o.number}</a> },
{ header: 'Total', cell: (o) => formatMoney(o.total) },
]}
/>
Mistakes Angular developers make in React
These come up in almost every team, so look for them in code review.
- Recreating services as classes or singletons. A
UserServiceclass with a cache and subscribers fights React. Use a query hook for server data and a small store for client state. - Effect chains. One effect sets state, which triggers another effect, which sets more state. This is the React version of nested subscriptions. Compute values during render and put logic in event handlers.
- Fetching in
useEffectby hand. It works in a demo, but you then rebuild caching, cancellation, retries and race-condition handling yourself. Use TanStack Query or route loaders. - Copying props into state.
const [name, setName] = useState(props.name)does not update when the prop changes. Use the prop directly, or give the component akeyto reset it. - Mutating objects and arrays.
order.items.push(x); setOrder(order)does not re-render. Always create new objects. - Giant components. An Angular component with a 600-line template becomes a 600-line function. Split early: a React component is cheap, often a few lines.
- Context as a global store. Putting everything in one big context re-renders the whole app on every change. Keep contexts small and focused.
- Index keys in dynamic lists, which make the wrong rows update after sorting or deleting.
Part 2: The migration process
The shape of the work
flowchart TB
A[1 Assess<br/>inventory, concept map, e2e tests] ==> B[2 Foundations<br/>React app, API client, auth, routing, design system]
B ==> C[3 Migrate screens<br/>one route or feature at a time]
C ==> D[4 Test and compare<br/>parity with the Angular screen]
D ==> E[5 Release behind the proxy<br/>switch the route to React]
E -.->|next feature| C
E ==> F[6 Retire Angular<br/>when no routes remain]
classDef core fill:#0f766e,stroke:#064e4a,stroke-width:2px,color:#ffffff
classDef ctrl fill:#ede7f6,stroke:#5e35b1,stroke-width:1.5px,color:#1d1c1a
classDef data fill:#e3f2fd,stroke:#1565c0,stroke-width:1.5px,color:#1d1c1a
class A,B ctrl
class C,D,E core
class F data
The key decision is in step 5: release React screens one at a time while Angular keeps running the rest. A full rewrite that ships in one go (“big bang”) freezes features for months and usually ends late, with old bugs rediscovered in production. An incremental approach, often called the strangler fig pattern, delivers value early and keeps every step reversible.
Three rules make it work:
- New features are built in React from day one, once the foundations exist. Otherwise the Angular app keeps growing while you migrate it.
- A route being migrated is frozen in Angular for the short time it takes (days, not months), so the team never builds the same change twice.
- Delete Angular code as soon as its route switches, and track progress by routes migrated, not by React code written.
Step 1: Assess the Angular app
Spend one to two weeks building an honest inventory before writing any React. Useful commands:
npx ng version # exact Angular, CLI and TypeScript versions
grep -rho "selector: '[^']*'" src | sort | wc -l # how many components
grep -rl "@Injectable" src | wc -l # services
grep -rl "implements CanActivate\|implements Resolve" src # guards and resolvers
grep -rl "HttpInterceptor" src # interceptors
cat package.json # third-party Angular libraries
Record, per route: the components it uses, the services and APIs it calls, its guards, forms and third-party widgets, and how many users depend on it. Then map every Angular concept to its React replacement. This table becomes the team’s shared reference:
| Angular 8 | React replacement | Notes |
|---|---|---|
NgModule |
Plain ES modules and folders | No module system to configure |
| Component class and template | Function component returning JSX | One .tsx file per component |
@Input() / @Output() |
Props / callback props | No EventEmitter |
| Services and DI | Query hooks, context, stores or plain modules | Split by what the service actually does |
HttpClient and interceptors |
fetch wrapper (or axios with interceptors) |
One API client module |
| RxJS in services | Promises with TanStack Query | Keep RxJS only for real streams |
| Router, guards, resolvers | React Router 8: routes, loaders, middleware, redirect |
Guards become loaders or middleware that redirect |
| Reactive Forms and validators | React Hook Form with Zod schemas | |
| Pipes | Functions, mostly using Intl |
|
| Directives | JSX expressions, custom hooks or wrapper components | |
| NgRx store and effects | TanStack Query for server data, Zustand or Redux Toolkit for the rest | Much of a typical NgRx store is cached server data |
environment.ts |
.env files read with import.meta.env |
|
| Karma, Jasmine, Protractor | Vitest, React Testing Library, Playwright | Protractor itself is deprecated |
And the third-party libraries. Most popular Angular libraries have a direct React counterpart, sometimes from the same vendor:
| Angular library | React option |
|---|---|
| Angular Material and CDK | MUI, or Radix-based components such as shadcn/ui |
| ng-bootstrap / ngx-bootstrap | React Bootstrap |
| ag-grid-angular | ag-grid-react (same grid and column definitions) |
| ngx-charts / ng2-charts | Recharts / react-chartjs-2 |
| ngx-toastr | Sonner or react-hot-toast |
| ngx-translate | react-i18next |
| ng-select | React Select |
| ngx-mask | react-imask |
| CDK drag and drop | dnd-kit |
| CDK virtual scroll | TanStack Virtual |
| FullCalendar for Angular | @fullcalendar/react |
| moment with Angular pipes | date-fns, or the built-in Intl API |
Not everything is rewritten. A lot of what you have moves across unchanged, which is good news for morale and for the schedule:
- TypeScript interfaces and models for API requests and responses
- Pure utility functions, constants and enums
- Validation logic (re-expressed as Zod schemas, but the rules are the same)
- Translation JSON files
- SCSS variables and global styles (Vite compiles Sass once the
sasspackage is installed) - The backend APIs, which do not change at all
- End-to-end tests written against the UI, if they use visible text and roles rather than Angular-specific selectors
Before migrating anything, protect the behaviour. Write Playwright end-to-end tests for the critical journeys (log in, search, create an order, pay) and run them against the Angular app. When the same tests later pass against the React version, you have strong evidence the migration preserved what matters.
Step 2: Choose how the two apps will live together
There are two practical ways to run Angular and React side by side.
Option A: split by route behind a reverse proxy (recommended). Both apps are served from the same domain. The proxy sends migrated routes to the React app and everything else to Angular. Each app is built by its own toolchain, so React never has to fit into Angular 8’s old build.
flowchart TB
U[User browser] ==> P[Nginx or ingress<br/>one domain]
P ==>|migrated routes| R[React 19 app<br/>assets under r]
P ==>|all other routes| A[Angular 8 app]
R ==> API[Backend APIs<br/>unchanged]
A ==> API
S[(Shared session<br/>cookie or stored token)] -.-> R
S -.-> A
classDef actor fill:#eceff1,stroke:#455a64,stroke-width:1.5px,color:#1d1c1a
classDef core fill:#0f766e,stroke:#064e4a,stroke-width:2px,color:#ffffff
classDef ctrl fill:#ede7f6,stroke:#5e35b1,stroke-width:1.5px,color:#1d1c1a
classDef data fill:#e3f2fd,stroke:#1565c0,stroke-width:1.5px,color:#1d1c1a
classDef store fill:#fff3e0,stroke:#e65100,stroke-width:1.5px,color:#1d1c1a
class U actor
class P ctrl
class R core
class A,API data
class S store
An Nginx configuration for this, with the Angular build at the web root and the React build copied into /r/:
server {
listen 80;
root /usr/share/nginx/html;
# React static files, built with Vite base '/r/'
location /r/ {
try_files $uri =404;
}
# Routes already migrated to React
location ~ ^/(orders|reports)(/|$) {
try_files /r/index.html =404;
}
# Everything else is still Angular
location / {
try_files $uri $uri/ /index.html;
}
}
With base: '/r/' in the Vite config, the React app’s assets load from /r/assets/... while its routes keep their normal paths (/orders, /orders/42), so users and bookmarks see no change. Because both apps share the origin, a token or cookie set by the Angular login works in React too.
One catch: links between the two apps must be full page loads. An Angular routerLink="/orders" is handled by Angular’s own router and never reaches the proxy. Use a plain <a href="/orders"> (or window.location.assign) for links that cross from one app to the other.
Option B: embed React widgets inside Angular pages. Useful when one Angular page needs a single new React feature, or when a route is too large to move in one step. Do not try to compile React 19 inside the Angular 8 CLI build: its TypeScript and webpack versions are too old. Instead, build the widget separately as a custom element and load it as a script.
// react-app/src/widgets/orders-widget.tsx
import { createRoot, type Root } from 'react-dom/client';
import { OrdersWidget } from '../features/orders/OrdersWidget';
class OrdersWidgetElement extends HTMLElement {
static observedAttributes = ['customer-id'];
private root?: Root;
connectedCallback() {
this.root = createRoot(this);
this.render();
}
attributeChangedCallback() {
this.render();
}
disconnectedCallback() {
this.root?.unmount();
}
private render() {
this.root?.render(<OrdersWidget customerId={this.getAttribute('customer-id') ?? ''} />);
}
}
customElements.define('orders-widget', OrdersWidgetElement);
Build it with Vite’s library mode. Library mode does not replace process.env.NODE_ENV, which React reads, so define it:
// react-app/vite.widgets.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
define: { 'process.env.NODE_ENV': JSON.stringify('production') },
build: {
outDir: 'dist-widgets',
lib: { entry: 'src/widgets/orders-widget.tsx', formats: ['es'], fileName: 'orders-widget' },
},
});
On the Angular side, load the script in index.html (<script type="module" src="/widgets/orders-widget.js"></script>), add CUSTOM_ELEMENTS_SCHEMA to the module that uses it, and use it like any element:
<orders-widget [attr.customer-id]="customer.id"></orders-widget>
Each widget bundles its own copy of React, so keep their number small. They are a bridge, not a destination.
Step 3: Set up the React project
You need Node.js 22.22 or later (or Node 24 LTS). Create React App was deprecated in 2025, so start with Vite:
npm create vite@latest web-react -- --template react-ts
cd web-react
npm install
npm install react-router @tanstack/react-query react-hook-form zod @hookform/resolvers zustand
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom msw
npm run dev
If you want a full framework with server rendering instead of a single-page app, React Router’s framework mode or Next.js are the usual choices. For replacing an Angular single-page app, a Vite SPA is the closest match and the easiest to deploy behind the same proxy.
Organise by feature, the way you organised feature modules in Angular:
Angular React
src/app/orders/ src/features/orders/
orders.module.ts (not needed)
orders-routing.module.ts routes are declared in src/app/router.tsx
order-list/order-list.component.ts OrderList.tsx
order-list/order-list.component.html (part of OrderList.tsx)
order-list/order-list.component.scss OrderList.module.css or .scss
order-list/order-list.component.spec.ts OrderList.test.tsx
orders.service.ts useOrders.ts (query hooks)
models/order.ts src/api/types.ts (copied as is)
src/app/shared/ src/components/ and src/lib/
src/app/core/ src/app/ (router, providers, layout) and src/api/
The Vite configuration, with the base path for the proxy, an @ import alias, and the API proxy that replaces Angular’s proxy.conf.json:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
base: '/r/',
plugins: [react()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
server: {
port: 5173,
proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } },
},
});
Add "paths": { "@/*": ["./src/*"] } under compilerOptions in tsconfig.app.json so TypeScript resolves the alias too. If your backend does not expect the /api prefix, add rewrite: (p) => p.replace(/^\/api/, '') to the proxy entry, just like pathRewrite in proxy.conf.json.
Environment settings. Angular’s environment.ts files become .env files. Only variables starting with VITE_ reach the browser code, and like environment.ts they are public once built, so never put secrets in them.
# .env.development (requests go through the dev proxy above)
VITE_API_URL=/api
# .env.production
VITE_API_URL=https://api.example.com
const apiUrl = import.meta.env.VITE_API_URL;
Linting and the React Compiler. Add eslint-plugin-react-hooks with its recommended configuration from day one; it catches broken hook rules and missing effect dependencies, which are the most common bugs for newcomers. With Vite 8 and @vitejs/plugin-react v6, the React Compiler is an opt-in Babel preset. Enable it on a new codebase so the team does not need to write manual memoisation; follow the installation page in the React documentation for the exact plugin setup. Turn on strict in TypeScript as well, since React’s type checking of props replaces much of what Angular’s template type checking did.
Step 4: Build the shared foundations
Do these once, before migrating screens, so every screen uses the same building blocks. In a team, give this phase to the two or three developers who will act as React champions (see Part 3).
The API client (replaces HttpClient and interceptors)
// src/api/client.ts
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('access_token'); // same key the Angular app uses
const res = await fetch(`${import.meta.env.VITE_API_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
});
if (res.status === 401) { // what the auth interceptor did
window.location.assign('/login');
throw new ApiError(401, 'Unauthorized');
}
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
}
Copy the request and response interfaces from the Angular app into src/api/types.ts. They are plain TypeScript and move across unchanged. If your interceptors also refresh tokens, put that logic here too: on a 401, call the refresh endpoint once, then retry the original request.
Server data with TanStack Query (replaces services, RxJS and much of NgRx)
Most Angular services exist to fetch data, cache it and share it between components. TanStack Query does exactly that, plus loading and error states, retries, background refresh and cache invalidation. For many teams this single library removes the largest part of the old NgRx code.
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from 'react-router';
import { router } from './app/router';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
// src/features/users/useUsers.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/api/client';
import type { User } from '@/api/types';
export function useUsers(search: string) {
return useQuery({
queryKey: ['users', search],
queryFn: ({ signal }) => api<User[]>(`/users?search=${encodeURIComponent(search)}`, { signal }),
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Omit<User, 'id' | 'createdAt'>) =>
api<User>('/users', { method: 'POST', body: JSON.stringify(body) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
});
}
The signal gives you what RxJS’s switchMap gave you: when the search term changes, the outdated request is cancelled. The queryKey works like a cache key; any component asking for ['users', 'asha'] shares one request and one cached result, which is what a shareReplay in a service used to do.
Client state (replaces the rest of NgRx)
After server data moves to TanStack Query, what remains is usually small: a shopping cart, UI preferences, a multi-step wizard. Use useState and context for local and narrow state, and a small store such as Zustand for app-wide client state. If the team relies heavily on NgRx’s actions, reducers and devtools, Redux Toolkit is the closest match.
import { create } from 'zustand';
type CartState = {
items: CartItem[];
add: (item: CartItem) => void;
clear: () => void;
};
export const useCart = create<CartState>()((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
clear: () => set({ items: [] }),
}));
// In a component: const count = useCart((s) => s.items.length);
A Zustand store is the closest thing to a root service holding a BehaviorSubject: one shared instance, components subscribe to the slice they need, and they re-render only when that slice changes.
Routing (replaces RouterModule, guards and resolvers)
React Router 8 (June 2026) is a single react-router package; the old react-router-dom package is gone.
// src/app/router.tsx
import { createBrowserRouter, redirect } from 'react-router';
import { AppLayout } from './AppLayout';
import { NotFound } from './NotFound';
function requireAuth() { // replaces a CanActivate guard
if (!localStorage.getItem('access_token')) throw redirect('/login');
return null;
}
export const router = createBrowserRouter([
{
path: '/',
Component: AppLayout,
loader: requireAuth,
HydrateFallback: () => <p>Loading…</p>, // shown while the first loaders run
children: [
{
path: 'orders',
lazy: async () => ({ Component: (await import('@/features/orders/OrdersPage')).OrdersPage }),
},
{
path: 'orders/:orderId',
lazy: async () => {
const m = await import('@/features/orders/OrderDetailPage');
return { Component: m.OrderDetailPage, loader: m.orderLoader }; // replaces a resolver
},
},
{ path: '*', Component: NotFound },
],
},
]);
// src/app/AppLayout.tsx
import { NavLink, Outlet } from 'react-router';
export function AppLayout() {
return (
<div className="shell">
<nav>
<NavLink to="/orders">Orders</NavLink>
<a href="/customers">Customers</a> {/* still in Angular: full page load */}
</nav>
<main><Outlet /></main> {/* <router-outlet> */}
</div>
);
}
| Angular router | React Router 8 |
|---|---|
<router-outlet> |
<Outlet /> |
routerLink, routerLinkActive |
<Link>, <NavLink> (adds an active class automatically) |
ActivatedRoute.params |
useParams() |
ActivatedRoute.queryParams |
useSearchParams() |
Router.navigate([...]) |
useNavigate() |
loadChildren (lazy modules) |
lazy on a route |
CanActivate guard |
A loader that throws redirect(), or route middleware for checks shared by many routes |
Resolve |
A route loader, read with useLoaderData() |
Forms (replaces Reactive Forms)
React Hook Form handles registration, validation, dirty and touched state with very few re-renders. Zod describes the rules once and gives you the TypeScript type for free, which replaces both your validators and the separate form model interface.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useCreateUser } from './useUsers';
const schema = z.object({
name: z.string().min(2, 'Name must have at least 2 characters'),
email: z.email('Enter a valid email'),
role: z.enum(['admin', 'editor', 'viewer']),
});
type FormValues = z.infer<typeof schema>;
export function UserForm({ onDone }: { onDone: () => void }) {
const createUser = useCreateUser();
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { role: 'viewer' },
});
const onSubmit = async (values: FormValues) => {
await createUser.mutateAsync(values);
onDone();
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label>Name <input {...register('name')} /></label>
{errors.name && <p role="alert">{errors.name.message}</p>}
<label>Email <input type="email" {...register('email')} /></label>
{errors.email && <p role="alert">{errors.email.message}</p>}
<label>Role
<select {...register('role')}>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
</label>
<button disabled={isSubmitting}>Save</button>
{createUser.isError && <p role="alert">Could not save the user.</p>}
</form>
);
}
| Reactive Forms | React Hook Form and Zod |
|---|---|
FormGroup, FormControl |
useForm() and register('field') |
Validators.required, minLength |
Zod rules (z.string().min(1)) |
| Custom validator | .refine() on a field |
| Cross-field validator on a group | .refine() on the object (for example, password and confirmation) |
form.valid, dirty, touched |
formState.isValid, isDirty, touchedFields |
patchValue / reset |
setValue / reset |
FormArray |
useFieldArray |
Pipes, translations and the design system
- Pipes become functions. The browser’s
IntlAPI covers most built-in pipes:const inr = new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }); const day = new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }); export const formatMoney = (n: number) => inr.format(n); // ₹1,25,000.00 export const formatDay = (iso: string) => day.format(new Date(iso)); // 10 Jan 2026 - Translations.
react-i18nextreads the same kind of JSON files asngx-translate, so the translation files usually move over unchanged.{{ 'orders.title' | translate }}becomest('orders.title'). - Design system. Decide before the first screen. If users must not notice the switch, recreate the Angular Material look with MUI, or style accessible headless components (Radix, React Aria) with your existing CSS. Build the shared pieces first (buttons, inputs, table, dialog, toast), so screen migrations only assemble them. Move global styles, colours and fonts into CSS variables shared by both apps so mixed pages look consistent.
Step 5: Migrate a screen, end to end
Here is a typical Angular 8 component: a searchable user list with an input, an output, a service, *ngFor, *ngIf, the async and date pipes, and RxJS debouncing.
// Angular 8: user-list.component.ts
@Component({
selector: 'app-user-list',
template: `
<h2>{{ title }}</h2>
<input [formControl]="search" placeholder="Search users" />
<p *ngIf="loading">Loading…</p>
<ul>
<li *ngFor="let u of users$ | async; trackBy: trackById">
<button (click)="selected.emit(u)">
{{ u.name }} · joined {{ u.createdAt | date: 'mediumDate' }}
</button>
</li>
</ul>
`,
})
export class UserListComponent implements OnInit {
@Input() title = 'Users';
@Output() selected = new EventEmitter<User>();
search = new FormControl('');
users$: Observable<User[]>;
loading = false;
constructor(private userService: UserService) {}
ngOnInit() {
this.users$ = this.search.valueChanges.pipe(
startWith(''),
debounceTime(300),
distinctUntilChanged(),
tap(() => (this.loading = true)),
switchMap((q) => this.userService.search(q)),
tap(() => (this.loading = false)),
);
}
trackById(_: number, u: User) {
return u.id;
}
}
The same component in React:
// React: src/features/users/UserList.tsx
import { useState } from 'react';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { formatDay } from '@/lib/format';
import { useUsers } from './useUsers';
import type { User } from '@/api/types';
type Props = {
title?: string; // @Input() title = 'Users'
onSelect: (user: User) => void; // @Output() selected
};
export function UserList({ title = 'Users', onSelect }: Props) {
const [search, setSearch] = useState(''); // FormControl
const query = useDebouncedValue(search.trim(), 300); // debounceTime(300)
const { data: users = [], isPending, isError } = useUsers(query); // service + switchMap
return (
<section>
<h2>{title}</h2>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users"
aria-label="Search users"
/>
{isPending && <p>Loading…</p>}
{isError && <p role="alert">Could not load users.</p>}
<ul>
{users.map((u) => (
<li key={u.id}>
<button onClick={() => onSelect(u)}>
{u.name} · joined {formatDay(u.createdAt)}
</button>
</li>
))}
</ul>
</section>
);
}
What changed, and why it is simpler: no subscription management, no manual loading flag that can get stuck if a request fails (the Angular version above has exactly that bug: an error leaves loading true), and no trackBy function. TanStack Query caches each search term, so going back to an earlier search is instant. distinctUntilChanged is no longer needed, because the same query key reuses the same cached request.
The checklist to repeat for every screen (the team’s “definition of done”):
- Write down what the Angular screen does: inputs, outputs, API calls, permissions, validations, edge cases (empty list, errors, slow network, no permission).
- Build the React version from the shared foundations, with the same URL.
- Write component tests for the behaviour, not the implementation (next step), and make sure the end-to-end tests for the journey pass.
- Compare both versions side by side with the same test user and data, including error and empty states, keyboard use and screen-reader labels.
- Switch the route in the proxy, watch error tracking and analytics for a few days, then delete the Angular code for that route.
Step 6: Testing
Replace Karma and Jasmine with Vitest (fast, uses the Vite config) and React Testing Library, which tests what the user sees rather than component internals. Use Mock Service Worker to fake the API at the network level, so the same mocks work in tests and in local development.
The biggest change for Angular developers: there is no TestBed and no module configuration. You render the component, find elements the way a user would (by role, label or text), interact, and assert what is on the screen.
// vite.config.ts: import defineConfig from 'vitest/config' instead of 'vite', then add
test: { environment: 'jsdom', setupFiles: './src/test/setup.ts', globals: true },
Add "vitest/globals" to types in tsconfig.app.json, and create .env.test with an absolute API address, because Node’s fetch (used in tests) does not accept relative URLs:
# .env.test
VITE_API_URL=http://localhost/api
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw/http';
export const server = setupServer(
http.get('*/users', () =>
HttpResponse.json([{ id: '1', name: 'Asha', email: 'asha@example.com', createdAt: '2026-01-10' }]),
),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// src/features/users/UserList.test.tsx
import type { ReactElement } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UserList } from './UserList';
function renderWithQuery(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
test('selecting a user calls onSelect with that user', async () => {
const onSelect = vi.fn();
renderWithQuery(<UserList onSelect={onSelect} />);
await userEvent.click(await screen.findByRole('button', { name: /asha/i }));
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ name: 'Asha' }));
});
| Jasmine and TestBed | Vitest and Testing Library |
|---|---|
TestBed.configureTestingModule(...) |
Not needed; render the component with the providers it uses |
fixture.detectChanges() |
Not needed; findBy... queries wait for the screen to update |
debugElement.query(By.css('.x')) |
screen.getByRole('button', { name: 'Save' }) |
spyOn(service, 'get').and.returnValue(of(data)) |
An MSW handler returning the data, or vi.fn() for callbacks |
fakeAsync and tick() |
vi.useFakeTimers() and vi.advanceTimersByTime() |
For end-to-end tests, keep the Playwright suite from Step 1 and run it against every release.
Step 7: Build, release and retire Angular
Build and serve both apps from one image while they coexist. A multi-stage Dockerfile:
FROM node:24-alpine AS react
WORKDIR /app
COPY web-react/package*.json ./
RUN npm ci
COPY web-react/ .
RUN npm run build
FROM nginx:1.29-alpine
# Existing Angular build output
COPY angular-dist/ /usr/share/nginx/html/
COPY --from=react /app/dist/ /usr/share/nginx/html/r/
COPY nginx.conf /etc/nginx/conf.d/default.conf
(Build the Angular app in its own stage with the Node version it needs; Angular 8 expects an old Node release, typically Node 10 or 12, so keep that stage pinned and isolated.)
Release each route deliberately. Adding a route to the proxy rule is the release; removing it is the rollback. For risky screens, put a feature flag or a percentage rollout in front of the switch. Watch front-end error tracking (for example Sentry), API error rates and page performance for each newly switched route.
Measure performance. Set a bundle budget and check it in CI, as Angular’s budgets setting did. Route-level lazy imports keep the first load small; look at the build output and the Web Vitals numbers for each migrated page.
Retire Angular when the proxy no longer sends any route to it: remove the Angular stage, change Vite’s base back to /, simplify the Nginx config to a single SPA fallback, and delete the old code. Celebrate this step; it is the one that pays back the effort.
A realistic plan
For a medium-sized app (around 40 to 60 screens, a team of four to six front-end developers):
| Phase | Duration | Output |
|---|---|---|
| Team learning (Part 3) | 2 weeks, overlapping assessment | Everyone has built the practice exercises |
| Assessment and e2e safety net | 1 to 2 weeks | Inventory, risks, route order, chosen libraries, Playwright tests |
| Foundations | 2 to 3 weeks | React app, proxy, API client, auth, router, design system basics, CI with tests |
| Pilot route | 1 to 2 weeks | One simple but real screen released to users |
| Migration waves | 2 to 6 months | Routes moved in order of value and risk, Angular code deleted as you go |
| Retirement | 1 to 2 weeks | Angular removed, configuration simplified |
Start with a screen that is simple but used every day, so problems surface early. Leave the most complex, least used screens (admin tools, rare reports) for last.
Part 3: Bringing the team along
A migration succeeds when the whole team is comfortable in React, not just the two people who set it up. Experienced Angular developers learn React quickly, because the hard parts of front-end work (TypeScript, HTTP, state, testing, accessibility) are the same. What they need is a short, structured start and clear conventions.
A two-week learning path
Week 1: fundamentals.
- Day 1 and 2: the Learn section of react.dev: Quick Start, Thinking in React, and the tic-tac-toe tutorial. Then You Might Not Need an Effect.
- Day 3: Part 1 of this guide, especially the mindset shifts, the cheat sheet and the common mistakes.
- Day 4 and 5: exercises 1 to 3 below.
Week 2: the team’s stack.
- Day 1: TanStack Query (queries, mutations, invalidation).
- Day 2: React Router (routes, loaders, lazy routes) and React Hook Form with Zod.
- Day 3: Vitest, Testing Library and MSW.
- Day 4 and 5: exercises 4 and 5, reviewed by a React champion.
After that, each developer pairs with a champion on one real screen before migrating screens alone.
Practice exercises
Take each one from a real component in your own Angular app, so the practice doubles as migration groundwork:
- A presentational component with two
@Inputs and an@Output(a card or a badge). Practise props, callback props andchildren. - A filterable list (search box and list). Practise state, derived values and keys, with no effects at all.
- A form with validation (sign-up or edit profile). Practise React Hook Form, Zod and error display.
- A data page with loading, error and empty states, plus a “create” action. Practise TanStack Query queries, mutations and invalidation.
- Two routes with a parameter and a guard (
/ordersand/orders/:id). Practise React Router loaders,useParamsandredirect.
Each exercise should finish with Testing Library tests. Writing the test first is a good way to check the behaviour is understood.
Roles and pairing
- React champions (two or three developers). Usually the most experienced, or anyone with prior React exposure. They build the foundations in Step 4, write the conventions document, review early pull requests and answer questions.
- Pairing. Pair developers with two to four years of experience with a champion for their first screen. Senior Angular developers often become strong React reviewers within a month, because they already know what good front-end architecture looks like.
- Short weekly sessions. Thirty minutes to look at one real pull request together, discuss what was hard and update the conventions.
Team conventions
Write these down in the repository (a short CONTRIBUTING.md is enough) so every screen looks the same:
- One component per file, named exports, file named after the component (
OrderTable.tsx). - Code organised by feature (
src/features/orders/...); shared UI insrc/components/. - Props typed with a
type Props = {...}next to the component; callback props namedonSomething. - Server data only through query hooks in the feature folder (
useOrders,useCreateOrder); nofetchin components. - Global client state only in the agreed store; no new contexts without a reason.
- Forms with React Hook Form and Zod; schemas live next to the form.
- Tests next to the component (
OrderTable.test.tsx), querying by role and label. - ESLint (with the hooks plugin) and Prettier run in CI; TypeScript
strictstays on.
Code review checklist
- No state that can be derived from props or other state
- No effect that could be an event handler or a calculation during render
- Every effect that subscribes, listens or sets a timer has a cleanup
- State is replaced, never mutated
- List keys are stable ids, not array indexes
- Server data comes from query hooks, with loading, error and empty states handled
- No
dangerouslySetInnerHTMLwithout sanitising - Interactive elements are real buttons and links, with accessible names
- Tests cover the behaviour described in the screen’s checklist
Final checklist
- Decision to move to React, with the Angular-upgrade alternative considered
- Team learning path completed, champions named, conventions written
- Inventory of routes, services, guards, forms and third-party libraries
- Concept map and library map agreed by the team
- Playwright tests for critical journeys passing against Angular
- Coexistence approach chosen (route split behind a proxy, widgets only where needed)
- React app created with Vite 8, TypeScript strict, ESLint and the hooks plugin
- API client, authentication, TanStack Query, router, forms and design system in place
- Vitest, Testing Library, MSW and Playwright running in CI
- Pilot route released and monitored
- Routes migrated in waves, new features built only in React, Angular code deleted per route
- Angular removed, configuration simplified
React rewards teams that embrace its model: small components, data flowing down through props, events flowing up through callbacks, server data in a query cache and very little else in global state. Experienced Angular developers already have the discipline this needs. Give them a clear process, a few weeks of focused practice and agreed conventions, and the migration stays boring in the best possible way.
References: React documentation · You Might Not Need an Effect · React versions · React Compiler v1.0 · Vite 8 announcement · React Router v8 · TanStack Query cancellation · React Hook Form resolvers · Mock Service Worker · Zod · Angular releases and support