Thomas David Kehoe
Thomas David Kehoe

Reputation: 10930

Angular: How to pass data from parent to child constructor?

My app is passing data from a parent component to a child component view but the constructor in the child component (controller) keeps saying unknown. It seems like the @Input decorator is making the variable inaccessible to the constructor.

The data is going from a child component to a parent component and then to another child component. I could make a service to pass the data but I suspect I'd run into the same problem.

The data originates in this child component. The class L2Language has three properties. The default is English, which is emitted OnInit. The data is also emitted when the user changes the language to Spanish.

import { Component, OnInit, Output, EventEmitter } from '@angular/core';

export type L2Language = {
  short: string;
  long: string;
  videos: string;
};

export class HomeToolbarComponent implements OnInit {
  @Output() L2LanguageEvent = new EventEmitter<L2Language>();

  ngOnInit(): void {
    this.L2LanguageEvent.emit(this.language);
  }

  changeL2Language(lang: string) {
    switch (true) {
      case (lang === 'en'):
        this.language.short = 'en';
        this.language.long = 'English';
        this.language.videos = 'English_Videos';
        break;
      case (lang === 'es'):
        this.language.short = 'es';
        this.language.long = 'Spanish';
        this.language.videos = 'Spanish_Videos';
        break;
      default:
        console.log("Error in switch-case.");
    }
    this.L2LanguageEvent.emit(this.language);
  }
}

The data moves from the first child home-toolbar to the parent in the parent view. The data also move from the parent to the second child videos-vocabulary in the parent view.

<div>

    <app-home-toolbar (L2LanguageEvent)="L2LanguageEventHandler($event)"></app-home-toolbar>

    <app-videos-vocabulary [language]="language"></app-videos-vocabulary>

</div>

The parent controller binds the data from the parent view and then calls the Firebase database successfully, using the data from the first child.

import { Component, OnInit } from '@angular/core';
import { AngularFirestore } from '@angular/fire/compat/firestore';
import { Observable } from 'rxjs';
import { L2Language } from './home-toolbar/home-toolbar.component';

export class HomeComponent implements OnInit {
  language: L2Language;
  videos: Observable<any[]>;

  L2LanguageEventHandler(L2LanguageEvent: L2Language) {
    console.log(L2LanguageEvent.short);
    this.language = L2LanguageEvent;
    this.videos = this.firestore.collection('Videos').doc(L2LanguageEvent.long).collection(L2LanguageEvent.videos).valueChanges();
  }
}

That works fine.

To test, I display the data in the second child view:

{{language | json}} {{language.short}}

The data displays on initialization and changes when the user changes the language.

Here's the part that isn't working. I need to call Firebase from the second child component controller, from the constructor.

import { Component, Input } from '@angular/core';
import { AngularFirestore } from '@angular/fire/compat/firestore';
import { Observable } from 'rxjs';
import { L2Language } from '../home-toolbar/home-toolbar.component';

export class VideosVocabularyComponent{
  @Input() language: L2Language;
  videos: Observable<any[]>;

  constructor (
    public firestore: AngularFirestore,
    // public language: L2Language,
    ) { 
    console.log(this.language);
    this.videos = firestore.collection('Videos').doc(this.language.long).collection(this.language.videos).valueChanges(); }
}

The code compiles but console.log(this.language); logs unknown. The Firebase call throws an error Cannot read properties of undefined (reading 'long').

this.videos accesses the variable videos from inside the constructor. Why doesn't this.language access the variable language from inside the constructor?

It seems like the @Input decorator is making the variable language inaccessible to the constructor.

The documentation Dependency injection in Angular says

To inject a dependency in a component's constructor(), supply a constructor argument with the dependency type. The following example specifies the HeroService in the HeroListComponent constructor. The type of heroService is HeroService.

constructor(heroService: HeroService)

I tried this:

constructor (
    public firestore: AngularFirestore,
    public language: L2Language,
    ) { 
    console.log(this.language); 
    this.videos = firestore.collection('Videos').doc('English').collection('English_Videos').valueChanges(); 
}

That threw this error:

No suitable injection token for parameter 'language' of class 'VideosVocabularyComponent'.
Consider using the @Inject decorator to specify an injection token.

Upvotes: 0

Views: 2007

Answers (3)

Thomas David Kehoe
Thomas David Kehoe

Reputation: 10930

The answer is, build a service. What I learned is, data can pass between components passively or actively. Passive means, for example, that you want elements of the view to hide or show as data is passed to a component. Active means that you want to call a method when data is passed to a component.

In this case, I want to call the database when the user clicks between English and Spanish. The view presents Spanish videos when the user wants to learn Spanish, or English videos when the user wants to learn English.

Passing data between parent and child components with an event emitter is passive. I don't know of a way to make a method fire when new data arrives.

Passing data around with a service is active. You can set a method to fire when new data arrives.

I built a LanguageL2Service, put it in the constructor, and it works:

import { Component, OnInit, Input, OnChanges, Inject, } from '@angular/core';

// Firebase
import { AngularFirestore } from '@angular/fire/compat/firestore';
import { Observable } from 'rxjs';

// types
import { L2Language } from '../home-toolbar/home-toolbar.component';

// service
import { LanguageL2Service } from '../../services//languageL2/language-l2.service';

export declare type LearningModes = 'videos' | 'vocabulary' | 'wordSearch';

@Component({
  selector: 'app-videos-vocabulary',
  templateUrl: './videos-vocabulary.component.html',
  styleUrls: ['./videos-vocabulary.component.css']
})
export class VideosVocabularyComponent implements OnInit, OnChanges{
  @Input() language: L2Language;
  videos: Observable<any[]>;

  constructor (
    public firestore: AngularFirestore,
    @Inject(LanguageL2Service) private languageL2Service: LanguageL2Service
    ) { 
    console.log(this.language); // undefined
    this.languageL2Service.getLanguage().subscribe((lang: L2Language) => {
      this.language = lang;
      console.log(this.language); // data object is here
      this.videos = this.firestore.collection('Videos').doc(this.language.long).collection(this.language.videos).valueChanges(); 
    });
  }

}

The first console.log(this.language); logs unknown, the second (in the service) logs the data object.

The service also works in ngOnChanges and in ngOnInit:

export class VideosVocabularyComponent implements OnInit, OnChanges{
  @Input() language: L2Language;
  videos: Observable<any[]>;

  constructor (
    public firestore: AngularFirestore,
    @Inject(LanguageL2Service) private languageL2Service: LanguageL2Service
    ) { }

  ngOnChanges() { // or ngOnInit
    console.log(this.language); 

    this.languageL2Service.getLanguage().subscribe((lang: L2Language) => {
      this.language = lang;
      console.log(this.language); 
      this.videos = this.firestore.collection('Videos').doc(this.language.long).collection(this.language.videos).valueChanges(); 
    });
  }

}

What doesn't work is referencing the variable language with its @Input decorator from the constructor. This doesn't even compile:

export class VideosVocabularyComponent implements OnInit, OnChanges{
  @Input() language: L2Language;
  videos: Observable<any[]>;

 constructor (
    public firestore: AngularFirestore,
    @Inject(LanguageL2Service) private languageL2Service: LanguageL2Service
    ) { 
    this.videos = this.firestore.collection('Videos').doc(this.language.long).collection(this.language.videos).valueChanges(); 
  }
}

Calling the variable language from ngOnInit and ngOnChanges compiles and gets the initial data, that the default language is English. But changing the language to Spanish doesn't get new data.

What gets new data is to click a button in the view that runs a handler function.

export class VideosVocabularyComponent implements OnInit, OnChanges{
  @Input() language: L2Language;
  videos: Observable<any[]>;

  constructor (
    public firestore: AngularFirestore,
    @Inject(LanguageL2Service) private languageL2Service: LanguageL2Service
    ) {
        console.log(this.language);  
      }

  ngOnChanges() {
    console.log(this.language); 
    this.videos = this.firestore.collection('Videos').doc(this.language.long).collection(this.language.videos).valueChanges(); // doesn't get the data
  }

  ngOnInit() {
    console.log(this.language); 
    this.videos = this.firestore.collection('Videos').doc(this.language.long).collection(this.language.videos).valueChanges(); 
  }

  clickMe() {
    console.log(this.language); 
    this.videos = this.firestore.collection('Videos').doc(this.language.long).collection(this.language.videos).valueChanges(); 
  }

}

The console logs fire in the order above: constructor, ngOnChanges, ngOnInit, clickMe.

Upvotes: 0

Andrei Tătar
Andrei Tătar

Reputation: 8295

You won't have your input passed in the constructor. Your input is passed after an instance is created and it can be changed at any time during the lifetime of your component so you should probably cater for that (reloading the videos for the newly selected language).

I would refactor VideosVocabularyComponent to something like this:

import { Input } from '@angular/core';
import { of } from 'rxjs';
import { AngularFirestore } from '@angular/fire/compat/firestore';
import { L2Language } from '../home-toolbar/home-toolbar.component';

export class VideosVocabularyComponent {
    private language$ = new BehaviorSubject<L2Language | null>(null);

    @Input()
    get language() {
        return this.language$.value;
    }
    set language(value: L2Language) {
        this.language$.next(value);
    }

    readonly videos = this.language$.pipe(
        switchMap(l => l
            ? firestore.collection('Videos').doc(l.long).collection(l.videos).valueChanges()
            : of([])
        ),
    );

    constructor(
        public firestore: AngularFirestore,
    ) {
    }
}

Upvotes: 1

Amer
Amer

Reputation: 6706

The component's inputs (@Input) are not available at constructor, you can move the language @Input to one of the component's lifecycle hooks such as ngOnInit or ngOnChanges based on your requirements.

The ngOnChanges() method is your first opportunity to access those properties. Angular calls ngOnChanges() before ngOnInit(), but also many times after that. It only calls ngOnInit() once.

Read more here: https://angular.io/guide/lifecycle-hooks#initializing-a-component-or-directive

Upvotes: 2

Related Questions