Week 25: Angular Material & Enterprise Component Libraries

Phase 7 is about the stack real enterprise teams actually plug into Angular — starting with Material, the component library that shows up in most job listings alongside "Angular CDK," even though the two are frequently confused for the same thing.

Phase 7 of 8 Week 25 of 33 ~3–4 Hours Hands-on Exercise Included

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

  • Install and theme Angular Material with the M3 theming API, including a dark-mode-aware custom palette
  • Wire Material form controls into typed Reactive Forms (Week 12) correctly, including error states
  • Build a sortable, paginated, filterable MatTable backed by a signal-driven data source

1. Installing & Theming with the M3 API

ng add @angular/material installs the package, wires up Roboto and Material Symbols, and adds a base theme — but the theming API you land in depends on which Angular Material version the schematic picks. Current Material ships Google's Material Design 3 (M3) theming system, built on Sass mixins rather than the old prebuilt CSS files.

styles.scss
@use '@angular/material' as mat;

html {
  color-scheme: light dark;
  @include mat.theme((
    color: mat.$violet-palette,
    typography: Roboto,
    density: 0,
  ));
}

mat.theme() generates CSS custom properties for the full M3 token set — surface colors, on-surface text colors, elevation — and, critically, respects color-scheme: light dark to automatically switch tokens with the user's OS preference. That's a different mechanism from this site's own data-theme toggle (Week 7); when both exist in a real app, you'll want your own dark-mode toggle to set color-scheme on the root element directly rather than relying on the OS preference alone.

Don't import per-component theme mixins anymore

Older Material tutorials show a long list of @include mat.button-theme(...)-style calls per component. The M3 mat.theme() mixin themes every component that's present in your bundle automatically — you only reach for per-component overrides (mat.button-overrides(...)) when you need to deviate from the generated tokens for one component specifically.

2. Core Components in a Signals-First App

Material's components are standalone by default now, so importing them is exactly like importing any other standalone dependency (Week 3) — no MatModule grab-bag required.

confirm-dialog.ts
import { Component, inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';

@Component({
  selector: 'app-confirm-dialog',
  imports: [MatDialogModule, MatButtonModule],
  template: `
    <h2 mat-dialog-title>{{ data.title }}</h2>
    <mat-dialog-content>{{ data.message }}</mat-dialog-content>
    <mat-dialog-actions align="end">
      <button mat-button (click)="ref.close(false)">Cancel</button>
      <button mat-flat-button color="warn" (click)="ref.close(true)">Delete</button>
    </mat-dialog-actions>
  `,
})
export class ConfirmDialog {
  ref = inject(MatDialogRef<ConfirmDialog>);
  data = inject<{ title: string; message: string }>(MAT_DIALOG_DATA);
}
opening it — task-list.ts
import { MatDialog } from '@angular/material/dialog';

private dialog = inject(MatDialog);

confirmDelete(taskTitle: string) {
  const ref = this.dialog.open(ConfirmDialog, {
    data: { title: 'Delete task?', message: `Delete "${taskTitle}"? This can't be undone.` },
  });

  ref.afterClosed().subscribe((confirmed) => {
    if (confirmed) this.deleteTask();
  });
}

MatDialogRef.afterClosed() is still an Observable, not a signal — a reminder from Week 16 that plenty of Angular's own APIs haven't migrated to signals, and reaching for toSignal() at the boundary is often cleaner than subscribing manually everywhere.

3. Material + Reactive Forms, Correctly

mat-form-field and its controls implement ControlValueAccessor (Week 13), so they drop directly into a typed FormGroup from Week 12 with no special glue code — the part that trips people up is error display, which Material doesn't do for you automatically.

signup-form.html
<form [formGroup]="form" (ngSubmit)="submit()">
  <mat-form-field appearance="outline">
    <mat-label>Email</mat-label>
    <input matInput formControlName="email" type="email" />
    @if (form.controls.email.invalid && form.controls.email.touched) {
      <mat-error>
        @if (form.controls.email.hasError('required')) { Email is required }
        @else if (form.controls.email.hasError('email')) { Enter a valid email }
      </mat-error>
    }
  </mat-form-field>

  <mat-form-field appearance="outline">
    <mat-label>Plan</mat-label>
    <mat-select formControlName="plan">
      @for (plan of plans; track plan.id) {
        <mat-option [value]="plan.id">{{ plan.name }}</mat-option>
      }
    </mat-select>
  </mat-form-field>

  <button mat-flat-button type="submit" [disabled]="form.invalid">Sign up</button>
</form>

<mat-error> only renders while the control is genuinely invalid — but Material won't decide when to show it for you. The && .touched check above is doing the same job as the custom ErrorStateMatcher pattern you'll see in Material's own docs; writing it inline like this is simpler for most forms and only worth abstracting into a shared matcher once several forms repeat the exact same show/hide rule.

4. MatTable, Sort & Pagination — Signal-Driven

MatTableDataSource predates signals and expects a plain array or an Observable — the clean way to drive it from signal state is a small effect() that pushes new data in whenever your signal changes.

user-table.ts
import { Component, signal, effect, viewChild, AfterViewInit } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';

@Component({
  selector: 'app-user-table',
  imports: [MatTableModule, MatSortModule, MatPaginatorModule],
  templateUrl: './user-table.html',
})
export class UserTable implements AfterViewInit {
  users = signal<User[]>([]);
  displayedColumns = ['name', 'email', 'role', 'actions'];
  dataSource = new MatTableDataSource<User>([]);

  sort = viewChild.required(MatSort);
  paginator = viewChild.required(MatPaginator);

  constructor() {
    effect(() => {
      this.dataSource.data = this.users();
    });
  }

  ngAfterViewInit() {
    this.dataSource.sort = this.sort();
    this.dataSource.paginator = this.paginator();
  }

  applyFilter(value: string) {
    this.dataSource.filter = value.trim().toLowerCase();
  }
}

viewChild.required() (Week 4) replaces @ViewChild here, and the wiring still has to happen in ngAfterViewInit because MatSort/MatPaginator are view-queried, DOM-backed instances — signals don't remove the need for that lifecycle hook, they just remove it from everywhere else.

5. Material vs. Raw CDK — Which One Do You Reach For?

Week 8 covered the Angular CDK — unstyled, headless building blocks for overlays, drag-and-drop and accessibility. Angular Material is built on top of that same CDK, adding a full set of pre-styled, theme-aware components.

  • Reach for Material when you want a complete, accessible, on-brand component fast — buttons, form fields, tables, dialogs, date pickers — and you're fine adopting Material's visual language (or theming it to match your design system, Section 6).
  • Reach for raw CDK when your design system has entirely custom visuals and you only need the behavior — positioning, focus trapping, drag reordering — without Material's default look.
  • Most real apps use both: Material for the bulk of standard UI, raw CDK for the one or two bespoke pieces that don't fit Material's component set.

This is exactly the "build vs. reach for a library" judgment from Week 8, one level up — Material is itself "a library someone already built," and knowing when to accept its opinions versus drop back down to the CDK is a real day-to-day decision on any team using it.

6. Custom Theming for a Design System

A real product rarely ships Material's default violet palette — you'll generate a custom theme from your brand colors instead.

styles.scss — custom palette
@use '@angular/material' as mat;

$brand-palette: mat.$violet-palette; // swap for a generated palette from
                                      // Material's theme builder using your
                                      // brand's primary color

html {
  color-scheme: light dark;
  @include mat.theme((
    color: (
      theme-type: light,
      primary: $brand-palette,
      tertiary: mat.$orange-palette,
    ),
    typography: (
      brand-family: 'Inter',
      plain-family: 'Inter',
    ),
    density: -1,
  ));
}

:root[data-theme='dark'] {
  @include mat.theme((
    color: (
      theme-type: dark,
      primary: $brand-palette,
      tertiary: mat.$orange-palette,
    ),
  ));
}

Scoping a second mat.theme() call under :root[data-theme='dark'] is exactly how you connect Material's tokens to this site's own manual theme toggle (Week 7) instead of only the OS-level color-scheme preference — the same pattern applies to any app with a user-facing light/dark switch.

7. Hands-on Exercise

Hands-on

Build a Material admin table with an edit dialog

A small admin screen that exercises Sections 2 through 4 together.

Requirements:

  1. A MatTable of at least 15 seed records (any domain — users, products, tasks), with sorting (MatSort) on at least two columns and pagination (MatPaginator) at 5 rows per page.
  2. A text input above the table wired to MatTableDataSource.filter for live filtering as the user types.
  3. An "Edit" button per row that opens a MatDialog containing a Reactive Form (Section 3) pre-filled with that row's data, with at least one required field and one field with a format validator.
  4. Saving the dialog updates the underlying signal (not the MatTableDataSource directly) — the table should update via the effect() pattern from Section 4, confirming the data flow is signal-first.
  5. Apply Section 6's custom theming: generate a palette from any brand color of your choosing and confirm it's respected in both light and dark mode.
Hint

If the table doesn't visibly update after closing the edit dialog, check that you're calling this.users.update(...) (a new array) rather than mutating an element in place — the same immutability rule from Week 5's signals deep dive applies here, and MatTableDataSource's change detection depends on it just as much as Angular's own template bindings do.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What's the actual relationship between Angular Material and the Angular CDK?

The CDK is the unstyled, headless foundation (overlay positioning, focus trapping, drag-and-drop, a11y utilities); Angular Material is a fully-styled, theme-aware component library built on top of it. They're not alternatives to each other — Material depends on the CDK internally.

Q2

Why does <mat-error> need an explicit invalid && touched check in the template?

Material renders mat-error content whenever it's present and the control is invalid, but it doesn't decide the UX question of when that should be shown — without the touched check, every required field would show an error message before the user has even interacted with the form.

Q3

Why is an effect() the right bridge between a signal and MatTableDataSource.data?

MatTableDataSource predates signals and just holds a plain array property — it has no way to read a signal itself. An effect() re-runs whenever the signal it reads changes, so assigning dataSource.data = this.users() inside one keeps the table in sync without manual subscription management.

Q4

Why wire MatSort and MatPaginator in ngAfterViewInit instead of the constructor?

They're view-queried directives backed by real DOM elements (viewChild.required()), which aren't resolved yet in the constructor — same lifecycle rule as @ViewChild always had. Signals changed how you read state, not when Angular finishes rendering a view, so DOM-dependent wiring still belongs in ngAfterViewInit.