Architecting A Highly Dynamic Card List In Angular

Architecting A Highly Dynamic Card List In Angular

Let's look at one way to architect Angular's highly customizable and dynamic card list component. The goal is to make it easier to render different kinds of card lists using one single component.

Dynamic Card List Component

The component should be dynamic enough to render different designs while maintaining a common structure. The consumer should be able to use the component and provide just the config and a data source, the rest is all gonna be taken care of by our dynamic card list component.

For table-like cards, if you want to render display different data in different designs; after a point, it becomes very difficult with dedicated card lists for each of the use cases. Having a single card list component that is dynamic enough to render a wide variety of designs would make things much easier.

Features

  • Highly customizable 🧱

  • Re-usable ♻️

  • Easy to use 🔌

  • Less boilerplate code to maintain 🛠️

  • Consistency in design 📐

Concepts

There are a couple of concepts that we use to build this out. We also make good use of Angular's strong Dependency Injection (DI) system. We'll see how we can provide custom components and dynamically create and attach them to the view. We use concepts like Dynamic Component Creation, Injectors, Injection Tokens, etc.

Renderers

So if we consider the card like a table, then the columns are where we display the data. So each of these columns can display different types of information like text, images, buttons, etc. So we can have different renderers for each of these types. For example, we can have a text renderer that will render text, an image renderer that will render an image, etc.

Core Renderers

We will have some core renderers which will be used by default. For example,

  • Text Renderer

  • Badge Renderer

We can identify some common use cases for renderers and create components that become part of Core Renderers.

Custom Renderers

If there are use cases that are not covered by the core renderers, then we can create custom renderers. For example, we need to display a user's name with the profile picture. So we can create a custom renderer for this use case. Look at the NameWithAvatarColumnRendererComponent

Renderers Registry

So we need a way to keep track of the renderers that we will create and use in the application. Users can create a lot of custom renderers according to their use cases. We use a type to identify the renderer. So all the renders that are created should have a unique type. We can use this type type associated with them.

We use a Service as our registry (ColumnRenderersRegistryService). We expose a Map to store the type to renderer mapping. We expose two methods:

  • registerRenderers to register renderers

  • lookup to lookup a renderer by type

Registering Renderers

There are methods exposed to register default renderers and custom renderers. The default renderers are registered in the constructor of the sreyaj-exp-card-list.

export const registerDefaultRenderers = (): void => {
    registerCustomRenderers([
        TextColumnRendererComponent,
        BadgeColumnRendererComponent,
    ]);
};

export const registerCustomRenderers = (
    renderers: ListColumnRendererConstructor[]
): void => {
    const rendererLookupService = inject(ColumnRenderersRegistryService);
    rendererLookupService.registerRenderers(renderers);
};

We inject the ColumnRenderersRegistryService and use it to register the renderers.

Custom Decorator

We create a custom decorator called ListColumnRenderer to add some metadata information about the renderer component like its type which is the unique identifier of the renderer in the registry.

We could totally do this without a decorator, but we would have to manually set the type on each renderer component. It would look like this:

@Component()
export class TextColumnRendererComponent extends ListColumnRendererBase {
  public type = CoreListColumnRendererType.Text;
}

With the decorator, we just remove this explicit variable assignment. Also, the decorator helps us easily identify renderer components from normal components.

Class decorator is a function that receives the constructor as the argument. Since we want to pass in some arguments to the decorator itself we use a decorator factory.

export function ListColumnRenderer(
  metadata: ListColumnRendererMetadata
) {
  return (constructor: ListColumnRendererConstructor): void => {
    constructor.type = metadata.type;
  };
}

The default type of a Component is Type. For us, we have extra metadata so we say our components are of the type ListColumnRendererConstructor which is an extended Type with some additional properties like type (more can be added later).

export interface ListColumnRendererConstructor extends Type<unknown>,
    ListColumnRendererMetadata {}

export interface ListColumnRendererMetadata {
  type: string;
}

Implementation

Main Component

There is a main component sreyaj-exp-card-list which is the component the consumers will use. The component takes in two inputs:

  1. columnConfig - An array of column config that is used to render each column.

  2. dataSource - The data source which drives the component.

The column config is what drives the view part of the component. Here is how you define it:

[
  {
    id: "name",
    display: CustomRenderers.NameWithAvatar,
    width: 2
  },
  {
    id: "email",
    display: CoreListColumnRendererType.Text,
    width: 4
  }
]

The id is what connects the view and the data. The data source should contain an object with the keys mentioned id in the column config.

The display is the type of renderer. It can be a core renderer or a custom renderer. This key will be used to look up the corresponding renderer to be used for that particular column.

Inside the main component, it just loops through the columns:

<div>
  <sreyaj-exp-card-list-column-renderer [columnConfig]="column" [data]="item"></sreyaj-exp-card-list-column-renderer>
</div>

We pass the column information and the data for the column from the data source to a child component sreyaj-exp-card-list-column-renderer which takes care of dynamically creating and attaching the corresponding renderer to the view.

Attaching Renderers to the View

This magic happens in the sreyaj-exp-card-list-column-renderer component. This is how we do it:

if (!this.columnConfig || !this.data) {
  return;
}
// Get the corresponding renderer for the display type
const renderer = this.rendererLookupService.lookup(this.columnConfig?.display);
if (renderer) {
  // Dynamically create the component and pass the data with the help of Injection Token
  this.vcr.createComponent(renderer, {
    injector: Injector.create({
      providers: [
        {
          provide: COLUMN_RENDERER_DATA,
          useValue: this.data[this.columnConfig.id]
        }
      ],
      parent: this.parentInjector
    })
  });
}

If I were to breakdown what is happening inside the component:

  1. Get the renderer corresponding to the type passed in the columnConfig.

  2. Use ViewContainerRef to dynamically create the component using the createComponent method.

  3. To the createComponent method, we pass the renderer class along with an injector that holds the data.

  4. COLUMN_RENDERER_DATA injection token is provided the data for that particular column.

Inside a Renderer

Let's look at TextColumnRendererComponent which is part of the Core Renderers.

@Component({
  selector: "sreyaj-exp-text-column-renderer",
  template: `
    <div>
      {{ data }}
      <!-- Use the data here -->
    </div>
  `,
  standalone: true
})
@ListColumnRenderer({
  type: CoreListColumnRendererType.Text
})
export class TextColumnRendererComponent extends ListColumnRendererBase {}

The data property contains the data passed from the injector. Wondering how we get it? For that, let's look at ListColumnRendererBase :

export abstract class ListColumnRendererBase<ColumnDataType = unknown> {
  public static readonly type: CoreListColumnRendererType;
  // We inject the token here
  public readonly data: ColumnDataType =
    inject<ColumnDataType>(COLUMN_RENDERER_DATA);
}

We can see that the COLUMN_RENDERER_DATA InjectionToken is injected inside the class and so the component gets the value passed while creating the component. If we don't use the new inject method, it would look something like this:

@Component({
  selector: 'sreyaj-exp-text-column-renderer',
  template: `
    <div>
      {{ data }}
    </div>
  `,
  standalone: true,
})
export class TextColumnRendererComponent {
  constructor(@Inject(COLUMN_RENDERER_DATA) public data: unknown){}
}

References

The idea of this is taken from the OSS project called Hypertrace which is an Open source distributed tracing platform from Traceable (The company I'm currently working in).

The project has a table implementation that does exactly this. I've just used the same concept to create a card list instead of a table (also use standalone components).

The table is far more capable as it even takes care of sorting, pagination, etc. You can look at the codebase to get an idea of how the table is implemented.

Connect with me

Have any thoughts or questions? shoot'em below in the comments. Stay Safe ❤️

Buy me a pizzaments. Stay Safe ❤️

Did you find this article valuable?

Support DCode - Learnings by Adithya Sreyaj by becoming a sponsor. Any amount is appreciated!