Sitemap
Misbah Afzal

Misbah Afzal

Senior Software Engineer | Typescript | NestJS | ReactJS | Angular | NodeJS | Exploring Machine Learning | Book Reader | Writer | Love Productivity Hacks

Migrating a Legacy AngularJS App to React

This is the story of a legacy AngularJS application, the problems we found in it, and how we fixed them while moving to React and TypeScript.

15 min read3 days ago

--

Press enter or click to view image in full size

I have met two kinds of engineers. The first says, “This is bad, old legacy code with architectural problems, and I don’t want to work on this technology.” The second says, “Nice. Since this is legacy and messy, it has plenty of room for improvement. Let’s come up with a plan to migrate and improve it.”

I like the latter ones.

This is the story of a legacy AngularJS application, the problems we found in it, and how we fixed them while moving to React and TypeScript. I will be using a fake scenario here but learnings, key points and experience are all real ❤️

AngularJS reached end of life in 2021, and most modern tooling has dropped support for it. Staying put meant every feature cost more than the last. So we didn’t rewrite everything in one big bang. We moved page by page or component by component, and fixed the root causes along the way instead of just porting them to a new framework.

The approach was incremental replacement, often called the strangler pattern, new React code grew alongside the AngularJS app, each migrated piece shipped behind a feature flag, and legacy code was deleted only after its replacement had proved itself. What follows is what that looked like in practice, including the trade-offs we accepted.

If you are sitting on a codebase like this, treat what follows as a checklist.

The article is grouped into six parts:

  1. The code itself: hardcoded values, coupling, controllers, giant files, duplicated logic
  2. The UI: no design system
  3. Data and state: stale pages and needless API calls
  4. APIs and backend: slow endpoints and PDFs in the browser
  5. Quality and knowledge: no tests, no types, no documentation
  6. Rolling it out safely: feature flags and canary releases

Part 1: The code itself

The first group of problems was all about how the code was written. None of them were exotic. They were the result of years of small shortcuts.

Hardcoded values everywhere

The problem: Magic numbers, status strings and labels were scattered across controllers and templates. Changing one value meant searching the whole codebase and hoping you found every copy.

// AngularJS controller (before)
if (file.size > 10485760) { alert('File too large'); }
if (order.status === 'pending') { $scope.canEdit = true; }

What we fixed: We created a global constants file for values shared across the app, and a constants file next to each component for values only that component uses. Derived types come for free once TypeScript is in place.

// src/constants/app.ts
export const MAX_UPLOAD_SIZE_MB = 10;

export const ORDER_STATUS = {
PENDING: 'pending',
APPROVED: 'approved',
REJECTED: 'rejected',
} as const;

export type OrderStatus = (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS];

// src/components/OrderTable/OrderTable.constants.ts
export const PAGE_SIZE = 25;

Too much tight coupling

The problem: Controllers called $http directly, mutated $scope, and talked to other parts of the app through $rootScope events. You could not change one thing without breaking three others.

What we fixed: We separated concerns into layers. Components render UI, hooks hold UI state, services hold business logic, and a thin API layer talks to the backend. Each layer only knows about the one below it.

// api/http.ts: one shared client for base URL, auth and error handling
export async function http<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...authHeaders(), ...init?.headers },
});
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json() as Promise<T>;
}
// api/orders.ts: one file per entity, typed, no business logic
export const ordersApi = {
list: (signal?: AbortSignal) => http<Order[]>('/orders', { signal }),
get: (id: string) => http<Order>(`/orders/${id}`),
update: (id: string, patch: Partial<Order>) =>
http<Order>(`/orders/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
};
// api/customers.ts, api/invoices.ts: same shape
// services/orderService.ts: only knows business rules
export const getEditableOrders = (orders: Order[]) =>
orders.filter((o) => o.status === ORDER_STATUS.PENDING);
// hooks/useOrders.ts: only knows UI state, and cleans up after itself
export function useOrders() {
const [orders, setOrders] = useState<Order[]>([]);
const [error, setError] = useState<ApiError | null>(null);

useEffect(() => {
const controller = new AbortController();
ordersApi
.list(controller.signal)
.then((list) => setOrders(getEditableOrders(list)))
.catch((e) => {
if (!controller.signal.aborted) setError(e);
});
return () => controller.abort();
}, []);

return { orders, error };
}

Two rules made this stick. First, one API module per entity (orders.ts, customers.ts, invoices.ts), so every endpoint has exactly one home and components never import the HTTP client directly. Second, errors are normalized once, in the shared client, instead of being handled differently in every screen. The cost is more files and a little boilerplate. In return, changing an endpoint touches one place, and the whole API layer can be mocked in tests.

Logic calculated in controllers instead of services

The problem: Totals, discounts and eligibility rules were computed inside controllers. They could not be reused, and they could not be tested without spinning up the whole UI.

Controllers, and React components for that matter, should stay thin: take input, call logic, render output. Business rules belong in services or pure functions, where they can be reused across screens, unit tested without rendering anything, and changed without touching the UI. One caution: a service is not a dumping ground. Keep UI-only state, such as whether a modal is open, in the component, and split services by domain instead of growing one giant OrderService.

What we fixed: We moved calculations into pure functions inside service files.

// services/pricing.ts
export function calculateOrderTotal(items: OrderItem[], discount = 0): number {
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
return Math.max(subtotal - discount, 0);
}

// pricing.test.ts
test('discount never makes the total negative', () => {
expect(calculateOrderTotal([{ price: 10, quantity: 1 }], 50)).toBe(0);
});

Huge component and HTML files with no linters or formatters

The problem: Single templates ran to hundreds of lines of raw HTML. With no linter or formatter, every engineer used their own style, and reviewing a change meant scrolling endlessly.

What we fixed: We broke big screens into small components with one job each, and added ESLint and Prettier so style debates ended automatically (the setup is in Part 5).

// Before: one 800-line template
// After: a page that reads like a table of contents
export function OrderPage() {
return (
<>
<OrderHeader />
<OrderFilters />
<OrderTable />
<OrderSummary />
</>
);
}

A single function doing too much

The problem: We found a Promise().then(() => { ... }) callback with more than 3,000 lines inside it. Loading data, transforming it, updating five parts of the screen and handling errors all lived in one block. Nobody dared touch it.

What we fixed: We broke it into small, named, single-purpose functions and used async/await so the flow reads top to bottom.

// Before
loadData().then((res) => {
// 3000+ lines: transform, validate, update UI, handle errors...
});

// After
async function refreshOrder(id: string) {
const raw = await fetchOrder(id);
const order = normalizeOrder(raw);
validateOrder(order);
updateOrderStore(order);
}

Each step now has a name, a single responsibility, and can be tested on its own.

Reusable logic copied between controllers

The problem: Behavior such as debounced search, pagination and form handling tended to be re-implemented in each controller, slightly differently every time. Fixing a bug meant finding and fixing every copy.

What we fixed: We extracted each of these into a custom hook: a small function that owns one piece of stateful logic and can be reused by any component.

// hooks/useDebouncedValue.ts
export function useDebouncedValue<T>(value: T, delayMs = 300): T {
const [debounced, setDebounced] = useState(value);

useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);

return debounced;
}

// any search box
const query = useDebouncedValue(searchText);

A few rules kept hooks healthy. A hook owns one concern. Business rules stay in plain functions that the hook calls, rather than living inside it. And anything that can be computed from props or state is computed during render, not copied into more state through useEffect.

Part 2: The UI

No design system

The problem: One engineer needed a button and built one in the style they liked. A few months later another engineer needed a button and built a different one. Multiply that by every input, modal and dropdown, and the product slowly stopped looking like one product. There was no Storybook, so nobody could even see which components already existed before building a new one.

What we fixed: We started a shared component library with a small set of design tokens (colors, spacing, typography) and one implementation of each core component. We documented every component in Storybook, so the rule became simple: look in Storybook first, build second.

// design-system/tokens.ts
export const colors = { primary: '#2563eb', danger: '#dc2626' };
export const spacing = { sm: 8, md: 16, lg: 24 };
// design-system/Button/Button.tsx
type ButtonProps = {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md';
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

export function Button({ variant = 'primary', size = 'md', ...rest }: ButtonProps) {
return <button className={`btn btn--${variant} btn--${size}`} {...rest} />;
}

// design-system/Button/Button.stories.tsx
export default { title: 'Design System/Button', component: Button };
export const Primary = { args: { children: 'Save', variant: 'primary' } };
export const Danger = { args: { children: 'Delete', variant: 'danger' } };

A nice side effect: Storybook doubles as living documentation, which matters a lot for the lost-context problem below. The trade-off is ownership: a design system without an owner slowly drifts back into five different buttons.

Part 3: Data and state

Several of the problems turned out to be variations of one root cause: the app had no single source of truth for data.

Every change triggered every API call

The problem: When one piece of data changed, the page re-called all of its APIs, including the ones whose data had not changed. A small edit could fire a dozen requests.

Stale pages and no syncing between pages

The problem: Sometimes the opposite happened. A page did not refresh after data changed, and if page A updated a record, page B kept showing the old version until the user reloaded the browser.

No store, so the backend was hit every time

The problem: There was no client-side store. Every screen asked the backend for what it needed, even if another screen had just fetched the same data.

Get Misbah Afzal’s stories in your inbox

Join Medium for free to get updates from this writer.

What we fixed: We introduced Zustand as a shared store. Data is fetched once and lives in the store. Every page reads from the same place, so a change made on page A is instantly visible on page B. When something changes, we update or refetch only the affected slice instead of re-calling everything.

// store/orderStore.ts
import { create } from 'zustand';

type OrderState = {
orders: Record<string, Order>;
status: 'idle' | 'loading' | 'ready' | 'error';
loadOrders: () => Promise<void>;
updateOrder: (id: string, patch: Partial<Order>) => Promise<void>;
};

export const useOrderStore = create<OrderState>((set, get) => ({
orders: {},
status: 'idle',

// fetch once, reuse everywhere; concurrent callers don't trigger a second request, and a failed load can be retried
loadOrders: async () => {
if (get().status === 'loading' || get().status === 'ready') return;
set({ status: 'loading' });
try {
const list = await ordersApi.list();
set({ orders: Object.fromEntries(list.map((o) => [o.id, o])), status: 'ready' });
} catch {
set({ status: 'error' });
}
},

// update only the record that changed
updateOrder: async (id, patch) => {
const saved = await ordersApi.update(id, patch);
set((s) => ({ orders: { ...s.orders, [id]: saved } }));
},
}));
import { useShallow } from 'zustand/react/shallow';

// both pages read the same store, so they cannot drift apart
function OrderListPage() {
const orders = useOrderStore(useShallow((s) => Object.values(s.orders)));
/* ... */
}

function OrderDetailPage({ id }: { id: string }) {
const order = useOrderStore((s) => s.orders[id]);
/* ... */
}

The selector pattern matters too: a component subscribes only to the slice it uses, so it re-renders only when that slice changes. Selectors that build a new array or object, like the list above, need useShallow, otherwise the component re-renders on every store update, and in Zustand 5 it can even loop.

A caveat worth stating, a store that caches server data needs an invalidation story. Ours is simple, since a mutation updates or refetches only the slice it touched, but it is a rule the team has to follow. For data that is purely server state, a dedicated data-fetching library such as TanStack Query handles caching, deduplication and revalidation out of the box and is worth evaluating before building this by hand. Zustand fits best for client state shared across pages.

Part 4: APIs and backend

Frontend optimization isn’t only a frontend job. Some of our biggest wins came from changing what the frontend asked for, and who did the heavy work.

Bloated, slow APIs

The problem: A single endpoint returned far too much information: the record, its related items, its history, its permissions. Because each API did so much, each one was slow, and the screen could not show anything until the slowest part finished. Most screens needed only a fraction of the payload.

What we fixed: We broke the big endpoints into smaller, focused ones and created new endpoints for new needs instead of making old ones bigger. The frontend now loads the essentials first and fetches the rest in parallel or on demand.

# Before
GET /orders/42 -> order + items + history + customer + permissions (slow)

# After
GET /orders/42 -> core order fields (fast)
GET /orders/42/items -> line items
GET /orders/42/history -> audit trail, loaded only when the tab is opened
function OrderDetail({ id }: { id: string }) {
const order = useOrder(id); // fast, renders the page shell
const items = useOrderItems(id); // loads in parallel
const [showHistory, setShowHistory] = useState(false);
const history = useOrderHistory(id, { enabled: showHistory }); // only on demand
/* ... */
}

Smaller endpoints are also easier to cache, easier to test, and easier to fix when one of them gets slow. The trade-off is more round trips and more ways for a screen to be half loaded, so we load in parallel and let each section render on its own with a loading state. Where a screen truly needs combined data, a purpose-built endpoint beats bringing the do-everything one back.

PDFs generated in the browser

The problem: PDFs were built on the frontend and downloaded as a blob. For large reports this was slow, froze the tab, and held the whole file in browser memory.

// Before: heavy work on the user's machine
const blob = await buildPdfInBrowser(reportData);
saveAs(blob, 'report.pdf');

What we fixed: We moved PDF processing to the backend. The frontend now just requests the report, gets an immediate response, and the user is notified by email when the file is ready. The browser stays responsive and the user can carry on working.

// Frontend (after)
await reportsApi.requestPdf(reportId); // POST /reports/pdf, returns 202
toast.success('We are preparing your PDF. We will email you when it is ready.');
// Backend (after), simplified pseudo-code
app.post('/reports/pdf', async (req, res) => {
const job = await queue.add(
'generate-pdf',
{ reportId: req.body.reportId, userId: req.user.id },
{ attempts: 3, backoff: { type: 'exponential', delay: 5000 } }, // retry transient failures
);
res.status(202).json({ jobId: job.id }); // accepted, not finished
});

queue.process('generate-pdf', async ({ reportId, userId }) => {
const file = await generatePdf(reportId);
const key = await storage.upload(file);
const url = await storage.signedUrl(key, { expiresInMinutes: 60 }); // private, expiring link
await mailer.send(userId, 'Your PDF is ready', url);
});

The trade-off is that the result is no longer instant. For large reports, a PDF in the inbox beats a frozen browser tab. Retries, an expiring download link, and a way to check job status, so users never wonder whether it worked, are what make this production-ready.

Part 5: Quality and knowledge

The last group is the one that hurts most in the long run, because these problems compound. No tests lead to fear, fear leads to no refactoring, and no refactoring leads to more bad code.

Memory leaks, no types, too many bugs

The problem: The code was plain JavaScript with no interfaces and no type checking. A value could be a string in one place and an object in another, and nothing warned us. Event listeners and timers were never cleaned up, so memory crept up the longer a session ran.

What we fixed: We added TypeScript, then ESLint and Prettier. TypeScript catches whole categories of bugs, like wrong shapes and missing fields, before the code runs. Turn on strict mode from the first file; tightening it later is far harder than starting strict. ESLint catches risky patterns (like missing effect dependencies), and Prettier removes every formatting argument.

// tsconfig.json
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true } }
// eslint.config.js (flat config)
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import prettier from 'eslint-config-prettier';

export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
plugins: { 'react-hooks': reactHooks },
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-explicit-any': 'error',
},
},
);
// Interfaces make contracts explicit
interface Order {
id: string;
status: OrderStatus;
items: OrderItem[];
}

React also gave us a clear pattern for cleanup, so listeners, timers and in-flight requests are released when a component unmounts (the AbortController in the earlier hook is the same idea):

useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); // cleanup
}, []);

No test coverage, and regressions with an unknown blast radius

The problem: Modern test tooling had largely dropped support for AngularJS, so the app had effectively no automated tests. Every change was a gamble. We could not tell what else a change might break, so regressions kept slipping into production.

What we fixed: Moving to React unlocked modern testing. We write unit tests for service functions and component tests for behavior, and TypeScript now flags every place affected when a type changes, which shrinks the blast radius before a single test runs.

// OrderTable.test.tsx
test('shows only pending orders as editable', () => {
render(<OrderTable orders={[pendingOrder, approvedOrder]} />);
expect(screen.getAllByRole('button', { name: /edit/i })).toHaveLength(1);
});

We also added end-to-end (E2E) tests for the critical flows: logging in, and the main landing page loading. Unit tests tell you a function works. E2E tests tell you a real user can still get into the product. A small suite covering the paths that must never break gives more safety than a huge suite nobody maintains. Everything else stays lower in the test pyramid, where tests are faster and far less flaky.

// e2e/critical-flows.spec.ts (Playwright shown as an example)
import { test, expect } from '@playwright/test';

test('user can log in and reach the landing page', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.E2E_USER!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Log in' }).click();

await expect(page).toHaveURL('/');
await expect(page.getByRole('main')).toBeVisible();
});

Lost context

The problem: The engineers who built the original system had left, and they left no documentation behind. We were reverse-engineering decisions from the code alone.

What we fixed: We stopped relying on memory and wrote things down where engineers will actually see them: a README per module, short decision records explaining why we chose something, Storybook for UI components, and TypeScript types that document what data looks like. The goal is that the next engineer is never in the position we were in.

<!-- docs/decisions/003-use-zustand.md -->
# Use Zustand for shared client state
Status: accepted
Context: pages showed stale data and re-fetched constantly.
Decision: a shared store, fetched once, updated per slice.
Consequences: one source of truth; team must follow the selector pattern.

Part 6: Rolling it out safely

Tests reduce risk, but they never remove it. Real customers use the product in ways no test suite predicts, so we also changed how we shipped.

The approach: Whatever we migrated went out behind a feature flag, first to a small set of customers (a canary release). We watched how it behaved, collected feedback and fixed what came up. Only once the feedback was addressed did we move on to the next group of customers, and so on until everyone was on the new version.

  1. Ship the new React version behind a flag that is off by default.
  2. Turn it on for a few customers.
  3. Test with them, collect feedback and fix the issues.
  4. Widen the rollout to the next group of customers.
  5. Once everyone is on it, delete the flag and the legacy code. A flag left behind becomes tomorrow’s legacy.
// Flag configuration: enable per customer
export const flags = {
'new-orders-page': { enabledFor: ['customer-a', 'customer-b'] },
};
function OrdersRoute() {
const { isEnabled } = useFeatureFlags();
return isEnabled('new-orders-page') ? <OrderPage /> : <LegacyOrdersPage />;
}

This changed the question from “did we break anything?” to “how many people would it affect if we did?” With a flag, the answer to a bad release is to switch it off for the few customers who have it, instead of rolling back the whole app. That is the best answer we found to the unknown blast radius problem.

Flags are not free. Every flag is a second code path to test and maintain, which is why the last step, deleting the flag and the legacy code, matters, and why it helps to keep the number of live flags small.

What I learned

Here is the whole journey on one page:

Press enter or click to view image in full size

Four lessons stayed with me.

  1. Legacy code is a record of past constraints, not past stupidity. Most of those shortcuts made sense at the time, under deadlines. Understanding why they existed made the fixes better.
  2. Fix root causes, not just the framework. If we had ported the same coupling, the same giant functions and the same missing store into React, we would have a modern-looking legacy app in two years.
  3. Tooling is a team-level fix. Types, linters, formatters, tests and Storybook don’t make any single engineer better. They make the whole team’s worst day safer.
  4. Make every step shippable and reversible. Small increments behind feature flags meant a bad release touched a few customers instead of everyone, and the fix was often just switching the flag off.

Migrating a legacy app is slow, unglamorous work. But every constant extracted, every function split and every test added makes the next change cheaper, and that is how a codebase people feared becomes one people are happy to work in.

If you find this article helpful, please upvote 👏

--

--

Misbah Afzal
Misbah Afzal

Written by Misbah Afzal

Senior Software Engineer | Typescript | NestJS | ReactJS | Angular | NodeJS | Exploring Machine Learning | Book Reader | Writer | Love Productivity Hacks