Skip to main content
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Joshua Ford
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Joshua Ford

I'm Going To Stop Worrying About Tightly-Coupled DOM Access In Angular 7.2.7

By
Published in Comments (14)

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.

Embracing DOM-access in an Angular 7.2.7 component when it leads to easier-to-understand and maintain code.

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

448 Comments

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:

@Directive({
  selector: '[autoFocus]'
})
export class AutoFocusDirective implements AfterContentInit {

  @Input() autoFocusInput;

  @ContentChild(autoFocusInput) input: any;

  constructor() {
  }
  
  ngAfterContentInit(): void {
    console.log('ngAfterContentInit');
    console.log(this.input);
  }

  @HostListener('submit', ['$event'])
  onSubmit(e) {
    this.input.focus();
  }
  
}


@Component({
  selector: 'my-app',
  template: `
     <form (submit)="processForm()" autoFocus autoFocusInput="name">
 
      <input type="text" name="name" [(ngModel)]="form.name" #name autofocus />
      
    </form>
    
  `,
})
export class App {}
15,848 Comments

@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.

15,848 Comments

@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

448 Comments

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...

15,848 Comments

@Charles,

Sorry about that, I should prevent multiple submissions :D

Re: things like classList, I agree. At first, I tried to use Renderer2; 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.

448 Comments

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:

this.renderer.setStyle([element],[property],[value]);

But, no corresponding:

this.renderer.getStyle([element],[property]);

Which is little disappointing!

15,848 Comments

@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 with Renderer2; 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.

8 Comments

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.

15,848 Comments

@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:

  • The "Controller".
  • The Directive "glue".
  • The Directive 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:

function link( scope, element, attributes, controller ) {
	controller.setHostRect( element.getBoundingClientRect() );
}

... 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:

@Component({
	selector: "my-custom-element"
})

... that provides the "controller" bits; and then:

@Directive({
	selector: "my-custom-element"
})

... 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

8 Comments

@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.

15,848 Comments

@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 of Renderer2 and 1 use of elementRef.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 other Renderer2 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 ....

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel