+1 (726) 227-3745

Migrating an AngularJS Controller to a Signals-Based Angular Component (Step by Step)

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:

AngularJSRoleAngular equivalent
orderId (from ng-init)Input from the parentinput.required<string>()
order, loadingAsync state from HTTPresource() or signal set from HttpClient
discountCodeForm statesignal('') bound with [(ngModel)] or a model()
total, discountDerived values kept in sync by $watchcomputed()
removeItemEvent handler that mutates stateMethod calling signal.update()
$broadcast('order:changed')Child-to-parent notificationoutput<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

  1. Deep $watch on arrays becomes identity changes. Signals compare by reference. Mutating an array in place (items.push(x)) does not notify; use update() and return a new array.
  2. $scope.$apply has no equivalent and none is needed. If you find yourself wanting it, a signal is being mutated outside the signal API.
  3. ng-init was hiding an input. Make it an explicit input(); the parent owns that value.
  4. Filters with side effects. AngularJS filters that called services or cached results need to become computed() values, not pipes.
  5. $timeout for "after render" work. Use afterNextRender() from @angular/core or, more often, rethink whether the DOM access is needed at all.
  6. $http interceptors that mutate $scope. These become HttpInterceptorFn functions and cannot reach component state; surface errors through a service signal.
  7. $broadcast chains three levels deep. Replace with a service, not nested outputs.
  8. this vs $scope in controllerAs code. Class fields behave like controllerAs; the $scope methods you bound with .bind(this) just become methods.
  9. track by $index in ng-repeat. Angular's @for requires a track expression and performs badly with index tracking on reorderable lists; use the domain key.
  10. Two-way binding to a parent's object. [(ngModel)] on a property of an input object works but hides mutation; prefer model() 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.