Week 4: Component Fundamentals & Templates

This is the week it stops being abstract. You've bootstrapped an app and understand the mental model — now you'll build real components with real inputs, outputs, and templates, using the exact APIs you'll reach for in every component for the rest of the course.

Phase 1 of 7 Week 4 of 26 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Build components with typed, signal-based inputs and outputs
  • Read and write Angular templates fluently
  • Hook into a component's lifecycle the modern, signal-first way

1. Component Decorator Options

You've seen @Component a few times already; this week you learn it properly. Three options account for almost everything you'll configure day to day:

rating-stars.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-rating-stars',
  templateUrl: './rating-stars.html',
  styleUrl: './rating-stars.css',
})
export class RatingStars {}
  • selector — the HTML tag this component is used as, e.g. <app-rating-stars>
  • templateUrl / template — an external file, or an inline string for very small components
  • styleUrl / styleUrls — one or more stylesheets scoped to just this component (more on that scoping in Week 7)

Small, purely presentational components are often written with an inline template instead of a separate file — a judgment call you'll get a feel for as you write more of them.

2. Interpolation, Property & Event Binding

Three binding syntaxes cover almost every template you'll write:

example.html
<!-- Interpolation: render a value as text -->
<h2>{{ product.name }}</h2>

<!-- Property binding: set a DOM/component property -->
<img [src]="product.imageUrl" [alt]="product.name" />
<app-badge [count]="cartItemCount" />

<!-- Event binding: call a method when something happens -->
<button (click)="addToCart(product)">Add to cart</button>

<!-- Class and style bindings -->
<div [class.out-of-stock]="product.stock === 0"
     [style.opacity]="product.stock === 0 ? 0.5 : 1">
  {{ product.name }}
</div>
Attribute vs. property

[src]="..." sets the DOM property, not the HTML attribute — that distinction rarely matters, except for a handful of attributes (like custom data-* ones) where you need [attr.data-id]="..." instead. Angular defaults to property binding because it's faster and type-checked.

3. input(), output() & model()

These three functions are how a component receives data, emits events, and supports two-way binding — the signal-based replacement for the older @Input()/@Output() decorators.

rating-stars.ts
import { Component, input, output, model } from '@angular/core';

@Component({
  selector: 'app-rating-stars',
  templateUrl: './rating-stars.html',
})
export class RatingStars {
  // Required input -- consumer MUST provide a value
  max = input.required<number>();

  // Optional input with a default
  readOnly = input(false);

  // Output: an event this component emits
  rated = output<number>();

  // model(): two-way bindable signal -- both an input and an output, paired
  value = model(0);

  select(stars: number) {
    if (this.readOnly()) return;
    this.value.set(stars);
    this.rated.emit(stars);
  }
}
using it — parent.html
<app-rating-stars
  [max]="5"
  [(value)]="productRating"
  (rated)="onRated($event)"
/>

[(value)] is Angular's "banana in a box" syntax — shorthand for binding [value] and listening to (valueChange) at once. model() generates that valueChange output for you automatically; you never write it by hand.

4. Template Reference Variables

A #name on any element gives you a handle to it — the DOM element, or the component/directive instance if there is one — usable anywhere else in that same template.

search.html
<input #searchBox type="text" (keyup.enter)="search(searchBox.value)" />
<button (click)="search(searchBox.value)">Search</button>

This is a lightweight escape hatch for simple cases — reading an input's current value without wiring up a full FormControl (which you'll learn properly in Week 12). For anything beyond "read this one value," reactive forms are the better tool.

5. ng-template & ng-container

ng-container groups elements — for a binding, or just for organization — without adding an extra element to the rendered DOM. It's invisible at runtime.

ng-container.html
<ng-container [ngTemplateOutlet]="header"></ng-container>

<!-- No wrapping <div> in the rendered output -- useful when you need
     a binding target but a real DOM element would break your CSS layout -->

ng-template defines a block of markup that isn't rendered by default — something else decides when (or whether) to render it. You'll meet it again this way in Week 11, where @defer uses a similar "placeholder content" idea for lazy-loaded UI:

ng-template.html
@for (item of items(); track item.id) {
  <li>{{ item.name }}</li>
} @empty {
  <ng-template #noItems>
    <li>No items yet.</li>
  </ng-template>
}

In practice, you'll write @if/@for's own @empty and @else blocks (Week 6) far more often than a raw ng-template — but recognizing it matters, because libraries and older codebases still use it directly.

6. Lifecycle Hooks & Signal-Based Alternatives

Angular calls specific methods on your component class at specific moments. The three you'll use most:

  • ngOnInit() — once, after the component's inputs are first set
  • ngOnChanges(changes) — whenever an @Input()-decorated property changes
  • ngOnDestroy() — right before the component is removed, for cleanup (unsubscribing, clearing timers)
classic lifecycle hooks
import { Component, OnInit, OnDestroy } from '@angular/core';

export class SearchBox implements OnInit, OnDestroy {
  private intervalId?: ReturnType<typeof setInterval>;

  ngOnInit() {
    this.intervalId = setInterval(() => this.refresh(), 30_000);
  }

  ngOnDestroy() {
    clearInterval(this.intervalId);
  }

  refresh() { /* ... */ }
}

With signals, ngOnChanges specifically is often unnecessary — effect() (Week 5) reacts to a signal input changing directly, without the boilerplate of comparing an old and new value yourself:

signal-based alternative
import { Component, input, effect } from '@angular/core';

export class SearchBox {
  query = input('');

  constructor() {
    effect(() => {
      const q = this.query(); // re-runs automatically whenever `query` changes
      console.log('Query changed to', q);
    });
  }
}

ngOnInit and ngOnDestroy remain useful and aren't going anywhere — it's specifically ngOnChanges that signals make largely redundant.

7. Hands-on Exercise

Hands-on

Build a reusable rating-stars component

Turn the code from Section 3 into a fully working, reusable component.

Requirements:

  1. Create RatingStars with a required max input, a readOnly input defaulting to false, a two-way value via model(), and a rated output.
  2. In the template, render max star icons (plain / characters are fine) and highlight however many match the current value.
  3. Clicking a star updates value and emits rated — unless readOnly is true, in which case clicks do nothing.
  4. In a parent component, bind [(value)] to a signal and display the live value next to the component, proving the two-way binding works both directions (also try setting the parent's signal programmatically and confirm the stars update).
  5. Render a second, readOnly instance of the same component to prove one component handles both modes.
Hint

To render "max stars, some filled," an @for loop over an array built with Array.from({ length: this.max() }) works well — you'll get proper practice with the new control-flow syntax a couple weeks early.

Part 2: Template refs, lifecycle hooks & a shared empty-state template

Part 1 never touched Sections 4, 5 or 6. This part adds three small features to the parent page that exercise all three.

Requirements:

  1. Template reference variable (Section 4): add a plain <input #filterBox> and a button next to it that logs filterBox.value to the console when clicked — no FormControl, just the reference variable reading the element directly.
  2. Lifecycle hooks vs. effect() (Section 6): give RatingStars an ngOnDestroy() that logs "RatingStars destroyed", and add an effect() in its constructor that logs every time value changes — from a click and from the parent setting it programmatically. Wrap one instance in an @if block toggled by a button, so you can watch ngOnDestroy fire when it's removed.
  3. Shared ng-template (Section 5): the parent has two empty signal-backed lists, favorites and recentlyViewed. Define one <ng-template #emptyState> with a "Nothing here yet." message, then reference it from both lists' @empty blocks via <ng-container [ngTemplateOutlet]="emptyState"></ng-container> — proving one template can back two spots without duplicating markup or adding an extra wrapper element.
parent.html — shape to fill in
<input #filterBox type="text" />
<button (click)="logFilter(filterBox.value)">Log filter</button>

<ng-template #emptyState>
  <p>Nothing here yet.</p>
</ng-template>

<h3>Favorites</h3>
@for (item of favorites(); track item.id) {
  <p>{{ item.name }}</p>
} @empty {
  <ng-container [ngTemplateOutlet]="emptyState"></ng-container>
}

<h3>Recently viewed</h3>
@for (item of recentlyViewed(); track item.id) {
  <p>{{ item.name }}</p>
} @empty {
  <ng-container [ngTemplateOutlet]="emptyState"></ng-container>
}
Hint

ngTemplateOutlet needs NgTemplateOutlet in the parent's imports array — the exact "not imported, doesn't render" mistake from Section 2's callout applies here too.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What does [(value)]="productRating" actually expand to?

[value]="productRating" plus (valueChange)="productRating = $event". It's shorthand for a property binding and an event binding to a conventionally-named xChange output at once — model() is what generates that paired output automatically.

Q2

When would you need [attr.data-id]="..." instead of a plain property binding?

Whenever there's no matching DOM property to bind to — custom data-* attributes, ARIA attributes, and SVG attributes are the common cases. Angular's default [x]="..." binds a DOM property; [attr.x]="..." explicitly sets the HTML attribute instead.

Q3

What's the key difference between ng-container and ng-template?

ng-container renders its contents immediately, just without adding a wrapping DOM element. ng-template's contents render not at all by default — something else (a structural directive, @defer, a template outlet) has to explicitly decide to render them.

Q4

Why does effect() often replace the need for ngOnChanges, but not ngOnInit or ngOnDestroy?

ngOnChanges exists specifically to react to input changes — which effect() does automatically and more precisely, by tracking exactly which signals it reads. ngOnInit (run setup once) and ngOnDestroy (run cleanup once) aren't about reacting to changing values at all, so an effect isn't a substitute for either.