Post contents
Previously, we learned how to create components for our file application. These components included a way to create a component tree, add inputs to each component to pass data, and add an output of data back to a parent component.
Where we last left off, we manually input a list of files, which included file names and dates inside a button
. Let's take a look back at our existing file component to start:
- React
- Angular
- Vue
const File = ({ href, fileName, isSelected, onSelected }) => { // `href` is temporarily unused return ( <button onClick={onSelected} style={ isSelected ? { backgroundColor: "blue", color: "white" } : { backgroundColor: "white", color: "blue" } } > {fileName} <FileDate inputDate={new Date()} /> </button> );};
@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 }} <file-date [inputDate]="inputDate" /> </button> `,})class FileComponent { @Input() fileName!: string; // `href` is temporarily unused @Input() href!: string; @Input() isSelected!: boolean; @Output() selected = new EventEmitter(); inputDate = new Date();}
<!-- File.vue --><script setup>import FileDate from "./FileDate.vue";const inputDate = new Date();// `href` is temporarily unusedconst props = defineProps(["isSelected", "fileName", "href"]);const emit = defineEmits(["selected"]);</script><template> <button v-on:click="emit('selected')" :style=" isSelected ? 'background-color: blue; color: white' : 'background-color: white; color: blue' " > {{ fileName }} <FileDate :inputDate="inputDate" /> </button></template>
This is a strong basis for a component without needing many changes.
We would love to add the ability to see folders listed alongside files. While we could - and arguably should - add in a component that copies/pastes the code from the File
component to create a new Folder
component, let's reuse what we already have!
To do this, we'll create a new property called isFolder
, which hides the date when set to true.
Conditional Rendering
One way we can hide the date
from displaying the user is by reusing an HTML attribute we introduced in the last chapter's challenge: hidden
.
<div hidden="true"> <!-- This won't display to the user --> <FileDate /></div>
This works but introduces a potential problem; while the contents are not shown to the user (and are similarly hidden from screen-readers), they are still present within the DOM.
This means that a large number of these HTML elements marked as hidden
will still be in the DOM. They can still impact performance and memory usage as if they were being displayed to the user.
This might sound counterintuitive at first, but in-memory, non-displayed UI elements have their place; they're particularly useful when building animation systems that visually transition items in and out of view.
To sidestep these performance concerns, React, Angular, and Vue all have a method to "conditionally render" HTML elements based off of a boolean. This means that if you pass false
, it will entirely remove the child HTML elements from the DOM.
Let's see what that looks like in usage:
- React
- Angular
- Vue
const ConditionalRender = ({ bool }) => { return <div>{bool && <p>Text here</p>}</div>;};
We're using React's {}
JavaScript binding to add an AND
statement. This works by using Boolean logic of "short-circuiting". This means that if we have:
const val = true || {};
val
will be set to {}
, while if we have:
const val = false || {};
val
will be set to false
.
React then uses this return value to render the value when the condition inside the curly braces is not undefined
or null
.
This means that these examples will render their contained values:
<div>{0}</div><div>{"Hello"}</div><div>{true || <Comp/>}</div><div>{true}</div>// Renders as<div>0</div><div>Hello</div><div><Comp/></div><div>true</div>
But the following examples will not render their contained values:
<div>{undefined}</div><div>{false}</div>// Both render as<div></div>
import { Component, Input } from "@angular/core";@Component({ selector: "conditional-render", standalone: true, template: `<div> @if(bool) { <p>Text here</p> } </div>`,})class ConditionalRenderComponent { @Input() bool!: boolean;}
Here, we're using a special syntax called "Control Flow Blocks" wrapping our p
tag to stop rendering the element if bool
is false
.
<script setup>const props = defineProps(["bool"]);</script><template> <div><p v-if="bool">Text here</p></div></template>
Unlike Angular, where you need to import the ability to conditionally render an element, Vue treats v-if
as a global attribute that can be added to any element or component.
In this example, when we pass bool
as true
, the component's HTML is rendered as:
<div><p>Text here</p></div>
But when bool
is set to false
, it instead renders the following HTML:
<div></div>
This is possible because React, Angular, and Vue control what is rendered on the screen. Using this, they can remove or add HTML rendered to the DOM with nothing more than a boolean instruction.
Knowing this, let's add conditional rendering to our application.
Conditional Rendering Our Date
Right now, we have a list of files to present to the user. However, if we look back at our mockups, we'll notice that we wanted to list folders alongside files.
Luckily for us, our File
component already manages much of the behavior we'd like to have with a potential Folder
component as well. For example, just like files, we want to select a folder when the user has clicked on it so that we can select multiple files and folders at once.
However, unlike files, folders do not have a creation date since there may be ambiguity of what the "Last modified" date would mean for a folder. Is the last modified date when the folder was renamed? Or was it when a file within said folder was last modified? It's unclear, so we'll axe it.
Despite this difference in functionality, we can still reuse our File
component for folders as well. We can reuse this component by conditionally rendering the date if we know we're showing a folder instead of a file.
Let's add an input to our File
component called isFolder
and prevent the date from rendering if said input is set to `true``.
- React
- Angular
- Vue
const File = ({ href, fileName, isSelected, onSelected, isFolder }) => { return ( <button onClick={onSelected} style={ isSelected ? { backgroundColor: "blue", color: "white" } : { backgroundColor: "white", color: "blue" } } > {fileName} {!isFolder && <FileDate inputDate={new Date()} />} </button> );};const FileList = () => { return ( <ul> <li> <File fileName="File one" href="/file/file_one" /> </li> <li> <File fileName="Folder one" href="/file/folder_one/" isFolder={true} /> </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) { <file-date [inputDate]="inputDate"></file-date> } </button> `,})class FileComponent { @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> <li> <file-item fileName="File one" href="/file/file_one" /> </li> <li> <file-item fileName="Folder one" href="/file/folder_one/" [isFolder]="true" /> </li> </ul> `,})class FileListComponent {}
<!-- File.vue --><script setup>import FileDate from "./FileDate.vue";const inputDate = new Date();const props = defineProps(["isSelected", "isFolder", "fileName", "href"]);const emit = defineEmits(["selected"]);</script><template> <button v-on:click="emit('selected')" :style=" isSelected ? 'background-color: blue; color: white' : 'background-color: white; color: blue' " > {{ fileName }} <FileDate v-if="!isFolder" :inputDate="inputDate" /> </button></template>
<!-- FileList.vue --><script setup>import File from "./File.vue";</script><template> <ul> <li> <File fileName="File one" href="/file/file_one" /> </li> <li> <File fileName="Folder one" href="/file/folder_one/" :isFolder="true" /> </li> </ul></template>
Conditional Branches
We're now able to conditionally show the user the last modified date depending on the isFolder
boolean. However, it may still be unclear to the user what is a folder and what is a file, as we don't have this information clearly displayed to the user yet.
Let's use conditional rendering to show the type of item displayed based on the isFolder
boolean.
- React
- Angular
- Vue
<div> {isFolder && <span>Type: Folder</span>} {!isFolder && <span>Type: File</span>}</div>
<div> @if (isFolder) { <span>Type: Folder</span> } @if (!isFolder) { <span>Type: File</span> }</div>
<div> <span v-if="isFolder">Type: Folder</span> <span v-if="!isFolder">Type: File</span></div>
While working on this, it might become clear that we're effectively reconstructing an if ... else
statement, similar to the following logic in JavaScript.
// This is pseudocode for the above using JavaScript as the syntaxif (isFolder) return "Type: Folder";else return "Type: File";
Like the JavaScript environment these frameworks run in, they also implement a similar else
-style API for this exact purpose.
- React
- Angular
- Vue
One of the benefits of React's JSX templating language is that you're able to embed JavaScript directly inside an element. This embedded JavaScript will then render the return value of the JavaScript inside.
For example, we can use a JavaScript ternary to return a different value if a boolean is true
or false
:
// Will show "Folder" if `isFolder` is true, otherwise show "File"const displayType = isFolder ? "Folder" : "File";
We can combine this information with JSX's ability to treat a tag as a value you can assign to memory to create a if...else
-style render in React:
<div>{isFolder ? <span>Type: Folder</span> : <span>Type: File</span>}</div>
Here, if isFolder
is true
, the following will be rendered:
<div><span>Type: Folder</span></div>
Otherwise, if isFolder
is false
, this will be rendered:
<div><span>Type: File</span></div>
<div> @if (isFolder) { <span>Type: Folder</span> } @else { <span>Type: File</span> }</div>
<div> <span v-if="isFolder">Type: Folder</span> <span v-else>Type: File</span></div>
Here, Vue's if...else
syntax looks fairly similar to the JavaScript pseudo-syntax we displayed above.
It's worth noting that a
v-else
tag must immediately follow av-if
tag; otherwise, it won't work.
Expanded Branches
While an if ... else
works wonders if you only have a single Boolean value you need to check, you'll often need more than a single conditional branch to check against.
For example, what if we added an isImage
Boolean to differentiate between images and other file types?
While we could move back to a simple if
statement for each condition:
- React
- Angular
- Vue
<div> {isFolder && <span>Type: Folder</span>} {!isFolder && isImage && <span>Type: Image</span>} {!isFolder && !isImage && <span>Type: File</span>}</div>
<div> @if (isFolder) { <span>Type: Folder</span> } @if (!isFolder && isImage) { <span>Type: Image</span> } @if (!isFolder && !isImage) { <span>Type: File</span> }</div>
<span v-if="isFolder">Type: Folder</span><span v-if="!isFolder && isImage">Type: Image</span><span v-if="!isFolder && !isImage">Type: File</span>
This can get hard to read with multiple conditionals in a row. As a result, these frameworks have tools that you can use to make things a bit more readable.
- React
- Angular
- Vue
We can chain together ternary operations to treat them as nested if
statements.
By doing so, we can represent the following JavaScript pseudo-syntax:
function getType() { // JavaScript if (isFolder) { return "Folder"; } else { if (isImage) { return "Image"; } else { return "File"; } }}
As the following React JSX
<div> {isFolder ? ( <span>Type: Folder</span> ) : isImage ? ( <span>Type: Image</span> ) : ( <span>Type: File</span> )}</div>
We're able to reuse much of the same logic as JavaScript:
function getType() { if (isFolder) return "Folder"; else if (isImage) return "Image"; else return "File";}
To recreate an if...else
using Angular's Control Flow syntax.
<div> @if (isFolder) { <span>Type: Folder</span> @else if (isImage) { <span>Type: Image</span> } @else { <span>Type: File</span> }</div>
In addition to the if/else
syntax, Angular also has a mechanism for utilizing switch/case
statements. These switch/case statements work by matching a value from a case
to the switch
value. So, if you had:
<div> @switch (type) { @case ("folder") { <span>Type: Folder</span> } @case ("image") { <span>Type: Image</span> } @default { <span>Type: File</span> } }</div>
It would render:
<div><span>Type: Folder</span></div>
Because the @switch
value of 'folder'
matched the @case
value of 'folder'
.
Using this tool, we can even set the @switch
value to true
and add a conditional into the @case
.
<div> @switch (true) { @case (isFolder) { <span>Type: Folder</span> } @case (isImage) { <span>Type: Image</span> } @default { <span>Type: File</span> } }</div>
Just as Vue's v-if/v-else
attributes match JavaScript's if...else
syntax, we can reuse similar logic to JavaScript's:
function getType() { if (isFolder) return "Folder"; else if (isImage) return "Image"; else return "File";}
Using Vue's v-else-if
attribute:
<div> <span v-if="isFolder">Type: Folder</span> <span v-else-if="isImage">Type: Image</span> <span v-else>Type: File</span></div>
Once again, the v-else-if
and v-else
tags must follow one another to work as intended.
Rendering Lists
While we've primarily focused on improvements to our File
component in this chapter, let's take another look at our original FileList
component.
- React
- Angular
- Vue
const FileList = () => { const [selectedIndex, setSelectedIndex] = useState(-1); const onSelected = (idx) => { if (selectedIndex === idx) { setSelectedIndex(-1); return; } setSelectedIndex(idx); }; return ( <ul> <li> <File isSelected={selectedIndex === 0} onSelected={() => onSelected(0)} fileName="File one" href="/file/file_one" isFolder={false} /> </li> <li> <File isSelected={selectedIndex === 1} onSelected={() => onSelected(1)} fileName="File two" href="/file/file_two" isFolder={false} /> </li> <li> <File isSelected={selectedIndex === 2} onSelected={() => onSelected(2)} fileName="File three" href="/file/file_three" isFolder={false} /> </li> </ul> );};
@Component({ selector: "file-list", standalone: true, imports: [FileComponent], template: ` <ul> <li> <file (selected)="onSelected(0)" [isSelected]="selectedIndex === 0" fileName="File one" href="/file/file_one" [isFolder]="false" /> </li> <li> <file (selected)="onSelected(1)" [isSelected]="selectedIndex === 1" fileName="File two" href="/file/file_two" [isFolder]="false" /> </li> <li> <file (selected)="onSelected(2)" [isSelected]="selectedIndex === 2" fileName="File three" href="/file/file_three" [isFolder]="false" /> </li> </ul> `,})class FileListComponent { selectedIndex = -1; onSelected(idx: number) { if (this.selectedIndex === idx) { this.selectedIndex = -1; return; } this.selectedIndex = idx; }}
<script setup>import { ref } from "vue";import File from "./File.vue";const selectedIndex = ref(-1);function onSelected(idx) { if (selectedIndex.value === idx) { selectedIndex.value = -1; return; } selectedIndex.value = idx;}</script><template> <ul> <li> <File @selected="onSelected(0)" :isSelected="selectedIndex === 0" fileName="File one" href="/file/file_one" :isFolder="false" /> </li> <li> <File @selected="onSelected(1)" :isSelected="selectedIndex === 1" fileName="File two" href="/file/file_two" :isFolder="false" /> </li> <li> <File @selected="onSelected(2)" :isSelected="selectedIndex === 2" fileName="File three" href="/file/file_three" :isFolder="false" /> </li> </ul></template>
Upon second glance, something that might immediately jump out at you is just how long these code samples are! Interestingly, this is primarily due to the copy-pasted nature of our File
component being repeated.
What's more, this method of hard-coding file components means that we cannot create new files in JavaScript and display them in the DOM.
Let's fix that by replacing the copy-pasted components with a loop and an array.
- React
- Angular
- Vue
React uses JavaScript's built-in Array.map
method to loop through each item and map them to some React component.
const filesArray = [ { fileName: "File one", href: "/file/file_one", isFolder: false, }, { fileName: "File two", href: "/file/file_two", isFolder: false, }, { fileName: "File three", href: "/file/file_three", isFolder: false, },];const FileList = () => { const [selectedIndex, setSelectedIndex] = useState(-1); const onSelected = (idx) => { if (selectedIndex === idx) { setSelectedIndex(-1); return; } setSelectedIndex(idx); }; // This code sample is missing something and will throw a warning in development mode. // We'll explain more about this later. return ( <ul> {filesArray.map((file, i) => ( <li> <File isSelected={selectedIndex === i} onSelected={() => onSelected(i)} fileName={file.fileName} href={file.href} isFolder={file.isFolder} /> </li> ))} </ul> );};
We can then use the second argument inside the map
to gain access to the index of the looped item.
Just as how the previous @if
control flow block is used to conditionally render items, Angular uses a different block to render a list of items: @for
.
@Component({ selector: "file-list", standalone: true, imports: [FileComponent], template: ` <ul> @for (file of filesArray; track file; let i = $index) { <li> <file-item (selected)="onSelected(i)" [isSelected]="selectedIndex === i" [fileName]="file.fileName" [href]="file.href" [isFolder]="file.isFolder" /> </li> } </ul> `,})class FileListComponent { selectedIndex = -1; onSelected(idx: number) { if (this.selectedIndex === idx) { this.selectedIndex = -1; return; } this.selectedIndex = idx; } filesArray = [ { fileName: "File one", href: "/file/file_one", isFolder: false, }, { fileName: "File two", href: "/file/file_two", isFolder: false, }, { fileName: "File three", href: "/file/file_three", isFolder: false, }, ];}
Inside our @for
, $index
may not seem like it is being defined; however, Angular declares it whenever you attempt to utilize @for
under the hood. Assigning it to a template variable using let
allows you to use it as the index of the looped item.
Likewise, there may be some questions as to what track: file
is doing. We'll answer this in our next section: "Keys"
Vue provides a v-for
global attribute that does for lists what v-if
does for conditional rendering:
<!-- FileList.vue --><script setup>import { ref } from "vue";import File from "./File.vue";const filesArray = [ { fileName: "File one", href: "/file/file_one", isFolder: false, }, { fileName: "File two", href: "/file/file_two", isFolder: false, }, { fileName: "File three", href: "/file/file_three", isFolder: false, },];const selectedIndex = ref(-1);function onSelected(idx) { if (selectedIndex.value === idx) { selectedIndex.value = -1; return; } selectedIndex.value = idx;}</script><template> <ul> <!-- This will throw a warning, more on that soon --> <li v-for="(file, i) in filesArray"> <File @selected="onSelected(i)" :isSelected="selectedIndex === i" :fileName="file.fileName" :href="file.href" :isFolder="file.isFolder" /> </li> </ul></template>
Inside our v-for
, we're accessing both the value of the item (file
) and the index of the looped item (i
).
If we look at the rendered output, we can see that all three files are listed as expected!
Using this code as a base, we could extend this file list to any number of files just by adding another item to the hard-coded filesArray
list; no templating code changes are required!
Keys
Regardless of the framework, you may have encountered an error in the previous code sample that read like the following:
- React
- Angular
- Vue
Warning: Each child in a list should have a unique "key" prop.
NG5002: @for loop must have a "track" expression
Elements in iteration expect to have 'v-bind:key' directives
This is because, in these frameworks, you're expected to pass a special property called the key
(or, track
in Angular), which the respective framework uses to keep track of which item is which.
Without this key
prop, the framework doesn't know which elements have been unchanged and, therefore, must destroy and recreate each element in the array for every list re-render. This can cause massive performance problems and stability headaches.
If you're confused, no worries — there was a lot of technical speech in that last paragraph. Continue reading to see what this means in practical terms and don't be afraid to come back and re-read this section when you're done with the chapter.
Say you have the following:
- React
- Angular
- Vue
const WordList = () => { const [words, setWords] = useState([]); const addWord = () => { const newWord = getRandomWord(); // Remove ability for duplicate words if (words.includes(newWord)) return; setWords([...words, newWord]); }; const removeFirst = () => { const newWords = [...words]; newWords.shift(); setWords(newWords); }; return ( <div> <button onClick={addWord}>Add word</button> <button onClick={removeFirst}>Remove first word</button> <ul> {words.map((word) => { return ( <li> {word.word} <input type="text" /> </li> ); })} </ul> </div> );};const wordDatabase = [ { word: "who", id: 1 }, { word: "what", id: 2 }, { word: "when", id: 3 }, { word: "where", id: 4 }, { word: "why", id: 5 }, { word: "how", id: 6 },];function getRandomWord() { return wordDatabase[Math.floor(Math.random() * wordDatabase.length)];}
@Component({ selector: "word-list", standalone: true, template: ` <div> <button (click)="addWord()">Add word</button> <button (click)="removeFirst()">Remove first word</button> <ul> @for (word of words; track word) { <li> {{ word.word }} <input type="text" /> </li> } </ul> </div> `,})class WordListComponent { words: Word[] = []; addWord() { const newWord = getRandomWord(); // Remove ability for duplicate words if (this.words.includes(newWord)) return; this.words = [...this.words, newWord]; } removeFirst() { const newWords: Word[] = []; for (let i = 0; i < this.words.length; i++) { if (i === 0) continue; // We could just push `this.words[i]` without making a new object // But when we do so the bug I'm hoping to showcase isn't visible. // Further, this is commonplace to make a new object in a list to // avoid accidental mutations newWords.push({ ...this.words[i] }); } this.words = newWords; }}const wordDatabase = [ { word: "who", id: 1 }, { word: "what", id: 2 }, { word: "when", id: 3 }, { word: "where", id: 4 }, { word: "why", id: 5 }, { word: "how", id: 6 },];function getRandomWord() { return wordDatabase[Math.floor(Math.random() * wordDatabase.length)];}interface Word { word: string; id: number;}
<!-- WordList.vue --><script setup>import { ref } from "vue";const wordDatabase = [ { word: "who", id: 1 }, { word: "what", id: 2 }, { word: "when", id: 3 }, { word: "where", id: 4 }, { word: "why", id: 5 }, { word: "how", id: 6 },];function getRandomWord() { return wordDatabase[Math.floor(Math.random() * wordDatabase.length)];}const words = ref([]);function addWord() { const newWord = getRandomWord(); // Remove ability for duplicate words if (words.value.includes(newWord)) return; words.value.push(newWord);}function removeFirst() { words.value.shift();}</script><template> <div> <button @click="addWord()">Add word</button> <button @click="removeFirst()">Remove first word</button> <ul> <li v-for="word in words"> {{ word.word }} <input type="text" /> </li> </ul> </div></template>
Without using some kind of key
prop (or, when you use track obj
without a property in Angular), your list will be destroyed and recreated every time you run addWord
.
This can be demonstrated by typing some text into the input
and pressing the "Remove first word"
button. When you do so, the typed text behaves in a strange way.
In Angular, the input text simply disappears. In React and Vue, however, the text moves to the line of the word below the one you originally typed inside.
Both of these behaviors are quite peculiar — we've seemingly not modified the li
that contains the input
in question; why are its contents moving or being removed entirely?
The reason the input text changes is that the framework isn't able to detect which item in your array has changed and, as a result, marks all DOM elements as "outdated". These "outdated" elements are then destroyed by the framework, only to be immediately reconstructed to ensure the most up-to-date information is displayed to the user.
Instead, we can tell the framework which list item is which with a unique "key" associated with every list item. This key is then able to allow the framework to intelligently prevent the destruction of items that were not changed in a list data change.
Let's see how we can do this in each framework.
- React
- Angular
- Vue
<div> <button onClick={addWord}>Add word</button> <ul> {words.map((word) => { return <li key={word.id}>{word.word}</li>; })} </ul></div>
Here, we're using the key
property to tell React which li
is related to which word
via the word
's unique id
field.
While Angular doesn't have quite the same API for key
as React and Vue, Angular instead uses a track
keyword to figure out which item is which.
@Component({ selector: "word-list", standalone: true, template: ` <div> <button (click)="addWord()">Add word</button> <ul> @for (word of words; track word.id) { <li>{{ word.word }}</li> } </ul> </div> `,})class WordListComponent { words: Word[] = []; // ...}
Using a tracker without a property accessor (track obj
vs track obj.id
) is a way to opt-out of this behavior, as it will track the object reference (===
) of the object in the array. This is useful when you don't have a unique identifier for each item in the array, but is highly discouraged due to the aforementioned performance and behavior benefits of having a unique identifier.
To learn more about object reference tracking, see our article explaining how memory addresses are tracked in JavaScript.
<!-- WordList.vue --><template> <div> <button @click="addWord()">Add word</button> <ul> <li v-for="word in words" :key="word.id">{{ word.word }}</li> </ul> </div></template><!-- ... -->
Here, we're using the key
property to tell Vue which li
is related to which word
via the word
's unique id
field.
Now, when we re-render the list, the framework is able to know exactly which items have and have not changed.
As such, it will only re-render the new items, leaving the old and unchanged DOM elements alone.
Keys as Render Hints
As we mentioned earlier, the key
property is used by the framework to figure out which element is which. Change this key
property for a given element, and it will be destroyed and recreated as if it were a fresh node.
While this is most applicable within lists, this is also true outside them; assign a key
to an element and change it, and it will be recreated from scratch.
For example, let's assume we have a basic input
that we want to be able to reset when a button is pressed.
To do this, we can assign a key
property to the input
and change the value of said key
to force a re-creation of the input
.
- React
- Angular
- Vue
function KeyExample() { const [num, setNum] = useState(0); const increase = () => setNum(num + 1); return ( <div> <input key={num} /> <button onClick={increase}>Increase</button> <p>{num}</p> </div> );}
Because Angular does not have the same API as the key
property, it is unable to follow the same behavior as Vue and React in this instance. Therefore, this section is more useful in understanding the underlying DOM diffing logic as opposed to functional coding advice for Angular in particular.
This isn't necessarily a bad thing, however. We'll touch on this more in a bit, but using key
in this way is often an antipattern.
<!-- KeyExample.vue --><script setup>import { ref } from "vue";const num = ref(0);function increase() { num.value++;}</script><template> <input :key="num" /> <button @click="increase()">Increase</button> <p>{{ num }}</p></template>
This refresh works because we are not persisting the input
's value
, and therefore, when key
is updated and a new input
is rendered in its place, the in-memory DOM value is reset and not bound again.
This reset is what's causing the input
to blank out after a button press.
This idea of an element's "reference" to a framework's understanding of an element can be a bit confusing.
Putting It to Production
Since we now understand the stability and performance benefits of providing a key to our lists, let's add them to our FileList
components.
- React
- Angular
- Vue
const filesArray = [ { fileName: "File one", href: "/file/file_one", isFolder: false, id: 1, }, { fileName: "File two", href: "/file/file_two", isFolder: false, id: 2, }, { fileName: "File three", href: "/file/file_three", isFolder: false, id: 3, },];const FileList = () => { const [selectedIndex, setSelectedIndex] = useState(-1); const onSelected = (idx) => { if (selectedIndex === idx) { setSelectedIndex(-1); return; } setSelectedIndex(idx); }; return ( <ul> {filesArray.map((file, i) => ( <li key={file.id}> <File isSelected={selectedIndex === i} onSelected={() => onSelected(i)} fileName={file.fileName} href={file.href} isFolder={file.isFolder} /> </li> ))} </ul> );};
@Component({ selector: "file-list", standalone: true, imports: [FileComponent], template: ` <ul> @for (file of filesArray; track file.id; let i = $index) { <li> <file-item (selected)="onSelected(i)" [isSelected]="selectedIndex === i" [fileName]="file.fileName" [href]="file.href" [isFolder]="file.isFolder" /> </li> } </ul> `,})class FileListComponent { selectedIndex = -1; onSelected(idx: number) { if (this.selectedIndex === idx) { this.selectedIndex = -1; return; } this.selectedIndex = idx; } filesArray: File[] = [ { fileName: "File one", href: "/file/file_one", isFolder: false, id: 1, }, { fileName: "File two", href: "/file/file_two", isFolder: false, id: 2, }, { fileName: "File three", href: "/file/file_three", isFolder: false, id: 3, }, ];}interface File { fileName: string; href: string; isFolder: boolean; id: number;}
<!-- FileList.vue --><script setup>import { ref } from "vue";import File from "./File.vue";const filesArray = [ { fileName: "File one", href: "/file/file_one", isFolder: false, id: 1, }, { fileName: "File two", href: "/file/file_two", isFolder: false, id: 2, }, { fileName: "File three", href: "/file/file_three", isFolder: false, id: 3, },];const selectedIndex = ref(-1);function onSelected(idx) { if (selectedIndex.value === idx) { selectedIndex.value = -1; return; } selectedIndex.value = idx;}</script><template> <ul> <li v-for="(file, i) in filesArray" :key="file.id"> <File @selected="onSelected(i)" :isSelected="selectedIndex === i" :fileName="file.fileName" :href="file.href" :isFolder="file.isFolder" /> </li> </ul></template>
Using It All Together
Let's use our newfound knowledge of conditional and list rendering and combine them in our application.
Say that our users want to filter our FileList
to only display files and not folders. We can enable this functionality by adding a conditional statement inside our template loop!
- React
- Angular
- Vue
const FileList = () => { // ... const [onlyShowFiles, setOnlyShowFiles] = useState(false); const toggleOnlyShow = () => setOnlyShowFiles(!onlyShowFiles); return ( <div> <button onClick={toggleOnlyShow}>Only show files</button> <ul> {filesArray.map((file, i) => ( <li> {(!onlyShowFiles || !file.isFolder) && ( <File key={file.id} isSelected={selectedIndex === i} onSelected={() => onSelected(i)} fileName={file.fileName} href={file.href} isFolder={file.isFolder} /> )} </li> ))} </ul> </div> );};
@Component({ selector: "file-list", standalone: true, imports: [FileComponent], template: ` <div> <button (click)="toggleOnlyShow()">Only show files</button> <ul> @for (file of filesArray; track file.id; let i = $index) { <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> </div> `,})class FileListComponent { onlyShowFiles = false; toggleOnlyShow() { this.onlyShowFiles = !this.onlyShowFiles; } // ...}
<!-- FileList.vue --><script setup>import { ref } from "vue";// ...const onlyShowFiles = ref(false);function toggleOnlyShow() { onlyShowFiles.value = !onlyShowFiles.value;}</script><template> <div> <button @click="toggleOnlyShow()">Only show files</button> <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> </div></template>
While this code works, there's a silent-yet-deadly bug present. While we'll explain what that bug is within our "Partial DOM Application" chapter, I'll give you a hint: It has to do with conditionally rendering the
File
component instead of theli
element.
Challenge
In our last chapter's challenge, we started to create dropdown file structure sidebar components.
We did this by hard-coding each of our ExpandableDropdown
components as individual tags:
- React
- Angular
- Vue
const ExpandableDropdown = ({ name, expanded, onToggle }) => { return ( <div> <button onClick={onToggle}> {expanded ? "V " : "> "} {name} </button> <div hidden={!expanded}>More information here</div> </div> );};const Sidebar = () => { const [moviesExpanded, setMoviesExpanded] = useState(false); const [picturesExpanded, setPicturesExpanded] = useState(false); const [conceptsExpanded, setConceptsExpanded] = useState(false); const [articlesExpanded, setArticlesExpanded] = useState(false); const [redesignExpanded, setRedesignExpanded] = useState(false); const [invoicesExpanded, setInvoicesExpanded] = useState(false); return ( <div> <h1>My Files</h1> <ExpandableDropdown name="Movies" expanded={moviesExpanded} onToggle={() => setMoviesExpanded(!moviesExpanded)} /> <ExpandableDropdown name="Pictures" expanded={picturesExpanded} onToggle={() => setPicturesExpanded(!picturesExpanded)} /> <ExpandableDropdown name="Concepts" expanded={conceptsExpanded} onToggle={() => setConceptsExpanded(!conceptsExpanded)} /> <ExpandableDropdown name="Articles I'll Never Finish" expanded={articlesExpanded} onToggle={() => setArticlesExpanded(!articlesExpanded)} /> <ExpandableDropdown name="Website Redesigns v5" expanded={redesignExpanded} onToggle={() => setRedesignExpanded(!redesignExpanded)} /> <ExpandableDropdown name="Invoices" expanded={invoicesExpanded} onToggle={() => setInvoicesExpanded(!invoicesExpanded)} /> </div> );};
@Component({ selector: "expandable-dropdown", standalone: true, template: ` <div> <button (click)="toggle.emit()"> {{ expanded ? "V" : ">" }} {{ name }} </button> <div [hidden]="!expanded">More information here</div> </div> `,})class ExpandableDropdownComponent { @Input() name!: string; @Input() expanded!: boolean; @Output() toggle = new EventEmitter();}@Component({ selector: "app-sidebar", standalone: true, imports: [ExpandableDropdownComponent], template: ` <div> <h1>My Files</h1> <expandable-dropdown name="Movies" [expanded]="moviesExpanded" (toggle)="moviesExpanded = !moviesExpanded" /> <expandable-dropdown name="Pictures" [expanded]="picturesExpanded" (toggle)="picturesExpanded = !picturesExpanded" /> <expandable-dropdown name="Concepts" [expanded]="conceptsExpanded" (toggle)="conceptsExpanded = !conceptsExpanded" /> <expandable-dropdown name="Articles I'll Never Finish" [expanded]="articlesExpanded" (toggle)="articlesExpanded = !articlesExpanded" /> <expandable-dropdown name="Website Redesigns v5" [expanded]="redesignExpanded" (toggle)="redesignExpanded = !redesignExpanded" /> <expandable-dropdown name="Invoices" [expanded]="invoicesExpanded" (toggle)="invoicesExpanded = !invoicesExpanded" /> </div> `,})class SidebarComponent { moviesExpanded = false; picturesExpanded = false; conceptsExpanded = false; articlesExpanded = false; redesignExpanded = false; invoicesExpanded = false;}
<!-- ExpandableDropdown.vue --><script setup>const props = defineProps(["name", "expanded"]);const emit = defineEmits(["toggle"]);</script><template> <div> <button @click="emit('toggle')"> {{ expanded ? "V" : ">" }} {{ name }} </button> <div :hidden="!expanded">More information here</div> </div></template>
<!-- Sidebar.vue --><script setup>import { ref } from "vue";import ExpandableDropdown from "./ExpandableDropdown.vue";const moviesExpanded = ref(false);const picturesExpanded = ref(false);const conceptsExpanded = ref(false);const articlesExpanded = ref(false);const redesignExpanded = ref(false);const invoicesExpanded = ref(false);</script><template> <div> <h1>My Files</h1> <ExpandableDropdown name="Movies" :expanded="moviesExpanded" @toggle="moviesExpanded = !moviesExpanded" /> <ExpandableDropdown name="Pictures" :expanded="picturesExpanded" @toggle="picturesExpanded = !picturesExpanded" /> <ExpandableDropdown name="Concepts" :expanded="conceptsExpanded" @toggle="conceptsExpanded = !conceptsExpanded" /> <ExpandableDropdown name="Articles I'll Never Finish" :expanded="articlesExpanded" @toggle="articlesExpanded = !articlesExpanded" /> <ExpandableDropdown name="Website Redesigns v5" :expanded="redesignExpanded" @toggle="redesignExpanded = !redesignExpanded" /> <ExpandableDropdown name="Invoices" :expanded="invoicesExpanded" @toggle="invoicesExpanded = !invoicesExpanded" /> </div></template>
What's more, we used the hidden
HTML attribute to visually hide the collapsed content.
Let's use what we learned in this chapter to improve both of these challenges. In this challenge, we'll:
- Use a list instead of hard-coding each
ExpandableDropdown
individually - Use an object map to keep track of each dropdown's
expanded
property - Migrate the usage of the
hidden
attribute to conditionally render instead
Migrating hard-coded Elements to a List
Let's start by creating an array of strings that we can use to render each dropdown with.
Don't worry about the
expanded
functionality yet, for now let's hard-codeexpanded
tofalse
and point the toggle capability to an empty function.We'll come back to this soon.
- React
- Angular
- Vue
const categories = [ "Movies", "Pictures", "Concepts", "Articles I'll Never Finish", "Website Redesigns v5", "Invoices",];const Sidebar = () => { const onToggle = () => {}; return ( <div> <h1>My Files</h1> {categories.map((cat) => ( <ExpandableDropdown key={cat} name={cat} expanded={false} onToggle={() => onToggle()} /> ))} </div> );};
@Component({ selector: "app-sidebar", standalone: true, imports: [ExpandableDropdownComponent], template: ` <div> <h1>My Files</h1> @for (cat of categories; track cat) { <expandable-dropdown [name]="cat" [expanded]="false" (toggle)="onToggle()" /> } </div> `,})class SidebarComponent { categories = [ "Movies", "Pictures", "Concepts", "Articles I'll Never Finish", "Website Redesigns v5", "Invoices", ]; onToggle() {}}
<!-- Sidebar.vue --><script setup>import ExpandableDropdown from "./ExpandableDropdown.vue";const categories = [ "Movies", "Pictures", "Concepts", "Articles I'll Never Finish", "Website Redesigns v5", "Invoices",];const onToggle = () => {};</script><template> <div> <h1>My Files</h1> <ExpandableDropdown v-for="cat of categories" :key="cat" :name="cat" :expanded="false" @toggle="onToggle()" /> </div></template>
Now that we've got an initial list of dropdowns rendering, let's move forward with re-enabling the expanded
functionality.
To do this, we'll use an object map that uses the name of the category as the key and the expanded
state as the key's value:
({ // This is expanded "Articles I'll Never Finish": true, // These are not Concepts: false, Invoices: false, Movies: false, Pictures: false, "Website Redesigns v5": false,});
To create this object map, we can create a function called objFromCategories
that takes our string array and constructs the above from above:
function objFromCategories(categories) { let obj = {}; for (let cat of categories) { obj[cat] = false; } return obj;}
Let's see this in use:
- React
- Angular
- Vue
const categories = [ "Movies", "Pictures", "Concepts", "Articles I'll Never Finish", "Website Redesigns v5", "Invoices",];const Sidebar = () => { const [expandedMap, setExpandedMap] = useState(objFromCategories(categories)); const onToggle = (cat) => { const newExpandedMap = { ...expandedMap }; newExpandedMap[cat] = !newExpandedMap[cat]; setExpandedMap(newExpandedMap); }; return ( <div> <h1>My Files</h1> {categories.map((cat) => ( <ExpandableDropdown key={cat} name={cat} expanded={expandedMap[cat]} onToggle={() => onToggle(cat)} /> ))} </div> );};function objFromCategories(categories) { let obj = {}; for (let cat of categories) { obj[cat] = false; } return obj;}
@Component({ selector: "app-sidebar", standalone: true, imports: [ExpandableDropdownComponent], template: ` <div> <h1>My Files</h1> @for (cat of categories; track cat) { <expandable-dropdown [name]="cat" [expanded]="expandedMap[cat]" (toggle)="onToggle(cat)" /> } </div> `,})class SidebarComponent { categories = [ "Movies", "Pictures", "Concepts", "Articles I'll Never Finish", "Website Redesigns v5", "Invoices", ]; expandedMap = objFromCategories(this.categories); onToggle(cat: string) { this.expandedMap[cat] = !this.expandedMap[cat]; }}function objFromCategories(categories: string[]) { const obj: Record<string, boolean> = {}; for (const cat of categories) { obj[cat] = false; } return obj;}
<!-- Sidebar.vue --><script setup>import { ref } from "vue";import ExpandableDropdown from "./ExpandableDropdown.vue";const categories = [ "Movies", "Pictures", "Concepts", "Articles I'll Never Finish", "Website Redesigns v5", "Invoices",];const expandedMap = ref(objFromCategories(categories));const onToggle = (cat) => { expandedMap.value[cat] = !expandedMap.value[cat];};function objFromCategories(categories) { let obj = {}; for (let cat of categories) { obj[cat] = false; } return obj;}</script><template> <h1>My Files</h1> <ExpandableDropdown v-for="cat of categories" :key="cat" :name="cat" :expanded="expandedMap[cat]" @toggle="onToggle(cat)" /></template>
Conditionally Rendering Hidden Content
Now that we've migrated our dropdowns to use a list instead of hard-coding each component instance, let's migrate our dropdown's collapsed content to conditionally render instead of using the hidden
HTML attribute.
- React
- Angular
- Vue
const ExpandableDropdown = ({ name, expanded, onToggle }) => { return ( <div> <button onClick={onToggle}> {expanded ? "V " : "> "} {name} </button> {expanded && <div>More information here</div>} </div> );};
Final code output
@Component({ selector: "expandable-dropdown", standalone: true, template: ` <div> <button (click)="toggle.emit()"> {{ expanded ? "V" : ">" }} {{ name }} </button> @if (expanded) { <div>More information here</div> } </div> `,})class ExpandableDropdownComponent { @Input() name!: string; @Input() expanded!: boolean; @Output() toggle = new EventEmitter();}
Final code output
<!-- ExpandableDropdown.vue --><script setup>const props = defineProps(["name", "expanded"]);const emit = defineEmits(["toggle"]);</script><template> <div> <button @click="emit('toggle')"> {{ expanded ? "V" : ">" }} {{ name }} </button> <div v-if="expanded">More information here</div> </div></template>