DEV Community

Ria Pacheco
Ria Pacheco

Posted on • Edited on

Parent-Child Component Communication with Angular and Vanilla JS

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: 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
}
Enter fullscreen mode Exit fullscreen mode

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.

Image description


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?: [];
}
Enter fullscreen mode Exit fullscreen mode
  • 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
  • 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>
Enter fullscreen mode Exit fullscreen mode
  • 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
Image description

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.
Image description

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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
// sidebar.component.ts

export class SidebarComponent implements OnInit {
  @Input() showsSidebar = true;
}
Enter fullscreen mode Exit fullscreen mode
<!--article.component.html-->
<app-sidebar
  [showsSidebar]="showsTableOfContents"
  [sections]="sections">
</app-sidebar>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

You can identify it in the component like this:

@ViewChild('thisDiv') divElement!: ElementRef;
Enter fullscreen mode Exit fullscreen mode

Which you can then perform functions on, like a pretty scrollIntoView():

onSomeEvent(event) {
  if (event) { 
    this.divElement.nativeElement.scrollIntoView({ behavior: 'smooth'}); 
  }
}
Enter fullscreen mode Exit fullscreen mode

This is our desired use-case:
Image description


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 and EventEmitter 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 of string
// 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>();
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

So now our selector is complete:

<!--article.component.html-->

<app-sidebar
  (onSelection)="onSelect($event)"
  [showsSidebar]="showsTableOfContents"
  [sections]="sections">
</app-sidebar>
Enter fullscreen mode Exit fullscreen mode

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 and mb-3 classes come from my @riapacheco/yutes package that uses SCSS arguments for margin- and padding-manipulating classes (e.g. mt-5 translates to margin-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>
Enter fullscreen mode Exit fullscreen mode

With a little SCSS:

:host {
  width: 100%;
}

.article-view {
  width: 65%;
  margin: auto;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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' });
}
Enter fullscreen mode Exit fullscreen mode

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!

Image description

Ri

Top comments (1)

Collapse
 
fr4nks profile image
Fr4nks • Edited

Can you use document with angular universal. I'm not sure, but I think moving away from document.getElementById gives less problems in server side rendering. I've been trying to find a way around this, the parent child communication is a problem, and I'm having problems with the animations 'smooth' in nativeElement.scrollTo() , but only in chrome and edge. I don't know if these problems are fixable.