All articles

Angular Components and Signals

An Angular component is a TypeScript class marked with @Component that pairs a template with logic, and a signal is a reactive value that notifies Angular…

0 · log in to like, save & follow Share on LinkedIn Share on X

An Angular component is a TypeScript class marked with @Component that pairs a template with logic, and a signal is a reactive value that notifies Angular when it changes so the view updates automatically. In Angular 22 you build screens from standalone components (no NgModules) and manage state with signalssignal(), computed(), and effect(). This tutorial walks through the anatomy of a standalone component, how to bootstrap an app, how signals replace manual change detection, the new built-in control flow, and a worked counter-plus-product-list example you can run.

Angular Components and Signals

What is a standalone component in Angular 22?

Standalone is the default in Angular 22 — every component you generate is standalone, so there is no NgModule and no declarations array. A component declares its own dependencies through an imports array. The @Component decorator wires together four things: the selector (the custom HTML tag), the template or templateUrl (the markup), optional styles/styleUrl, and the imports it needs.

import { Component } from '@angular/core';

@Component({
  selector: 'app-hello',
  template: `<h2>Hello, {{ name }}</h2>`,
  styles: `h2 { color: #2563eb; }`,
})
export class HelloComponent {
  name = 'GeeksArray';
}

Note there is no standalone: true flag — it is implied. If you are migrating from an older project that still sets standalone: false, that opts back into the legacy NgModule model. The imports array is where standalone components pull in what a template uses: other components, pipes, directives, and things like RouterLink or FormsModule. Because each component lists its own dependencies, you can read a component and know exactly what it depends on without hunting through a shared module — a big readability and tree-shaking win over the old approach.

How do you generate a component?

The Angular CLI scaffolds the class, template, styles, and spec in one command:

ng generate component product-list
# short form
ng g c product-list

This creates a product-list folder with product-list.ts, product-list.html, product-list.css, and a spec file, and it registers nothing globally — you import the component wherever you use it. In Angular 22 the generated files drop the older .component suffix, so the class file is simply product-list.ts and the class is ProductList. Add --inline-template or --inline-style if you prefer everything in the .ts file for small components, as the examples in this article do.

How is an Angular app bootstrapped?

There is no AppModule. You bootstrap a single root component with bootstrapApplication and pass application-wide providers through app.config.ts.

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';

bootstrapApplication(App, appConfig)
  .catch((err) => console.error(err));
// app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes)],
};

Providers such as the router or HTTP client live here instead of in a module's providers array. Keeping configuration in app.config.ts gives you one obvious place to register application-wide services, and the root component named here — often called App — is the single entry point Angular renders into index.html.

How do signals manage state?

A signal is a container around a value. You create one with signal(), read it by calling it like a function, and change it with .set() or .update(). A computed() signal derives a value from other signals and recomputes lazily only when a dependency changes. An effect() runs side effects — logging, syncing to localStorage — whenever the signals it reads change.

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

const count = signal(0);
const doubled = computed(() => count() * 2);

effect(() => console.log('count is', count()));

count.set(5);        // replace the value
count.update((n) => n + 1); // derive from the current value

In a template you read a signal by calling it — {{ count() }} — and Angular tracks that read, so only the DOM that depends on count updates when it changes. This is a shift from the old model, where Angular relied on zone.js to patch async APIs and then dirty-checked the entire component tree on every event. Signals make dependencies explicit and updates surgical.

A practical rule of thumb: reach for signal() for any state a template renders or a user mutates, computed() for values you can derive from other signals rather than store separately, and effect() only for genuine side effects — never to update another signal, which creates feedback loops. Signals also replace much of what lifecycle hooks and ngOnChanges used to do, because derived state recalculates on its own. Avoid mutating the object inside a signal in place; call .set() or .update() with a new value so Angular sees the change.

The new built-in control flow

Angular 22 uses block control flow directly in templates instead of the older *ngIf and *ngFor structural directives. The @for block requires a track expression so Angular can identify items across updates.

@if (products().length > 0) {
  <ul>
    @for (p of products(); track p.id) {
      <li>{{ p.name }} — {{ p.price | currency }}</li>
    }
  </ul>
} @else {
  <p>No products yet.</p>
}

@switch (status()) {
  @case ('loading') { <p>Loading…</p> }
  @case ('ready')   { <p>Ready</p> }
  @default          { <p>Idle</p> }
}

There is nothing to import for @if, @for, or @switch — they are part of the template compiler.

Standalone Angular component using signal, computed, and the new @for control flow

Event binding and dependency injection with inject()

You bind events with the (event)="handler()" syntax. For dependency injection, the inject() function is the modern alternative to constructor parameters and reads cleanly as a field initializer.

import { Component, signal, inject } from '@angular/core';
import { Router } from '@angular/router';

@Component({
  selector: 'app-counter',
  template: `
    <button (click)="inc()">Count: {{ count() }}</button>
  `,
})
export class Counter {
  private router = inject(Router);
  count = signal(0);
  inc() { this.count.update((n) => n + 1); }
}

A worked example: product list with signals

Here is a small standalone component that holds a list of products in a signal, derives a total with computed(), and renders them with @for.

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

interface Product { id: number; name: string; price: number; }

@Component({
  selector: 'app-product-list',
  template: `
    <h2>Products ({{ count() }})</h2>
    <ul>
      @for (p of products(); track p.id) {
        <li>{{ p.name }} — {{ p.price | currency }}</li>
      }
    </ul>
    <p>Total: {{ total() | currency }}</p>
    <button (click)="addSample()">Add product</button>
  `,
})
export class ProductList {
  products = signal<Product[]>([
    { id: 1, name: 'Keyboard', price: 49 },
    { id: 2, name: 'Mouse', price: 25 },
  ]);
  count = computed(() => this.products().length);
  total = computed(() =>
    this.products().reduce((sum, p) => sum + p.price, 0));

  addSample() {
    this.products.update((list) => [
      ...list,
      { id: list.length + 1, name: 'New item', price: 10 },
    ]);
  }
}

Because count and total are computed, they stay in sync automatically whenever products changes — no lifecycle hook or manual refresh required. Notice the update uses spread syntax to build a new array rather than pushing into the existing one; that immutable style is what lets the signal detect the change and recompute the derived values. Drop this component into your root App template with <app-product-list /> after adding it to the root component's imports, and clicking the button re-renders only the affected list rows thanks to the track p.id expression.

Zoneless change detection

Angular 22 supports zoneless applications. Because signals tell the framework exactly what changed, you can drop zone.js entirely with provideZonelessChangeDetection() in app.config.ts. That removes the zone.js dependency, shrinks the bundle, and avoids whole-tree dirty checking — updates are driven purely by signal reads. Zoneless is production-ready in Angular 22 and is the direction the framework is heading.

Key takeaways

  • Standalone components are the default in Angular 22 — @Component with an imports array, no NgModules.
  • Bootstrap with bootstrapApplication and configure providers in app.config.ts.
  • Use signal() for state, computed() for derived values, and effect() for side effects; update with .set() and .update().
  • Read signals in templates by calling them, e.g. {{ count() }}.
  • Prefer the built-in @if, @for (with track), and @switch over the older structural directives.
  • Signals plus zoneless change detection replace the zone.js dirty-checking model with surgical updates.

Frequently asked questions

Do I still need NgModules in Angular 22?

No. Standalone components are the default and NgModules are optional. New apps bootstrap a root component with bootstrapApplication and never define an AppModule.

What is the difference between signal() and computed()?

signal() holds a writable value you change with .set() or .update(). computed() derives a read-only value from other signals and recalculates automatically and lazily when its dependencies change.

Why do I need track in an @for block?

track gives Angular a stable identity for each item so it can reuse DOM nodes instead of recreating the list on every change. It improves performance and preserves element state; a unique id is the usual choice.

Is zoneless change detection ready to use?

Yes. Angular 22 offers provideZonelessChangeDetection() as a production-ready option. Combined with signals, it removes the zone.js dependency and avoids checking the whole component tree.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.