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

Sometimes, There Is Unavoidable Coupling To The DOM In AngularJS

By
Published in

In the last few posts, I've touched upon the "Angular Way" which includes a strong separation of concerns between the Controllers, which know about the view-model, and the Directives, which know how to "glue" the DOM (Document Object Model) to the Controllers. But sometimes, I think there is coupling that is simply unavoidable. This coupling may not be direct, in so much as that there is no direct reference to the DOM from within the Controller; but, rather, an indirect coupling by way of a workflow that is there for no other reason than to accomodate browser behaviors and / or animations.

Run this demo in my JavaScript Demos project on GitHub.

To explore this edge-case of coupling, I put together a little demo in which there is a grid of dots that is evenly distributed over a contained area. As new dots are added to the grid, the Controller has to update the location of the dots to keep them evenly distributed. Now, when a user clicks on the grid, I want a new dot to be created at the click location and then animated into place (by way of CSS transition properties).

At first, the separation of concerns seems easy - the link() function of the directive is the only thing that can know about the X/Y coordinate-local offsets; as such, it has to be the one that tells the controller where to add the dot. The controller, on the other hand, manages the collection of dots; so, it has to be the aspect of the code that knows how to calculate the location of each evenly-distributed dot. No problem.

But, things get tricky when the dot is initially added to the grid. While the link() function tells the Controller where to add the dot (at the initial click-location), the Controller immediately overrides the given X/Y values with evenly-distributed coordinates. As such, we need something to put a repaint between the initial rendering of the dot and the recalculation of the layout:

  1. Add dot to grid at click-coordinate.
  2. Repaint browser (forces new dot to render).
  3. Recalculate grid layout (transitions click-location to grid-location).

That said, the very fact that a repaint is required is a condition of the browser, not necessarily of the view-model. As such, it would make sense to have the link() function implement the repaint. But, if the link() function needs to insert a forced-repaint, it would then also have to coordinate the recalculation of the grid layout, which feels very much like an overstepping of bounds; if the Controller manages the grid, it should be the one that knows when to recalculate the layout based on changes in the view-model.

On the flip-side, however, if the Controller coordinates both the adding of the dot and the recalculation of the layout, it would then need to implement the forced-repaint, which again, is a concern of the DOM. So it seems, no matter who does what, by fact of coordination, there has to be some degree of coupling in both directions.

In the following code, I chose to move the coupling into the Controller by way of an internally-consumed $timeout(). This way, the Controller is coupled to the DOM through a "leaky workflow." But, I felt that it was more important to keep the internal workflow - adding dots and recalculating distribution - together within the Controller.

<!doctype html>
<html ng-app="Demo">
<head>
	<meta charset="utf-8" />

	<title>
		Sometimes, There Is Unavoidable Coupling To The DOM In AngularJS
	</title>

	<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body>

	<h1>
		Sometimes, There Is Unavoidable Coupling To The DOM In AngularJS
	</h1>

	<dot-grid>
		<ul>
			<li
				ng-repeat="dot in dg.dots track by dot.id"
				ng-style="{ left: dot.x, top: dot.y }">

				<span>{{ dot.id }}</span>

			</li>
		</ul>
	</dot-grid>


	<!-- Load scripts. -->
	<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.7.min.js"></script>
	<script type="text/javascript">

		// Create an application module for our demo.
		angular.module( "Demo", [] );


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I provide a dot-grid component that the user can click to add new dots. The
		// dots rearrange to be evenly distributed as the set-count increases.
		angular.module( "Demo" ).directive(
			"dotGrid",
			function dotGridDirective( $window ) {

				// Return the directive configuration object.
				return({
					controller: DotGridController,
					controllerAs: "dg",
					link: link,
					restrict: "E"
				});


				// I bind the JavaScript events to the view-model.
				function link( scope, element, attributes, controller ) {

					// When the user clicks on the grid, we have to add a new dot.
					element.on( "click", handleClick );

					// When the user resizes the window, we have to adjust the layout.
					angular.element( $window ).on( "resize", handleResize );

					// Report the initial layout to the controller.
					scope.$evalAsync( reportLayout );


					// ---
					// PRIVATE METHODS.
					// ---


					// When the user clicks on the grid, we need to tell the controller
					// to add a new dot at the click coordinates.
					function handleClick( event ) {

						scope.$apply(
							function changeModel() {

								var offset = element.offset();

								controller.addDot(
									( event.pageX - offset.left ),
									( event.pageY - offset.top )
								);

							}
						);

					}


					// When the window is resized, it changes the dimensions of the
					// gird. But, since the Controller doesn't know anything about the
					// DOM, we have to report the new dimensions.
					function handleResize() {

						// NOTE: Using applyAsync() for micro-debouncing.
						scope.$applyAsync( reportLayout );

					}


					// I report the current physical dimensions of the grid to the
					// controller so that it knows how to distribute the dots.
					function reportLayout() {

						controller.setDimensions( element.width(), element.height() );

					}

				}


				// I manage the dot-grid.
				function DotGridController( $scope, $timeout ) {

					var vm = this;

					// I hold the collection of rendered dots.
					vm.dots = [];

					// I keep track of the dimensions and cardinality of the grid.
					var grid = {
						columns: 0,
						rows: 0,
						width: 0,
						height: 0
					};

					// Expose the public methods.
					vm.addDot = addDot;
					vm.setDimensions = setDimensions;


					// ---
					// PUBLIC METHODS.
					// ---


					// I add a dot at the given x/y pixel coordinates.
					function addDot( x, y ) {

						vm.dots.push({
							id: ( vm.dots.length + 1 ),
							x: x,
							y: y
						});

						// CAUTION: This is the real point of coupling between the DOM
						// and the controller. The reason we have to use the $timeout()
						// here is because we want the initial x/y coordinates to render
						// on the new dot before we adjust the layout to keep the dots
						// evenly distributed.
						//
						// I can't think of a nicer way to do this. We either build the
						// coupling into this method by way of the timeout; or, we let
						// the link() function coordinate an explicit call to
						// adjustLayout(), at which point, the two layers are still
						// coupled because the controller has to know NOT to call
						// adjustLayout(), even though it would make sense from a data
						// point of view, in order to facilitate the animation.
						$timeout( adjustLayout, 50 );

					}


					// I set the new dimensions of the grid.
					function setDimensions( width, height ) {

						grid.width = width;
						grid.height = height;

						adjustLayout();

					}


					// ---
					// PRIVATE METHODS.
					// ---


					// I adjust the layout of the grid - both in column and row count as
					// well as in spacing - to accommodate the current number of dots.
					function adjustLayout() {

						var maxCapacity = ( grid.rows * grid.columns );

						// Add columns or rows to be able to fit the dots.
						if ( vm.dots.length > maxCapacity ) {

							if ( ! grid.columns ) {

								grid.columns = grid.rows = 1;

							} else if ( grid.columns === grid.rows ) {

								grid.columns++;

							} else {

								grid.rows++;

							}

						}

						// Calculate the inter-column and inter-row spacing needed
						// to keep the dots evenly distributed within the grid.
						var columnSpacing = Math.floor( grid.width / ( grid.columns + 1 ) );
						var rowSpacing = Math.floor( grid.height / ( grid.rows + 1 ) );

						// Iterate over each dot and adjust the x/y coordinates to
						// keep the dots evenly distributed.
						for ( var i = 0, length = vm.dots.length ; i < length ; i++ ) {

							var dot = vm.dots[ i ];
							var currentColumn = ( i % grid.columns );
							var currentRow = Math.floor( i / grid.columns );

							dot.x = ( columnSpacing * ( currentColumn + 1 ) );
							dot.y = ( rowSpacing * ( currentRow + 1 ) );

						}

					}

				}

			}
		);

	</script>

</body>
</html>

And, when we run this code, we are able to get new dots zoom in from the click location thanks to the embedded $timeout() call:

Sometimes, there is unavoidable coupling of the Controller to the DOM in AngularJS - as seen when animating an item into from a click location.

Now, you could argue that this coupling is OK since this is a single component designed to create a single, cohesive user experience (UX). Which, in this case, I think is probably true. But, being too comfortable with that mentality can quickly lead to a slippery slope of overly-tight coupling. So, while I do think that sometimes there is just unavoidable coupling of the Controller to the DOM, it should still be thought of as an edge-case.

Want to use code from this post? Check out the license.

Reader Comments

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