Creating reusable tables using Angular

Virtually every web application needs a table. If there are many of them, we might have a problem.

Angular takes a modular approach to the development of a web app and a table is clearly identifiable as a component. There are, however, different ways to architect this component: this post intends to explore a few of them, and to suggest what I consider to be the best.

Before we start#

The examples involve three data types:

export interface Car {
  manufacturer: string;
  model: string;
  powerSupply: "petrol" | "diesel" | "electricity";
}
export interface Dog {
  name: string;
  breed: string;
  weightInKg: number;
}
export interface Person {
  firstName: string;
  lastName: string;
  dateOfBirth: Date;
}

These were arbitrarily chosen just to show that code reuse between components can be easily achieved even for data structures that are not related to each other. The code samples in this article use Angular Material as a UI library, but all the considerations are independent from it.

You can find here the complete code with additional commentary.

Duplicating code#

Let’s say you are working on a web application that requires displaying some information about cars. You create an appropriate data structure, Car, and then create a component containing a table with the proper rows and columns:

@Component({
  selector: "app-cars-table",
  templateUrl: "./cars-table.component.html",
  styleUrls: ["./cars-table.component.css"],
})
export class CarsTableComponent {
  public displayedColumns = ["manufacturer", "model", "powerSupply"];
  public dataSource: Car[] = [
    {
      manufacturer: "Tesla",
      model: "Model 3",
      powerSupply: "electricity",
    },
    {
      manufacturer: "Ferrari",
      model: "458",
      powerSupply: "petrol",
    },
  ];

  constructor() {}
}
<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
  <ng-container matColumnDef="manufacturer">
    <mat-header-cell *matHeaderCellDef> Manufacturer </mat-header-cell>
    <mat-cell *matCellDef="let element"> {{ element.manufacturer }} </mat-cell>
  </ng-container>

  <ng-container matColumnDef="model">
    <mat-header-cell *matHeaderCellDef> Model </mat-header-cell>
    <mat-cell *matCellDef="let element"> {{ element.model }} </mat-cell>
  </ng-container>

  <ng-container matColumnDef="powerSupply">
    <mat-header-cell *matHeaderCellDef> Power supply </mat-header-cell>
    <mat-cell *matCellDef="let element"> {{ element.powerSupply }}</mat-cell>
  </ng-container>

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

After a week, someone asks you to quickly add another table: this time, it should display dogs names. You create a new data structure, Dog, then proceed to copy and paste all the code you wrote for the CarsTableComponent:

@Component({
  selector: "app-dogs-table",
  templateUrl: "./dogs-table.component.html",
  styleUrls: ["./dogs-table.component.css"],
})
export class DogsTableComponent {
  public displayedColumns = ["name", "breed", "weightInKg"];
  public dataSource: Dog[] = [
    {
      name: "Charlie",
      breed: "Basset Hound",
      weightInKg: 30,
    },
    {
      name: "Bella",
      breed: "Cocker Spaniel",
      weightInKg: 15,
    },
  ];

  constructor() {}
}
<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
  <ng-container matColumnDef="name">
    <mat-header-cell *matHeaderCellDef> Name </mat-header-cell>
    <mat-cell *matCellDef="let element"> {{ element.name }} </mat-cell>
  </ng-container>

  <ng-container matColumnDef="breed">
    <mat-header-cell *matHeaderCellDef> Breed </mat-header-cell>
    <mat-cell *matCellDef="let element"> {{ element.breed }} </mat-cell>
  </ng-container>

  <ng-container matColumnDef="weightInKg">
    <mat-header-cell *matHeaderCellDef> Weight </mat-header-cell>
    <mat-cell *matCellDef="let element"> {{ element.weightInKg }} kg</mat-cell>
  </ng-container>

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

Ten minutes later, the new table is ready to be deployed to production - and your manager compliments you for your speed.

At this point, you practically have no choice: if someone asks you to - quickly - add a new table to display information about people, you will keep copying and pasting code around once again.

Think about this, though: what if during the course of the application’s lifecyle hundreds of tables like this get created? What happens when the need to add a selection column, a sortable header or, even worse, pagination arises?

Duplicate code always turns into a nightmare: if it’s not your problem, it will be someone else’s.

Clearly, there must be a better way.

On a sidenote, this approach has merits: by being just a little bit diligent, we can enforce types on the table’s rows and columns. This lets us avoid trivial mistakes, like accessing non existing fields from the template.

Creating a base table structure#

The procedure detailed above is - to put it mildly - not maintainable.

You acknowledge this and decide to perform a refactor. You create a single table component, flexible enough to accept any kind of data as input. In particular, this “generic” component accepts two inputs:

  • an array of strings, containing the column names
  • an array of any kind of object, containing the rows.
@Component({
  selector: "app-any-table",
  templateUrl: "./any-table.component.html",
  styleUrls: ["./any-table.component.css"],
})
export class AnyTableComponent implements OnInit {
  @Input() columns: string[];
  @Input() rows: any[];

  public dataSource = new MatTableDataSource<any>();

  constructor() {}

  ngOnInit(): void {
    this.dataSource.data = this.rows;
  }
}
<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
  <ng-container [matColumnDef]="column" *ngFor="let column of columns">
    <mat-header-cell *matHeaderCellDef>
      {{ column }}
    </mat-header-cell>
    <mat-cell *matCellDef="let element">
      {{ element[column] }}
    </mat-cell>
  </ng-container>

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

Creating the same cars table as before simply consists in using two variables to hold the appropriate data and pass them to the table component:

public carsColumns = ["manufacturer", "model", "powerSupply"];
public carsRows: Car[] = [
  {
    manufacturer: "Tesla",
    model: "Model 3",
    powerSupply: "electricity",
  },
  {
    manufacturer: "Ferrari",
    model: "458",
    powerSupply: "petrol",
  },
];
<app-any-table [columns]="carsColumns" [rows]="carsRows"></app-any-table>

This approach, however, has a serious flaw. We are not enforcing the consistency between rows and columns at all.

Even if we can properly add a type to the column names, there is no guarantee that they match the rows - both in size and content. It is not possible to ensure that each row contains the same number of elements either.

Using Typescript’s Generics#

From the examples above we can extract a few key concepts. We want to:

  • avoid code duplication, so we need a generic table component;
  • keep the table component flexible enough to handle different types of data;
  • be diligent and take advantage of Typescript’s type system. Every second spent avoiding to use any variables is worth the trouble, because it saves hours of debugging;
  • be able to enforce the proper relationship between rows and columns.

A notable exception to the last two points is when incrementally porting legacy Javascript code: there are situations in which using any makes sense, but this shouldn’t be an excuse to be lazy.

Taking all of this into account, we can exploit Typescript’s generics to enforce the correct relationship between rows and columns.

Let’s start by building a generic - this time, really generic - table. Columns and rows are passed as inputs, and the column names are extracted:

@Component({
  selector: "app-generic-table",
  templateUrl: "./generic-table.component.html",
  styleUrls: ["./generic-table.component.css"],
})
export class GenericTableComponent<T> implements OnInit {
  @Input() columns: Column<T>[];
  @Input() rows: Row<T>[];

  public dataSource = new MatTableDataSource<Row<T>>();
  public columnNames: string[];

  constructor() {}

  ngOnInit(): void {
    this.dataSource.data = this.rows;
    this.columnNames = this.columns.map((column) => column.name.toString());
  }
}

The input interfaces are defined as:

export interface Column<T> {
  name: keyof T;
  // Add here any additional action or information
  // about a generic table column, like whether it is
  // sortable or not.
  sortable?: boolean;
}
export interface Row<T> {
  values: T;
  // Add here any additional action or information
  // about a generic table row, like a navigation
  // target if the row is clicked.
}

A few notes:

  • the component is still generic, but types are enforced;
  • lots of the magic is achieved by extracting the column name as a keyof the generic type. This will enforce the relationship between rows and columns, since it will not be possible to have a column whose name is not included in the row’s object keys;
  • it’s possible to pass additional information to the table, without polluting either the component or the object. Information like sorting or navigation can be handled in the Row or Column interface.

The template is quite similar to what we were using before:

<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
  <ng-container [matColumnDef]="column.name" *ngFor="let column of columns">
    <mat-header-cell *matHeaderCellDef>
      {{ column.name }}
    </mat-header-cell>
    <mat-cell *matCellDef="let element">
      {{ element.values[column.name] }}
    </mat-cell>
  </ng-container>

  <mat-header-row *matHeaderRowDef="columnNames; sticky: true"></mat-header-row>
  <mat-row *matRowDef="let row; columns: columnNames"></mat-row>
</mat-table>

If we were to create once again our cars table, here’s what we should do:

public carsColumns: Column<Car>[] = [
  { name: 'manufacturer' },
  { name: 'model' },
  { name: 'powerSupply' },
];
public carsRows: Row<Car>[] = [
  {
    values: {
      manufacturer: 'Tesla',
      model: 'Model 3',
      powerSupply: 'electricity',
    },
  },
  {
    values: {
      manufacturer: 'Ferrari',
      model: '458',
      powerSupply: 'petrol',
    },
  },
];
<app-generic-table
  [columns]="carsColumns"
  [rows]="carsRows"
></app-generic-table>

Compare it to the code needed to create a table component leveraging any as input type: with just a little more thought and effort, we are ensuring consistency and making our table more robust.

Wrapping it up#

This post sums up what I’ve painfully learnt by wasting too many hours of my life testing and debugging tables: I hope you’ll find it helpful.

You can find all the code at this repo.

In software, avoiding duplication always pays off: just be diligent enough to keep your components generic without compromising on safety and you’ll be moving blazingly fast in the medium to long run.

Thanks to Vincenzo Greco for the prolific discussions that shaped this.

© 2022