Parent-Child Component Communication with Angular and Vanilla JS
Ria Pacheco
Posted on May 25, 2022
This post uses the code picked up from my last article (that created a custom slide-out menu with dynamic data), the code of which you can fork from this branch. As an aside: this is a long article because it's the one I needed when I started coding with Angular.
Core Concepts
- Defining Types with Interfaces (and why)
- Component-to-Component Communication with
@Input()
and@Output()
- Two-way Data Binding through Child Selectors
- One-Way Binding of Events with
EventEmitter
- Labelling DOM elements with Dynamic Parent Data
Skip Ahead
- Brief Refresher: Data and Event
- Why Define Types with Interfaces?
- Parent Component Placement and Preparation
- Binding Data Across Components
- Event Binding for Dynamic Functions
- Scrolling to Dynamically Anchored Elements
- End Result
Brief Refresher: Component Data and Click Event
In the last article, you might remember that we added data directly to a component (.ts
file) so that we could bind it dynamically to the template (.html
file).
If you missed it, fork this branch from my repo
FYI: I took the example
strings
from a Systems Engineering textbook that was sitting next to me (yes, I read textbooks for fun)
export class SidebarComponent implements OnInit {
showsSidebar = true;
sections = [
{
sectionHeading: 'The Evolving State of SE',
sectionTarget: 'theEvolvingStateOfSe',
sectionContents: [
{
title: 'Definitions and Terms',
target: 'definitionsAndTerms',
},
{
title: 'Root Cause Analysis',
target: 'rootCauseAnalysis',
},
{
title: 'Industry and Government',
target: 'industryAndGovernment',
},
{
title: 'Engineering Education',
target: 'engineeringEducation',
},
{
title: 'Chapter Exercises',
target: 'chapterExercises'
}
]
},
{
// ... another section's data
}
];
// more component code
}
Click Event
We also created a click event that would pass the target
data (called sectionTarget
at the first level and target
within the sectionContents
array) through to the console.
Why Define Types with Interfaces?
- Adding the wrong data type by accident (e.g. a property type that has an
s
added to the end which isn't in the original format, like "contents" versus "content") is extremely difficult to debug - Writing lots of code (without defined data types), will not notify us of any mismatched data between a component (or service's) function and the data source until runtime
- We want some order when it comes to the wild wild west of JavaScript
Creating the Interface
Let's create an interface that defines the data we revisited in the previous section. Create the file by running $ ng g interface interfaces/system-concepts --type=interface
.
- The
--type=interface
flag simply adds-.interface.ts
extension to the file name so that it's neat and pretty.
First we'll add the main shape of the data. Notice that I added an I
to the beginning of the interface name to differentiate it from classes and to take advantage of VSCode's IntelliSense plugins for TS:
// system-concepts.interface.ts
export interface ISystemConcepts {
sectionHeading?: string;
sectionTarget?: string;
sectionContents?: [];
}
- Adding question marks makes the property optional so that no errors occur if we don't mention this property when mentioning the object.
Since the sectionContents
property contains an array of objects which have their own defined properties (for title
and target
), we'll add another interface before this one so we can refer to it with this array:
// system-concepts.interface.ts
export interface ISectionContents {
title?: string;
target?: string;
}
export interface ISystemConcepts {
sectionHeading?: string;
sectionTarget?: string;
sectionContents?: ISectionContents[]; // We refer to ISectionContents here
}
Adding the Interface to the Component's Data
Now back inside the component we can import the interface and add it to the sections
array. Make sure to follow it with []
since it's defining an array instead of a single object (section):
// sidebar.component.ts
import { ISystemConcepts } from './../../interfaces/system-concepts.interface';
export class SidebarComponent implements OnInit {
showsSidebar = true;
// Here is where we add the imported interface
sections: ISystemConcepts[] = [
{
sectionHeading: 'The Evolving State of SE',
sectionTarget: 'theEvolvingStateOfSe',
sectionContents: [
{
title: 'Definitions and Terms',
target: 'definitionsAndTerms',
// More component code
}
- If you run
$ ng serve
, you'll see that there's no errors - While the app is still served locally, try changing the interface to see what errors pop up
Parent Component Placement and Preparation
- Create a parent component called Article by running
$ ng g c views/article
in your terminal. - In the
app.component.html
file, remove the<app-sidebar>
selector and replace it with the new<app-article></app-article
selector - If you run
$ ng serve
, you should see "article works!" in your browser.
<!-- app.component.html -->
<app-article></app-article>
- Now remove the content from the Article component and replace it with the child
<app-sidebar></app-sidebar>
selector
You'll see it behave exactly the same as before
Making the Parent the Main Data Reader
Copy the data from the child component's sections
array and paste it into a new sections
property created in the parent component.
If you erase the data from the child component, leaving behind an empty sections: ISystemConcepts[] = [];
property, the locally served app will still show the actual sidebar itself, but none of the data will render.
Binding Data Across Components
To render the data that's now defined in the Parent article.component.ts
file, we have to create a door that translates one property as another (since they wouldn't need the same names). To do this, we import the @Input()
decorator (the "door") from Angular's core package into the child sidebar.component.ts
file, and prefix that child's sections
property with it like this:
// sidebar.component.ts
// We import `Input` from angular core ⤵️
import { Component, Input, OnInit } from '@angular/core';
// ... other code
export class SidebarComponent implements OnInit {
showsSidebar = true;
@Input() sections: ISystemConcepts[] = [];
// ... more code
}
This "door" we created allows us to now bind the data to the <app-sidebar></app-sidebar>
selector that's inside the parent component. In Angular, anything with []
indicates two-way data binding, while ()
indicates events (comes up later).
<!--article.component.html-->
<app-sidebar [sections]>
</app-sidebar>
However, this will shoot out an error since we haven't defined any parent properties for the [sections]
property to translate into. Since the parent component's data property is also called sections
we can add it here:
<!--article.component.html-->
<!--This means [childDataProperty]="parentDataProperty"-->
<app-sidebar [sections]="sections">
</app-sidebar>
Now, if you serve the app, you'll see that the data is back.
Before moving on, remember to add the ISystemConcepts[]
interface to the parent's sections
property like we had in the child component and to create a showsTableOfContents = false;
property in the parent that the child should access the same way as above:
// article.component.ts
export class ArticleComponent implements OnInit {
showsTableOfContents = false;
}
// sidebar.component.ts
export class SidebarComponent implements OnInit {
@Input() showsSidebar = true;
}
<!--article.component.html-->
<app-sidebar
[showsSidebar]="showsTableOfContents"
[sections]="sections">
</app-sidebar>
Event Binding for Dynamic Functions
Now that we've reviewed component-to-component binding (using []
in the selector element), we can similarly bind events using ()
.
The Goal: Scrolling to a "Section"
Generally you can use Angular's @ViewChild
decorator to identify an element in the DOM and perform actions on it. As an example, if your template has an element with an anchored ID like this:
<div #thisDiv></div>
You can identify it in the component like this:
@ViewChild('thisDiv') divElement!: ElementRef;
Which you can then perform functions on, like a pretty scrollIntoView()
:
onSomeEvent(event) {
if (event) {
this.divElement.nativeElement.scrollIntoView({ behavior: 'smooth'});
}
}
The Challenge: Anchoring Elements with Dynamic Data
Since we want the child component to remain "dumb", and for any instantiated data to come from the parent, our creating static IDs for elements we'd want to scroll into wouldn't match any data fed from the child's emitted event. Ideally, we want the app to define anchor IDs with dynamic parent data; and to scroll to those exact anchors if they match the parent data coming through the click event.
Binding the Event
First, we want the child (sidebar) component to send an event to the parent. We can do this using Angular's EventEmitter
from the core package.
- Import
Output
andEventEmitter
from the core package into the sidebar component - Prefix a label [I’m calling it “onSelection”] with
@Output()
and have it instantiate an event emitter to the type ofstring
// sidebar.component.ts
// Import from core package like this ⤵️
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
// ... more code
export class SidebarComponent implements OnInit {
@Input() showsSidebar = true;
@Input() sections: ISystemConcepts[] = [];
// Here's where we label a new EventEmitter with `onSelection` ⤵️
@Output() onSelection = new EventEmitter<string>();
Now, instead of the child's onTargetContentClick()
function simply sending target strings to your console, we can use the emitter to emit the string to the parent (where another parallel method will catch it):
// sidebar.component.ts
onTargetContentClick(targetString: string | undefined, event: Event) {
if (event) {
this.onSelection.emit(targetString);
this.closeSidebar();
}
}
Similar to how we bounded data to the selector to populate the template, we can bind events with ()
instead of []
to match up with events in the component:
<!--article.component.html-->
<app-sidebar
(onSelection)=""
[showsSidebar]="showsTableOfContents"
[sections]="sections">
</app-sidebar>
And just like before, it will return an error unless we create a parallel event for it to pass the data to:
// article.component.ts
onSelect(event: Event) {
console.log(event);
}
So now our selector is complete:
<!--article.component.html-->
<app-sidebar
(onSelection)="onSelect($event)"
[showsSidebar]="showsTableOfContents"
[sections]="sections">
</app-sidebar>
Scrolling to Dynamically Anchored Elements
In the article.component.html
file, I created some structure beneath the <app-sidebar>
selector for populating article content.
Note: the
mt-5
andmb-3
classes come from my @riapacheco/yutes package that uses SCSS arguments for margin- and padding-manipulating classes (e.g.mt-5
translates tomargin-top: 5rem
)
<!--article.component.html -->
<!--Child Component-->
<app-sidebar
[showsSidebar]="showsTableOfContents"
[sections]="sections"
(onSelection)="onSelect($event)">
</app-sidebar>
<div class="article-view">
<!--Section Block -->
<div
*ngFor="let section of sections"
class="section mt-5 mb-3">
<!--Section Title -->
<h1>
{{ section.sectionHeading }}
</h1>
<!--Content Block-->
<div
*ngFor="let content of section.sectionContents"
class="section-items mt-3">
<h3>
{{ content.title }}
</h3>
<!--Filler text that would have otherwise come from the data source but I was lazy -->
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Maxime odio molestiae doloremque nulla facilis. Pariatur officiis repudiandae ab vero ratione? Sint illum rem error aliquam consectetur incidunt quo laborum. Neque!
Lorem ipsum dolor sit amet consectetur adipisicing elit. Odit repellendus fugiat commodi impedit incidunt mollitia sed non dicta qui velit earum quos eligendi, eveniet porro labore dolore, fugit inventore alias.
</p>
</div>
</div>
</div>
With a little SCSS:
:host {
width: 100%;
}
.article-view {
width: 65%;
margin: auto;
}
Dynamic IDs
Ideally, we want the anchor IDs to match the target strings fed through the click event. So, for the section heading itself (that we want to scroll to), we add the following ID (which matches the data passed through). We do the same for the content block too!:
<div class="article-view">
<!--Section Block -->
<div
*ngFor="let section of sections"
class="section mt-5 mb-3">
<!--**ADDED THIS ID⤵️** -->
<h1 id="{{section.sectionTarget}}">
{{ section.sectionHeading }}
</h1>
<!--Content Block-->
<div
*ngFor="let content of section.sectionContents"
class="section-items mt-3">
<!--**ADDED THIS ID ⤵️ **-->
<h3 id="{{content.target}}">
{{ content.title }}
</h3>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Maxime odio molestiae doloremque nulla facilis. Pariatur officiis repudiandae ab vero ratione? Sint illum rem error aliquam consectetur incidunt quo laborum. Neque!
Lorem ipsum dolor sit amet consectetur adipisicing elit. Odit repellendus fugiat commodi impedit incidunt mollitia sed non dicta qui velit earum quos eligendi, eveniet porro labore dolore, fugit inventore alias.
</p>
</div>
Vanilla JS and scrollIntoView
In the parent component we can now use some Vanilla JS to grab that ID and attach it to a const; which will act as our anchor for scrolling:
onSelect(targetString: string) {
const targetLocation = document.getElementById(targetString);
targetLocation?.scrollIntoView({ behavior: 'smooth' });
}
End Result
And there you have it: a reusable sidebar component that can scroll to dynamic IDs you don't need to statically populate every time. Now you can create menus, documentation views, and whatever else you’d like with a reusable sidebar component you made from scratch!
Ri
Posted on May 25, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 2, 2024