Adaptive Components & Parent-Driven Behavior in Angular
AngularInDepth is moving away from Medium. More recent articles are hosted on the new platform inDepth.dev. Thanks for being part of indepth movement!
It takes time to fully grasp concepts such as dumb/smart/stateful/stateless components, unidirectional data flow, content projection, views and DOM manipulation, and dependency injection in Angular. All that and probably some further information is necessary to build a consistent, scalable, and flexible component architecture. Then, just as you dig deeper and put what you learn into use, new questions and requirements arise. Should I really set this style up through an input property? Should a parent component be aware of how its child component works?
Occasionally, methods you apply with similar questions in mind are contrary to some best practices you know and implement by heart. They could even be identified as anti-patterns, yet still help you build your app. With this article, I will attempt to elaborate on an arguably convenient but possibly unorthodox approach: Employing adaptive components which adjust their own look and behavior with regard to an ancestor component, a directive, or an injectable in the dependency tree. You may think of them as dumb components that are, well, not totally dumb. Please note that what I am after is an examination rather than a suggestion and it may never be a good idea to create one at all.
A Sample Case
In order to explore how adaptive components can be thinkable, we are going to build two card components: One vertical and one horizontal. Here are the end results we are going to get:
As you see, although there are minor visual and structural differences, from the point of members, they are identical: Both have an image, a title, a body text, and a call-to-action. Component classes are going to share members as well, wherefore both extend the same abstract card class. Let us take a look at the code:
There are a few points I must clear up. First off, working with an abstract class here gives you a very important edge in terms of dependency injection. If you spot providers
meta data in both class decorators of CardVerticalComponent
and CardHorizontalComponent
, you will realize that I am about to inject the abstract class somewhere else. Second, if you are unfamiliar with select
property of ng-content
, it works somewhat like a CSS selector and helps Angular place projected content to correct position in the DOM. Finally, those cards could have completely different contents, but I am too lazy for that. 🤷♂️
Now that you are acquainted with the context, it is time to go through the child components and find out how they will adapt to their parent.
Setting a Style on an Angular Component When a Parent Is Present
That, actually, is a common and basic need. Atomic components can often be reused in multiple component trees with only a slight style adjustment, frequently determined by the existence of an ancestor in dependency tree. Check out the following card image component:
The component can be used in both layouts, still its placement
attribute has to be set as 'side'
or 'top'
depending on the layout. Now, looking into this scenario, we have following options:
- Keep
CardImageComponent
as is and suffer from not only repetitive work, but also another tiny detail about the component API. - Add separate card image components or an attribute directive for vertical and horizontal layouts and suffer from an expanded API surface area, several additional bytes, and probably duplicate/useless code.
- Make this component smarter. 🤔
So, let us revisit that component and see what we can change:
We have completely removed the placement
attribute and used dependency injection to our advantage. When this component is used inside a vertical card, the injected instance will have a truthy value and <img>
element will get the card-img-side
class. The Optional
decorator, on the other hand, replaces an unprovided dependency with null
. Therefore, when inside a horizontal card, vertical
will have a falsy value and <img>
element will get the card-img-top
class.
Setting Styles on an Angular Component Depending on the Context (or How to Use :host-context)
The heading in our example has an underline when used in horizontal layout. We must achieve this by adding a border-bottom
property to <h5>
element, without giving the vertical card one. A global CSS rule is always an option, yes, but it is much more difficult to trace for modification or removal and unused CSS becomes a problem quite quickly. Here is how we can fix that:
The :host-context()
CSS function selects the host element only if parameter matches element’s ancestor(s) and any descendant can also be targeted, like our example demonstrates. The immediate benefits are obvious: The border is only placed on horizontal card headings and it is easy to find and modify the rule. Nevertheless, there are some facts that you have to keep in mind:
- View encapsulation other than
None
is required for this selector to work. However, although :host-context() is a native CSS pseudo-class function for shadow DOM, it is experimental and the browser support is poor. Good news is, when emulated encapsulation is in effect, Angular will replace it with a simple selector scoped by an attribute containing a surrogate id. - Angular compiler does not (and probably should not) validate the rule, so it cannot detect if you still need one. In other words, that CSS rule, inlined or not, shall not magically disappear from your bundle just because you do not have a horizontal card instance in your app anymore.
Using Properties on Parent Component in Angular
So far, we have styled child components based only on presence or absence of an ancestor. We could also bring parent properties and methods into play:
Indeed, CardButtonComponent
sets its classes after referring to card color
and calls its action
property upon click event. Once again, our child component is characterized by an ancestor. Please pay attention to the practical use of AbstractCard
class though. Since both card component classes extend and provide it, we can inject it to the child component and thus grant a type-safe reference to their shared interface.
Consuming ancestor properties directly may look like a terrific idea at first. Yet, there are some caveats:
- If
ChangeDetectionStrategy
is set toOnPush
on a child component, it shall not be re-rendered on value changes of the parent property. In order to accomplish that, you will need a utility function the details of which I do not intend to include in this article. You may find a working example on the StackBlitz project linked below and here. - Arrow functions as class properties can be called from child components without any trouble, because
this
will always refer to the class instance the property belongs to (AppComponent
in our example). While consuming methods, however, you have to stay sharp about closures. - Setting a new value to a parent property from within a child component is usually not a good idea, since it is not easy to discover where this is done and unintended overwrites are likely to happen. One-way data flow had better be applied.
Conclusion
Thanks to dependency injection in Angular, creating adaptive components characterized by an ancestor component, directive or service is available. We have a couple of these in our current project and they serve us marvelously. However, before you crucify me, let me emphasize once again that I am well aware of its drawbacks, merely displaying what can be done in Angular, and not recommending a general adoption of this technique.
You may find the StackBlitz project link below. Live long and prosper. 🖖