Rendering multiple elements from a single Angular component

Lesezeit: 5 Min, veröffentlicht am 30.10.2024
Rendering multiple elements from a single Angular component

Rendering multiple elements from a single Angular component

This article is about a piece of knowledge that I would like to share, because it cost me a couple of hours of research and I think the solution was pretty well hidden on the web, at least for my search terms.

The problem

Imaging, you are building a web-app and you use some kind of component framework, in order to split your UI into components. Furthermore, you a have a component that renders a grid with three columns.

.my-grid {
    display: grid;
    grid-template-columns: 1fr 1fr 1fr;
}

This child component that is rendered into the grid is relatively straight forward, unless you need to render multiple HTML elements in one component. This might happen, if the elements are semantically related and use the same data or even interact with each other.

In React, you would use a component that has React.Fragment as root element:

import React from 'react';

export function App(props) {
    return (
        <div className='App' style={{display: "grid", gridTemplateColumns: "1fr 1fr 1fr"}}>
            <MyCompoment/>
            <MyCompoment/>
            <MyCompoment/>
        </div>
    );
}

function MyCompoment() {
    return (
        <React.Fragment>
            <div>a</div>
            <div>b</div>
        </React.Fragment>
    )
}

This will render as

aba
bab

and MyComponent does not have a single root element.

In Vue.js 3.x you would just use a <template> with multiple elements:

// App.vue
<script setup>
import MyComponent from "./MyComponent.vue"
</script>

<template>
  <div style="display: grid; grid-template-columns: 1fr 1fr 1fr;">
    <MyComponent />    
    <MyComponent />
    <MyComponent />
  </div>
</template>

// MyComponent.vue
<script setup></script>

<template>
  <div>a</div>
  <div>b</div>
</template>

This will achieve the same thing.

In Angular.js there is no out of the box solution, at least none that I could find on the web. By default, every component is wrapped by a custom HTML element that represents the component. For example, this component

<div>with host element 1</div>
<div>with host element 2</div>
import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-with-host-element',
  templateUrl: './with-host-element.component.html',
  styleUrls: ['./with-host-element.component.css']
})
export class WithHostElementComponent  {
}

will always render as

<app-with-host-element>
    <div>with host element 1</div>
    <div>with host element 2</div>
</app-with-host-element>

and there is no builtin way to omit the host element. Because of this, it is not possible to render multiple HTML tags with a single component, i.e. without messing up with the grid-layout.

…or is it?

NgTemplateOutlets and embedded views:

It took me a while to find out what to look for: The root element of a component is called “host” element, so googling for “root” did not find helpful results. So, in order to find this, we have to ask the following questions:

  • How can I render elements outside the host element?
  • How can I omit the host-element?

I finally found this Stack Overflow Post that provided exactly the solution I needed.

  • Use <ng-template #template> to render to contents into an NgTemplateOutlet.
  • Use @ViewChild to gain access to that outlet from within the component.
  • Use ViewContainerRef#createEmbeddedView() to render the outlet contents directly embedded into the parent container.
  • Hide the components host element with a display: none style.

Template:

<ng-template #template>
    <div>outside host element 1</div>
    <div>outside host element 2</div>
</ng-template>

Component:

import {Component, OnInit, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core';
import {NgTemplateOutlet} from "@angular/common";

@Component({
  selector: 'app-outside-host-element',
  templateUrl: './outside-host-element.component.html',
  styleUrls: ['./outside-host-element.component.css']
})
export class OutsideHostElementComponent implements OnInit {

  @ViewChild('template', { static: true })
  template!: TemplateRef<NgTemplateOutlet>;

  constructor(private viewContainerRef: ViewContainerRef) {
  }

  ngOnInit(): void {
    this.viewContainerRef.createEmbeddedView(this.template);
  }
}

CSS:

:host {
  display: none;
}

I have set up an example repository on GitHub and also deployed the output to GitHub pages, if you want to take a look.

Note that I am using an older version of Angular, so it works at least with Angular 13, probably earlier versions as well.

display: contents

When researching this, I was wondering if there is no native CSS solution to this. And indeed there is.

There is a display: contents style that replaces an element by a pseudo-box, and acts as if it’s child elements are directly rendered inside the parents. caniuse.com mentions that “buttons are not accessible”. It may also be a problem when JavaScript is used to modify elements, because the element is effectively still there.

For me, it did not work correctly, but it’s worth mentioning that it exists.

Conclusion

The following image shows the result of the different approaches:

Grid with three columns, one sub-component with host element, one with display: contents, one rendering outside the host element

I solved my problem with TemplateOutlets and ViewContainerRefs and I hope the mention of React. Fragment and multiple elements in Vue will make this post easier to find, this helping you find a solution to it more quickly than I did.

But there is a disclaimer: I am not an Angular expert. This solution might have caveats that I have not seen yet and there might be better solutions out there. If you know more than I do, or if this does not work out for you, please contact me, preferably by opening an issue at the example repository.

Tags

Verfasst von:

Foto von Nils

Nils

Nils ist Full Stack Developer bei cosee und treibt sich sowohl im Front- als auch im Backend rum.