📜 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;
}
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();
}
}
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);
}
}
}
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.
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.
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);
}
}
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
}
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();
}
}
⚡ 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]));
})
);
}
}
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>
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);
}
}
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();
}
}
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).
Top comments (1)
You could just make the initialValue property a setter (or use ngOnChanges) and re-emit it from parent to child.
You have registration, but you don’t have unregistration. In general, sub-components (and their directives) might have a shorter lifecycle than parents.