I'm Going To Stop Worrying About Tightly-Coupled DOM Access In Angular 7.2.7
I love a clean separation of concerns. Especially when such a separation makes code easier to reason about. Sometimes, however, I find myself stressing way too much about tight-coupling; and, I find that my attempts to decrease coupling can lead to code that is way harder to understand or maintain. Sometimes, the dogmatic pursuit of low-coupling leads me to a Pyrrhic Victory. And so, I'm going to stop worrying so much about it. Of course, when low-coupling is easily achieved, I will still favor it (I'm not a maniac). But, when directly accessing the Document Object Model (DOM) from within my Angular 7.2.7 components makes life easier, I'm just going to do it. I can always refactor the code later if this becomes a problem (the same way all code gets refactored over time).
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
Historically, I've tried hard to build my Component Classes such that they knew nothing about the DOM. The Component Classes were there to define the "view model"; and, it was left up to the Angular framework to reconcile this "view model" with the declarative component templates. But, not every kind of template-state fits so cleanly into the "view model" construct.
The perfect example (and a recurring point of friction) of this, for me, is "input focus." Often times, I will want to set the focus of different User Interface (UI) elements in reaction to various user interactions. And, while I can jump through hoops to make "focus" a declarative workflow, I've never realized any benefits from such added complexity.
So, rather than fight it, when it makes sense, I'm going to embrace DOM access right in my View Components. To see what I mean, here's a quick demo in which I want to re-focus the first input within a Form after the form is submitted:
// Import the core angular services.
import { Component } from "@angular/core";
import { ElementRef } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface User {
id: number;
name: string;
email: string;
};
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<form (submit)="processForm()">
<div class="entry">
<label>Name:</label>
<input type="text" name="name" [(ngModel)]="form.name" autofocus />
</div>
<div class="entry">
<label>Email:</label>
<input type="email" name="email" [(ngModel)]="form.email" />
</div>
<div class="actions">
<button type="submit">
Process Form
</button>
</div>
</form>
<h2>
Users
</h2>
<ul>
<li *ngFor="let user of users">
{{ user.name }} ( ID: {{ user.id }} )
</li>
</ul>
`
})
export class AppComponent {
public form: {
name: string;
email: string;
};
public users: User[];
private elementRef: ElementRef;
// I initialize the app component.
constructor( elementRef: ElementRef ) {
this.elementRef = elementRef;
this.form = {
name: "",
email: ""
};
this.users = [];
}
// ---
// PUBLIC METHODS.
// ---
// I process the new user form.
public processForm() : void {
// Blah blah blah, form validation...
this.users.push({
id: Date.now(),
name: this.form.name,
email: this.form.email
});
// Reset the form model.
this.form.name = "";
this.form.email = "";
// Once the form is submitted, we want to make it easy for the administrator to
// continue adding new users one after another. As such, we want to implicitly
// focus the first input field so that the administrator doesn't even have to
// touch their mouse.
this.focusNameInput();
}
// ---
// PRIVATE METHODS.
// ---
// I bring DOM focus to the "name" input element.
private focusNameInput() : void {
// NOTE: I am directly accessing the DOM here and imperatively changes its state.
// This tightly-couples the Component Class to the template AND to the browser
// platform; but, that's OK. Such coupling can always be decoupled later if it is
// actually necessary.
this.elementRef.nativeElement
.querySelector( "input[ name = 'name' ]" )
.focus()
;
}
}
As you can see, in the method that processes the form submission, I'm calling .focusNameInput(). The .focusNameInput() method then turns around and uses the Browser's native .querySelector() method to access the "name" input, which I then call .focus() on.
At this point, I have tightly-coupled the View Component to the template structure, the Document Object Model, and the "Browser Platform."
But, who cares? This code is easy to reason about, which makes it easy to maintain. And, in the unlikely chance that I'll ever need to render this code outside of the Browser and the DOM, I can always refactor it then. As the complexity of the rendering requirements increases, the code complexity will have to increase in order to meet the demands. And, that's OK.
To be clear, I am in no way advocating for the haphazard application of tight-coupling. When it makes sense, I'm all about a clean separation of concerns and the favoring of declarative rendering over imperative rendering. What I'm going to stop caring about is the authoring of "overly clever" code that does nothing but attempt to maintain a separation of concerns. At that point, the trade-off of "overly clever" outweighs the benefits of low-coupling.
Over the years, I've written a lot of "overly clever" code in the pursuit of one dogmatic belief or another. But now, when it comes to DOM access in Angular 7.2.7, I'm trying to channel my inner-Morpheus, realizing that, "Some rules can be bent, others can be broken." If accessing the DOM directly from within my Component or EventPlugIn classes makes the code easier to write, read, and maintain, then I'm going to embrace that as a benefit, not a drawback. And, if I ever need to refactor the code, no problem, I will.
Want to use code from this post? Check out the license.
Reader Comments
Dr. Benlove or How I Learned To Stop Worrying And Love The DOM
Yes. I often use querySelector, because sometimes there is no other option or there is another option, but it is prohibitively complicated to implement.
Sometimes, I even inject the 'document' into the constructor and use that instead of ElemementRef.
However, I was thinking that perhaps, one could create a simple directive on the form & use HostListener. I haven't checked this, so it could be full of bugs:
@All,
I wanted to follow-up with a quick post about a common use-case that I come across in which embracing tightly-coupled DOM-access makes life easier: scrolling an overflow-container back to the top when its content changes:
www.bennadel.com/blog/3586-scrolling-an-overflow-container-back-to-the-top-on-content-change-in-angular-7-2-7.htm
For example, in a tabbed-widget interface, we have to scroll the tab-body back to the top when the user navigates from one tab to another.
@JC,
Ha ha, exactly :D
@Charles,
Exactly -- there is always something that you can do to create some sort of indirection so that you're not calling something explicitly from your Classes. But, to your point, it can be "prohibitively complicated to implement". At some point, you have to make the choice that is best for the "long term maintenance" of the application - not the "theoretical maintenance" of the application :D
I'll be honest, I'm probably guilty of over using stuff like 'querySelector'. I should do some research on what the side effects of such behaviour are? When I first started using Angular, I was obsessed about doing everything the Angular way, and then, one day, I saw an article, where the author was directly accessing the DOM. And, after that I became a bit lazy. I still try and use the renderer2 methods, when I can, but I also learnt the other day, that it's possible to use things like the native DOM 'classList' API , and now I use this stuff regularly! Doh...
Sorry about the repeat posts. Not sure what happened! It would be cool, if there was a delete button!
@Charles,
Sorry about that, I should prevent multiple submissions :D
Re: things like
classList
, I agree. At first, I tried to useRenderer2
; but, after a while it just felt so tedious and with no apparent benefit, especially since I was writing code that would never be rendered anywhere but in the Browser platform. I totally get the idea of want to abstract that stuff out so you can be more flexible. But, only in the cases where that feels like a likely outcome.Yes. I totally forgot that renderer2's purpose is to provide cross application support. And, I only use Angular for browser based applications.
However, it doesn't do any harm getting familiar with it.
It is really a very light version of jQuery, except I've noticed that it only provides add or remove functionality and not read functionality.
For instance, there is:
But, no corresponding:
Which is little disappointing!
@Charles,
And, there's no way to call methods on it. So, for example - as in my following blog post - if I want to call
element.scrollTo(0,0)
, there's no way to do that withRenderer2
; at least, not that I know how. So, the out-of-the-box cross-platform solution is already not sufficient for many things I want to do.Frankly, at the end of the day, I think I'm operating on the false-assumption that most code is viable from a cross-platform standpoint. I can't say one way or the other since I have no experience in the matter. But, just because I have an app that abstracts the Router and the DOM access, it is still built to have a layout that is very much geared towards the web. I'm not sure that all or even many concepts translate that well.
... but again, just theory on my part.
Ben,
Did you have a case in mind where you wrote "overly clever" code to avoid the tight coupling?
I've gotten to the age where I don't admire "clever" code, but I'm curious to see what you're reacting to.
@Ted,
Sure, in the past, I've tried to target the same Element selector using two different classes. So, if you ever created Directives in the AngularJS days, you may remember that you could have three aspects:
link()
function.The "glue" basically associated a Controller with a View-Template. And then the
link()
function was a way to bind speical DOM-interactions to the Controller. So, for example, if you needed to measure the dimensions of the host DOM element, you might have a link function that looks like this:... where the
link()
function glues the "DOM" to the "Controller" so the controller doesn't have to know about the DOM.Well, in Angular 7, we don't have
link()
any more; but, we can "fake" it try to get the same kind of indirection. We can create two classes that bind to the same Element:... that provides the "controller" bits; and then:
... that provides the "link()" bits. Then, the Directive version can ask the Dependency-Injector for the Controller instance and the
ElementRef
and attempt to glue the two together.I took a look at this a few years ago, before I was doing TypeScript, so the example is kind of crazy:
www.bennadel.com/blog/3001-creating-a-pseudo-link-function-for-a-component-in-angularjs-2-beta-1.htm
So, yeah, it can get crazy to try and avoid direct DOM-access in the Component. But, mostly, I just even stopped caring about
Renderer2
:D@Ben,
That's positively Mad Scientist-y. We need to get you a white lab coat. :)
I've used Renderer / Renderer2, and I've used native DOM methods. My preference would be to try to use the Renderer first; sometimes it doesn't do what you need to do, and you need to get lower. I see no problem with the kind of coupling you're talking about, as long as you're informed enough to make a good judgement as to the trade-offs, and there is no easier, more data-driven way to do what you want.
"Render unto Caesar... uh, the DOM", and all that.
@Ted,
Ha ha, yeah, the mad-sciency stuff is fun, but feels like a victory achieved at cost.
The biggest point-of-friction that I feel with the
Renderer2
is it's all "set" oriented method. I don't think it has any methods for "get". So, what I often find happens is that I'll start off with it, and everything is going fine. Then, more edits, more edits, more edits, and suddenly I realize that I need to check the state of the DOM (in such a way that I couldn't use a view-model ... such as getting the bounding-rect of an element). Now, all of a sudden I have 10 uses ofRenderer2
and 1 use ofelementRef.nativeElement
. And, the asymmetry of it drives me nuts :D And, once I have to use the native reference, I might as well go back and replace all the otherRenderer2
references with native calls.But, the trade-off being that if I do ever need to render these outside of the Browser, I would have to go back and make a lot of changes. Only, I've never had to do that before. Maybe some day ....