You have seen an unopinionated library (React), a progressive framework adopted in layers (Vue) and, before both of them, your own plain-JavaScript application where every decision was yours. Angular sits at the other end of the axis that 10-01 called "library versus framework": it is a complete platform, with a router, an HTTP client, a forms system, dependency injection, a testing environment, a code generator and server rendering included and maintained by the same team. And with TypeScript not as an option but as a de facto requirement. That makes it the most expensive of the three to learn and, at the same time, the most predictable: two Angular projects from different companies look far more alike than two React projects. In this lesson you will see what "opinionated" means when taken all the way; the minimum TypeScript needed to read the code —types, interfaces and decorators—, deferring to 11-07 to learn it properly; the CLI and a project's structure; standalone components with their imports, which today are the recommended form; the four kinds of data binding and the current control-flow syntax (@if, @for with its mandatory track —the same old data-id—, @switch and @defer); signals (signal, computed, effect, and signal-based inputs and outputs) as Angular's current reactivity model, compared with Vue's ref and computed, with a mention of Zone.js and the zoneless trend; dependency injection, which is the piece that most distinguishes Angular and which you already practiced without knowing it in 08-04; HttpClient and just enough RxJS —what a stream is, subscribe, pipe, the template's async and why you have to unsubscribe— applied to your listTasks from 07-02; the router with lazy loading, picking up 09-05; reactive forms with validators that reuse your rules R1–R10; testing with TestBed and a note about SSR. And at the end, the same task list with its filter, for the fourth time.

Contents

  1. Angular as a complete platform
  2. What "opinionated" means when taken all the way
  3. TypeScript: the minimum needed to read this lesson
  4. Decorators: what they are and why Angular uses them
  5. The CLI and a project's structure
  6. Standalone components
  7. Template and styles: the three ways of writing them
  8. Data binding in its four forms
  9. The current control-flow syntax
  10. @for and its mandatory track
  11. @defer: declarative lazy loading
  12. Signals: signal, computed and effect
  13. Signal-based inputs and outputs
  14. Zone.js, change detection and the zoneless trend
  15. Angular's signals versus Vue's ref
  16. Dependency injection: services and inject()
  17. Why dependency injection sets Angular apart
  18. The resemblance to what you did in 08-04
  19. HttpClient and RxJS: just enough
  20. The template's async and why you have to unsubscribe
  21. When signals and when observables
  22. The router with lazy loading
  23. Reactive forms versus template-driven forms
  24. Validators with rules R1–R10
  25. Testing with TestBed
  26. A note about SSR
  27. Nómada Tasks in Angular: the complete list
  28. Comparison with the three previous versions
  29. Common Mistakes and Tips
  30. Exercises
  31. Conclusion

  1. Angular as a complete platform

The difference starts with the inventory. This comes included, it is official and it is updated in step:

Need Angular React Vue
Components and reactivity Included Included Included
Routing Included (Angular Router) You choose Official but separate (Vue Router)
HTTP requests Included (HttpClient) You choose You choose
Forms and validation Included (two systems) You choose You choose
Dependency injection Included Does not exist Does not exist
Advanced asynchronous programming Included (RxJS) You choose You choose
Code generation Included (ng generate) No Partial
Testing environment Included (TestBed) You choose Official but separate
Internationalization Included You choose You choose
Server rendering Included Meta-framework Meta-framework (Nuxt)
Automatic code updates Included (ng update) No Partial
Language TypeScript Either Either

That last ng update row deserves attention because it is a rarely discussed consequence of having everything under one roof: when Angular changes an API, it publishes automatic migrations that rewrite your code. Updating a large Angular project is usually a matter of running a command and reviewing the result. In a React project with twenty third-party dependencies, each one updates at its own pace and the incompatibilities are yours. It is a real advantage of vertical integration, and it is the direct counterpart to 10-01's cost 5, ecosystem churn.

  1. What "opinionated" means when taken all the way

In practice, "opinionated" means five concrete things:

  1. You do not choose tools, you choose Angular. There is no decision to make about the router, the HTTP client or the forms system. That removes decision fatigue and it also removes the possibility of using something better suited to your case.
  2. The project structure is given. ng generate component task-card creates four files with predictable names, locations and content. Every project looks alike.
  3. There is one correct way to do each thing, documented, and the rest of the community follows it.
  4. The verbosity is deliberate. Angular prefers explicit and long over concise and magical. You will see more lines to do the same thing, and those lines say exactly what happens.
  5. The curve is steeper at the start and flatter afterwards. There are more concepts to learn before being productive (dependency injection, decorators, observables), and fewer surprises once they are learned.

The profile where Angular shines is recognizable: large, long-lived applications, with sizable teams and staff turnover, typically internal or corporate. And the profile where it gets in the way is recognizable too: a prototype, a widget, a small website, one person in a hurry.

  1. TypeScript: the minimum needed to read this lesson

Angular is written in TypeScript and its documentation, its examples and its tooling take it for granted. It can be used with JavaScript, but nobody does and the ecosystem does not support it. So you need the minimum to read the code. This is not a TypeScript course: in 11-07 you will see what it is, why it has won and how to learn it properly. Here are four ideas.

TypeScript is JavaScript with type annotations. All valid JavaScript is valid TypeScript. The annotations are checked at compile time and disappear from the result: what runs in the browser is ordinary JavaScript.

// Annotations on variables, parameters and return values
let hours: number = 12;
let title: string = 'Redesign the multipurpose room';
let reviewer: string | null = null;          // union: one thing or the other

function openHours(tasks: Task[]): number {
  return tasks.filter((t) => t.status !== 'done')
              .reduce((sum, t) => sum + t.estimatedHours, 0);
}

Interfaces describe the shape of an object. They are documentation that the compiler checks:

// src/app/domain/task.ts
export type Priority = 'high' | 'medium' | 'low';          // literal type: only those three values
export type Status = 'pending' | 'in-progress' | 'done';

export interface Task {
  id: number;
  title: string;
  assignee: string | null;         // R8: null, never ''
  priority: Priority;
  status: Status;
  tags: string[];
  estimatedHours: number;
  dueDate: string;                 // ISO 'yyyy-mm-dd'
  reviewer: string | null;
}

Notice Priority. It is a literal union type, and it does something no comment achieves: if you write priority: 'urgent', the compiler rejects it before anything runs. The rules R1–R10 you have spent the whole course checking by hand become, to a large extent, things the compiler stops you from even expressing.

Generics are types with parameters. Task[] is an array of tasks; Signal<number> is a signal containing a number; Observable<Task[]> is a stream that emits arrays of tasks. They read as "X of Y".

Access modifiers are TypeScript's version of 05-03's encapsulation:

class BoardService {
  private tasks: Task[] = [];        // only inside the class (checked at compile time)
  readonly today = '2026-09-20';     // cannot be reassigned
}

With one nuance worth knowing: TypeScript's private disappears at compile time and only protects during development, whereas JavaScript's #fields from 05-03 are genuinely private at runtime. Angular mostly uses private.

  1. Decorators: what they are and why Angular uses them

Decorators are the @ syntax you will see everywhere:

@Component({ … })
export class TaskCardComponent { }

@Injectable({ providedIn: 'root' })
export class TasksService { }

A decorator is a function that receives the class and attaches metadata to it. @Component({...}) does not change the class's behavior: it attaches information —what its template is, which selector it uses, which other components it imports— that Angular reads at compile time and at runtime.

The reason Angular uses them is consistent with its philosophy: configuration lives next to what it configures, declaratively and readably. In React that information is spread across the file name, the imports and the JSX; in Angular it is in an explicit object above the class.

To read this lesson it is enough to understand them as "metadata declared above a class". You do not need to write one.

  1. The CLI and a project's structure

Angular's CLI does much more than create projects:

npm install -g @angular/cli

ng new nomada-angular            # creates the project and asks about SSR, styles, etc.
cd nomada-angular

ng generate component components/task-card    # or: ng g c components/task-card
ng generate service data/tasks
ng generate guard security/authenticated

ng serve                         # development server
ng build                         # production build
ng test                          # unit tests
ng update                        # automatic migrations when updating

ng generate component creates four files and registers them where appropriate:

src/app/components/task-card/
  task-card.component.ts      ← logic
  task-card.component.html    ← template
  task-card.component.css     ← styles (scoped by default)
  task-card.component.spec.ts ← test skeleton

That fourth file says a lot about Angular's opinions: the test is generated with the component, it is not added later if somebody feels like it. It is the same idea 08-03 argued for, turned into the tool's default behavior.

And the overall structure:

src/
  main.ts                    ← bootstrap
  index.html
  styles.css                 ← global styles
  app/
    app.config.ts            ← application configuration (providers)
    app.routes.ts            ← routes
    app.component.ts         ← root component
    domain/                  ← types and rules: pure TypeScript
    data/                    ← data services
    components/              ← components

Compare with your js/model/, js/data/, js/view/, js/util/. The separation is the same one you chose; the difference is that here it is given and everybody uses it the same way.

  1. Standalone components

For years, Angular organized everything into NgModules: a component did not exist until it was declared in a module, and modules imported other modules. It was a layer of indirection that was hard to explain and that was often unnecessary.

Today the recommended form is standalone components, which declare directly what they need:

// src/app/components/assignee-filter.component.ts
import { Component, input, output } from '@angular/core';

@Component({
  selector: 'app-assignee-filter',
  template: `
    <label class="filter" for="assignee-filter">
      Assignee:
      <select
        id="assignee-filter"
        [value]="value() ?? ''"
        (change)="onChange($event)"
      >
        <option value="">All</option>
        @for (name of assignees(); track name) {
          <option [value]="name">{{ name }}</option>
        }
      </select>
    </label>
  `,
  styles: `
    .filter { display: flex; gap: 0.5rem; align-items: center; }
  `
})
export class AssigneeFilterComponent {
  assignees = input.required<string[]>();
  value = input<string | null>(null);
  changed = output<string | null>();

  onChange(event: Event): void {
    const selection = (event.target as HTMLSelectElement).value;
    this.changed.emit(selection || null);      // R8: null, never ''
  }
}

The decorator's elements:

  • selector is the HTML tag it is used with: <app-assignee-filter>. The app- prefix is the convention for avoiding collisions with native elements.
  • template (or templateUrl) is the template.
  • styles (or styleUrls) are the styles, scoped by default, like Vue's scoped in 10-04 but without having to ask for it.
  • imports —not needed here because no external component or directive is used— lists what the template requires.

Standalone components are now the CLI's default. You will see a lot of older code with NgModule; it still works and there are automatic migrations, but for new code it is not the recommended form.

  1. Template and styles: the three ways of writing them

Form When How
Separate files Large components templateUrl: './x.component.html', styleUrls: ['./x.component.css']
Inline with backticks Small components template: \…``
A mix Template outside, styles inside Both

This is a real difference from Vue, and it cuts both ways. Angular by default uses three files for a component (logic, template, styles), which pushes the parts apart; but style scoping comes enabled without asking and the editor's tooling navigates between the three with no friction. For small components, the inline form gives you something very close to a .vue file.

Style scoping is implemented with unique attributes added by the compiler, just as in Vue. It can be switched to real Shadow DOM with encapsulation: ViewEncapsulation.ShadowDom, or disabled — which is almost never a good idea.

  1. Data binding in its four forms

Angular has a very explicit syntax for distinguishing the direction of the data, and once learned it reads very well:

<!-- 1 · Interpolation: value → text -->
<h3>{{ task.title }}</h3>
<p>{{ task.estimatedHours }} h</p>

<!-- 2 · Property: value → the element's attribute/property -->
<button [disabled]="next() === null">Start</button>
<li [attr.data-id]="task.id" [class.task--done]="task.status === 'done'">

<!-- 3 · Event: element → the component's method -->
<button (click)="advance(task.id)">Start</button>
<form (submit)="create($event)">

<!-- 4 · Two-way: property + event at once -->
<input [(ngModel)]="title">

The official mnemonic is useful: the square brackets point inward (the data goes into the element), the parentheses point outward (the event comes out of the element), and [()] —"banana in a box"— does both.

And [(ngModel)] is, like Vue's v-model, pure sugar:

<input [(ngModel)]="title">
<!-- is equivalent to -->
<input [ngModel]="title" (ngModelChange)="title = $event">

A property coming down and an event going up. Once again the unidirectional flow with a shortcut on top. A practical warning: ngModel belongs to FormsModule and has to be imported in the component; and for forms of any substance, Angular recommends the reactive forms of section 23, not ngModel.

For classes and styles there are specific forms:

<li
  class="task"
  [class.task--done]="task.status === 'done'"
  [class.task--overdue]="overdue()"
  [ngClass]="'task--' + task.priority"
  [style.opacity]="task.status === 'done' ? 0.6 : 1"
>

  1. The current control-flow syntax

For years Angular used structural directives with an asterisk (*ngIf, *ngFor, ngSwitch). Today there is a block syntax built into the compiler, which is the recommended one for new code:

@if (loading()) {
  <p role="status">Loading tasks…</p>
} @else if (error()) {
  <p role="alert">Could not load: {{ error()!.message }}</p>
} @else {
  <ul class="task-list">
    @for (task of visible(); track task.id) {
      <app-task-card [task]="task" (advance)="advance($event)" />
    } @empty {
      <p class="empty-list">No task matches the filter.</p>
    }
  </ul>
}

@switch (task.status) {
  @case ('pending')     { <span class="badge">○</span> }
  @case ('in-progress') { <span class="badge">◐</span> }
  @case ('done')        { <span class="badge">●</span> }
}

Four advantages over the old directives, all of them practical:

  1. Nothing needs importing. *ngIf required importing CommonModule or NgIf; forgetting it produced a silent failure where the content simply did not appear.
  2. There is a real @else. With *ngIf you had to use <ng-template> and references, which was considerably more awkward.
  3. @empty covers the empty state without an extra @if. It is exactly the case 06-06 forced you to handle separately.
  4. The compiler understands it better and generates more efficient code, as well as better error messages.

Compare the three syntaxes for the same thing:

Operation Angular Vue React
Conditional @if (x) { … } @else { … } v-if / v-else {x ? … : …}
List @for (t of ts; track t.id) { … } v-for + :key ts.map(t => <X key={t.id}/>)
Empty state @empty { … } v-if="ts.length === 0" if (ts.length === 0) return …
Multiple selection @switch / @case Chained v-ifs A lookup object, or ternaries
Lazy loading @defer defineAsyncComponent lazy + Suspense

  1. @for and its mandatory track

For the fourth time in the module, the same concept. And Angular is where it is best resolved, because track is not optional:

@for (task of visible(); track task.id) {
  <app-task-card [task]="task" />
}

If you write @for without track, the code does not compile. It is not a console warning as in React or an ESLint rule as in Vue: it is a compilation error that prevents the project from building.

That decision sums up Angular's philosophy. Stable identity in lists is so important —you have known it since 06-06, when reconcile without data-id would have cost you the focus, the transitions and the internal state— that the framework does not let you forget it. It is "opinionated" in its most useful form: the opinion forces you to do the right thing.

The same three rules, with the same trap: track $index is allowed and is correct only if the list is never reordered or filtered. With an id available, use the id.

Angular also offers contextual variables inside the block:

@for (task of visible(); track task.id; let i = $index, first = $first) {
  <li [class.highlighted]="first">{{ i + 1 }}. {{ task.title }}</li>
}

Available: $index, $first, $last, $even, $odd, $count.

  1. @defer: declarative lazy loading

This block deserves attention of its own because it connects directly with 09-05 and because it has no equally integrated equivalent in the other frameworks:

@defer (on viewport) {
  <app-workload-report [tasks]="tasks()" />
} @placeholder (minimum 500ms) {
  <div class="skeleton" style="min-height: 300px" aria-hidden="true"></div>
} @loading (after 100ms; minimum 300ms) {
  <p role="status">Loading the report…</p>
} @error {
  <p role="alert">The report could not be loaded.</p>
}

What it does: the app-workload-report component and all of its dependencies are split into a separate chunk at build time, and they are only downloaded when the condition is met. The available triggers are on viewport, on interaction, on hover, on idle, on timer(…), on immediate, and when <expression>, plus prefetch on … to preload without rendering.

Compare with what you wrote in 09-05:

// Your version, by hand
let reportPromise = null;
button.addEventListener('click', async () => {
  indicator.hidden = false;
  indicator.setAttribute('aria-busy', 'true');
  try {
    reportPromise ??= import('./report/report.js');
    const { mountReport } = await reportPromise;
    mountReport(container, board);
  } catch (error) {
    reportPromise = null;                   // allow a retry
    showFailure(container);
  } finally {
    indicator.hidden = true;
  }
});

And with the IntersectionObserver with rootMargin that you also wrote. @defer does all of that —the code split, the trigger, the indicator with its minimum delay to avoid flicker, the reserved space that avoids CLS, and the failure handling— declaratively and checked by the compiler. It is exactly 10-01's argument: it is not that you did not know how to do it; it is that here you write it in eight lines of template and you cannot forget the error state.

  1. Signals: signal, computed and effect

Signals are Angular's current reactivity model, and their resemblance to Vue's refs is no coincidence: they are the same family 2 from 10-01.

import { signal, computed, effect } from '@angular/core';

// A writable signal
const assignee = signal<string | null>(null);

// Reading: it is called like a function
console.log(assignee());          // null

// Writing: two ways
assignee.set('Iván');                             // a new value directly
assignee.update((current) => current ?? 'Iván');  // based on the current one

The syntactic difference from Vue: where Vue uses .value (a property), Angular uses () (a call). The reason is the same as in 10-04's section 12 —JavaScript does not allow reading a variable to be intercepted—, solved with a different tool: a function instead of a getter.

computed declares a derived value, with a cache and automatic dependencies:

const tasks = signal<Task[]>(BACKLOG);

const visible = computed(() => {
  const a = assignee();
  return a === null ? tasks() : tasks().filter((t) => t.assignee === a);
});

const openHours = computed(() =>
  visible().filter((t) => t.status !== 'done')
           .reduce((s, t) => s + t.estimatedHours, 0)
);

Semantically identical to Vue's computed: lazy, cached, composable, with dependencies discovered on read. And therefore also identical to your version cache from 09-02, with automatic invalidation.

effect runs side effects when its dependencies change:

effect(() => {
  document.title = `Nómada Tasks (${pending()})`;
});

// With cleanup, which is once again your destroy()
effect((onCleanup) => {
  const channel = new BoardChannel();
  channel.subscribe(onMessage);
  onCleanup(() => channel.close());
});

With the same warning as useEffect and watch: effect is not for deriving values. Writing to a signal from inside an effect in order to compute something is the same antipattern you have already seen twice, and Angular explicitly discourages it (to the point that writing signals inside an effect requires a special option).

There is a relevant detail about immutability. A signal detects change by comparing with Object.is, like React and unlike Vue: there is no proxy intercepting the write to a nested property.

// ❌ Detects nothing: the array's reference does not change
tasks()[5].status = 'done';

// ✅ A new reference
tasks.update((ts) => ts.map((t) => (t.id === 6 ? { ...t, status: 'done' } : t)));

That is: in Angular with signals, 04-07's immutability is mandatory again, just as in React. It is a good example of the three families from 10-01 crossing over: Angular has fine-grained reactivity with an immutability requirement; Vue has fine grain with mutation allowed; React has a virtual DOM with mandatory immutability.

  1. Signal-based inputs and outputs

Communication between components uses input() and output(), which replace the @Input() and @Output() decorators of earlier Angular:

import { Component, input, output, computed } from '@angular/core';
import { Task, Status } from '../domain/task';
import { NEXT, LABEL, isOverdue } from '../domain/rules';

@Component({
  selector: 'app-task-card',
  template: `
    <li
      class="task"
      [class]="'task--' + task().priority"
      [class.task--done]="task().status === 'done'"
      [class.task--overdue]="overdue()"
      [attr.data-id]="task().id"
    >
      <h3 class="task__title">{{ task().title }}</h3>

      <p class="task__meta">
        {{ task().assignee ?? 'unassigned' }} · {{ task().estimatedHours }} h
        @if (overdue()) { <span class="task__warning"> · ⚠ overdue</span> }
      </p>

      <ul class="task__tags">
        @for (tag of task().tags; track tag) {
          <li class="tag">{{ tag }}</li>
        }
      </ul>

      <button
        type="button"
        [disabled]="next() === null"
        [attr.aria-label]="LABEL[task().status] + ': ' + task().title"
        (click)="advance.emit(task().id)"
      >
        {{ LABEL[task().status] }}
      </button>
    </li>
  `,
  styles: `
    .task { border-left: 4px solid var(--border); padding: 0.75rem; }
    .task--high { border-left-color: var(--red); }
    .task--done .task__title { text-decoration: line-through; opacity: 0.6; }
    .task__tags { list-style: none; display: flex; gap: 0.3rem; padding: 0; }
  `
})
export class TaskCardComponent {
  task = input.required<Task>();
  advance = output<number>();

  protected readonly LABEL = LABEL;

  overdue = computed(() => isOverdue(this.task()));
  next = computed<Status | null>(() => NEXT[this.task().status]);
}

Important points:

  • input.required<Task>() declares a required, typed input. If the parent does not pass it, it fails at compile time. Compare it with React's props, where only TypeScript catches it, or with Vue's defineProps, which validates at runtime in development.
  • An input is a signal. It is read with task() and can be used directly as a dependency of a computed. That means overdue recomputes itself when the parent passes another task, with nothing resembling a dependency array.
  • output<number>() declares a typed event. The parent listens with (advance)="…".
  • LABEL is exposed as a property because Angular templates only see class members, not the module's imports. It is a real difference from JSX (where the file's whole scope is available) and from Vue (where <script setup> exposes everything automatically): in Angular it has to be made explicit.

  1. Zone.js, change detection and the zoneless trend

To understand the Angular you will see in real projects you need to know where its reactivity comes from.

For almost all of its history, Angular did not know what had changed: it checked everything. The mechanism was Zone.js, a library that patches the browser's asynchronous APIs —setTimeout, addEventListener, fetch, Promise— to notify Angular every time something happens. On receiving the notification, Angular walks the component tree evaluating every expression in every template and comparing the results with the previous ones.

graph TD
  E["Any asynchronous event<br/>(click, timeout, HTTP response)"] --> Z["Zone.js detects it"]
  Z --> D["Angular runs change detection"]
  D --> T["It walks ALL the components<br/>and evaluates ALL the expressions"]
  T --> C["It updates the DOM wherever something differs"]

It works, and it is the reason Angular "just updates" when you mutate a property. But it has three costs: it checks far more than necessary; Zone.js has weight and patches global APIs, which complicates debugging and interoperability; and it gives no fine-grained information, so you have to optimize by hand with ChangeDetectionStrategy.OnPush.

Signals change that. Since a signal knows exactly which expressions read it, Angular can update only what depends on what changed. That is the foundation of the zoneless trend: applications where all reactivity goes through signals and Zone.js is no longer needed, so it disappears from the bundle and change detection becomes data-driven instead of poll-driven.

In practice, to write modern Angular the rule is: use signals for all state. If you do, the mental model is the same as Vue's and you do not need to think about Zone.js. But when reading existing code you will find ordinary class properties updating themselves: that is Zone.js at work, and now you know why.

  1. Angular's signals versus Vue's ref

Vue's ref Angular's signal
Reading x.value (template: x) x() (template: x())
Writing x.value = 5 x.set(5) or x.update(f)
Deriving computed(() => …) computed(() => …)
Effect watchEffect, watch effect
Change detection Proxy: it intercepts writes to properties Object.is on the value
Nested objects Deeply reactive No: the reference has to be replaced
Immutability Not needed Mandatory
Lost when destructuring Yes (toRefs avoids it) No: a signal is a function, it is passed whole
Outside components Yes Yes

Two rows deserve comment.

The immutability row is the most visible practical difference, and it comes with a compensation: since there is no proxy, Angular's signals are cheaper and more predictable; you never have to wonder whether an object is wrapped or whether a Map is reactive. In exchange, you write 04-07's immutable map on every update.

The destructuring row is a clear advantage of signals: since the value is obtained by calling a function, passing the signal elsewhere never breaks anything. The whole of 10-04's section 15 —Vue's four limits of reactivity— simply does not apply here. It is a case where the more awkward syntax (() on every read) buys a valuable property.

  1. Dependency injection: services and inject()

Here is what really sets Angular apart. The other two frameworks have nothing equivalent.

A service is a class with logic that does not belong to any component: data access, shared state, business rules, event logging.

// src/app/data/tasks.service.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Task } from '../domain/task';
import { NEXT } from '../domain/rules';

@Injectable({ providedIn: 'root' })     // ← a single instance for the whole application
export class TasksService {
  private readonly http = inject(HttpClient);      // ← function-based injection

  private readonly _tasks = signal<Task[]>([]);
  readonly tasks = this._tasks.asReadonly();        // exposed as read-only

  readonly assignees = computed(() =>
    [...new Set(this.tasks().map((t) => t.assignee).filter(Boolean))].sort()
  );

  readonly openHours = computed(() =>
    this.tasks().filter((t) => t.status !== 'done')
                .reduce((s, t) => s + t.estimatedHours, 0)
  );

  load(): void {
    this.http.get<Task[]>('/api/tasks')
      .subscribe((tasks) => this._tasks.set(tasks));
  }

  advance(id: number): void {
    this._tasks.update((tasks) =>
      tasks.map((t) => {
        if (t.id !== id) return t;
        const target = NEXT[t.status];
        return target === null ? t : { ...t, status: target };   // R6
      })
    );
  }
}

And its use from a component:

@Component({ … })
export class BoardComponent {
  private readonly service = inject(TasksService);

  tasks = this.service.tasks;
  openHours = this.service.openHours;
}

Three things to understand about this code:

@Injectable({ providedIn: 'root' }) registers the service in the root injector. Angular creates a single instance and gives it to everybody who asks. It is a singleton, but managed by the framework instead of by a module exporting an object.

inject(TasksService) asks for the instance. There is no new, there is no import of a specific instance: you ask for the type and the injector decides what to give. That indirection is the key to everything that follows.

The state is in the service, not in the component. The pattern of a private writable _tasks plus a public read-only tasks (asReadonly()) is 05-03's encapsulation applied to state: the service is the only one that can change it, and components only read. It is exactly the discipline your Board class imposed with #tasks and the getter that returned a copy.

  1. Why dependency injection sets Angular apart

The reasonable question is: what does asking for inject(TasksService) add over importing an instance?

It means that whoever decides which instance is handed over is not whoever uses it. And from that come four capabilities:

1 · Substitution in tests. When testing the component, it is given a fake service without touching the component:

TestBed.configureTestingModule({
  providers: [{ provide: TasksService, useClass: FakeTasksService }]
});

2 · Changing the implementation per environment. The same component uses a service against the real API in production and against local data in development, decided in the configuration.

3 · Different scopes. A service can be unique for the whole application (providedIn: 'root'), or one instance can be created per component if it is declared in its providers. That allows, for example, one state service for each open panel.

4 · Interceptors. HttpClient can be wrapped with interceptors that add authentication headers, retry (your withRetries from 07-03), log or handle errors, without any service or component finding out.

The cost, plainly stated: it is one more concept to learn, and one that does not exist in React or Vue. For a small application it is ceremony; for a large one with many layers and thorough testing, it is the piece that keeps the code decoupled.

  1. The resemblance to what you did in 08-04

And here is the connection that makes all of this feel familiar. In 08-04 you wrote this so you could test your repository:

// js/data/local-repository.js — 08-04
export class LocalRepository {
  #store;

  constructor(store = window.localStorage) {   // ← injection by parameter
    this.#store = store;
  }

  save(board) {
    this.#store.setItem('nomada:board', JSON.stringify(board));
  }
}
// test/local-repository.test.js
const fakeStore = new InMemoryStore();
const repo = new LocalRepository(fakeStore);    // ← the double is injected

That is dependency injection. Angular did not invent it: it is a design pattern that is decades old, and you applied it for the right reason —to be able to test without depending on localStorage. What Angular does is two further things:

  1. Turn it into the norm, not something you do when somebody remembers.
  2. Automate the wiring: in your version, whoever constructs LocalRepository has to know which store to pass it, and that knowledge propagates upward. With an injector, you declare once which implementation corresponds to each type and the framework resolves it across the whole tree.

If the pattern seemed useful to you in 08-04 —and it was, because without it those tests would have needed a real browser—, Angular gives it to you across the whole application with no additional effort.

  1. HttpClient and RxJS: just enough

HttpClient is the included HTTP client, and it returns observables instead of promises:

import { inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, retry, map, of } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class TasksApiService {
  private readonly http = inject(HttpClient);

  listTasks(assignee: string | null) {
    let params = new HttpParams();
    if (assignee) params = params.set('assignee', assignee);

    return this.http.get<Task[]>('/api/tasks', { params }).pipe(
      retry({ count: 3, delay: 500 }),                   // your withRetries from 07-03
      map((tasks) => tasks.filter((t) => t.title.trim() !== '')),   // R2
      catchError((error) => {
        console.error('Failed to list tasks', error);
        return of([]);                                    // dignified degradation
      })
    );
  }
}

The minimum RxJS needed to read this, and no more:

An observable is a stream of values over time. A promise produces one value (or a failure) and finishes; an observable can produce zero, one or many, and it may never finish. A mouse click is a stream. A WebSocket —your BoardChannel from 07-04— is a stream. An HTTP request is a stream that emits one value and finishes, and that is why it looks like a promise with odd syntax.

subscribe starts the stream. An observable is lazy: until somebody subscribes, nothing happens. This is trap number one for anyone coming from promises: calling listTasks() without subscribe makes no request at all. A promise, by contrast, starts running as soon as it is created.

this.api.listTasks(null).subscribe({
  next: (tasks) => this.tasks.set(tasks),
  error: (failure) => this.error.set(failure),
  complete: () => this.loading.set(false)
});

pipe chains operators. An operator transforms the stream and returns another stream. The ones that come up constantly:

Operator What it does Your equivalent
map Transforms each value Array.prototype.map from 04-04
filter Discards values Array.prototype.filter
retry Retries on failure Your withRetries from 07-03
catchError Catches the error and returns another stream 02-05's catch
debounceTime Waits until emissions stop Your debounce from 09-02
switchMap Switches to another stream and cancels the previous one Your AbortController from 07-03
takeUntilDestroyed Unsubscribes when the component is destroyed Your destroy() from 09-03

switchMap deserves an example, because it elegantly solves 10-02's race condition:

// The filter is a stream; every change cancels the previous request
readonly tasks = toSignal(
  toObservable(this.assignee).pipe(
    debounceTime(300),                                    // do not query on every keystroke
    switchMap((a) => this.api.listTasks(a))               // cancels the previous request
  ),
  { initialValue: [] as Task[] }
);

That switchMap is the canonical answer to the problem you solved in React by aborting by hand and in Vue with onCleanup. When a new value arrives, the previous subscription is cancelled —and with it the HTTP request— and the new one is subscribed to. The race is impossible by construction.

And toSignal/toObservable are the bridges between the two worlds: they turn an observable into a signal and vice versa. They are the tool that lets you write the application with signals and use RxJS only where it contributes.

  1. The template's async and why you have to unsubscribe

In the template, the async pipe subscribes and unsubscribes automatically:

@if (tasks$ | async; as tasks) {
  <ul>
    @for (t of tasks; track t.id) { <li>{{ t.title }}</li> }
  </ul>
}

It is the recommended form when working with observables in templates, precisely because it removes the unsubscribing problem.

And that problem is real. An observable that does not finish —a WebSocket, a stream of events, a timer— keeps its subscription alive even if the component disappears. It is literally 09-03's memory leak: the handler stays in memory, keeps processing, and keeps the component and its whole subtree alive.

// ❌ Leak: the subscription outlives the component
export class BoardComponent implements OnInit {
  ngOnInit() {
    this.channel.messages$.subscribe((m) => this.apply(m));
  }
}

// ✅ Automatic unsubscribe when the component is destroyed
export class BoardComponent {
  private readonly destroyRef = inject(DestroyRef);

  constructor() {
    this.channel.messages$
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((m) => this.apply(m));
  }
}

takeUntilDestroyed is your destroy(), once again, with the call guaranteed by the framework. The three correct ways of handling unsubscription, in order of preference: the async pipe in the template, takeUntilDestroyed, and —if there is no other way— storing the subscription and calling unsubscribe() in ngOnDestroy.

  1. When signals and when observables

Angular now has two reactive systems coexisting, which is disconcerting. The current criterion is reasonably clear:

Use… For… Examples
Signals Synchronous state the interface displays The current filter, the task list, whether a panel is open
Observables Asynchronous streams with complex composition HTTP, WebSocket, DOM events with a delay, sequences with cancellation
The bridges Moving from one world to the other toSignal to paint a stream; toObservable to compose over a signal

In practice, modern Angular tends towards: signals for almost all the application's state, RxJS for the data layer and complex interactions, and toSignal at the boundary. It is a model with more pieces than Vue or React, and that is a legitimate criticism of Angular's learning curve — with the counterpart that RxJS solves asynchronous coordination problems that in the other frameworks are solved by hand or with libraries.

  1. The router with lazy loading

// src/app/app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', redirectTo: 'board', pathMatch: 'full' },

  {
    path: 'board',
    // Lazy loading: this component goes in its own chunk
    loadComponent: () => import('./components/board.component')
                            .then((m) => m.BoardComponent),
    title: 'Board · Nómada Tasks'
  },
  {
    path: 'task/:id',
    loadComponent: () => import('./components/task-detail.component')
                            .then((m) => m.TaskDetailComponent)
  },
  {
    path: 'report',
    loadComponent: () => import('./components/report.component')
                            .then((m) => m.ReportComponent),
    canActivate: [authenticatedGuard]           // route guard
  },
  { path: '**', loadComponent: () => import('./components/not-found.component')
                                        .then((m) => m.NotFoundComponent) }
];

That loadComponent with a dynamic import() is exactly 09-05's code splitting, integrated into the router: each route is compiled into its own chunk and is only downloaded when you navigate to it. It is the "Angular ships code splitting in the router" line that 09-05 announced.

And there is a piece your router.js did not have: guards. canActivate runs a function before activating the route and can block the navigation or redirect:

export const authenticatedGuard: CanActivateFn = () => {
  const session = inject(SessionService);
  const router = inject(Router);
  return session.authenticated() ? true : router.createUrlTree(['/sign-in']);
};

In the template, navigation is done with directives:

<nav>
  <a routerLink="/board" routerLinkActive="active">Board</a>
  <a [routerLink]="['/task', task.id]">{{ task.title }}</a>
</nav>

<router-outlet />

routerLink generates a real <a href> —important for accessibility and SEO— and intercepts the click to navigate without reloading, which is exactly what your router did with the History API in 07-06.

  1. Reactive forms versus template-driven forms

Angular includes two systems, and this is the comparison that decides which to use:

Template-driven (ngModel) Reactive (FormGroup)
Where the structure is defined In the HTML In TypeScript
Typing Weak Strong, checked at compile time
Validation Attributes in the template Validator functions
Asynchronous validation Awkward Built in
Dynamic fields Hard FormArray
Testing Requires rendering The model is tested directly
Simple forms Quick to write More ceremony
Angular's recommendation Simple cases Everything else

Reactive forms in action:

import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-task-form',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()" novalidate>
      <label for="title">Title</label>
      <input id="title" formControlName="title"
             [attr.aria-invalid]="title.invalid && title.touched">
      @if (title.touched && title.hasError('required')) {
        <p class="error" role="alert">The title is required (R2).</p>
      }
      @if (title.touched && title.hasError('onlyWhitespace')) {
        <p class="error" role="alert">The title cannot be only whitespace (R2).</p>
      }

      <label for="hours">Estimated hours</label>
      <input id="hours" type="number" formControlName="estimatedHours">
      @if (hours.touched && hours.invalid) {
        <p class="error" role="alert">Between 1 and 40 hours (R3).</p>
      }

      <button type="submit" [disabled]="form.invalid">Create task</button>
    </form>
  `
})
export class TaskFormComponent {
  private readonly fb = inject(FormBuilder);

  form = this.fb.nonNullable.group({
    title: ['', [Validators.required, notOnlyWhitespace]],
    estimatedHours: [1, [Validators.required, Validators.min(1), Validators.max(40)]],
    dueDate: ['', [Validators.required, notBeforeToday]],
    assignee: [null as string | null]
  });

  get title() { return this.form.controls.title; }
  get hours() { return this.form.controls.estimatedHours; }

  submit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();     // shows every error at once
      return;
    }
    const data = this.form.getRawValue();   // typed: {title: string, …}
    // …create the task
  }
}

  1. Validators with rules R1–R10

A validator is a pure function that receives the control and returns null if it is valid or an error object if it is not. That is: exactly the shape of your validation functions from 03-03, with a different signature.

// src/app/domain/validators.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { TODAY } from './rules';

/** R2: the title cannot be empty or contain only whitespace. */
export function notOnlyWhitespace(control: AbstractControl): ValidationErrors | null {
  const value = control.value as string;
  return typeof value === 'string' && value.trim() === '' ? { onlyWhitespace: true } : null;
}

/** R4: the due date cannot be earlier than the creation date. */
export function notBeforeToday(control: AbstractControl): ValidationErrors | null {
  const value = control.value as string;
  if (!value) return null;
  return value < TODAY ? { pastDate: { minimum: TODAY, current: value } } : null;
}

/** R9: tags are stored in lowercase and without duplicates. */
export function normalizedTags(control: AbstractControl): ValidationErrors | null {
  const value = (control.value ?? []) as string[];
  const normalized = value.map((t) => t.trim().toLowerCase());
  const hasDuplicates = new Set(normalized).size !== normalized.length;
  const hasUppercase = value.some((t) => t !== t.toLowerCase());
  if (hasDuplicates) return { duplicateTags: true };
  if (hasUppercase) return { uppercaseTags: true };
  return null;
}

/** R7: nobody can exceed 40 h assigned in the same week. Group validator. */
export function weeklyLimit(currentWorkload: Map<string, number>): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const assignee = group.get('assignee')?.value as string | null;
    const hours = Number(group.get('estimatedHours')?.value ?? 0);
    if (!assignee) return null;                        // R8: with no assignee, it does not apply

    const total = (currentWorkload.get(assignee) ?? 0) + hours;
    return total > 40 ? { weeklyLimit: { assignee, total, maximum: 40 } } : null;
  };
}

Three observations:

  • They are pure functions and are tested with Jest without Angular. notOnlyWhitespace({value: ' '} as AbstractControl) returns {onlyWhitespace: true}. It is the same ease of testing your validators from 03-03 and your reducers from 10-03 had.
  • weeklyLimit is a validator factory —a function that returns a function, from 03-06— because it needs external data. And it is a group validator, because R7 depends on two fields at once.
  • The error object carries data, not just true. That allows specific messages: "Iván would reach 45 h out of the 40 allowed" instead of "error". It is the same idea as your ValidationError with .field from js/model/errors.js.

  1. Testing with TestBed

Angular brings its own environment, which sets up a mini-injector for the test:

import { TestBed } from '@angular/core/testing';
import { BoardComponent } from './board.component';
import { TasksService } from '../data/tasks.service';
import { signal } from '@angular/core';
import { BACKLOG } from '../data/backlog';

describe('BoardComponent', () => {
  let fakeService: Partial<TasksService>;

  beforeEach(async () => {
    fakeService = {
      tasks: signal(BACKLOG).asReadonly(),
      advance: jasmine.createSpy('advance')
    };

    await TestBed.configureTestingModule({
      imports: [BoardComponent],
      providers: [{ provide: TasksService, useValue: fakeService }]   // ← the double
    }).compileComponents();
  });

  it('shows the 6 tasks of the canonical backlog', () => {
    const fixture = TestBed.createComponent(BoardComponent);
    fixture.detectChanges();

    const rows = fixture.nativeElement.querySelectorAll('[data-id]');
    expect(rows.length).toBe(6);
  });

  it('filtering by Lucía leaves one task of 14 h', () => {
    const fixture = TestBed.createComponent(BoardComponent);
    fixture.componentInstance.assignee.set('Lucía');
    fixture.detectChanges();

    const rows = fixture.nativeElement.querySelectorAll('[data-id]');
    expect(rows.length).toBe(1);
    expect(fixture.nativeElement.textContent).toContain('Update the bookings website');
  });
});

The line that matters is providers: [{ provide: TasksService, useValue: fakeService }]. That is what dependency injection buys. The component asks for TasksService; in production it gets the real one, in the test it gets the double, and the component neither changes nor finds out. It is exactly what you did by hand in 08-04 by passing InMemoryStore to LocalRepository's constructor, with the wiring automated.

Angular includes Karma and Jasmine by default, but Jest or Vitest can be used, and Testing Library has an Angular version, with the same queries by role you have been using since 08-05. The philosophy of testing what the user sees does not change with the framework.

  1. A note about SSR

Angular includes server rendering with no external libraries:

ng add @angular/ssr

That adds a server that renders the HTML before sending it, with hydration on the client so that the page remains interactive, and support for incremental hydration combined with @defer, which lets you decide which parts are hydrated and when.

We do not develop it here: client, server, static and hybrid rendering is the topic of the next lesson, where you will see why that decision usually matters more than the framework decision.

  1. Nómada Tasks in Angular: the complete list

The fourth and last version of the same screen. The domain, in TypeScript but conceptually identical:

// src/app/domain/rules.ts
import { Task, Status } from './task';

export const NEXT: Record<Status, Status | null> = {
  pending: 'in-progress',
  'in-progress': 'done',
  done: null
};

export const LABEL: Record<Status, string> = {
  pending: 'Start',
  'in-progress': 'Mark done',
  done: 'Completed'
};

export const WEIGHTS = { high: 3, medium: 2, low: 1 } as const;
export const TODAY = '2026-09-20';

export const isOverdue = (t: Task, today = TODAY): boolean =>
  t.status !== 'done' && t.dueDate < today;

The service, which is where the state lives:

// src/app/data/board.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { Task } from '../domain/task';
import { NEXT } from '../domain/rules';
import { BACKLOG } from './backlog';

@Injectable({ providedIn: 'root' })
export class BoardService {
  private readonly _tasks = signal<Task[]>(BACKLOG);
  readonly tasks = this._tasks.asReadonly();

  readonly assignees = computed(() =>
    [...new Set(this.tasks().map((t) => t.assignee).filter((a): a is string => a !== null))]
      .sort()
  );

  readonly summary = computed(() => {
    const all = this.tasks();
    const open = all.filter((t) => t.status !== 'done');
    return {
      total: all.length,
      totalHours: all.reduce((s, t) => s + t.estimatedHours, 0),
      openHours: open.reduce((s, t) => s + t.estimatedHours, 0)
    };
  });

  /** Advances a task's status respecting R6. Immutable: the signal compares by reference. */
  advance(id: number): void {
    this._tasks.update((tasks) =>
      tasks.map((t) => {
        if (t.id !== id) return t;
        const target = NEXT[t.status];
        return target === null ? t : { ...t, status: target };
      })
    );
  }
}

The card is the one from section 13. The list:

// src/app/components/task-list.component.ts
import { Component, input, output } from '@angular/core';
import { Task } from '../domain/task';
import { TaskCardComponent } from './task-card.component';

@Component({
  selector: 'app-task-list',
  imports: [TaskCardComponent],        // ← the standalone component declares what it uses
  template: `
    <ul class="task-list">
      @for (task of tasks(); track task.id) {
        <app-task-card [task]="task" (advance)="advance.emit($event)" />
      } @empty {
        <p class="empty-list">No task matches the filter.</p>
      }
    </ul>
  `,
  styles: `
    .task-list { list-style: none; padding: 0; display: grid; gap: 0.5rem; }
    .empty-list { color: var(--gray); font-style: italic; }
  `
})
export class TaskListComponent {
  tasks = input.required<Task[]>();
  advance = output<number>();
}

And the root component:

// src/app/app.component.ts
import { Component, inject, signal, computed } from '@angular/core';
import { BoardService } from './data/board.service';
import { AssigneeFilterComponent } from './components/assignee-filter.component';
import { TaskListComponent } from './components/task-list.component';

@Component({
  selector: 'app-root',
  imports: [AssigneeFilterComponent, TaskListComponent],
  template: `
    <main class="board">
      <header class="board__header">
        <h1>Nómada Tasks</h1>

        <app-assignee-filter
          [assignees]="service.assignees()"
          [value]="assignee()"
          (changed)="assignee.set($event)"
        />

        <p class="board__summary">
          {{ visible().length }} of {{ service.summary().total }} tasks ·
          {{ visibleHours() }} h open
        </p>
      </header>

      <app-task-list [tasks]="visible()" (advance)="service.advance($event)" />
    </main>
  `,
  styles: `
    .board__header { display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; }
    .board__summary { color: var(--gray); margin-left: auto; }
  `
})
export class AppComponent {
  protected readonly service = inject(BoardService);

  readonly assignee = signal<string | null>(null);

  readonly visible = computed(() => {
    const a = this.assignee();
    return a === null ? this.service.tasks()
                      : this.service.tasks().filter((t) => t.assignee === a);
  });

  readonly visibleHours = computed(() =>
    this.visible().filter((t) => t.status !== 'done')
                  .reduce((s, t) => s + t.estimatedHours, 0)
  );
}

The canonical numbers, checked: with no filter, "6 of 6 tasks · 45 h open". With "Iván", "3 of 6 · 25 h". With "Lucía", "1 of 6 · 14 h". With "Marta", "2 of 6 · 6 h".

  1. Comparison with the three previous versions

Concept Plain JavaScript React Vue Angular
Language JavaScript JS or TS JS or TS TypeScript
Unit Module + <template> + CSS .jsx function .vue file Class with a decorator
Isolated styles No Not by default Yes (scoped) Yes, by default
State Object + render() useState ref signal
Derived values Version cache useMemo computed computed
Immutability Recommended Mandatory Not needed Mandatory
Identity in lists data-id + reconcile key (a warning if missing) :key (an ESLint error) track (a compilation error)
Cleanup destroy() by hand The effect's return onUnmounted DestroyRef, takeUntilDestroyed
Remote data Your own fetchJson You choose You choose HttpClient included
Shared state A module object You choose (10-03) Pinia An injected service
Dependency injection By hand (08-04) Does not exist Does not exist Built in
Code splitting import() by hand lazy defineAsyncComponent @defer and loadComponent
Forms FormData + your own validation You choose v-model Two systems included
Testing Jest + Testing Library Testing Library Vue Test Utils TestBed included

And the metrics for the same screen, with all four versions:

Metric Plain JavaScript React Vue Angular
Lines of the view ~210 ~130 ~120 ~180
Lines of your own infrastructure ~60 0 0 0
Production dependencies 0 2 1 ~8 (@angular/* packages)
Engine weight (compressed, approx.) 0 ~45 kB ~35 kB ~60–90 kB depending on what is used
Prior concepts required DOM, modules JSX, hooks, reconciliation Templates, refs TS, decorators, DI, signals, RxJS
Time to become productive Weeks Days or weeks Weeks or months
Predictability across projects None Low Medium High

What Angular wins, with no embellishment: total cohesion. Everything fits because the same team built it. Typing comes as standard and catches errors before anything runs. The mandatory track prevents a real bug. Injected services give you shared state and testability with nothing installed. Automatic migrations make ten-year update horizons viable. And two different projects look alike, which reduces the cost of onboarding somebody.

What it loses, with the same frankness: it is the most expensive to learn, by a wide margin. TypeScript, decorators, dependency injection, signals, RxJS and two forms systems are a lot to take on before the first screen. It is the most verbose: 180 lines against 120 for the same thing. It is the heaviest at startup. And it is the one that fits worst in small projects, where all that infrastructure has nothing to hold up.

Common Mistakes and Tips

Forgetting the parentheses when reading a signal. task.title instead of task().title compares or accesses on the function, not on the value. In the template, {{ tasks }} displays the function's code. TypeScript catches many of these cases, but not all.

Mutating a signal's value. tasks()[0].status = 'done' triggers nothing: signals compare with Object.is. You have to use update with an immutable map, as in React.

Forgetting imports in a standalone component. If the template uses <app-task-card> or [formGroup] and it is not in imports, the compilation error is clear but disconcerting at first. It is the price of every component declaring what it needs.

Using class properties instead of signals. It works because of Zone.js, but it is the old model: you lose the fine grain, you rule out the zoneless strategy and you mix two reactivity systems. For new code, signals.

Calling a method in the template instead of a computed. {{ computeHours() }} runs on every change-detection cycle, which with Zone.js can be an enormous number of times per second. computed is cached. It is the classic cause of an Angular application being slow for no apparent reason.

Not subscribing to an observable. Observables are lazy: without subscribe, nothing happens. It is trap number one for anyone coming from promises, which run as soon as they are created.

Not unsubscribing. It is 09-03's memory leak under another name. Use the async pipe in the template or takeUntilDestroyed; never leave a subscription to an infinite stream unmanaged.

Putting business logic in the components. In Angular it is especially avoidable because services exist precisely for that, with injection and testing included. 10-01's discipline applies just the same: the domain is pure TypeScript, the component paints.

Using ngModel for complex forms. Angular recommends reactive forms for anything non-trivial: typing, asynchronous validation, dynamic fields and testing without rendering.

Tip: learn TypeScript before Angular. Trying to learn both at once multiplies the confusion, because you will not know whether an error is the language's or the framework's. Lesson 11-07 is the starting point.

Tip: start with signals and leave RxJS for the data layer. Modern Angular lets you write almost the whole application with signals and use observables only where they contribute —HTTP, WebSocket, sequences with cancellation. toSignal is the bridge.

Tip: always use ng generate. It generates the correct structure, the test file and the necessary registrations. Writing components by hand is a source of silly errors that the CLI avoids.

Exercises

Exercise 1 · From React to Angular

Translate this React component (from 10-02) into a standalone Angular component with signals. Keep the same behavior and respect rules R6 and R10.

function AssigneeSummary({ tasks, activeAssignee }) {
  const [expanded, setExpanded] = useState(false);

  const byPerson = tasks
    .filter((t) => t.status !== 'done')
    .reduce((acc, t) => {
      const n = t.assignee ?? 'unassigned';
      const previous = acc[n] ?? { tasks: 0, hours: 0 };
      acc[n] = { tasks: previous.tasks + 1, hours: previous.hours + t.estimatedHours };
      return acc;
    }, {});

  const rows = Object.entries(byPerson).sort(([a], [b]) => a.localeCompare(b, 'en'));
  const visible = expanded ? rows : rows.slice(0, 2);

  return (
    <section>
      <h3>Workload by assignee</h3>
      <ul>
        {visible.map(([name, { tasks: n, hours }]) => (
          <li key={name} className={name === activeAssignee ? 'active' : ''}>
            {name}: {n} tasks · {hours} h
          </li>
        ))}
      </ul>
      {rows.length > 2 && (
        <button onClick={() => setExpanded(!expanded)}>
          {expanded ? 'See less' : `See ${rows.length - 2} more`}
        </button>
      )}
    </section>
  );
}

Indicate in comments what corresponds to useState, to the props and to the derived computation.

Exercise 2 · A service with HttpClient, signals and cancellation

Write a BoardApiService service that:

  1. Exposes tasks, loading and error as read-only signals.
  2. Has a filterBy(assignee: string | null) method that updates an internal signal.
  3. Loads the tasks from the server every time the assignee changes, with debounceTime(300) and cancelling the previous request.
  4. Retries three times with half a second of waiting on a network failure.
  5. On a definitive error, leaves the list empty and fills in error without breaking the stream.

Use toObservable, switchMap, retry, catchError and toSignal. Explain which operator solves 10-02's race condition and why.

Exercise 3 · Validators for rules R3, R6 and R10

Write three pure functions, with no Angular dependencies in their logic, and their tests:

  1. An hoursInRange validator for R3 (greater than 0, maximum 40) that returns an error with data useful for the message.
  2. A canAdvance(currentStatus, target) function that implements R6 using NEXT, and explain why it is not a form validator.
  3. A dateConsistentWithStatus group validator that applies R10 the other way round: if the status is 'done', a past due date must not flag an error; if it is not 'done' and the date has already passed, it must return a warning (not a blocking error).

Then answer: why is it a good idea for these functions to live in domain/ and not in the component?

Solutions

Solution 1

// src/app/components/assignee-summary.component.ts
import { Component, input, signal, computed } from '@angular/core';
import { Task } from '../domain/task';

interface WorkloadRow {
  name: string;
  tasks: number;
  hours: number;
}

@Component({
  selector: 'app-assignee-summary',
  template: `
    <section class="summary">
      <h3>Workload by assignee</h3>

      <ul>
        @for (row of visible(); track row.name) {
          <li [class.active]="row.name === activeAssignee()">
            {{ row.name }}: {{ row.tasks }} {{ row.tasks === 1 ? 'task' : 'tasks' }}
            · {{ row.hours }} h
          </li>
        }
      </ul>

      @if (rows().length > 2) {
        <button type="button" (click)="toggle()">
          {{ expanded() ? 'See less' : 'See ' + (rows().length - 2) + ' more' }}
        </button>
      }
    </section>
  `,
  styles: `
    .active { font-weight: 600; }
    ul { list-style: none; padding: 0; }
  `
})
export class AssigneeSummaryComponent {
  // ← React's props: signal-based inputs
  tasks = input.required<Task[]>();
  activeAssignee = input<string | null>(null);

  // ← React's useState: a local writable signal
  readonly expanded = signal(false);

  // ← the computation derived during the render: a cached computed
  readonly rows = computed<WorkloadRow[]>(() => {
    const byPerson = this.tasks()
      .filter((t) => t.status !== 'done')            // open ones only
      .reduce<Record<string, { tasks: number; hours: number }>>((acc, t) => {
        const name = t.assignee ?? 'unassigned';        // R8
        const previous = acc[name] ?? { tasks: 0, hours: 0 };
        acc[name] = { tasks: previous.tasks + 1, hours: previous.hours + t.estimatedHours };
        return acc;
      }, {});

    return Object.entries(byPerson)
      .map(([name, data]) => ({ name, ...data }))
      .sort((a, b) => a.name.localeCompare(b.name, 'en'));
  });

  readonly visible = computed(() =>
    this.expanded() ? this.rows() : this.rows().slice(0, 2)
  );

  toggle(): void {
    this.expanded.update((v) => !v);      // like setExpanded(v => !v)
  }
}

Notable differences from the original:

  • The rows are transformed into an array of objects with name inside, instead of [key, value] pairs. In Angular's template, @for's destructuring is more limited than in JSX, and an object reads better.
  • track row.name is mandatory: without it, the project does not compile. In the React version, key was a warning you could ignore.
  • The grouping logic could —and probably should— live in domain/workload.ts as a pure function, leaving the component with the presentation only. With the canonical backlog it returns: Iván 3 / 25 h, Lucía 1 / 14 h, Marta 1 / 6 h.

Solution 2

// src/app/data/board-api.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap, debounceTime, retry, catchError, tap, of, startWith } from 'rxjs';
import { Task } from '../domain/task';

@Injectable({ providedIn: 'root' })
export class BoardApiService {
  private readonly http = inject(HttpClient);

  private readonly _assignee = signal<string | null>(null);
  private readonly _loading = signal(false);
  private readonly _error = signal<string | null>(null);

  readonly loading = this._loading.asReadonly();
  readonly error = this._error.asReadonly();

  readonly tasks = toSignal(
    toObservable(this._assignee).pipe(
      debounceTime(300),
      tap(() => { this._loading.set(true); this._error.set(null); }),
      switchMap((assignee) => {
        let params = new HttpParams();
        if (assignee) params = params.set('assignee', assignee);

        return this.http.get<Task[]>('/api/tasks', { params }).pipe(
          retry({ count: 3, delay: 500 }),               // 07-03: retries
          tap(() => this._loading.set(false)),
          catchError((failure) => {
            this._error.set(failure.message ?? 'Network error');
            this._loading.set(false);
            return of([] as Task[]);                     // the stream does NOT die
          })
        );
      }),
      startWith([] as Task[])
    ),
    { initialValue: [] as Task[] }
  );

  readonly openHours = computed(() =>
    this.tasks().filter((t) => t.status !== 'done')
                .reduce((s, t) => s + t.estimatedHours, 0)
  );

  filterBy(assignee: string | null): void {
    this._assignee.set(assignee);
  }
}

Which operator solves the race condition: switchMap. Its semantics are "switch to the new stream and cancel the subscription to the previous one". When Marta goes from "Iván" to "Lucía", Iván's request is cancelled the moment Lucía's value arrives. When the subscription is cancelled, HttpClient aborts the underlying HTTP request —internally it uses the same mechanism as your AbortController from 07-03—, so its response never reaches the tap or the toSignal. The race is impossible by construction, not because of a check somebody wrote.

Compare the three solutions to the same problem:

Framework Solution Risk of forgetting
React An AbortController created and aborted by hand in the effect High: you have to remember the return
Vue onCleanup(() => controller.abort()) in the watchEffect Medium: you have to remember the cleanup
Angular switchMap None: it is the operator's semantics

Two details of the code: catchError returns of([]) instead of rethrowing, because if the error propagates, the stream ends and filterBy stops working forever — it is the classic RxJS mistake. And retry goes inside the switchMap, applied to the specific request; if it were outside, it would retry the whole outer stream, debounceTime included.

Solution 3

// src/app/domain/validators.ts
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { Status, NEXT, TODAY } from './rules';

/** R3: estimatedHours > 0 and <= 40. */
export function hoursInRange(control: AbstractControl): ValidationErrors | null {
  const value = Number(control.value);
  if (Number.isNaN(value)) return { hoursNotNumeric: { current: control.value } };
  if (value <= 0)  return { hoursOutOfRange: { current: value, minimum: 1, maximum: 40 } };
  if (value > 40)  return { hoursOutOfRange: { current: value, minimum: 1, maximum: 40,
                                               suggestion: 'Split the task into several' } };
  return null;
}

/** R6: only the transitions declared in NEXT are allowed. */
export function canAdvance(current: Status, target: Status): boolean {
  return NEXT[current] === target;
}

/** R10, as a warning: overdue = past date and status other than 'done'. */
export function dateConsistentWithStatus(group: AbstractControl): ValidationErrors | null {
  const status = group.get('status')?.value as Status | undefined;
  const date = group.get('dueDate')?.value as string | undefined;
  if (!status || !date) return null;

  if (status !== 'done' && date < TODAY) {
    return { taskOverdue: { dueDate: date, today: TODAY, blocking: false } };
  }
  return null;    // if it is 'done', a past date is completely normal
}

The tests, with Jest and without Angular:

describe('hoursInRange (R3)', () => {
  const control = (value: unknown) => ({ value }) as AbstractControl;

  test('accepts valid values', () => {
    expect(hoursInRange(control(1))).toBeNull();
    expect(hoursInRange(control(12))).toBeNull();
    expect(hoursInRange(control(40))).toBeNull();
  });

  test('rejects 0 and negatives', () => {
    expect(hoursInRange(control(0))?.['hoursOutOfRange']).toBeDefined();
    expect(hoursInRange(control(-3))?.['hoursOutOfRange']).toBeDefined();
  });

  test('rejects more than 40 and suggests splitting', () => {
    const error = hoursInRange(control(55));
    expect(error?.['hoursOutOfRange'].suggestion).toContain('Split');
  });
});

describe('canAdvance (R6)', () => {
  test('allows the transitions in the diagram', () => {
    expect(canAdvance('pending', 'in-progress')).toBe(true);
    expect(canAdvance('in-progress', 'done')).toBe(true);
  });

  test('rejects skips and reversals', () => {
    expect(canAdvance('pending', 'done')).toBe(false);
    expect(canAdvance('done', 'in-progress')).toBe(false);
  });
});

Why canAdvance is not a form validator. A validator checks whether an entered value is acceptable. R6 is not about values but about transitions: it depends on the previous status, which is not in the form. It is a model rule, and its natural place is the service —where advance() already applies it— and the button, which is disabled when NEXT[status] is null. Putting transition rules in the forms layer is a placement error that ends up duplicating the logic.

Why the domain goes in domain/. Four reasons, and you have seen all four already in this module:

  1. It is tested without a framework. The tests above run in milliseconds, with no TestBed, no jsdom and nothing rendered.
  2. It is reused across all the layers. The component, the service, the router and —if the server shares code— the API all use the same functions.
  3. It outlives the framework. It is exactly 10-01's discipline: domain/rules.js has been the same file in all four versions of this screen. Changing framework means rewriting the view, not the application.
  4. It is where the business is read. Somebody who wants to know which rules govern Nómada Tasks opens one folder, they do not rummage through the components.

Conclusion

You have seen the module's fourth and last approach, and the most different of them all.

You know what Angular as a complete platform is: a router, an HTTP client, two forms systems, dependency injection, RxJS, code generation, a testing environment, internationalization, SSR and automatic migrations, all official and updated in step. You understand what "opinionated" means when taken all the way —you do not choose tools, the structure is given, there is one correct way to do each thing, the verbosity is deliberate, and the curve is steep at the start and flat afterwards— and which project profile it pays off in: large, long-lived applications with sizable teams and staff turnover.

You have the minimum TypeScript needed to read the code —annotations that disappear at compile time, interfaces that describe an object's shape, literal union types that turn rules R1–R10 into things the compiler will not let you express, generics that read as "X of Y", and access modifiers with the nuance that private is not #private—, with the pointer to 11-07 for learning it properly. And you know what decorators are: metadata declared above a class, consistent with the philosophy that configuration lives next to what it configures.

You know the CLI and what it says about Angular's opinions —that ng generate component creates a test file by default is not a small detail—, and standalone components with their imports, which today replace NgModules. You are fluent in the four kinds of data binding with the mnemonic of square brackets pointing inward and parentheses pointing outward, and [(ngModel)] demystified like Vue's v-model: a property coming down and an event going up. And you handle the current control-flow syntax@if/@else, @for with @empty, @switch— with its four advantages over the old directives, and @defer with its triggers, its @placeholder, its @loading and its @error, which declaratively does everything you wrote by hand in 09-05.

You know that @for's track is mandatory and that without it the project does not compile: the same stable key as your reconcile from 06-06, raised from a warning to an error, which is "opinionated" in its most useful form. You are fluent in signalssignal with set and update, a cached and lazy computed, effect with its cleanup— and you know that, unlike Vue, they compare with Object.is and therefore 04-07's immutability is mandatory again, with the counterpart that a signal is never lost on destructuring. You know Zone.js and why it existed —checking everything because there was no knowing what had changed— and the zoneless trend that signals make possible.

You understand dependency injection: services with @Injectable({providedIn: 'root'}), inject() asking for a type instead of importing an instance, the pattern of a private writable signal plus a public read-only signal which is 05-03's encapsulation applied to state, and the four capabilities it buys —substituting in tests, changing per environment, controlling the scope, and intercepting. And you know that you already practiced it in 08-04, when you passed InMemoryStore to LocalRepository's constructor so you could test without localStorage: Angular makes that pattern the norm and automates the wiring.

You have just enough RxJS: an observable is a stream of values over time, it is lazy and nothing happens without subscribe —trap number one for anyone coming from promises—, pipe chains operators, and the seven that always come up all have an equivalent in something you already wrote: map, filter, retry (your withRetries), catchError, debounceTime (your debounce from 09-02), switchMap (your AbortController) and takeUntilDestroyed (your destroy()). You know why you have to unsubscribe —it is 09-03's leak under another name— and the three correct ways of doing it, and you have the criterion for choosing between signals and observables, with toSignal and toObservable as the bridges.

You know the router with lazy loading via loadComponent, which is 09-05's dynamic import() integrated, with guards your router.js did not have; reactive forms versus template-driven ones with the table that decides; and validators that are pure functions implementing R2, R4, R7 and R9, with errors that carry data so as to produce useful messages, in the same vein as your ValidationError with .field. And you know how to test with TestBed, where the line providers: [{provide: TasksService, useValue: fake}] is the practical demonstration of what dependency injection buys.

You have written the fourth version of the same screen —~180 lines, with the domain in TypeScript but conceptually identical to the three previous ones— and you have the complete comparison table: Angular wins on cohesion, typing as standard, mandatory track, injected services, automatic migrations and predictability across projects; and it loses on learning curve, verbosity, weight and fit in small projects.

You have now seen all four approaches with the same screen written four times. What remains is the question that gives the whole module its point, and that has no single answer: how you choose. In the last lesson you are going to put the four versions side by side with their metrics, rank the criteria that really matter —product, team, market, longevity, performance, SEO, accessibility, cost—, and discover that there is one decision that usually weighs more than the framework one: where the rendering happens. And you will close by connecting with the final project, which is built in plain JavaScript and which from now on will be an informed decision: Choosing the Right Framework.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved