Migration guides tend to stay at the architecture level. This one does the opposite: it takes a single, realistic AngularJS 1.x controller and walks it, step by step, into a standalone Angular component built on signals. The example is an order-summary panel, the kind of thing every MEAN.JS application has a dozen of. It uses $scope, $watch, $http, a filter, and a $broadcast, which between them cover most of what you will meet in a real codebase.
Versions: AngularJS 1.8.x on the left, Angular 22 on the right. Everything in the "after" column compiles on Angular 20 and later.
Before: the AngularJS controller
angular.module('shop').controller('OrderSummaryCtrl', function ($scope, $http, $rootScope) {
$scope.order = null;
$scope.loading = true;
$scope.discountCode = '';
$scope.total = 0;
$http.get('/api/orders/' + $scope.orderId).then(function (res) {
$scope.order = res.data;
$scope.loading = false;
});
$scope.$watch('order.items', function (items) {
if (!items) { return; }
$scope.total = items.reduce(function (sum, i) { return sum + i.price * i.qty; }, 0);
}, true);
$scope.$watch('discountCode', function (code) {
$scope.discount = code === 'SAVE10' ? $scope.total * 0.1 : 0;
});
$scope.removeItem = function (item) {
$scope.order.items = $scope.order.items.filter(function (i) { return i.sku !== item.sku; });
$rootScope.$broadcast('order:changed', $scope.order);
};
});
<div ng-controller="OrderSummaryCtrl" ng-init="orderId = 'A-1001'">
<p ng-if="loading">Loading...</p>
<ul>
<li ng-repeat="item in order.items">
{{ item.name }} x {{ item.qty }} = {{ item.price * item.qty | currency }}
<button ng-click="removeItem(item)">Remove</button>
</li>
</ul>
<input ng-model="discountCode" placeholder="Discount code" />
<p>Total: {{ total - discount | currency }}</p>
</div>
Step 1: identify what each piece is
Before writing any Angular, classify every $scope member:
| AngularJS | Role | Angular equivalent |
|---|---|---|
orderId (from ng-init) | Input from the parent | input.required<string>() |
order, loading | Async state from HTTP | resource() or signal set from HttpClient |
discountCode | Form state | signal('') bound with [(ngModel)] or a model() |
total, discount | Derived values kept in sync by $watch | computed() |
removeItem | Event handler that mutates state | Method calling signal.update() |
$broadcast('order:changed') | Child-to-parent notification | output<Order>() |
Notice that two of the three $watch calls exist only to recompute derived values. Those disappear entirely; computed() is a $watch that runs exactly when needed and cannot go stale.
Step 2: the standalone component skeleton
ng generate component order-summary
Angular 22's CLI produces a standalone component by default, with the v20-style file naming (order-summary.ts, no .component suffix). Replace its contents:
import { Component, inject, input, output, signal, computed, resource } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
interface Item { sku: string; name: string; price: number; qty: number; }
interface Order { id: string; items: Item[]; }
@Component({
selector: 'app-order-summary',
imports: [CurrencyPipe, FormsModule],
templateUrl: './order-summary.html',
})
export class OrderSummary {
private http = inject(HttpClient);
orderId = input.required<string>();
changed = output<Order>();
private orderResource = resource({
params: () => ({ id: this.orderId() }),
loader: ({ params }) => firstValueFrom(this.http.get<Order>(`/api/orders/${params.id}`)),
});
// Local, editable copy of the loaded items (removals happen here).
private removed = signal<Set<string>>(new Set());
items = computed(() =>
(this.orderResource.value()?.items ?? []).filter(i => !this.removed().has(i.sku)),
);
loading = this.orderResource.isLoading;
discountCode = signal('');
total = computed(() => this.items().reduce((sum, i) => sum + i.price * i.qty, 0));
discount = computed(() => (this.discountCode() === 'SAVE10' ? this.total() * 0.1 : 0));
removeItem(item: Item) {
this.removed.update(set => new Set(set).add(item.sku));
const order = this.orderResource.value();
if (order) this.changed.emit({ ...order, items: this.items() });
}
}
resource() replaces the $http call plus the loading flag plus the implicit re-fetch you would have needed when orderId changed. When the orderId input changes, the params function re-runs and the loader is re-invoked automatically, with the previous request aborted.
Step 3: the template
@if (loading()) {
<p>Loading...</p>
}
<ul>
@for (item of items(); track item.sku) {
<li>
{{ item.name }} x {{ item.qty }} = {{ item.price * item.qty | currency }}
<button (click)="removeItem(item)">Remove</button>
</li>
}
</ul>
<input [(ngModel)]="discountCode" placeholder="Discount code" />
<p>Total: {{ total() - discount() | currency }}</p>
[(ngModel)] binds directly to a writable signal in Angular 17.2 and later, so the form state is a signal without any glue. The built-in control flow (@if, @for) replaces ng-if and ng-repeat; track is required and should be a stable key, not $index.
Step 4: the parent
Where AngularJS used $rootScope.$broadcast('order:changed'), the parent now listens to a typed output:
<app-order-summary orderId="A-1001" (changed)="onOrderChanged($event)" />
If the event genuinely needs to reach a distant ancestor or an unrelated component, the modern pattern is a shared service exposing a signal, not an event bus.
Step 5: running it inside the legacy app (optional)
During an incremental migration you often need the new component to render inside an AngularJS page before the surrounding page is migrated. @angular/upgrade's downgradeComponent handles that:
import { downgradeComponent } from '@angular/upgrade/static';
angular.module('shop').directive('appOrderSummary', downgradeComponent({ component: OrderSummary }));
The AngularJS template then uses <app-order-summary [order-id]="'A-1001'" (changed)="onChanged($event)">. See the Angular upgrade guide for bootstrap details.
The ten gotchas we hit most
- Deep
$watchon arrays becomes identity changes. Signals compare by reference. Mutating an array in place (items.push(x)) does not notify; useupdate()and return a new array. $scope.$applyhas no equivalent and none is needed. If you find yourself wanting it, a signal is being mutated outside the signal API.ng-initwas hiding an input. Make it an explicitinput(); the parent owns that value.- Filters with side effects. AngularJS filters that called services or cached results need to become
computed()values, not pipes. $timeoutfor "after render" work. UseafterNextRender()from@angular/coreor, more often, rethink whether the DOM access is needed at all.$httpinterceptors that mutate$scope. These becomeHttpInterceptorFnfunctions and cannot reach component state; surface errors through a service signal.$broadcastchains three levels deep. Replace with a service, not nested outputs.thisvs$scopeincontrollerAscode. Class fields behave likecontrollerAs; the$scopemethods you bound with.bind(this)just become methods.track by $indexinng-repeat. Angular's@forrequires atrackexpression and performs badly with index tracking on reorderable lists; use the domain key.- Two-way binding to a parent's object.
[(ngModel)]on a property of an input object works but hides mutation; prefermodel()signals or emit explicit change events.
What you get
The AngularJS version had five pieces of mutable state and two watchers keeping them consistent. The Angular version has two sources of truth (orderResource and discountCode) plus a removal set; everything else is derived and cannot drift. Multiply that across a few hundred controllers and the migration pays for itself in bugs you stop having.
This is the component-level core of our AngularJS to Angular migration service. If your application has more controllers than your team has time, talk to us.