Angular in Practice #5: Charts and Data Tables

9 min read

A dashboard without a single table or chart feels a little bare. Now that #4 wrapped up auth and Product CRUD, it’s time for our dashboard to become a screen that actually “shows” something. In this post, we use Angular Material’s MatTable to make a table with sorting, pagination, and search, and ng2-charts to draw sales and category charts. At the end we add a dark mode toggle to round out the dashboard skeleton.

For those new to this series, a short recap — in #1 we set up Angular Material and built a dashboard shell with a sidebar and toolbar; on top of that we layered routing, forms, auth, and CRUD in turn. This post is about putting “content” inside that shell.

Angular Material Table — why MatTable? #

Material Table (MatTable) is the standard data-table component provided by Angular Material. There are powerful grid libraries like AG Grid and PrimeNG, but Material Table is our first-pick recommendation for three reasons.

  • If you’re already using Material, no extra dependency.
  • It pairs smoothly with MatPaginator and MatSort.
  • The design auto-aligns with the rest of your Material components.

Let’s switch the Product list screen built in #4 from a card grid to a table version. First, import the Material modules we need and lay down the component skeleton.

src/app/products/product-table.component.ts
@Component({
  selector: 'app-product-table',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    MatTableModule, MatPaginatorModule, MatSortModule,
    MatFormFieldModule, MatInputModule, MatIconModule,
  ],
  templateUrl: './product-table.component.html',
})
export class ProductTableComponent {
  private productService = inject(ProductService);

  displayedColumns = ['name', 'category', 'price', 'stock', 'actions'];
  dataSource = new MatTableDataSource<Product>([]);

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

  constructor() {
    this.productService.list().subscribe((rows) => (this.dataSource.data = rows));
    effect(() => {
      this.dataSource.paginator = this.paginator();
      this.dataSource.sort = this.sort();
    });
  }
}

viewChild.required() (Angular 17.2+) is the signal version of ViewChild. Inside effect(), the moment the paginator and sort are bound via ViewChild, they’re automatically connected to the data source. Much clearer than the old @ViewChild + AfterViewInit combination.

Template — Column definitions and Sort/Paginator #

src/app/products/product-table.component.html
<table mat-table [dataSource]="dataSource" matSort class="full-width">
  <ng-container matColumnDef="name">
    <th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
    <td mat-cell *matCellDef="let row">{{ row.name }}</td>
  </ng-container>

  <ng-container matColumnDef="price">
    <th mat-header-cell *matHeaderCellDef mat-sort-header>Price</th>
    <td mat-cell *matCellDef="let row">{{ row.price | currency:'KRW':'symbol':'1.0-0' }}</td>
  </ng-container>

  <!-- category and stock columns follow the same pattern -->

  <ng-container matColumnDef="actions">
    <th mat-header-cell *matHeaderCellDef></th>
    <td mat-cell *matCellDef="let row">
      <button mat-icon-button [routerLink]="['/products', row.id, 'edit']">
        <mat-icon>edit</mat-icon>
      </button>
    </td>
  </ng-container>

  <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
  <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>

<mat-paginator [pageSize]="10" [pageSizeOptions]="[5, 10, 25, 50]" />

Columns with mat-sort-header are sorted on click, and MatPaginator automatically draws page controls below the table. We’ve drawn just one table and already gotten sort and pagination for free.

MatTable data source — MatTableDataSource vs signals #

MatTableDataSource is a convenience class that handles client-side sort/filter/pagination all at once. For small datasets (a few hundred rows or fewer), this is enough, but it has two limitations.

  • It’s not signal-friendly. The imperative dataSource.data = rows assignment doesn’t naturally fit OnPush components.
  • For larger datasets coming from the server, you ultimately have to handle the data flow yourself.

In modern Angular, the pattern of passing signals/arrays directly to [dataSource] and managing sort/filter in computed signals ourselves is becoming standard. The table just receives data and draws; data shaping happens in the signal graph. This pattern shows up naturally in the search filter shortly.

Sort / Pagination — client-side vs server-side #

There’s one decision that always comes up when building tables. Should sort and pagination happen on the client or the server?

  • Client-side: fetch all data once and handle sort/pagination in the browser. Simple code and instant response. Suitable when data is up to a few hundred rows.
  • Server-side: send page and sort key as API parameters and slice on the server. Required when data exceeds thousands of rows or when permission-based visibility differs.

The dividing line is usually “an amount the browser can comfortably hold at once.” Memory aside, sorting tens of thousands of rows once on every comparison stalls the main thread. The Product list in this practice series has little seed data, so we go client-side; in a real-world admin, assuming server-side from the start is the safer bet.

Search input and filter — RxJS debounceTime + toSignal #

Let’s add a search input above the table. Simply binding via [(ngModel)] and re-running the filter on every keystroke feels sluggish when typing fast. The proper pattern is RxJS’s debounceTime to run the filter “only when typing pauses briefly.” Then bring the result into the signal world via toSignal().

src/app/products/product-table.component.ts
export class ProductTableComponent {
  searchCtrl = new FormControl('', { nonNullable: true });

  search = toSignal(
    this.searchCtrl.valueChanges.pipe(startWith(''), debounceTime(200)),
    { initialValue: '' },
  );

  constructor() {
    effect(() => {
      this.dataSource.filter = this.search().trim().toLowerCase();
    });
  }
}
src/app/products/product-table.component.html
<mat-form-field appearance="outline" class="search">
  <mat-label>Search by name or category</mat-label>
  <input matInput [formControl]="searchCtrl" />
  <mat-icon matSuffix>search</mat-icon>
</mat-form-field>

Setting a string on MatTableDataSource.filter triggers the default filter, which joins all column values for searching. To filter differently per column, swap in a function for dataSource.filterPredicate.

Tip
When mixing RxJS and signals, just lock in the “entry” and “exit” and you won’t get lost. The entry is toSignal() (Observable → Signal); the exit is toObservable() (Signal → Observable). As in this example, having form controls handled fully on the RxJS side, then taking the result as a signal to apply via effect, keeps the flow clean.

Choosing a chart library — ng2-charts / ApexCharts / D3 #

There are so many chart libraries that choosing one is a task in itself. A short comparison of three commonly used ones:

  • ng2-charts (Chart.js wrapper): the most-used safe choice. Uses Chart.js’s power directly with an Angular-friendly API. License-free (MIT).
  • ApexCharts: more flashy and interactive. Good when you want a “pretty” dashboard look, but the bundle is heavy.
  • D3: less a library and more visualization “raw materials.” Unlimited freedom, but a steep learning curve since you build nearly everything yourself.

This series goes with ng2-charts. Two reasons. First, for typical dashboard use (line/bar/donut/pie), there’s nothing it can’t do. Second, Chart.js itself has overwhelmingly more material out there, making it easier to get unstuck.

Setting up ng2-charts #

terminal
npm i ng2-charts chart.js

In the standalone era, ng2-charts registers configuration via provideCharts.

src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';

export const appConfig: ApplicationConfig = {
  providers: [
    // ... existing providers
    provideCharts(withDefaultRegisterables()),
  ],
};

withDefaultRegisterables() registers commonly used chart types and controllers in one shot. To shrink the bundle further, you can register only what you need, but the default is usually fine.

Sales chart (Line) — visualizing monthly revenue #

Let’s draw a monthly revenue line chart as the dashboard’s first chart.

src/app/dashboard/sales-chart.component.ts
@Component({
  selector: 'app-sales-chart',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [BaseChartDirective],
  template: `<canvas baseChart [data]="data()" [options]="options" type="line"></canvas>`,
})
export class SalesChartComponent {
  raw = signal<SalesPoint[]>([
    { month: 'Jan', amount: 12_400_000 },
    { month: 'Feb', amount: 15_100_000 },
    { month: 'Mar', amount: 13_800_000 },
    { month: 'Apr', amount: 18_900_000 },
    { month: 'May', amount: 21_500_000 },
  ]);

  data = computed<ChartData<'line'>>(() => ({
    labels: this.raw().map((p) => p.month),
    datasets: [{
      label: 'Monthly revenue (KRW)',
      data: this.raw().map((p) => p.amount),
      tension: 0.3,
      fill: true,
    }],
  }));

  options: ChartConfiguration<'line'>['options'] = {
    responsive: true,
    maintainAspectRatio: false,
    plugins: { legend: { position: 'top' } },
  };
}

Two key points. data is built as a signal-based computed, and maintainAspectRatio: false lets the chart stretch to fit its container. By making it a signal, when we change the period via the Date Picker shortly, the chart auto-redraws.

Category distribution (Doughnut) — shaping data inside the component #

The second chart is a doughnut showing per-category revenue share. We build a per-category sum from the raw transactions array and feed it to the chart.

src/app/dashboard/category-chart.component.ts
@Component({
  selector: 'app-category-chart',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [BaseChartDirective],
  template: `<canvas baseChart [data]="data()" type="doughnut"></canvas>`,
})
export class CategoryChartComponent {
  sales = input.required<Sale[]>();

  data = computed<ChartData<'doughnut'>>(() => {
    const sums = new Map<string, number>();
    for (const s of this.sales()) {
      sums.set(s.category, (sums.get(s.category) ?? 0) + s.amount);
    }
    const labels = [...sums.keys()];
    return {
      labels,
      datasets: [{ data: labels.map((l) => sums.get(l)!) }],
    };
  });
}

Inside the component, we collect data using a Map and then build labels/data. When the parent component updates the sales signal, the doughnut chart auto-redraws.

Note
Once the chart’s data-shaping logic starts to grow, it’s cleaner to factor it out as a separate “selector” function or a service-level computed signal rather than the chart component. When the component starts “cooking” data, tests get harder, and other widgets that need the same shaping start duplicating it.

Date Picker (Mat) + chart binding #

A “period selector” is essential for a dashboard. With Material’s Date Range Picker, you can take the start and end dates at once.

src/app/dashboard/range-picker.component.ts
@Component({
  selector: 'app-range-picker',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [provideNativeDateAdapter()],
  imports: [ReactiveFormsModule, MatFormFieldModule, MatDatepickerModule],
  template: `
    <mat-form-field appearance="outline">
      <mat-label>Select period</mat-label>
      <mat-date-range-input [formGroup]="form" [rangePicker]="picker">
        <input matStartDate formControlName="start" placeholder="Start" />
        <input matEndDate formControlName="end" placeholder="End"
               (dateChange)="changed.emit(form.getRawValue())" />
      </mat-date-range-input>
      <mat-datepicker-toggle matIconSuffix [for]="picker" />
      <mat-date-range-picker #picker />
    </mat-form-field>
  `,
})
export class RangePickerComponent {
  changed = output<{ start: Date | null; end: Date | null }>();
  form = new FormGroup({
    start: new FormControl<Date | null>(null),
    end: new FormControl<Date | null>(null),
  });
}

The parent dashboard takes this event, updates a signal, and the filteredSales computed reshapes the chart data. A unidirectional flow of form → signal → chart emerges naturally.

src/app/dashboard/dashboard.component.ts
export class DashboardComponent {
  range = signal<{ start: Date | null; end: Date | null }>({ start: null, end: null });
  allSales = signal<Sale[]>([/* ... */]);

  filteredSales = computed(() => {
    const { start, end } = this.range();
    if (!start || !end) return this.allSales();
    return this.allSales().filter((s) => s.date >= start && s.date <= end);
  });
}

Dark mode — Material theme + CSS variables #

For the final touch on the dashboard, let’s add a dark mode toggle. From Angular Material 18, system-level theming and CSS variables have become more natural. Defining both light and dark palettes in styles.scss and swapping them via a class on the body is the simplest approach.

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

html {
  @include mat.theme((
    color: (
      primary: mat.$indigo-palette,
      tertiary: mat.$pink-palette,
    ),
    typography: Roboto,
    density: 0,
  ));

  color-scheme: light;
}

html.dark {
  color-scheme: dark;
}

The toggle component is a single signal away.

src/app/shared/theme-toggle.component.ts
@Component({
  selector: 'app-theme-toggle',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [MatIconButton, MatIcon],
  template: `
    <button mat-icon-button (click)="dark.update(v => !v)">
      <mat-icon>{{ dark() ? 'light_mode' : 'dark_mode' }}</mat-icon>
    </button>
  `,
})
export class ThemeToggleComponent {
  dark = signal<boolean>(localStorage.getItem('theme') === 'dark');

  constructor() {
    effect(() => {
      const isDark = this.dark();
      document.documentElement.classList.toggle('dark', isDark);
      localStorage.setItem('theme', isDark ? 'dark' : 'light');
    });
  }
}

The color-scheme property also pulls along browser-default scrollbar and form colors. For charts, the same approach applies — pass dark-mode colors through. The pattern of updating Chart.defaults.color inside a signal effect works well.

Wrapping up #

In this post, we filled in the dashboard’s “visible parts.” MatTable for a sorted, paginated, searchable table; ng2-charts for sales and category charts; Material Date Range Picker for period filtering; and Material theme + CSS variables for dark mode — a screen that’s now actually a “usable dashboard.”

In the next post, #6 Testing and Deployment, the last in the practice track, we organize how to protect this app with unit tests (Jest/Karma + Testing Library) and e2e tests (Playwright), and how to choose between static hosting (Firebase/Vercel) and SSR deployment options. It’s the place where we wrap the track in one breath — see you then.

X