Leveraging @angular 15 host directives
Denis Severin
Posted on January 26, 2023
Not while ago, Angular team released a stable 15 version with few neat features, such as host directives.
In this article I will try to explain how to leverage the directive composition approach and move from old class-inheritance to a composition approach.
What are host directives?
Host directives are a standalone directives that can be added to a component via @Component
decorator, thus avoiding the need to apply the directive through the markup. Developers can expose its inputs and outputs. Additionally, they can also map their names to avoid the confusion between components and directives properties.
Why would you want to use host directives?
With a complex component comes complex business logic inside its class. Typescript has mixins support to divide logic between multiple classes and then join it into one giant class.
Mixins are widely used in @angular/material project. For example, Material Button Component.
But, as you can see, it itself requires complex structures to actually use it. Not mentioning setting inputs/outputs properties as an array for the decorator itself.
In short, developers can start struggling with input/output properties and class dependencies if they use lots of mixins.
Another way is to use long chains of class inheritance.
In the end, the final component would have a huge constructor (prior to inject
function) and supporting this constructor sometimes becomes too painful.
Another way would be to use services injected into your component, but this creates an additional headache with keeping the config up-to-date (triggering some configuration update for the service when some component's input property was changed, etc.).
Directive composition approach works similarly to the Typescript's mixins: you have multiple classes that contain their own logic, and in the end they all end up used for one final class (component). The difference is that mixins are combined into one class, and for directive composition you need to inject your directive instances into your component class.
Leveraging the host directives in your application
First, I'll leave a link to an example app built with nx to split the app and its libs.
Let's take an example of simple form control component:
This component implements ControlValueAccessor
and implements its methods such as writeValue
, registerOnChange
, registerOnTouched
.
And this stuff is commonly repeated across multiple components in your app.
Previously, to simplify the logic you could extract those methods into base abstract class. But this class might require it's dependencies, which needed to be passed via super
call in constructor. This complicates things.
Let's simplify the code, and first, create a standalone directive called CvaDirective
.
Let me explain what's going on inside this directive.
First, we are declaring it as a standalone, which means we can apply it to a component via hostDirectives
property of the @Component
.
Next, since we need to support template-driven and reactive forms, lets' inject necessary dependencies such as NgControl
, NgForm
and ControlContainer
. We will need these properties later.
You may see that we also injected ChangeDetectorRef
from the host
. This is needed to get the components change detector and call it when the state of the control being changed (valid/invalid).
Next, we implement all members of ControlValueAccessor
interface for further usage in the component.
We also have support for a disabled state of the form control, which may be handy in real-case scenarios. This input property is optional and you can ignore it during input exposing.
We also have updateErrorState
method which automatically checks whether the control is valid and checks whether the user interacted with the control itself or submitted the form.
That's all for the directive itself, now let's update our combobox component to use this directive instead direct ControlValueAccessor
implementation:
So, we've removed all form-related stuff to the directive. This gives us a more clear and readable component.
You see that we've injected CvaDirective
into the component to call its members such as setValue
and use the initial value of the form control to set it for the input field.
Bonus: Advanced Directives composition
With the example above I've shown how to simplify your component and move all background logic into a separate class without the need of inheritance.
Now, let's say we want it to accept not only string[]
, but also Observable<string[]>
, or even custom data source class which retrieves the data from some backend.
And again, host directives to the rescue!
Before we start with the directive itself, let's define what our directive should support:
- Automatically subscribe/unsubscribe from the dataSource;
- When datasource's data changes, or new dataSource instance being passed, notify parent component of the changes in data;
In this example, we will create a simple data source class which will convert passed data into an observable and simply return it.
First, let's generate abstract data source provider class which our components would implement in own way:
So, here we're declaring that our DataSource
can accept three types of data: Class instance, Observable of an array and a plain array.
With AbstractDataSourceProvider
we can now actually create our directive called DataSourceDirective
:
Quick explanation of what's going on here:
We have
T
and P
generic types which are responsible for providing awareness of the data types we are working with in our components, so IDE also knows it, and provides suggestions.
Next, we have a dataSource
input property which accepts our DataSource
type.
When the data is set, we call _initializeDataSource
method which does couple of things:
First, it closes the stream of the previous data source.
Then, it transforms our data into acceptable data source provider with the help of our DataSourceParser
which is injected with a DATA_SOURCE_TRANSFORMER
injection token.
Lastly, it subscribes to the events of the data source provider and passes them to the component it applied to.
That's all for the directive itself and its dependencies.
Now, let's go back to our combobox component, and update it in order to accept multiple types of data.
First, we need to implement our AbstractDataSourceProvider
class:
As you can see, we defined an additional interface for the combobox item in case we want to render the label different than its value.
And for the data source provider, we are just checking whether the data is observable or a plain array. If it's an array, we wrap it into Observable and return it.
Additionally, we are implementing DataSourceParser
for combobox to be able to apply the necessary data source class for the data passed to it.
Now, let's update our component to work with the data source directive:
So, what's changed?
First, we added our DataSourceDirective to hostDirectives and exposed its dataSource
input property as options
input property which we previously had directly in the component.
Next, instead of relying directly on options
input property, we're subscribing to DataSourceDirective's dataChanged$
BehaviourSubject
and waiting for new data to come.
When the data is emitted, we update the inner options
property with the data received from the DataSourceProvider.
And that's pretty much it!
In conclusion: Even though Host Directives at the early stage of its development, and has some childish issues, such as explicit definition of the host directive, it already provides huge benefit of simplifying the codebase of your existing components and libraries by splitting the logic between multiple independent classes and reducing the amount of inheritance chains.
As I was mentioning at the beginning of the article, here's a complete example application used in this article.
Posted on January 26, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.