DEV Community

Kapil Kumar
Kapil Kumar

Posted on

Building a Reusable Table Component in Angular

In this article, we'll create a flexible and reusable table component in Angular that supports dynamic columns, data binding, and custom actions. Table will support sorting, filtering and can be extended to support other features. Let's break it down step by step.

What will we do in this article?

  • Basic Table Structure without Actions
  • Extend component to support actions
  • Enable component to enable/disable actions based on table data row
  • Extend component to support sorting

Prerequisites

Before we begin, ensure you have an Angular project set up. If not, follow these commands to create a new Angular project:

npm install -g @angular/cli
ng new my-app
cd my-app
ng serve --open
Enter fullscreen mode Exit fullscreen mode

Once your Angular application is set up, you’re ready to proceed.

Step 1. Basic Table Structure without Actions

Let's start with creating a basic table structure:

1. First, define the interfaces

export interface IColumnDef<T> {
    headerText: string;     // Column header text
    field?: keyof T;       // Property name from data object
}

export interface IUser {    // Sample data interface
    name: string;
    email: string;
    role: string;
    status: string;
}
Enter fullscreen mode Exit fullscreen mode

2. Create column definitions in constants file

import { IColumnDef, IUser } from '../models/general.models';

export const COLUMN_DEFINITIONS: IColumnDef<IUser>[] = [
    { headerText: 'Name', field: 'name' },
    { headerText: 'Email', field: 'email' },
    { headerText: 'Role', field: 'role' }
];
Enter fullscreen mode Exit fullscreen mode

As we have defined field property in IColumnDef as keyOf T, it will give an error while defining column definitions in case field property is not from data object, in this case it's IUser.

Step 2: Create and use the Table Component

Generate a new table component:

ng generate component components/table

Define the Component Class (table.component.ts):

import { Component, input, output } from '@angular/core';

@Component({
  selector: 'app-table',
  standalone: true,
  imports: [],
  templateUrl: './table.component.html',
  styleUrl: './table.component.scss'
})
export class TableComponent<T> {
  // Input signals for columns and data
  columns = input<IColumnDef<T>[]>([]);
  tableData = input<T[]>([]);

}
Enter fullscreen mode Exit fullscreen mode

Define basic table component HTML

<table class="table">
    <thead>
        <tr>
            @for(col of columns(); track col){
                <th scope="col">{{col.headerText}}</th>
            }
        </tr>
    </thead>
    <tbody>
        @if(tableData().length >= 0){
            @for(data of tableData(); track data){
                <tr>
                    @for(col of columns(); track col){
                        @if(col.field){
                            <td>{{data[col.field]}}</td>
                        }
                    }
                </tr>
            }
        }
    </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

Use the table component, Ex. using in app.component.ts

Add app-table to HTML

 <app-table [columns]="columns" [tableData]="tableData"></app-table>
Enter fullscreen mode Exit fullscreen mode

Define columns and Data in .ts file

 columns= COLUMN_DEFINITIONS;
  tableData:IUser[] = [
    {name: 'Kapil', email:'[email protected]', role:'admin', status:'active'},
    {name: 'Kapil1', email:'[email protected]', role:'admin', status:'active'},

];
Enter fullscreen mode Exit fullscreen mode

That's it. basic table is ready with 3 columns.

Now display status column as well in the table, to do that we just need to add one more column in column definitions and

import { IColumnDef, IUser } from '../models/general.models';

export const COLUMN_DEFINITIONS: IColumnDef<IUser>[] = [
    { headerText: 'Name', field: 'name' },
    { headerText: 'Email', field: 'email' },
    { headerText: 'Role', field: 'role' },
    { headerText: 'Status', field: 'status' }
];
Enter fullscreen mode Exit fullscreen mode

As status is already there in data, it will be displayed in the table.

Basic table

Next Step: Add actions to the table

1. Update IColumnDef interface

export interface IColumnDef<T> {
    headerText: string;
    field?: keyof T;
    columnType?: string;
    actions?: IActions<T>[];
}

export interface IActions<T> {
    label: string;
    icon: string;
    tooltip: string;
}
Enter fullscreen mode Exit fullscreen mode

2. Update COLUMNN Definitions constants

import { IColumnDef, IUser } from "../models/general.models";

export const COLUMN_DEFINITIONS: IColumnDef<IUser>[] = [
    {
        headerText: "Name",
        field: "name",
        sortable: true
    },
    {
        headerText: "Email",
        field: "email"
    },
    {
        headerText: "Role",
        field: "role"
    },
    {
        headerText: "Status",
        field: "status"
    },
    {
        headerText: "Action",
        columnType: "action",
        actions: [
            {
                label: "Edit",
                icon: "pi pi-pencil",
                tooltip: "Edit",
            },
            {
                label: "Delete",
                icon: "pi pi-trash",
                tooltip: "Delete",
            }
        ]
    },
]
Enter fullscreen mode Exit fullscreen mode

3. Update table component .ts file to handle action click

 actionClickEmit = output<{action: IActions<T>, rowData: T}>();

  actionClick(action: IActions<T>, rowData: T) {
    this.actionClickEmit.emit({action, rowData});
  }
Enter fullscreen mode Exit fullscreen mode

4. Update table component .html file to display actions

<table class="table">
    <thead>
        <tr>
            @for(col of columns(); track col){
            <th scope="col">{{col.headerText}}</th>
            }
        </tr>
    </thead>
    <tbody>
        @if(tableData().length >= 0){
        @for(data of tableData(); track data){
        <tr>
            @for(col of columns(); track col){
            @if(col.field){
            <td>{{data[col.field]}}</td>
            } @else if(col.columnType == 'action'){
            <td>
                @for(action of col.actions; track action){
                <button type="button" class="btn btn-primary me-2"
                    (click)="actionClick(action, data)">{{action.label}}</button>
                }
            </td>
            }
            }
        </tr>
        }
        }


    </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

That's it, Edit and Delete actions added to the table, and it will emit the output events on click

Table with actions

Enable/Disable actions based on table data

Ex. The Requirement is to disable Edit if status is not active and disable the delete button if role is admin

  1. Edit the IActions interface to have a disabled functions
export interface IActions<T> {
    label: string;
    icon: string;
    tooltip: string;
    disabled?: (data: T) => boolean;
}
Enter fullscreen mode Exit fullscreen mode

2. Add the button disabling logic to Column definitions in the constants file
Updated actions in column definitions
disabled: (data: IUser) => data.status=== "inactive"
disabled: (data: IUser) => data.role === "admin"

export const COLUMN_DEFINITIONS: IColumnDef<IUser>[] = [
    {
        headerText: "Name",
        field: "name",
        sortable: true
    },
    {
        headerText: "Email",
        field: "email"
    },
    {
        headerText: "Role",
        field: "role"
    },
    {
        headerText: "Status",
        field: "status"
    },
    {
        headerText: "Action",
        columnType: "action",
        actions: [
            {
                label: "Edit",
                icon: "pi pi-pencil",
                tooltip: "Edit",
                disabled: (data: IUser) => data.status=== "inactive"
            },
            {
                label: "Delete",
                icon: "pi pi-trash",
                tooltip: "Delete",
                disabled: (data: IUser) => data.role === "admin"
            }
        ]
    },
]
Enter fullscreen mode Exit fullscreen mode

3. Update the table component HTML to call the disabled function
disable login added to button
[disabled]="action?.disabled(data)"

<table class="table">
    <thead>
        <tr>
            @for(col of columns(); track col){
            <th scope="col">{{col.headerText}}</th>
            }
        </tr>
    </thead>
    <tbody>
        @if(tableData().length >= 0){
        @for(data of tableData(); track data){
        <tr>
            @for(col of columns(); track col){
            @if(col.field){
            <td>{{data[col.field]}}</td>
            } @else if(col.columnType == 'action'){
            <td>
                @for(action of col.actions; track action){
                <button [disabled]="action?.disabled(data)" type="button" class="btn btn-primary me-2"
                    (click)="actionClick(action, data)">{{action.label}}</button>
                }
            </td>
            }
            }
        </tr>
        }
        }
    </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode
  1. Update user data in app component to have one inactive record
  tableData:IUser[] = [
    {name: 'Kapil', email:'[email protected]', role:'user', status:'inactive'},
    {name: 'Kapil1', email:'[email protected]', role:'admin', status:'active'},

];
Enter fullscreen mode Exit fullscreen mode

Table with disabled actions

Sorting the table

Now it's time to add the sorting logic, table component should allow sorting for one or more columns depends on configuration

1. Add sortable properties to our IColumnDef interface

export interface IColumnDef<T> {
    headerText: string;
    field?: keyof T;    
    columnType?: string;
    actions?: IActions<T>[];
    sortable?: boolean;
    sortDirection?: 'asc' | 'desc' | '';
}
Enter fullscreen mode Exit fullscreen mode

2. Update column definitions and define sortable columns

export const COLUMN_DEFINITIONS: IColumnDef<IUser>[] = [
    {
        headerText: "Name",
        field: "name",
        sortable: true,
        sortDirection: "asc"
    },
    {
        headerText: "Email",
        field: "email"
    },
    {
        headerText: "Role",
        field: "role",
        sortable: true,
        sortDirection: ""
    },
    {
        headerText: "Status",
        field: "status"
    },
    {
        headerText: "Action",
        columnType: "action",
        actions: [
            {
                label: "Edit",
                icon: "pi pi-pencil",
                tooltip: "Edit",
                disabled: (data: IUser) => data.status=== "inactive"
            },
            {
                label: "Delete",
                icon: "pi pi-trash",
                tooltip: "Delete",
                disabled: (data: IUser) => data.role === "admin"
            }
        ]
    },
]
Enter fullscreen mode Exit fullscreen mode

*3. Add sorting logic to table component *
As we using signal input, we can not update the same input after sorting. So adding a new signal to keep sorted data
Our component with sorting will be having below code

import { Component, computed, effect, input, output, signal } from '@angular/core';
import { IActions, IColumnDef } from '../../models/general.models';

@Component({
  selector: 'app-table',
  standalone: true,
  imports: [],
  templateUrl: './table.component.html',
  styleUrl: './table.component.scss'
})
export class TableComponent<T> {
  columns = input<IColumnDef<T>[]>([]);
  tableData = input<T[]>([]);
  actionClickEmit = output<{action: IActions<T>, rowData: T}>();

  actionClick(action: IActions<T>, rowData: T) {
    this.actionClickEmit.emit({action, rowData});
  }

  // Internal signals
  sortedData = signal<T[]>([]);

  constructor() {
    // Initialize sortedData when tableData changes
    effect(() => {
      this.sortedData.set(this.tableData());
    }, { allowSignalWrites: true });
  }

  sort(column: IColumnDef<T>) {
    if (!column.sortable || !column.field) return;

    // Reset other columns
    this.columns().forEach(col => {
      if (col !== column && col.sortDirection !== undefined) col.sortDirection = '';
    });

    // Toggle sort direction
    column.sortDirection = column.sortDirection === 'asc' ? 'desc' : 'asc';

    // Sort data
    const sorted = [...this.sortedData()].sort((a, b) => {
      const aVal = a[column.field!];
      const bVal = b[column.field!];
      return column.sortDirection === 'asc' ? 
             aVal > bVal ? 1 : -1 :
             aVal < bVal ? 1 : -1;
    });

    this.sortedData.set(sorted);
  }
}

Enter fullscreen mode Exit fullscreen mode
  1. Update table component .html file to handle sorting
<table class="table">
    <thead>
        <tr>
            @for(col of columns(); track col){
            <th scope="col"  (click)="sort(col)">
                {{col.headerText}}
                @if(col.sortDirection === 'asc'){↑}
                @if(col.sortDirection === 'desc'){↓}
                @if(col.sortDirection === ''){↓↑}
            </th>
            }
        </tr>
    </thead>
    <tbody>
        @if(sortedData().length >= 0){
        @for(data of sortedData(); track data){
        <tr>
            @for(col of columns(); track col){
            @if(col.field){
            <td>{{data[col.field]}}</td>
            } @else if(col.columnType == 'action'){
            <td>
                @for(action of col.actions; track action){
                <button [disabled]="action?.disabled(data)" type="button" class="btn btn-primary me-2"
                    (click)="actionClick(action, data)">{{action.label}}</button>
                }
            </td>
            }
            }
        </tr>
        }
        }
    </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

That's it, sorting will work for all the columns now. currently in column definitions it is defined for Name and Role. if we need it for status column as well need to update column definition for status column as below and it will work.

 {
        headerText: "Status",
        field: "status",
        sortable: true,
        sortDirection: ""
    },
Enter fullscreen mode Exit fullscreen mode

table with sorting

Key Features

  • Generic Typing: The table component uses generics () to ensure type safety for different data structures
  • Dynamic Columns: Columns are configured through the columns input property
  • Action Buttons: Supports custom action buttons with disable conditions
  • Signal-based Inputs/Outputs: Uses Angular's new signals for reactive data handling
  • Sorting based on configuration
  • Flexible Structure: Easy to extend with additional features like sorting, filtering, etc.

Github for complete code

Github

Future Enhancements

  • Implement filtering
  • Add pagination
  • Support for custom cell templates
  • Add loading state
  • Responsive design
  • Custom styling options

Conclusion

This reusable table component provides a solid foundation for displaying tabular data in Angular applications. It's type-safe, flexible, and can be easily extended with additional features as needed.

Remember to add appropriate styling and consider adding features like filtering, and pagination based on your specific needs.

Top comments (0)