Accessing directives located in child components in Angular

dmitryefimenko

Dmitry A. Efimenko

Posted on October 16, 2023

Accessing directives located in child components in Angular

📜 Scenario

In my application I had a directive that would track unsaved changes in a template-driven form. I won't bother you with the implementation of this directive, but its interface was as follows:

@Directive({
  selector: 'form[appUnsavedChangesTracker]',
  standalone: true,
  exportAs: 'unsavedChangesTracker',
})
export class UnsavedChangesTrackerDirective<T = any> {
  isDirty$: Observable<Boolean>;

  setInitialValue(value: T): void;
}
Enter fullscreen mode Exit fullscreen mode

There were two components. First, the smart component, which would provide data:

@Component({
  selector: `app-smart-component`,
  standalone: true,
  imports: [FormComponent],
  template: `
    <app-form-component
      [formModel]="formModel$ | async"
      (save)="saveData($event)"
    >
    </app-form-component>
  `
})
export class SmartComponent {
  apiService = inject(ApiService);

  formModel$ = this.apiService.getData();

  saveData(data: FormModel) {
    this.apiService.save(data).pipe(
      // when data saved successfully we need to access the
      // UnsavedChangesTrackerDirective and call setInitialValue method
    ).subscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

Second, a dumb component containing the form:

@Component({
  selector: `app-form-component`,
  standalone: true,
  imports: [FormsModule, UnsavedChangesTrackerDirective],
  template: `
    <form appUnsavedChangesTracker (ngSubmit)="saveForm()">
      <input [ngModel]="formModel.name" name="name" />

      // other input controls removed for brievety

      <button type="submit">Save</button>
    </form>
  `
})
export class FormComponent {
  @Input() formModel: FormModel;

  @Output() save = new EventEmitter<FormModel>();

  @ViewChild(NgForm) ngForm: NgForm;

  saveForm() {
    if (this.ngForm.valid) {
      this.save.emit(this.ngForm.value);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you'd like to learn more about the Smart/Dumb component approach, watch this video by Joshua Morony.

Here we encounter a problem: the UnsavedChangesTrackerDirective is attached to the <form>, which is located in a child component. However, the parent smart component needs to access the UnsavedChangesTrackerDirective to update the initial form value when data is saved to the server.

Diagram showing where the UnsavedChangesTrackerDirective is located relatively to the other components

We can't simply query for the UnsavedChangesTrackerDirective directive via @ViewChild() since it's not located in the view of the parent component. We need another way.

💡 Initial solution

There is a saying coined in a story by Francis Bacon:

If the mountain will not come to Muhammad, then Muhammad must go to the mountain

A reference to the directive located in the child component cannot be injected from the parent. However, a directive located in the child component can inject anything up the injection tree. So, we could:

  • Create a dedicated service provided somewhere higher in the injection tree, for example, at the level of the parent component.
  • Inject that new service into the directive located in the child component.
  • Then the directive could "register" itself within the injected service.

A diagram showing where UnsavedChangesTrackerService would be injected

So, here's the service where the directive will "register" itself:

@Injectable()
export class UnsavedChangesTrackerService {
  private tracker$$ = new BehaviorSubject<UnsavedChangesTrackerDirective | undefined>(undefined);
  tracker$ = this.tracker$$.asObservable();

  setTracker(tracker: UnsavedChangesTrackerDirective) {
    this.tracker$$.next(tracker);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the directive can inject the service and "register" itself:

export class UnsavedChangesTrackerDirective<T = any> {
  service = inject(UnsavedChangesTrackerService, { optional: true })

  constructor() {
    if (this.service) {
      this.service.setTracker(this);
    }
  }

  // rest of the functionality is removed for brievety
}
Enter fullscreen mode Exit fullscreen mode

Notice how we used a BehaviorSubject inside the service and "nexted" the instance of the directive into it. The reason for this is that the parent component, along with the provided service, will instantiate first, and the child component will instantiate later. Consequently, there will be a brief moment when the reference to the directive will be undefined. Whether or not this is a problem for you depends on when the tracker directive is being accessed. Using a BehaviorSubject eliminates this issue.

Now, we can update the parent smart component to provide the UnsavedChangesTrackerService and use the referenced directive from it:

@Component({
  selector: `app-smart-component`,
  standalone: true,
  imports: [CommonModule, FormComponent],
  providers: [UnsavedChangesTrackerService],
  template: `
    <app-form-component
      [formModel]="formModel$ | async"
      (save)="saveData($event)"
    >
    </app-form-component>
  `
})
export class SmartComponent {
  apiService = inject(ApiService);
  unsavedChangesTrackerService = inject(UnsavedChangesTrackerService);

  formModel$ = this.apiService.getData();

  saveData(data: FormModel) {
    this.apiService.save(data).pipe(
      // when data saved successfully we need to access the
      // UnsavedChangesTrackerDirective and call setInitialValue method
      withLatestFrom(this.unsavedChangesTrackerService.tracker$),
      tap(([_, tracker]) => {
        tracker.setInitialValue(data);
      })
    ).subscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

⚡ Reusable solution

In the previous example, we figured out how to access a directive used in a child component from a parent component. We achieved this by using a service to register a reference to the directive. This was possible because we had access to the directive's code and could customize it according to our needs. But what if we don't own the directive that we need access?

In such cases, we can still employ a similar approach, but we must make the service more generic so that it can register any type of directive.

@Injectable()
export class RefTrackerService<T> {
  private ref$$ = new BehaviorSubject<T | undefined>(undefined);
  ref$ = this.ref$$.asObservable();

  private definedRef$ = this.ref$.pipe(
    filter((x): x is T => x != null)
  );

  setRef(ref: T) {
    this.ref$$.next(ref);
  }

  withDefinedRef<Source>(): OperatorFunction<Source, [Source, T]> {
    return (source$) =>
      source$.pipe(
        switchMap((val) => {
          return this.definedRef$.pipe(map((ref) => [val, ref] as [Source, T]));
        })
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

There are a few additional features in this version compared to the one we saw earlier, especially the withDefinedRef function. This function is quite similar to the built-in RxJS withLatestFrom function, but don't worry about it for now. There will be an example of how it's used later.

Now that we have a service, we need a method to set the reference to any directive in that service. Angular provides a standard way to access references to directives or components from the template. In our case, we'll want to write something like this:

<form
  unsavedChangesTracker
  #ref="unsavedChangesTracker"
  [trackRef]="ref"
  (ngSubmit)="saveForm()"
>
  <input [ngModel]="formModel.name" name="name" />

  <!-- other input controls removed for brievety -->

  <button type="submit">Save</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Notice the #ref variable. We're defining a variable called "ref" which will contain an instance of UnsavedChangesTrackerDirective.

Also, observe the [trackRef]="ref". We'll create a custom directive that allows us to save that "ref" variable in the RefTrackerService. Here's the "trackRef" directive:

@Directive({
  selector: '[trackRef]',
  standalone: true,
})
export class TrackRefDirective<T = unknown> {
  private refTrackerService = inject(RefTrackerService);

  @Input({ required: true }) trackRef!: T;

  ngOnInit() {
    this.refTrackerService.setRef(this.trackRef);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the parent component can be updated like so:

@Component({
  selector: 'app-smart-component',
  standalone: true,
  imports: [CommonModule, FormComponent],
  providers: [RefTrackerService],
  template: `
  <ng-container *ngIf="formModel$ | async as formModel">
    <app-form-component
      [formModel]="formModel"
      (save)="saveData($event)"
    >
    </app-form-component>
  </ng-container>
  `,
})
export class SmartComponent {
  apiService = inject(ApiService);
  refTrackerService = inject(RefTrackerService<UnsavedChangesTrackerDirective>)

  formModel$ = this.apiService.getData();

  saveData(data: FormModel) {
    this.apiService.saveData(data).pipe(
      this.refTrackerService.withDefinedRef(),
      tap(([_, unsavedChangesTrackerDirective]) => {
        unsavedChangesTrackerDirective.setInitialValue(data);
      })
    ).subscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

The final working code can be found on StackBlitz.

️⚙️ A note on the architecture

There's something I don't quite like about the code example above - the saveData() method and everything within it.

  • The component contains logic for saving data, which makes the component much "smarter" than it needs to be, and this will make it harder to unit-test.
  • Additionally, there is some imperative code in the subscribe() call.

To understand why I believe these are problematic areas, please read my next article: "Dumb Components Are Overrated" (coming soon).

💖 💪 🙅 🚩
dmitryefimenko
Dmitry A. Efimenko

Posted on October 16, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related