Post contents
Whew! That last chapter was a doozy. Let's slow things down a bit for this chapter: Short and sweet.
Let's think back to the "Dynamic HTML" and "Intro to Components" chapters, where we were building our File
and FileList
components:
- React
- Angular
- Vue
const File = ({ href, fileName, isSelected, onSelected, isFolder }) => { const [inputDate, setInputDate] = useState(new Date()); // ... return ( <button onClick={onSelected} style={ isSelected ? { backgroundColor: "blue", color: "white" } : { backgroundColor: "white", color: "blue" } } > {fileName} {isFolder ? <span>Type: Folder</span> : <span>Type: File</span>} {!isFolder && <FileDate inputDate={inputDate} />} </button> );};const FileList = () => { // ... return ( // ... <ul> {filesArray.map((file, i) => ( <li key={file.id}> {(!onlyShowFiles || !file.isFolder) && ( <File isSelected={selectedIndex === i} onSelected={() => onSelected(i)} fileName={file.fileName} href={file.href} isFolder={file.isFolder} /> )} </li> ))} </ul> // ... );};
@Component({ selector: "file-item", standalone: true, imports: [FileDateComponent], template: ` <button (click)="selected.emit()" [style]=" isSelected ? 'background-color: blue; color: white' : 'background-color: white; color: blue' " > {{ fileName }} @if (isFolder) { <span>Type: Folder</span> } @else { <span>Type: File</span> } @if (!isFolder) { <file-date [inputDate]="inputDate" /> } </button> `,})class FileComponent implements OnInit, OnDestroy { @Input() fileName!: string; @Input() href!: string; @Input() isSelected!: boolean; @Input() isFolder!: boolean; @Output() selected = new EventEmitter(); inputDate = new Date(); // ...}@Component({ selector: "file-list", standalone: true, imports: [FileComponent], template: ` <!-- ... --> <ul> @for (file of filesArray; let i = $index; track file.id) { <li> @if (onlyShowFiles ? !file.isFolder : true) { <file-item (selected)="onSelected(i)" [isSelected]="selectedIndex === i" [fileName]="file.fileName" [href]="file.href" [isFolder]="file.isFolder" /> } </li> } </ul> <!-- ... --> `,})class FileListComponent { // ...}
<!-- File.vue --><script setup>import { ref, onMounted, onUnmounted } from "vue";import FileDate from "./FileDate.vue";const props = defineProps(["isSelected", "isFolder", "fileName", "href"]);const emit = defineEmits(["selected"]);const inputDate = ref(new Date());// ...</script><template> <button v-on:click="emit('selected')" :style=" isSelected ? 'background-color: blue; color: white' : 'background-color: white; color: blue' " > {{ fileName }} <span v-if="isFolder">Type: Folder</span> <span v-else>Type: File</span> <FileDate v-if="!isFolder" :inputDate="inputDate" /> </button></template>
<!-- FileList.vue --><script setup>// ...</script><template> <!-- ... --> <ul> <li v-for="(file, i) in filesArray" :key="file.id"> <File v-if="onlyShowFiles ? !file.isFolder : true" @selected="onSelected(i)" :isSelected="selectedIndex === i" :fileName="file.fileName" :href="file.href" :isFolder="file.isFolder" /> </li> </ul> <!-- ... --></template>
While this theoretically works, there's a significant problem with it. Let's take a look at what the HTML looks like when rendering with onlyShowFiles=true
and the following filesArray
:
[ { fileName: "File one", href: "/file/file_one", isFolder: false, id: 1, }, { fileName: "Folder one", href: "", isFolder: true, id: 2, },];
Because our conditional statement is on the li
when rendered to the DOM, it might look something like this:
<!-- ... --><ul> <li> <!-- File Component --> <button>...</button> </li> <li></li></ul><!-- ... -->
While this might not seem like a big problem at first, the fact that there's an empty li
in the middle of our ul
introduces three issues:
- It will leave an empty space created by any styling you have applied to the
li
. - Any assistive technologies, like screen readers, will read out that there's an empty item, which is a confusing behavior for those users.
- Any search engines reading data off of your page may incorrectly assume that your list is intentionally empty, thus potentially impacting your ranking on sites.
Solving these issues is where something called "transparent elements" comes into play. See, ideally, what we want to have is something like a tag that renders to nothing.
This means that if we could instead generate something like the following pseudo-syntax in framework code:
<ul> <nothing> <li> <button>...</button> </li> </nothing> <nothing></nothing></ul>
We could render this into the DOM itself:
<ul> <li> <button>...</button> </li></ul>
Luckily for us, each of the three frameworks provides a method for doing so, simply with a different syntax. Let's see how each framework does so:
- React
- Angular
- Vue
In React, we use something called a "Fragment" in place of the nothing
component.
import { Fragment } from "react";// ...<ul> {filesArray.map((file, i) => ( <Fragment key={file.id}> {(!onlyShowFiles || !file.isFolder) && ( <li> <File isSelected={selectedIndex === i} onSelected={() => onSelected(i)} fileName={file.fileName} href={file.href} isFolder={file.isFolder} /> </li> )} </Fragment> ))}</ul>;
Fragment
Alternative Syntax
Fragment
also has an alternative syntax in JSX. Instead of <Fragment></Fragment>
, you can simply do <></>
. This shorthand removes the need for the import and makes the above code sample read like this:
<ul> {filesArray.map((file, i) => ( <> {(!onlyShowFiles || !file.isFolder) && ( <li> <File /> </li> )} </> ))}</ul>
You may notice that
<>
syntax forFragment
does not have akey
associated with it. This is because the<>
syntax does not allow you to have props associated with it.However, this means that your loop will still misbehave and add performance overhead as a penalty for not including
key
(as we discussed in the "Dynamic HTML" chapter). For this reason, when inside amap
loop, you'll want to useFragment
with akey
property associated with it.
Angular's version of the nothing
element is the ng-container
element.
<ul> @for (file of filesArray; let i = $index; track file.id) { <ng-container> @if (onlyShowFiles ? !file.isFolder : true) { <li> <file-item (selected)="onSelected(i)" [isSelected]="selectedIndex === i" [fileName]="file.fileName" [href]="file.href" [isFolder]="file.isFolder" /> </li> } </ng-container> }</ul>
However, unlike React and Vue; we don't need to use ng-container
in this example. Angular allows us to have multiple elements at the root of any control flow block, so we can remove the ng-container
and have the following:
<ul> @for (file of filesArray; let i = $index; track file.id) { @if (onlyShowFiles ? !file.isFolder : true) { <li> <file-item (selected)="onSelected(i)" [isSelected]="selectedIndex === i" [fileName]="file.fileName" [href]="file.href" [isFolder]="file.isFolder" /> </li> } }</ul>
This has the same effect as the previous example, but with less code. We'll continue to use ng-container
in this chapter to keep the examples consistent with other frameworks, but there's little need for it in most modern Angular codebases.
To render out something akin to a nothing
element, we can use a template
element with a v-for
or v-if
associated with it.
<template> <ul> <template v-for="(file, i) of filesArray" :key="file.id"> <li v-if="onlyShowFiles ? !file.isFolder : true"> <File @selected="onSelected(i)" :isSelected="selectedIndex === i" :fileName="file.fileName" :href="file.href" :isFolder="file.isFolder" /> </li> </template> </ul></template>
Stacking Transparent Elements
Just as a quick note, not only can these nothing
elements be used once, but they can be stacked back-to-back to do... Well, nothing!
Here are some code samples that render out the following:
<p>Test</p>
- React
- Angular
- Vue
<> <> <> <p>Test</p> </> </></>
<ng-container> <ng-container> <ng-container> <p>Test</p> </ng-container> </ng-container></ng-container>
While the other frameworks have a more 1:1 mapping between our pseudo-syntax nothing
, Vue has a slightly different approach due to its reuse of the existing HTML <template>
tag.
By default, if you render a template
in Vue in any other place besides the root, it will render nothing to the screen:
<template> <template> <p>Test</p> </template></template>
It's worth mentioning that even if it shows nothing on screen, the
template
element is still in the DOM itself, waiting to be used in other ways. While explaining "why" an HTMLtemplate
element renders nothing by default is outside the scope of this book, it is expected behavior.
However, if you add a v-for
, v-if
, or a v-slot
(we'll touch on what a v-slot
is in our "Accessing Children" chapter), it will remove the <template>
and only render out the children.
This means that both:
<template> <template v-if="true"> <p>Test</p> </template></template>
And:
<template> <template v-if="true"> <template v-if="true"> <template v-if="true"> <p>Test</p> </template> </template> </template></template>
Will both render out to the following HTML:
<p>Test</p>
Of course, these rules don't apply to the root-level
template
, that acts as a container for our template code. It's a bit confusing at first, but makes sense when you practice more.
Challenge
Now that we understand how to render a transparent element (transparent to the DOM, anyway), let's build out an example where this would be useful.
Namely, let's assume that we want to build out a bar of buttons with a gap between them:
To do this with HTML, we might have the following template and styling:
<div style=" display: 'inline-flex', gap: 1rem; "> <button>Delete</button> <button>Copy</button> <button>Favorite</button> <button>Settings</button></div>
However, what if we wanted to only display the first three buttons:
- Delete
- Copy
- Favorite
Only when a file is selected?
Let's build this out using our favorite frameworks:
- React
- Angular
- Vue
const FileActionButtons = ({ onDelete, onCopy, onFavorite }) => { return ( <div> <button onClick={onDelete}>Delete</button> <button onClick={onCopy}>Copy</button> <button onClick={onFavorite}>Favorite</button> </div> );};const ButtonBar = ({ onSettings, onDelete, onCopy, onFavorite, fileSelected,}) => { return ( <div style={{ display: "flex", gap: "1rem", }} > {fileSelected && ( <FileActionButtons onDelete={onDelete} onCopy={onCopy} onFavorite={onFavorite} /> )} <button onClick={onSettings}>Settings</button> </div> );};
@Component({ selector: "file-action-buttons", standalone: true, template: ` <div> <button (click)="delete.emit()">Delete</button> <button (click)="copy.emit()">Copy</button> <button (click)="favorite.emit()">Favorite</button> </div> `,})class FileActionButtonsComponent { @Output() delete = new EventEmitter(); @Output() copy = new EventEmitter(); @Output() favorite = new EventEmitter();}@Component({ selector: "button-bar", standalone: true, imports: [FileActionButtonsComponent], template: ` <div style="display: flex; gap: 1rem"> @if (fileSelected) { <file-action-buttons (delete)="delete.emit()" (copy)="copy.emit()" (favorite)="favorite.emit()" /> } <button (click)="settings.emit()">Settings</button> </div> `,})class ButtonBarComponent { @Input() fileSelected!: boolean; @Output() delete = new EventEmitter(); @Output() copy = new EventEmitter(); @Output() favorite = new EventEmitter(); @Output() settings = new EventEmitter();}
<!-- FileActionButtons.vue --><script setup>const emit = defineEmits(["delete", "copy", "favorite"]);</script><template> <div> <button @click="emit('delete')">Delete</button> <button @click="emit('copy')">Copy</button> <button @click="emit('favorite')">Favorite</button> </div></template>
<!-- ButtonBar.vue --><script setup>import FileActionButtons from "./FileActionButtons.vue";const props = defineProps(["fileSelected"]);const emit = defineEmits(["delete", "copy", "favorite", "settings"]);</script><template> <div style="display: flex; gap: 1rem"> <FileActionButtons v-if="props.fileSelected" @delete="emit('delete')" @copy="emit('copy')" @favorite="emit('favorite')" /> <button @click="emit('settings')">Settings</button> </div></template>
Oh no! The rendered output isn't as we expected!
That's because when we used a div
for our FileActionButtons
component, it bypassed the gap
property of CSS. To fix this, we can use our handy dandy nothing
element:
- React
- Angular
- Vue
// FileActionButtons<> <button onClick={onDelete}>Delete</button> <button onClick={onCopy}>Copy</button> <button onClick={onFavorite}>Favorite</button></>
Final code output
@Component({ selector: "file-action-buttons", standalone: true, template: ` <ng-container> <button (click)="delete.emit()">Delete</button> <button (click)="copy.emit()">Copy</button> <button (click)="favorite.emit()">Favorite</button> </ng-container> `, styles: [ ` :host { display: contents; } `, ],})class FileActionButtonsComponent { // ...}
We can even simplify this by removing the ng-container
, since Angular supports multiple elements at the root of the component template.
@Component({ selector: "file-action-buttons", standalone: true, template: ` <button (click)="delete.emit()">Delete</button> <button (click)="copy.emit()">Copy</button> <button (click)="favorite.emit()">Favorite</button> `, styles: [ ` :host { display: contents; } `, ],})class FileActionButtonsComponent { // ...}
Unlike the other frameworks we're talking about, Angular's components add in an HTML element in the DOM.
For example, here, our rendered markup looks like:
<div style="display: flex; gap: 1rem;"> <file-action-buttons> <button>Delete</button> <button>Copy</button> <button>Favorite</button> </file-action-buttons> <button>Settings</button></div>
This causes our
gap
root to not apply to the inner buttons. To sidestep this, we need to usestyles
and tell ourhost
component to treat thebutton
s container as if it doesn't exist.
Final code output
Because Vue's root <template>
can support multiple elements without the need for v-if
, v-for
, or v-slot
, we can do the following:
<!-- FileActionButtons.vue --><template> <button @click="emit('delete')">Delete</button> <button @click="emit('copy')">Copy</button> <button @click="emit('favorite')">Favorite</button></template><!-- ... -->