AngularAtlased. 22.2

Ch. 13Architecture at scale58 / 75

Web Components with Angular Elements

createCustomElement: an Angular component used as an HTML tag anywhere, plus communication between apps through events and BroadcastChannel.

stable ~0 min read Official docs

A custom element (Web Component) is an HTML tag you define yourself — <atlas-rating> — that the browser recognises anywhere: in a static page, in a CMS, in a React application or in another Angular application. @angular/elements is the bridge: createCustomElement() turns an Angular component into a class that extends HTMLElement. Inputs become attributes and properties, outputs become CustomEvents, and the component creates itself when the tag enters the DOM.

The examples on this page are real: we define the elements at runtime with customElements.define, create them with document.createElement in a container that Angular does not manage, and listen to them with addEventListener — exactly what a page that knows nothing about Angular would do. Everything runs in this application, which is zoneless.

In short
  • createCustomElement turns a component into a Web Component you can use on any page: React, Vue, a CMS or plain HTML.
  • Inputs become properties and attributes (in kebab-case), and outputs become CustomEvent events.
  • With Shadow DOM, the element’s styles and the page’s styles no longer mix.
  • Every element brings the Angular runtime. Several elements on the same page should come from the same bundle.
  • It pays off for widgets embedded in pages you do not control, not for a regular Angular app.
Why it matters

Angular Elements is the right tool when a component has to live outside an Angular application: widgets for a CMS or for clients' websites, a design system used by teams on different frameworks, micro frontends that cannot share the same Angular version. Inside an ordinary Angular application it is almost always the wrong choice: you lose type checking in the template, the DI hierarchy and dynamic content projection.

What createCustomElement does

createCustomElement(Component, { injector }) displays nothing. It returns a class (NgElementConstructor) that you register with customElements.define(tag, class). The class reads the component's metadata (reflectComponentType) and prepares three things:

  • observedAttributes — the names of the inputs in kebab-case (maxStars → max-stars), so the browser notifies it of every attribute change;
  • a getter and a setter on the prototype for each input, so el.maxStars = 7 works like an ordinary DOM property;
  • a strategy (NgElementStrategy) that, at connectedCallback, calls createComponent with the element as hostElement, attaches the view to ApplicationRef and runs a first detectChanges() — synchronously, before append() returns.

The injector you pass is where the component gets its services (HttpClient, stores, configuration tokens). In a bundle dedicated to widgets you get it from createApplication(), an application without a root component; in an existing application, from ApplicationRef.injector.

import {
  Component,
  ViewEncapsulation,
  booleanAttribute,
  computed,
  input,
  linkedSignal,
  numberAttribute,
  output,
} from '@angular/core';

export interface RatingChange {
  value: number;
  previous: number;
}

@Component({
  selector: 'atlas-rating-cmp',               // ≠ the tag name (see below)
  encapsulation: ViewEncapsulation.ShadowDom, // the host page's CSS does not get in
  template: `
    @for (s of stars(); track s) {
      <button type="button" [class.on]="s <= current()" [disabled]="readonly()"
              (click)="pick(s)">★</button>
    }
  `,
})
export class AtlasRating {
  // attributes: value, max-stars, readonly
  readonly value = input(0, { transform: numberAttribute });
  readonly maxStars = input(5, { transform: numberAttribute });
  readonly readonly = input(false, { transform: booleanAttribute });

  // becomes a CustomEvent 'ratingChange', the value in event.detail
  readonly ratingChange = output<RatingChange>();

  // local state, reset when the host sends another value
  protected readonly current = linkedSignal(() => this.value());
  protected readonly stars = computed(() =>
    Array.from({ length: this.maxStars() }, (_, i) => i + 1),
  );

  protected pick(value: number): void {
    const previous = this.current();
    this.current.set(value);
    this.ratingChange.emit({ value, previous });
  }
}
The tag is not the selector

The official guide explicitly asks you not to use the component's selector as the tag name. If atlas-rating is both the selector and the custom element, and the component is also imported into a template, Angular creates two instances on the same element. Hence the convention atlas-rating-cmp (selector) vs. atlas-rating (tag). The tag must contain a hyphen, and define() on an already registered name throws NotSupportedError — that is why you check first with customElements.get().

Zone.js is not needed. From v19 the elements use the ordinary change detection scheduler (changelog 19.0.0: “switch to ComponentRef.setInput & remove custom scheduler”), and from v21 applications are zoneless by default. A setAttribute reaches componentRef.setInput(), which marks the view and schedules a render; a click in the element's template changes a signal — exactly as in an ordinary OnPush component.

Defining in an existing application

The typical case: the application receives HTML from a CMS and inserts it with innerHTML. The Angular components in that HTML would never start, but custom elements start on their own, because the browser recognises their tag.

// In an existing application (e.g. HTML from a CMS, inserted with innerHTML)
import { ApplicationRef, inject, provideAppInitializer } from '@angular/core';
import { createCustomElement } from '@angular/elements';
import { AtlasRating } from './rating';

function defineAtlasElements(): void {
  const injector = inject(ApplicationRef).injector; // root: lives as long as the application
  // define() on a name that is already registered throws NotSupportedError
  if (!customElements.get('atlas-rating')) {
    customElements.define('atlas-rating', createCustomElement(AtlasRating, { injector }));
  }
}

export const appConfig = {
  providers: [provideAppInitializer(defineAtlasElements)],
};

Attributes, properties, events

An element has two “inputs” for the same input. The attribute (max-stars="7") is always a string — that is why numeric or boolean inputs need transform: numberAttribute / booleanAttribute; without it, maxStars() + 1 becomes '71'. The property (el.maxStars = 7) accepts any type, so objects and arrays go through here only. When read, el.maxStars returns the input's current value; from v21 this also holds for input() on signals (previously you had to call el.maxStars()).

Input / outputHTML attributeJS propertyWorth remembering
maxStars
transform: numberAttribute
max-stars="7"el.maxStars = 7the attribute arrives as '7', the transform makes it a number
readonly
transform: booleanAttribute
readonlyel.readonly = trueremoveAttribute sends null → false
userId
alias: 'user'
user="42"el.userIdthe attribute uses the alias, the property the name in the class
items
input<Item[]>
—el.items = [...]through an attribute it would arrive as "[object Object]"
ratingChange
output<T>()
—addEventListener('ratingChange', …)CustomEvent, the value in event.detail
export class Widget {
  // the attribute = the input's name (or its alias) in kebab-case
  readonly maxStars = input(5, { transform: numberAttribute });     // max-stars="7"
  readonly compact = input(false, { transform: booleanAttribute }); // compact
  readonly userId = input('', { alias: 'user' });                   // user="42" (!)
  readonly items = input<Item[]>([]);                               // property only

  // an output becomes a CustomEvent with the output's name (or the alias)
  readonly ratingChange = output<RatingChange>();           // 'ratingChange'
  readonly closed = output<void>({ alias: 'atlasClosed' }); // 'atlasClosed'
}

// What createCustomElement generates for the class above:
//   static observedAttributes = ['max-stars', 'compact', 'user', 'items']
//   getter/setter on the prototype:  maxStars, compact, userId, items
//   (the property uses the class name, the attribute uses the alias)

The event is created with new CustomEvent(name, { detail }) — without bubbles and without composed. A listener on document or on the parent container receives nothing; you listen directly on the element. In the example below the container also has a listener for ratingChange: it will never appear in the log.

LiveReal element: <atlas-rating> defined at runtime
Host page (plain DOM code)
Send as
// press a button or change a field
A container Angular does not manage
in the DOM: nocomponents created: 0destroyed: 0read el.value: —
What happens (addEventListener + callbacks)
  • 09:11:35 customElements.define('atlas-rating', …) — defined now
Change the fields in “attribute” mode, then in “property” mode, and follow the command sent and what el.value reads. Click the stars: the log shows event.detail and bubbles=false. “Move” keeps the state; el.remove() followed by “append(same instance)” shows the trap described below.

Notice one detail: after you click a star, el.value stays the value received from the host. The input is a one-way channel; the component keeps the choice in a linkedSignal and announces it through an event. If the host wants to be the source of truth, it listens to the event and writes el.value back — the “controlled component” pattern.

// The event is created as new CustomEvent(name, { detail })
// → bubbles: false, composed: false.
el.addEventListener('ratingChange', (e: Event) => {
  const { value } = (e as CustomEvent<RatingChange>).detail;
});

document.addEventListener('ratingChange', handler); // ✗ receives nothing

// If the host really needs delegation (one listener for 50 widgets),
// emit explicitly, from the component, an event that bubbles and crosses the shadow root:
export class AtlasRating {
  private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);

  private announce(detail: RatingChange): void {
    this.host.nativeElement.dispatchEvent(
      new CustomEvent('atlas:rating-change', { detail, bubbles: true, composed: true }),
    );
  }
}

Lifecycle

The component is born at connectedCallback and dies at disconnectedCallback, but not immediately: the strategy schedules destroy() after 10 ms (DESTROY_DELAY = 10 in the package source). If the element returns to the DOM before that — a remove() + append() move in the same task, as when sorting a list — the destruction is cancelled and the internal state remains. Inputs set before connection are remembered and applied at creation. Moreover, properties set on an element not yet defined (the widget's script has not loaded) are picked up at upgrade, so the host can set el.items at any time.

document.createElement('atlas-rating')
    constructor; no component yet (the inputs set now are remembered)
container.append(el)
    connectedCallback → createComponent(...) SYNCHRONOUSLY, attachView(appRef), detectChanges()
el.setAttribute('value', '4')
    attributeChangedCallback → componentRef.setInput('value', '4') → transform → CD
el.value = 4
    setter on the prototype → the same path, without conversion to a string
el.remove()
    disconnectedCallback → destroy SCHEDULED in 10 ms
    ↳ re-added in < 10 ms (a move)      destroy cancelled: same instance, same state
    ↳ after 10 ms                        componentRef.destroy() → DestroyRef.onDestroy
container.append(el)   // same instance, after destroy
    ⚠︎ the component is NOT recreated: dead HTML + NG0953 in the console
document.createElement('atlas-rating')
    ✓ new, clean instance
Re-attaching an element that has already been destroyed

The official guide warns (Limitations section, issue #38778) that detaching and re-attaching an element created with @angular/elements is problematic. In v22.2.2, the behaviour can be seen in the demo: once the component has been destroyed, a new append of the same instance does not recreate it. The HTML left in the shadow root is dead (clicks do nothing), el.value is undefined, and Angular reports NG0953 (subscribing to a destroyed OutputRef). A second attempt throws NotSupportedError from attachShadow, because an element cannot have two shadow roots.

It happens when someone reuses the DOM node: AngularJS's ng-if, a CMS editor that moves blocks, an element cache in your code. The solution: do not reuse the instance — create another element with document.createElement — or, if you only want to hide it, leave it in the DOM with the hidden attribute.

Shadow DOM and styles

With Emulated (the default), Angular rewrites the component's selectors with unique attributes: your styles do not leak out, but the page's CSS comes in. A widget placed on a foreign site inherits any rule such as .content p or button { text-transform: uppercase !important } — the most common bug in widgets. With ViewEncapsulation.ShadowDom, the template is rendered in a shadow root (mode open), and the page's selectors no longer see inside. Only inherited properties (font, color) and CSS variables pass through — exactly what you need for a controlled theme.

A little-known nuance: in ShadowDom mode, Angular copies into each shadow root the styles of the other components in the same application too. For Emulated components this is dead weight (their selectors do not match), but a component with ViewEncapsulation.None ends up styling the inside of the widget as well. ExperimentalIsolatedShadowDom (experimental since v21) puts only the component's own styles in the shadow root. During SSR, both shadow modes are rendered as Emulated.

EncapsulationDoes the page's CSS get in?None styles from the applicationCSS variables
Emulated (default)yesyesyes
ShadowDomnoyes (copied into the shadow root)yes
ExperimentalIsolatedShadowDomexperimentalnonoyes
LiveThe same widget, three encapsulations
Elementel.shadowRoot<style> in shadow root
Tick the boxes one by one. The “CMS” CSS breaks only the Emulated variant. The None component also reaches ShadowDom (the number of style elements goes up by one), but not the isolated variant. The CSS variable themes all three.

Shadow DOM changes more than styles: events that leave a shadow root are retargeted (event.target becomes the host), document.querySelector finds nothing from the inside, and e2e tests have to pierce the shadow root. For theming, expose CSS variables and, where more control is needed, part attributes styleable with ::part().

@Component({
  selector: 'atlas-card-cmp',
  encapsulation: ViewEncapsulation.ShadowDom,
  template: `
    <h3 part="title">{{ title() }}</h3>
    <ng-content />
  `,
  styles: `
    :host { display: block; }
    /* CSS variables and inherited properties (font, color) DO enter the shadow root */
    h3 { color: var(--atlas-accent, #2b45d4); font: inherit; font-weight: 650; }
  `,
})
export class AtlasCard {
  readonly title = input('');
}

Build, bundle, distribution

To use the elements outside an Angular application, you need a separate build: an application whose main.ts calls createApplication() and customElements.define(), without a root component. The Native Federation schematic even has the --webcomponent option, which generates exactly this bootstrap (see Micro frontends).

The cost is the runtime. A single simple element, built with Angular 22.2.2 in production, zoneless, comes to ≈ 110 kB minified, ≈ 33 kB transferred (the CLI estimate) — practically @angular/core + platform-browser + a bit of RxJS. Three widgets from three different builds on the same page mean three copies of the runtime, three injectors and three applications that share no service.

  • Group a team's widgets into a single bundle: one createApplication(), several define() calls.
  • Stable names for integration via <script src>: --output-hashing=none and versioned folders (/1.4.0/main.js), so you can use a long cache without a hash in the name.
  • Lazy inside: @defer and import() also work inside an element, so the heavy part of the widget can arrive when it is needed.
# a separate project in the workspace, just for widgets
ng generate application atlas-widgets --ssr=false
# main.ts: createApplication + customElements.define (see main.ts above)

# stable file names, so that a CMS can include <script src=".../main.js">
ng build atlas-widgets --output-hashing=none
# → publish into versioned folders: /atlas-widgets/1.4.0/main.js
#   (long cache, no hash in the name; the lazy chunks keep their hash)

# measured with Angular 22.2.2, one simple element, zoneless, production build:
#   main.js ≈ 110 kB minified, ≈ 33 kB transfer estimated by the CLI
# each bundle built separately brings its own copy of
# @angular/core + @angular/platform-browser

Use from other environments

For static HTML a <script type="module"> and the tag are enough. Each of the other environments has a small configuration detail:

  • Angular: the host component declares schemas: [CUSTOM_ELEMENTS_SCHEMA]. The [prop] and (event) bindings work, but without type checking — if you are in the same application, use the component directly.
  • React 19 has full support for custom elements: props that match a property of the element are set as DOM properties (in earlier versions they arrived as attributes). For custom events, the portable option is a ref + addEventListener.
  • Vue 3 must know that the tag is not a Vue component: the compile option isCustomElement.
  • CMSs: the editor only inserts HTML, so everything has to be configurable through string attributes. Design the inputs as primitives; for complex configurations, use a JSON attribute with a transform that calls JSON.parse.
// Using a custom element (yours or another team's) in an Angular component
import { CUSTOM_ELEMENTS_SCHEMA, Component, signal } from '@angular/core';

@Component({
  selector: 'app-product',
  // without the schema: error "'atlas-rating' is not a known element"
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <atlas-rating
      [value]="rating()"
      max-stars="5"
      (ratingChange)="rating.set($any($event).detail.value)"
    ></atlas-rating>
  `,
})
export class Product {
  protected readonly rating = signal(4);
}
// [value] sets the DOM property, (ratingChange) does addEventListener —
// but without any type checking.

The content of a custom element appears only after the script has downloaded and run. For text that is essential for SEO or LCP (the page title, the price, the main content), a client-side widget is the wrong choice — see SSR.

Communication between applications

When several independent applications live on the same page (widgets from different teams, micro frontends), they do not share an injector. The platform's mechanisms remain, in the order worth trying:

MechanismReachWhat you can sendWatch out for
Attributes / properties + events on the elementthe host ↔ its elementanything (properties), detail (events)it is the widget's public contract: version it
CustomEvent on windowthe same pageany object (a reference, not a copy)prefixed names, the “late subscriber”, it is not state
BroadcastChanneltabs, windows, iframes, workers — same origincloneable values (structured clone)does not reach the sender; close() at the end
postMessageiframe / window, including another origincloneable valuesan explicit targetOrigin, check event.origin

A bus on window is the simplest contract between applications on the same page: prefixed event names (atlas:cart:add), types in a small, versioned package, two functions emit/listen. The classic trap is that an event is not state: an application mounted later missed everything that happened before. The solution is a handshake — on start you ask for the state, and its owner replies.

// contracts/atlas-events.ts — a small, versioned package, shared by the teams
export interface AtlasEvents {
  'atlas:cart:add': { sku: string; qty: number };
  'atlas:cart:changed': { count: number; total: number };
  'atlas:cart:state-request': { from: string };
}

export function emit<K extends keyof AtlasEvents>(type: K, detail: AtlasEvents[K]): void {
  window.dispatchEvent(new CustomEvent(type, { detail }));
}

export function listen<K extends keyof AtlasEvents>(
  type: K,
  fn: (detail: AtlasEvents[K]) => void,
): () => void {
  const handler = (e: Event) => fn((e as CustomEvent<AtlasEvents[K]>).detail);
  window.addEventListener(type, handler);
  return () => window.removeEventListener(type, handler);
}
LiveTwo applications on the same page, a bus on window
Application A · Catalog team in cart: ?
Laptop Pro 14
Studio Headphones
Ergo Mouse
Application B · Cart team 0 products · 0 RON
The cart is empty.
Traffic on window (a third, independent listener)
  • Add a product from A.
Add products from A: B owns the cart and announces atlas:cart:changed. Untick “A asks for the state on start”, then unmount and remount A: it no longer knows how many products there are. With the handshake ticked, B replies immediately. Unmount B: A keeps emitting, without any error.

BroadcastChannel links all the contexts of the same origin that open a channel with the same name: tabs, windows, iframes, workers. The message is copied through structured clone (no functions, no class instances with methods) and does not reach the object that sent it. Typical uses: logout in all tabs, synchronising the cart, invalidating a cache. For cross-origin iframes, postMessage remains, with the origin verified at both ends.

LiveBroadcastChannel between tabs
This tab: #lgjyOther tabs seen: none right nowOpen the page in a new tab ↗
Shared state
0
last write: —
Message
The sender does not receive its own message — only the other tabs.
  • Open a second tab and press +1 there.
Open the page in a second tab (the button above), put the tabs side by side and press +1 or send a message. A new tab receives the current state through the handshake; when it closes, the others receive “bye”.
// Same origin: other tabs, windows, iframes, workers
@Injectable({ providedIn: 'root' })
export class SessionSync {
  private readonly channel = new BroadcastChannel('atlas-session');
  private readonly router = inject(Router);

  constructor() {
    this.channel.onmessage = (e: MessageEvent<{ type: 'logout' }>) => {
      if (e.data?.type === 'logout') this.router.navigateByUrl('/login');
    };
    inject(DestroyRef).onDestroy(() => this.channel.close());
  }

  logoutEverywhere(): void {
    // reaches the other tabs, NOT this object
    this.channel.postMessage({ type: 'logout' });
  }
}

When YES, when NO

Angular Elements
  • Widgets for pages you do not control: a CMS (WordPress, AEM, Sitecore), clients' websites, a support chat, a product configurator.
  • A design system used from several frameworks, when its team writes Angular.
  • Interactive islands inside a server-side application (PHP, Java, .NET) that will not be rewritten soon.
  • Micro frontends that cannot share the same Angular version: each element brings its runtime, so the versions can differ.
  • Dynamic HTML (from a CMS, from markdown) inserted with innerHTML into an Angular application, where ordinary components would not start.
Something else
  • Inside the same Angular application → ordinary components (or dynamic components with createComponent/NgComponentOutlet).
  • Content critical for SEO or LCP → server-side rendering (SSR, SSG), not a client-side widget.
  • Several Angular applications on the same version that must share the runtime → Native Federation, not N separate bundles.
  • Only to reuse code between the Angular applications of the same team → a library in a monorepo.
  • Tiny widgets on weight-sensitive pages (a “share” button) → 33 kB of runtime is not justified; a hand-written web component or Lit is a better fit.

Common pitfalls

  • The component's selector used as the tag → two instances on the same element. Use a separate selector (atlas-rating-cmp).
  • define() called twice (HMR, tests, two bundles defining the same tag) → NotSupportedError. Check customElements.get(); between teams, prefix the tags with the product name.
  • Expecting events to bubble up → a listener on document that receives nothing. Listen on the element, or re-emit explicitly with bubbles: true, composed: true.
  • Outputs named change, input, click → they mix with the native events of the elements inside that reach the host. Use specific names (ratingChange).
  • A numeric input without a transform → the attribute arrives as a string and maxStars() + 1 becomes '71'. Put numberAttribute / booleanAttribute on every input that can be configured from HTML.
  • Objects through attributes → the component receives "[object Object]". Send objects and arrays as properties.
  • Expecting the element to see the parent component's services → it uses the injector given to createCustomElement, not the place in the DOM where you put it. Services from a component's or a route's providers do not reach it.
  • Children added after creation → the nodes for <ng-content> are collected only once, at connection. Dynamic content is sent through properties.
  • Reusing a destroyed element → dead HTML, NG0953, then NotSupportedError. Create a new element.
  • Listeners and channels not closed → after each mount, one more window.addEventListener or one more open BroadcastChannel. Clean up in DestroyRef.onDestroy.
  • postMessage(data, '*') and no check of event.origin → any page that frames you in an iframe can read or send messages. See Security.

Cheat sheet

// 1. The component: inputs with transform for attributes, outputs with specific names
readonly maxStars = input(5, { transform: numberAttribute }); // <x max-stars="7">
readonly ratingChange = output<RatingChange>();               // CustomEvent, detail

// 2. Definition: once only, tag ≠ selector, tag with a hyphen
createApplication({ providers: [] }).then(({ injector }) => { // separate bundle
  // (in an existing application: const injector = inject(ApplicationRef).injector)
  if (!customElements.get('atlas-rating')) {
    customElements.define('atlas-rating', createCustomElement(AtlasRating, { injector }));
  }
});

// 3. Usage
el.setAttribute('max-stars', '7');       // string → transform
el.maxStars = 7;                         // any type
el.addEventListener('ratingChange', fn); // on the element: bubbles false, composed false

// 4. Lifecycle: append = create (synchronous); remove = destroy after 10 ms;
//    do not re-attach a destroyed instance → create another element

// 5. Styles: ViewEncapsulation.ShadowDom + CSS variables + ::part() for theming

// 6. Communication: attributes/properties/events → CustomEvent on window (typed)
//    → BroadcastChannel (tabs, same origin) → postMessage + origin (iframes)

Ch. 13 · 0 of 4 complete

Finished this lesson?

Mark it and it shows up on your map: in the contents and on the home page.

Marks itself as done once you have read the whole lesson.

  1. Micro frontends
  2. Web Components with Angular Elements
  3. Monorepo, libraries & boundaries
  4. Hexagonal architecture (ports & adapters)
See your whole map

All 75 pages · type to filter

  • 01What's new in Angular 21 & 22All the important changes in v21 and v22, briefly, with a link to the page that explains each one.Start
  • 01Your first app, step by stepA reading list built from an empty component to a finished app — signals, @for, inputs and outputs, a service and a form, one step at a time.Start
  • 01Quick decisions“Where do I put this?”, “Signal or Observable?”, “Shared or feature?” — short answers to everyday questions.Start
  • 01Angular interview questions60 real interview questions for junior, mid and senior, with the answer to give, the follow-up and the red flags. Plus a mock interview.Start
  • 02Standalone & bootstrapHow an app starts without NgModule: bootstrapApplication, app.config.ts, imports in the component.Modern fundamentals
  • 02Data binding & eventsInterpolation, property and attribute bindings, events with $event and key filters, and how [( )] is built — what goes into the DOM and what comes back.Modern fundamentals
  • 02Control flow: @if, @for, @switchThe block syntax that replaces *ngIf/*ngFor/ngSwitch, with track, @empty and exhaustive checks.Modern fundamentals
  • 02@let and the new template expressionsLocal variables with @let, arrow functions, spread, regex, instanceof, comments inside tags.Modern fundamentals
  • 02@defer — lazy loading in templatesLoad heavy components only when needed: triggers, prefetch, placeholder, loading, error.Modern fundamentals
  • 02Class & style bindingsWhy [class.x] and [style.x] replaced ngClass and ngStyle (with automatic migration from v21).Modern fundamentals
  • 02Component styles & encapsulation:host, the three encapsulation modes, theming with CSS variables, and why ::ng-deep is a last resort.Modern fundamentals
  • 02Pipes: when and howPure vs impure pipes, custom pipes, and how you choose between a pipe, computed() and a method.Modern fundamentals
  • 03signal() and computed()The reactive primitives: writable state, derived state, equality, untracked and immutability rules.Signals & reactivity
  • 03effect() — when YES, when NOSide effects synchronised with signals: localStorage, logging, imperative APIs. And their traps.Signals & reactivity
  • 03linkedSignal() — editable derived stateA signal that resets when its source changes, but that you can also set manually.Signals & reactivity
  • 03resource, rxResource, httpResourceAsync data as signals: status, value, error, reload, automatic cancellation. Stable since v22.Signals & reactivity
  • 03debounced() — delayed signalsThe “debounce” version of a signal, without RxJS. Perfect for search.Signals & reactivity
  • 03Lists: search, filter, sort, paginateA chain of computed signals that recalculates only what changed, with the filters kept in the URL so a link reopens the same view.Signals & reactivity
  • 03Observables from zeroWhat an Observable is, subscribe and unsubscribe, the operators you use every week, cold vs hot — and when a signal is enough.Signals & reactivity
  • 03Signals ↔ RxJStoSignal, toObservable, takeUntilDestroyed, outputFromObservable — and when RxJS remains the right choice.Signals & reactivity
  • 04input() and output()Inputs as signals (required, transform, alias) and typed outputs. Data down, events up.Components: communication
  • 04model() — two-way bindingBanana-in-a-box [( )] with a single API. When to use it, and when input + output is better.Components: communication
  • 04viewChild & contentChildQueries as signals: viewChild, viewChildren, contentChild, contentChildren, the read option.Components: communication
  • 04Lifecycle & DestroyRefWhich hooks still matter in the signals era, afterNextRender, DestroyRef and typed ngOnChanges.Components: communication
  • 04Content projectionng-content with select, multiple slots, default content and the trap of conditional projection.Components: communication
  • 04ng-template & ngTemplateOutletTemplates as values: render props in Angular, typed context and customising components.Components: communication
  • 05Smart vs DumbContainer vs presentational: who holds the state, who only displays, and how to split them properly.Component design
  • 05Generic components <T>A correctly typed DataTable<T> or Select<T>, with templates for cells.Component design
  • 05Dynamic componentsNgComponentOutlet, createComponent with inputBinding/outputBinding, and a dashboard built from config.Component design
  • 05Directives & host compositionAttribute directives, the host: {} metadata and the Directive Composition API (hostDirectives).Component design
  • 05Custom form controlsFormValueControl (Signal Forms) vs ControlValueAccessor: a star rating, in both variants.Component design
  • 05Change detectionOnPush is the default from v22, zone.js is gone from v21. What triggers rendering and how not to get burned.Component design
  • 06inject() & @Service()The new @Service() decorator from v22, inject() vs the constructor, and the injection context.Dependency Injection
  • 06Providers & the injector hierarchyRoot vs route vs component, useClass/useValue/useFactory/useExisting, and the optional/self/skipSelf modifiers.Dependency Injection
  • 06InjectionToken & provideX()Typed configuration, provideFeature() functions, makeEnvironmentProviders and initializers.Dependency Injection
  • 06injectAsync() — lazy servicesLoad a heavy service only when you need it, with prefetch on idle.Dependency Injection
  • 07Folder structurecore / shared / features, what goes into each, and what a feature looks like inside.Application architecture
  • 07Shared vs featureWhen to move a component into shared and (especially) when NOT to. A concrete checklist.Application architecture
  • 07Interfaces & modelsInline interface or separate file? DTO vs domain model, type vs interface, where to put them.Application architecture
  • 07State management with signalsSimple signal stores, facades, local vs global state, and when you need NgRx.Application architecture
  • 07Style guide & namingThe current official conventions: file names without suffixes, protected, readonly, member order.Application architecture
  • 07Barrels, aliases & boundariesindex.ts, path aliases, a feature’s public API and dependency rules that can be checked automatically.Application architecture
  • 07Errors: @boundary & ErrorHandlerThe new @boundary block from v22.2, the global ErrorHandler, HTTP errors and errors from resource.Application architecture
  • 08Modern routing & lazy loadingloadComponent, child routes, parameters as inputs and parameter inheritance (the new default in v22).Routing
  • 08Guards & resolverscanActivate, canMatch, canDeactivate and resolve as functions, with redirects via UrlTree / RedirectCommand.Routing
  • 09Signal Forms — the basicsForms built on signals: form(), [formField], schema-based validation. Stable since v22.Forms
  • 09Signal Forms — advancedCustom and cross-field validation, async/HTTP with debounce, dynamic lists, disabled/hidden, reusable schemas.Forms
  • 09Typed Reactive FormsTyped FormGroup, nonNullable, FormArray — and how they coexist with Signal Forms.Forms
  • 10HttpClient, interceptors & the API layerHttpClient without setup (v21), Fetch by default (v22), functional interceptors and an API layer with DTO mapping.HTTP & data
  • 11Performance — a checklistWhat really matters: OnPush + signals, track, @defer, lazy routes, images, bundles and hydration.Quality & performance
  • 11Testing with VitestVitest is the default runner from v21. TestBed, components with signals, services, HTTP and harnesses.Quality & performance
  • 11DevTools: look inside your appChrome DevTools and Angular DevTools step by step: what each panel shows, with exercises that work on this site.Quality & performance
  • 11Angular AriaHeadless, accessible components that you style however you like: tabs, accordion, menu, listbox...Quality & performance
  • 12SSR, prerender & render modesWhen to render on the server, when at build time and when only in the browser: RenderMode per route, app.routes.server.ts and the real costs.SSR & hydration
  • 12Hydration: event replay & incrementalHow the browser takes over server HTML without re-rendering it: full hydration, event replay, @defer (hydrate on …) and mismatch errors.SSR & hydration
  • 12Server-compatible codewindow doesn’t exist on the server: PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, the HTTP cache and PendingTasks.SSR & hydration
  • 13Micro frontendsWhen it is worth splitting an app into several deployments and when it is just complexity: Native Federation, Module Federation, iframes, Web Components.Architecture at scale
  • 13Web Components with Angular ElementscreateCustomElement: an Angular component used as an HTML tag anywhere, plus communication between apps through events and BroadcastChannel.Architecture at scale
  • 13Monorepo, libraries & boundariesA workspace with several apps and libraries: ng generate library, Nx, secondary entry points, publishing and dependency rules.Architecture at scale
  • 13Hexagonal architecture (ports & adapters)A domain without Angular, ports as abstract classes and interchangeable adapters: HTTP, localStorage or in-memory, without touching the UI.Architecture at scale
  • 14Signals under the hoodThe reactive graph: producers and consumers, push-pull, why computed is lazy and glitch-free, the diamond problem and when effects run.Advanced reactivity
  • 14Advanced RxJS: higher-order operatorsswitchMap, mergeMap, concatMap and exhaustMap on a live diagram, plus retry with backoff, shareReplay and how to avoid leaks.Advanced reactivity
  • 14NgRx SignalStoreLarge-scale state management on signals: withState, withComputed, withMethods, rxMethod, entities and hooks — and when a simple store is enough.Advanced reactivity
  • 14State machines with signalsNo more impossible states: a typed machine on signals, with transitions, guards and a live state diagram.Advanced reactivity
  • 15WebSockets: real time that survives the networkA typed socket as signals: reconnect with backoff and jitter, heartbeat, an outbox while offline, and SSR safety.Real time & offline
  • 15Optimistic updates & rollbackAn instant UI that stays honest: snapshot and rollback, out-of-order responses, idempotency keys.Real time & offline
  • 15Offline-first: Service Worker & cachingThe Angular Service Worker: caching strategies, update prompts and an outbox for changes made offline.Real time & offline
  • 16Web WorkersHeavy computation without blocking the UI: a real worker in the page, message passing and when it is not worth it.Advanced UI & performance
  • 16Huge lists: virtual scroll100,000 rows without lag: CDK virtual scroll, itemSize, trackBy and the alternatives (pagination, infinite scroll).Advanced UI & performance
  • 16Modern animations & View Transitionsanimate.enter and animate.leave instead of the deprecated @angular/animations, CSS transitions and View Transitions on navigation.Advanced UI & performance
  • 16Memory leaks: finding and fixing themSubscriptions, timers and listeners that outlive their component — how to spot them in DevTools and close them.Advanced UI & performance
  • 17Security: XSS, sanitisation & CSPWhat Angular does automatically against XSS, when bypassSecurityTrust becomes dangerous, plus CSP with nonce, Trusted Types and XSRF.Security & globalisation
  • 17Authentication: tokens & refreshAn interceptor that attaches the token, refreshes once on 401 and replays the queued requests — plus where you keep the token.Security & globalisation
  • 17OAuth 2.1 & OpenID Connect with PKCEThe authorization code flow with PKCE, step by step: verifier and challenge, state and nonce, the ID token, and why a BFF is safer.Security & globalisation
  • 17Internationalisation (i18n)$localize, the i18n attribute, ICU plural/select, a build per language vs runtime translations, and locale-based formatting.Security & globalisation