Too many ViewChild? Try ViewChildren to query DOM elements

Reading Time: 3 minutes

Loading

Introduction

This post describes how to refactor component to use ViewChildren to query DOM elements. It is a common case when inline template has several elements of the same type and the component uses ViewChild to query them. When this pattern occurs, we can consider to render the elements using ngFor and applying ViewChildren to obtain the elements in QueryList.

let's go

Pokemon Controls component applying ViewChild

// pokemon-controls.component.ts

@Component({
  selector: 'app-pokemon-controls',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="container">
      <button class="btn" #btnMinusTwo>-2</button>
      <button class="btn" #btnMinusOne>-1</button>
      <button class="btn" #btnAddOne>+1</button>
      <button class="btn" #btnAddTwo>+2</button>
    </div>
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonControlsComponent implements OnInit, OnDestroy {
  @ViewChild('btnMinusTwo', { static: true, read: ElementRef })
  btnMinusTwo!: ElementRef<HTMLButtonElement>;

  @ViewChild('btnMinusOne', { static: true, read: ElementRef })
  btnMinusOne!: ElementRef<HTMLButtonElement>;

  @ViewChild('btnAddOne', { static: true, read: ElementRef })
  btnAddOne!: ElementRef<HTMLButtonElement>;

  @ViewChild('btnAddTwo', { static: true, read: ElementRef })
  btnAddTwo!: ElementRef<HTMLButtonElement>;
 
  ngOnInit() {
    const btnMinusTwo$ = this.createButtonClickObservable(this.btnMinusTwo, -2);
    const btnMinusOne$ = this.createButtonClickObservable(this.btnMinusOne, -1);
    const btnAddOne$ = this.createButtonClickObservable(this.btnAddOne, 1);
    const btnAddTwo$ = this.createButtonClickObservable(this.btnAddTwo, 2);

    this.subscription = merge(btnMinusTwo$, btnMinusOne$, btnAddOne$, btnAddTwo$)
      .pipe(... RxJS logic...)
      .subscribe(...subscribe logic...);
  }

  createButtonClickObservable(ref: ElementRef<HTMLButtonElement>, value: number) {
    return fromEvent(ref.nativeElement, 'click').pipe(
      map(() => ({ value, action: POKEMON_ACTION.ADD }))
    );
  }
}

In PokemonControlsComponent standalone component, the inline template has four buttons with different template variable names. In the component, I applied ViewChild decorators to access the button elements and create button click Observables.

<div class="container">
   <button class="btn" #btnMinusTwo>-2</button>
   <button class="btn" #btnMinusOne>-1</button>
   <button class="btn" #btnAddOne>+1</button>
   <button class="btn" #btnAddTwo>+2</button>
</div>

const btnMinusTwo$ = this.createButtonClickObservable(this.btnMinusTwo, -2);
const btnMinusOne$ = this.createButtonClickObservable(this.btnMinusOne, -1);
const btnAddOne$ = this.createButtonClickObservable(this.btnAddOne, 1);
const btnAddTwo$ = this.createButtonClickObservable(this.btnAddTwo, 2);

Then, I use merge operator to merge these Observables to emit the current Pokemon id and call Pokemon API to retrieve the current Pokemon

Since these buttons have the same type, I can refactor the template to render them using NgFor and assigning the same template variable name. Moreover, I can simplify all occurrences of ViewChild with a single ViewChildren decorator.

Rather than copy and paste <button> four times, I will render the buttons using ngFor. Before using ngFor, NgFor has to be imported because the component is standalone.

<div class="container">
      <button *ngFor="let delta of [-2, -1, 1, 2]" class="btn" #btn [attr.data-delta]="delta">
        {{ delta < 0 ? delta : '+' + delta }}
      </button>
</div>

The inline template renders all the buttons and assigns #btn template variable name to them. [attr.data-delta]="delta" stores the delta in data attribute named delta and the data attribute will be used when creating button click Observables. In the component, I update the code to apply ViewChildren to query the button elements.

@ViewChildren('btn', { read: ElementRef })
btns: QueryList<ElementRef<HTMLButtonElement>>;

this.btns is undefined in ngOnInit; therefore, I move the RxJS logic to ngAfterViewInit.

ngAfterViewInit(): void {
    const btns$ = this.btns.map(({ nativeElement }) => this.createButtonClickObservable(nativeElement));

    this.subscription = merge(...btns$)
      .pipe(...RxJS logic...)
      .subscribe(...subscribe logic...);
}

this.btns is QueryList and merge expects a spread array. Therefore, I iterate the QueryList to construct an array of Observables and pass the array to merge.

createButtonClickObservable(nativeElement: HTMLButtonElement) {
    const value = +(nativeElement.dataset['delta'] || 0);
    return fromEvent(nativeElement, 'click').pipe(
      map(() => ({ value, action: POKEMON_ACTION.ADD }))
    );
}

In createButtonClickObservable, I look up the delta in the data attribute and continue to create the new Observable.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

  1. Github Repo: https://github.com/railsstudent/ng-pokemon/blob/main/projects/pokemon-demo-6
  2. Stackblitz: https://stackblitz.com/edit/angular-kvsoxr?file=src%2Fpokemon%2Fpokemon-controls%2Fpokemon-controls.component.ts
  3. Youtube: https://www.youtube.com/watch?v=N74CSvHkuaQ
  4. PokeAPI: https://pokeapi.co/