Post contents
In our "Passing Children" chapter, we talked about how you can pass components and elements as children to another component:
<FileTableContainer> <FileTableHeader /> <FileTableBody /></FileTableContainer>
We also touched on the ability to access the following:
But only when the respective HTML elements or components are inside the parent's template itself:
// Inside FileTableContainer<FileTableHeader /><FileTableBody />
What if, instead, there was a way to access the children passed to an element through the slotted area one by one?
Well, is there a way to do that?
Yes.
Let's start with a simple example: Counting how many children a component has.
Counting a Component's Children
Let's count how many elements and components are being passed to our component:
- React
- Angular
- Vue
React has a built-in helper called Children
that will help you access data passed as a child to a component. Using this Children
helper, we can use the toArray
method to create an array from children
that we can then do anything we might otherwise do with a typical array.
This comes in handy when trying to access the length
count.
import { Children } from "react";const ParentList = ({ children }) => { const childArr = Children.toArray(children); console.log(childArr); // This is an array of ReactNode - more on that in the next sentence return ( <> <p>There are {childArr.length} number of items in this array</p> <ul>{children}</ul> </> );};const App = () => { return ( <ParentList> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ParentList> );};
Here, childArr
is an array of type ReactNode
. A ReactNode
is created by React's createElement
method.
Remember that JSX transforms to call React's
createElement
node under-the-hood.This means that the following JSX:
const el = <div />;
Becomes the following code after compilation:
const el = createElement("div");
There also exists a Children.count
method that we could use as an alternative if we wanted.
const ParentList = ({ children }) => { const childrenLength = Children.count(children); return ( <> <p>There are {childrenLength} number of items in this array</p> <ul>{children}</ul> </> );};
To get the count of the children elements within a component in Angular requires some pre-requisite knowledge. Let's go through each step until we find ourselves at the solution.
ContentChild
to Access a Single Child
<div #templVar></div>
In our previous example, we used them to conditionally render content using ngIf
, but these template tags aren't simply useful in ngIf
usage. We can also use them in a myriad of programmatic queries, such as ContentChild
.
ContentChild
is a way to query the projected content within ng-content
from JavaScript.
import { Component, AfterContentInit, ContentChild, ElementRef,} from "@angular/core";@Component({ selector: "parent-list", standalone: true, template: `<ng-content></ng-content>`,})class ParentListComponent implements AfterContentInit { @ContentChild("childItem") child!: ElementRef<HTMLElement>; // This cannot be replaced with an `OnInit`, otherwise `children` is empty. We'll explain soon. ngAfterContentInit() { console.log(this.child.nativeElement); // This is an HTMLElement }}@Component({ selector: "app-root", standalone: true, imports: [ParentListComponent], template: ` <parent-list> <p #childItem>Hello, world!</p> </parent-list> `,})class AppComponent {}
Here, we're querying for the template tag childItem
within the project content by using the ContentChild
decorator.
ContentChild
then returns a TypeScript generic type of ElementRef
.
ElementRef
is a type that has a single property called nativeElement
containing the HTMLElement in question.
ngAfterContentInit
Detects Child Initialization
If you were looking at the last code sample and wondered:
"What is
ngAfterContentInit
and why are we using it in place ofngOnInit
?"
Then you're asking the right questions!
See, if we replace our usage of ngAfterContentInit
with a ngOnInit
, then we get undefined
in place of this.child
:
@Component({ selector: "parent-list", standalone: true, template: ` <ng-content></ng-content> `,})class ParentListComponent implements OnInit { @ContentChild("childItem") child!: ElementRef<HTMLElement>; ngOnInit() { console.log(this.child); // This is `undefined` }}
This is because while ngOnInit
runs after the component has rendered, it has not yet received any values within ng-content
; this is where ngAfterContentInit
comes into play. This lifecycle method runs once ng-content
has received the values, which we can then use as a sign that ContentChild
has finished its query.
This can be solved by either:
- Using
ngAfterContentInit
if the content is dynamic - Using
{static: true}
on theContentChild
decorator if the content is static
Handle Multiple Children with ContentChildren
While ContentChild
is useful for querying against a single item being projected, what if we wanted to query against multiple items being projected?
This is where ContentChildren
comes into play:
import { Component, AfterContentInit, ContentChildren, QueryList,} from "@angular/core";@Component({ selector: "parent-list", standalone: true, template: ` <p>There are {{ children.length }} number of items in this array</p> <ul> <ng-content></ng-content> </ul> `,})class ParentListComponent implements AfterContentInit { @ContentChildren("listItem") children!: QueryList<HTMLElement>; ngAfterContentInit() { console.log(this.children); }}@Component({ selector: "app-root", standalone: true, imports: [ParentListComponent], template: ` <parent-list> <li #listItem>Item 1</li> <li #listItem>Item 2</li> <li #listItem>Item 3</li> </parent-list> `,})class AppComponent {}
ContentChildren
returns an array-like QueryList
generic type. You can then access the properties of children
inside of the template itself, like what we're doing with children.length
.
Unlike React and Angular, Vue's APIs don't allow us to count a child's list items easily. There are a lot of nuances as to why this is the case, but we'll do our best to explain that when we rewrite Vue from scratch in the third book of the series.
Instead, you'll want to pass the list from the parent component to the list display to show the value you intend:
<!-- ParentList --><script setup>const props = defineProps(["list"]);</script><template> <p>There are {{ props.list.length }} number of items in this array</p> <ul> <slot></slot> </ul></template>
<!-- App.vue --><script setup>import ParentList from "./ParentList.vue";const list = [1, 2, 3];</script><template> <ParentList :list="list"> <li v-for="i in list">Item {{ i }}</li> </ParentList></template>
Rendering Children in a Loop
Now that we're familiar with accessing children let's use the same APIs introduced before to take the following component template:
<ParentList> <span>One</span> <span>Two</span> <span>Three</span></ParentList>
To render the following HTML:
<ul> <li><span>One</span></li> <li><span>Two</span></li> <li><span>Three</span></li></ul>
- React
- Angular
- Vue
const ParentList = ({ children }) => { const childArr = Children.toArray(children); console.log(childArr); // This is an array of ReactNode - more on that in the next sentence return ( <> <p>There are {childArr.length} number of items in this array</p> <ul> {children.map((child) => { return <li>{child}</li>; })} </ul> </> );};const App = () => { return ( <ParentList> <span style={{ color: "red" }}>Red</span> <span style={{ color: "green" }}>Green</span> <span style={{ color: "blue" }}>Blue</span> </ParentList> );};
Since Angular's ContentChildren
gives us an HTMLElement
reference when using our template variables on HTMLElements
, we're not able to wrap those elements easily.
Instead, let's change our elements to ng-template
s and render them in an ngFor
, similarly to what we did in our "Directives" chapter:
@Component({ selector: "parent-list", standalone: true, imports: [NgTemplateOutlet], template: ` <p>There are {{ children.length }} number of items in this array</p> <ul> @for (child of children; track child) { <li> <ng-template [ngTemplateOutlet]="child"></ng-template> </li> } </ul> `,})class ParentListComponent { @ContentChildren("listItem") children!: QueryList<TemplateRef<any>>;}@Component({ standalone: true, imports: [ParentListComponent], selector: "app-root", template: ` <parent-list> <ng-template #listItem> <span style="color: red">Red</span> </ng-template> <ng-template #listItem> <span style="color: green">Green</span> </ng-template> <ng-template #listItem> <span style="color: blue">Blue</span> </ng-template> </parent-list> `,})class AppComponent {}
Just as before, Vue's APIs have a limitation when accessing direct children. Let's explore why in the third book in our book series.
Adding Children Dynamically
Now that we have a list of items being transformed by our component let's add the functionality to add another item to the list:
- React
- Angular
- Vue
const ParentList = ({ children }) => { const childArr = Children.toArray(children); console.log(childArr); return ( <> <p>There are {childArr.length} number of items in this array</p> <ul> {children.map((child) => { return <li>{child}</li>; })} </ul> </> );};const App = () => { const [list, setList] = useState([1, 42, 13]); const addOne = () => { // `Math` is built into the browser const randomNum = Math.floor(Math.random() * 100); setList([...list, randomNum]); }; return ( <> <ParentList> {list.map((item, i) => ( <span key={i}> {i} {item} </span> ))} </ParentList> <button onClick={addOne}>Add</button> </> );};
@Component({ selector: "parent-list", standalone: true, imports: [NgTemplateOutlet], template: ` <p>There are {{ children.length }} number of items in this array</p> <ul> @for (child of children; track child) { <li> <ng-template [ngTemplateOutlet]="child"></ng-template> </li> } </ul> `,})class ParentListComponent { @ContentChildren("listItem") children!: QueryList<TemplateRef<any>>;}@Component({ standalone: true, imports: [ParentListComponent], selector: "app-root", template: ` <parent-list> @for (item of list; track item; let i = $index) { <ng-template #listItem> <span>{{ i }} {{ item }}</span> </ng-template> } </parent-list> <button (click)="addOne()">Add</button> `,})class AppComponent { list = [1, 42, 13]; addOne() { const randomNum = Math.floor(Math.random() * 100); this.list.push(randomNum); }}
While Vue can't render children in a list, it has many more capabilities to showcase. Read on, dear reader.
Here, we can see that whenever a random number is added to the list, our list item counter still increments properly.
Passing Values to Projected Content
While counting the number of items in a list is novel, it's not a very practical use of accessing projected content in JavaScript.
Instead, let's see if there's a way that we can pass values to our projected content. For example, let's try to change the background color of each li
item if the index is even or odd.
- React
- Angular
- Vue
By now, we should be familiar with the children
property in React. Now get ready to forget everything you know about it:
const AddTwo = ({ children }) => { return 2 + children;};// This will display "7"const App = () => { return <AddTwo children={5} />;};
WHAT?!
Yup — as it turns out, you can pass any JavaScript value to React's children
prop. It even works when you write it out like this:
const AddTwo = ({ children }) => { return 2 + children;};const App = () => { return <AddTwo>{5}</AddTwo>;};
Knowing this, what happens if we pass a function as children
?:
const AddTwo = ({ children }) => { return 2 + children();};// This will display "7"const App = () => { return <AddTwo>{() => 5}</AddTwo>; // OR <AddTwo children={() => 5} />};
Sure enough, it works!
Now, let's combine this knowledge with the ability to use JSX wherever a value might go:
const ShowMessage = ({ children }) => { return children();};const App = () => { return <ShowMessage>{() => <p>Hello, world!</p>}</ShowMessage>;};
Finally, we can combine this with the ability to pass values to a function:
const ShowMessage = ({ children }) => { return children("Hello, world!");};const App = () => { return <ShowMessage>{(message) => <p>{message}</p>}</ShowMessage>;};
Confused about how this last one is working? It might be a good time to review your knowledge on how functions are able to pass to one another and call each other.
Displaying the List in React
Now that we've seen the capabilities of a child function, let's use it to render a list with alternating backgrounds:
const ParentList = ({ children, list }) => { return ( <> <ul> {list.map((item, i) => { return ( <Fragment key={item}> {children({ backgroundColor: i % 2 ? "grey" : "", item, i, })} </Fragment> ); })} </ul> </> );};const App = () => { const [list, setList] = useState([1, 42, 13]); const addOne = () => { const randomNum = Math.floor(Math.random() * 100); setList([...list, randomNum]); }; return ( <> <ParentList list={list}> {({ backgroundColor, i, item }) => ( <li style={{ backgroundColor: backgroundColor }}> {i} {item} </li> )} </ParentList> <button onClick={addOne}>Add</button> </> );};
Let's use the ability to pass values to an ngTemplate using context to provide the background color to the passed template to our ParentList
component:
@Component({ selector: "parent-list", standalone: true, template: ` <p>There are {{ children.length }} number of items in this array</p> <ul> @for (let template of children; track template; let i = $index) { <ng-template [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{ backgroundColor: i % 2 ? 'grey' : '' }" ></ng-template> } </ul> `,})class ParentListComponent { @ContentChildren("listItem", { read: TemplateRef }) children: QueryList< TemplateRef<any> >;}@Component({ selector: "app-root", standalone: true, imports: [ParentListComponent], template: ` <parent-list> @for (item of list; track item; let i = $index) { <ng-template #listItem let-backgroundColor="backgroundColor"> <li [style]="{ backgroundColor }">{{ i }} {{ item }}</li> </ng-template> } </parent-list> <button (click)="addOne()">Add</button> `,})class AppComponent { list = [1, 42, 13]; addOne() { const randomNum = Math.floor(Math.random() * 100); this.list.push(randomNum); }}
Vue ca... Wait! Vue can do this one!
Let's take our code from the start of this chapter and refactor it so that we don't have to have our v-for
inside of the App.vue
. Instead, let's move it into ParentList.vue
and pass properties to the <slot>
element.
<!-- ParentList.vue --><script setup>const props = defineProps(["list"]);</script><template> <p>There are {{ props.list.length }} number of items in this array</p> <ul id="parentList"> <slot v-for="(item, i) in props.list" :item="item" :i="i" :backgroundColor="i % 2 ? 'grey' : ''" ></slot> </ul></template>
<!-- App.vue --><script setup>import { ref } from "vue";import ParentList from "./ParentList.vue";const list = ref([1, 2, 3]);function addOne() { const randomNum = Math.floor(Math.random() * 100); list.value.push(randomNum);}</script><template> <ParentList :list="list"> <!-- Think of this as "template is receiving an object we'll call props" from "ParentList" --> <template v-slot="props"> <li :style="'background-color:' + props.backgroundColor"> {{ props.i }} {{ props.item }} </li> </template> </ParentList> <button @click="addOne()">Add</button></template>
This v-slot
is similar to how you might pass properties to a component, but instead, we're passing data directly to a template
to be rendered by v-slot
.
You can object destructure the
v-slot
usage to gain access to the property names without having to repeatprops
each time:<template v-slot="{item, i}"> <li>{{i}} {{item}}</li></template>
Challenge
Let's write a table component! Something like this:
Heading One | Heading Two |
---|---|
Some val 1 | Some val 2 |
Some val 3 | Some val 4 |
Some val 5 | Some val 6 |
However, instead of having to write out the HTML ourselves, let's try to make this table easy to use for our development team.
Let's pass:
- An array of object data
- A table header template that receives the length of object data
- A table body template that receives the value for each row of data
This way, we don't need any loops in our App
component.
- React
- Angular
- Vue
const Table = ({ data, header, children }) => { const headerContents = header({ length: data.length }); const body = data.map((value, rowI) => children({ value, rowI })); return ( <table> <thead>{headerContents}</thead> <tbody>{body}</tbody> </table> );};const data = [ { name: "Corbin", age: 24, }, { name: "Joely", age: 28, }, { name: "Frank", age: 33, },];function App() { return ( <Table data={data} header={({ length }) => ( <tr> <th>{length} items</th> </tr> )} > {({ rowI, value }) => ( <tr> <td style={ rowI % 2 ? { background: "lightgrey" } : { background: "lightblue" } } > {value.name} </td> </tr> )} </Table> );}
Final code output
@Component({ selector: "table-comp", standalone: true, imports: [NgTemplateOutlet], template: ` <table> <thead> <ng-template [ngTemplateOutlet]="header" [ngTemplateOutletContext]="{ length: data.length }" /> </thead> <tbody> @for (item of data; track item; let index = index) { <ng-template [ngTemplateOutlet]="body" [ngTemplateOutletContext]="{ rowI: index, value: item }" /> } </tbody> </table> `,})class TableComponent { @ContentChild("header", { read: TemplateRef }) header!: TemplateRef<any>; @ContentChild("body", { read: TemplateRef }) body!: TemplateRef<any>; @Input() data!: any[];}@Component({ selector: "app-root", standalone: true, imports: [TableComponent], template: ` <table-comp [data]="data"> <ng-template #header let-length="length"> <tr> <th>{{ length }} items</th> </tr> </ng-template> <ng-template #body let-rowI="rowI" let-value="value"> <tr> <td [style]=" rowI % 2 ? 'background: lightgrey' : 'background: lightblue' " > {{ value.name }} </td> </tr> </ng-template> </table-comp> `,})class AppComponent { data = [ { name: "Corbin", age: 24, }, { name: "Joely", age: 28, }, { name: "Frank", age: 33, }, ];}
Final code output
<!-- App.vue --><script setup>import Table from "./Table.vue";const data = [ { name: "Corbin", age: 24, }, { name: "Joely", age: 28, }, { name: "Frank", age: 33, },];</script><template> <Table :data="data"> <template #header="{ length }"> <tr> <th>{{ length }} items</th> </tr> </template> <template v-slot="{ rowI, value }"> <tr> <td :style="rowI % 2 ? 'background: lightgrey' : 'background: lightblue'" > {{ value.name }} </td> </tr> </template> </Table></template>
<!-- Table.vue --><script setup>const props = defineProps(["data"]);</script><template> <table> <thead> <slot name="header" :length="props.data.length"></slot> </thead> <tbody> <slot v-for="(item, i) in props.data" :key="i" :rowI="i" :value="props.data[i]" /> </tbody> </table></template>