Angular provides several well-documented patterns for communication between components. Those are perfect for most cases but once in awhile a peculiar case forces us to think in a different direction.
A couple of weeks ago, we were implementing a design system as a set of components that style native HTML and Angular elements according to our preferences. The usual navbars, buttons, labels, etc were quick and easy to implement but then we got to the forms…
We wanted easy to use styling components that keep the semantic HTML and native angular functionalities as much as possible. We decided to stick to the native HTML syntax and native validators. And of course, native attributes (e.g. disabled) should still work as expected. The syntax we came up with to cover all of those considerations and still be easy to enforce, looks like this for reactive forms:
Code language: HTML, XML (xml)
<form> <input type="text" required crt-input formControlName="full-name" /> <input type="checkbox" required ctr-checkbox formControlName="agree-with-terms" /> <input type="submit" crt-button /> </form>
Or similarly for the template-driven ones:
Code language: HTML, XML (xml)
<form> <input type="text" required crt-input [(ngModel)]="formModel.fullName" name="fullName" /> <input type="checkbox" required ctr-checkbox [(ngModel)]="formModel.agreeWithTerms" name="agreedWithTerms" /> <input type="submit" crt-button /> </form>
In both examples
crt-button are selectors for the new styling components.
The only problem with this implementation came from the validations. We wanted to simulate the native browser behavior and so needed the error messages to be encapsulated in the component itself instead of the usually recommended angular way of showing/hiding an error message somewhere on the page with ngIf statements. You can read more about angular validations here. The tricky part is that those validations have implications for the styling of elements too (e.g. a red border on invalid inputs) and so we needed our clean styling components to communicate with the model of the field.
Obligatory review of Angular recommended patterns
Just so everyone can follow along, let’s briefly review the recommended Angular patterns for communication between components and directives.
The first and most basic is the case in which a parent component needs to send some information to a component that is instantiated somewhere in its template. That’s an easy one, you can use Angular’s Inputs. You can read more about them here and here.
The second communication pattern covers the cases where you need to send some information from a child to its direct parent. We can do that using Angular’s Outputs. You can read more about them here and here.
The above two approaches are core Angular functionality and you can find lots of resources for them. So let’s not go into further details about them.
Angular also gives you a way to get instances of a given component inside the template of the direct parent. It works both for child components instantiated directly in the template (View Child[ren]) or for ones transcluded inside it (Content Child[ren]). After that, you can call methods of the instances and communicate directly. You can see a full example of this technique here.
For any case in which the components are not in a direct parent-child relationship Angular docs advise to resort to services. For this approach, you only need to have a common parent somewhere along the component tree, but that is easily covered for most projects as there is the root NgModule and AppComponent. You can see an example of the above approach here.
Back to our problem
Going back to our initial problem, we can easily see we cannot use any of the first three approaches as the component (e.g. crt-input) and the directive (ngModel or formControl) are not in a parent-child relationship. The only useful approach we’re left with is the Service pattern. It is a very powerful solution and can help us achieve a lot but it has two major downsides:
- it requires both the component and directive to be refactored to communicate through the Service
- you either need to instantiate a communication service for each couple of styling component + directive OR implement a strategy to uniquely identify each such couple if a single service instance is to be used
Those limitations are show-stoppers for us – either of them leads to cumbersome changes to the templates and add clutter to the otherwise clean syntax. So we needed a different approach.
ElementInjector to the rescue
Luckily, Angular also provides components with the ability to inject their own Injector. Because the ElementInjector hierarchy is a replica of the DOM structure our CrtInput directive and FormControlName directive will share an injector instance. Through that shared instance of the Injector, we can in-theory get direct access from the styling CrtInput component to the instance of the sibling NgControl directive. So good when theories hold in practice as well – check our example below.
This approach gives us the same level of control and freedom as with a Content/View Child but for siblings. Even more powerfully, as the Injector we get has references to its parent, we can even traverse the ElementInjector hierarchy and reach other components up the component tree. It’s a powerful technique so PLEASE DO NOT RUN WITH SCISSORS.
But I need more Power
The previous approach also has an inherent limitation – it relies on either a sibling or child to parent relationship. What could you do if you have to communicate between components that are not direct siblings and don’t have a custom parent element that can be used as an easy communication channel? Enter the world of native HTML selectors and custom DOM events – a very deep rabbit hole we can explore in a future article.