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

Keeping Modules Decoupled Using Signals And Mediators

By
Published in Comments (2)

Lately, I've been struggling to wrap my head around the architecture of event-driven JavaScript applications. While I do love the idea of an evented system, I am finding it harder to see the benefit of using events for bi-directional communication. To me, it seems more natural to observe events for the purpose of explicitly invoking subsequent functionality. To this end, I wanted to try using Signal-based event binding in the context of a Mediator to keep two decoupled modules in harmony.

To experiment with this concept, I wanted to create a list of Friends that could be filtered using a set of toggle-filters. The filters would create one module; the list of friends would create the second module. A page mediator would then observe (ie. subscribe, bind, etc.) events on the filter module and subsequently invoke methods on the list module.

The mediator translates events raised on one module into explicit method invocation on another module.

As you can see (somewhat) from this graphic, neither the filter module nor the list module know about each other. In fact, neither of them even know about the mediator. In this architecture, the filter module knows to announce events when its state changes; the list module, on the other hand, exposes an API for state augmentation. The mediator then keeps the two modules both decoupled and in-sync by translating events on one module into method calls on the other module.

In the code, I wanted to use the RequireJS AMD (Asynchronous Module Definition) loader since it allows me to really think about my code in a modular fashion. And, the more I think about the modularity of the code, the easier it will be to keep the modules decoupled (or so I hope).

Before we get into how the page is wired together, let's take a look at the individual modules to see how they are defined. The first one we'll look at it is the Filter module. This module announces events when individual filters are selected or deselected:

filter-controller.js (Our Filter Module Definition)

// Define the filter-controller class.
define(
	[ "signal" ],
	function( Signal ){


		// Define the constructor method.
		function Controller( container ){

			// Store the container reference for DOM access.
			this.dom = {};
			this.dom.container = container;

			// Create an event surface.
			this.events = {
				filterSelected: new Signal( this, "filterselected" ),
				filterDeselected: new Signal( this, "filterdeselected" )
			};

			// Delegate the filter click.
			this.dom.container.delegate(
				"a",
				"click",
				$.proxy( this, "handleFilterClick" )
			);

			// Return this object reference.
			return( this );

		}


		// Define the class methods.
		Controller.prototype = {


			// I return the collection of active filters.
			getActiveFilters: function(){

				// Get the collection of active filters.
				var activeFilters = $.map(
					this.dom.container.find( "a.on" ),
					function( node ){

						// Return the text of the active filter.
						return( $( node ).text() );

					}
				);

				// Return the active filters.
				return( activeFilters );

			},


			// I handle the clicks on filters.
			handleFilterClick: function( event ){

				// Get the link that was clicked.
				var filter = $( event.target );

				// Toggle the filter on /off.
				filter.toggleClass( "on off" );

				// Check to see if the filter is no on.
				if (filter.is( ".on" )){

					// Announce the filter selection.
					this.events.filterSelected.dispatch(
						filter.text()
					);

				} else {

					// Announce the filter deselection.
					this.events.filterDeselected.dispatch(
						filter.text()
					);

				}

			}


		};


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


		// Return the controller constructor.
		return( Controller );


	}
);

This module defines the class which controls our filters. Notice that the constructor for this class is being returned as the actual module definition. Even for singleton objects, I'm not quite sold on the use of object-literals; it seems to me that using constructor functions allows for more flexible instantiation and initialization.

Once the Signal dependency has loaded (our event-binding mechanism), you can see that our constructor function defines an event surface: this.events. Two signals are then created - one for "filterSelected" and one for "filterDeselected". These events will be dispatched as the state of the filter view changes.

Notice that this module knows nothing about how it will be used. It knows one thing and one thing only: how it works internally.

Ok, now let's take a look at the List module. Since this module is for display purposes only, it has no need to announce events; all it does is provide a way to filter the list items.

friends-controller.js (Our List Module Definition)

// Define the friends-controller class.
define(
	[ /* No dependencies. */ ],
	function(){


		// Define the constructor method.
		function Controller( container ){

			// Store the container reference for DOM access.
			this.dom = {};
			this.dom.container = container;

			// Return this object reference.
			return( this );

		}


		// Define the class methods.
		Controller.prototype = {


			// I update the list display based on the current filter.
			displayUsingFilter: function( filter ){

				// Loop over each of the lists.
				this.dom.container.children().each(
					function(){

						// Get the current friend.
						var friend = $( this );

						// Check to see if the current friend should
						// be shown or hidden.
						var showFriend = filter(
							$.trim( friend.text() ),
							{
								funny: friend.is( ".funny" ),
								sassy: friend.is( ".sassy" ),
								smart: friend.is( ".smart" ),
								witty: friend.is( ".witty" )
							}
						);

						// Check to see if the friend should be shown
						// or hidden.
						if (showFriend){

							friend.show();

						} else {

							friend.hide();

						}

					}
				);

			}


		};


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


		// Return the controller constructor.
		return( Controller );


	}
);

Notice again that this module returns a class constructor. Unlike the filter module, however, the list module has no dependencies since it doesn't expose an event surface. Instead, is simply exposes one method, displayUsingFilter(), which allows the visible list to be filtered using a callback function.

The critical point to note here is that this module doesn't bind to the events dispatched by the filter module. It doesn't bind to any global "filterSelected" or "filterDeselected" events. In fact, this list module knows nothing at all about the filter module.

Right now, we have two modules that work together but know absolutely nothing about each other. This means that we need a mediator to facilitate communication between these related modules. In the following demo, the mediator isn't really defined as an additional module; rather, the mediator is just the code on the page - the code that creates the two user interface (UI) modules and then constructs a line of communication between them.

<!DOCTYPE html>
<html>
<head>
	<title>Experimenting With Signals And Mediators</title>

	<!-- Stylesheets. -->
	<link rel="stylesheet" type="text/css" href="./styles.css"></link>
</head>
<body>

	<h1>
		Experimenting With Signals And Mediators
	</h1>

	<p class="filters">
		<a class="off">Funny</a>
		<a class="off">Sassy</a>
		<a class="off">Smart</a>
		<a class="off">Witty</a>
	</p>

	<ul class="friends">
		<li class="funny">
			Sarah
		</li>
		<li class="sassy funny">
			Kit
		</li>
		<li class="smart">
			Libby
		</li>
		<li class="smart witty sassy">
			Tricia
		</li>
		<li class="sassy smart">
			Joanna
		</li>
	</ul>


	<!-- Load the RequireJS + jQuery library. -->
	<script type="text/javascript" src="./require-jquery.js"></script>
	<script type="text/javascript">

		// Configure the page mediator once the controllers have
		// been loaded into the dependency management system.
		require(
			[
				"filter-controller",
				"friends-controller"
			],
			function( FilterController, FriendsController ){


				// Create our filter controller.
				var filter = new FilterController( $( "p.filters" ) );

				// Create our friends controller.
				var friends = new FriendsController( $( "ul.friends" ) );


				// I check to see if all the given traits can be
				// found in the given collection.
				var hasAllTraits = function( filters, traits ){

					// Now, try to find one that doesn't matche.
					for (var i = 0 ; i < filters.length ; i++){

						// Check to see if the given filter could not
						// be found in the traits.
						if (!traits[ filters[ i ].toLowerCase() ]){

							// At least one could not be found.
							return( false );

						}

					}

					// If we made it this far, all traits were found.
					return( true );

				};


				// Define a method that will update the list of
				// friends based on the current state of the filter.
				var updateFriends = function( event, trait ){

					// Get the active filters.
					var filters = filter.getActiveFilters();

					// Update the friends display.
					friends.displayUsingFilter(
						function( name, traits ){

							// Only show friends that have all the
							// traits dicated by the active filters.
							return( hasAllTraits( filters, traits ) );

						}
					);

				};


				// Bind to selection chagnes on the filters.
				filter.events.filterSelected.bind( updateFriends );
				filter.events.filterDeselected.bind( updateFriends );


			}
		);


	</script>

</body>
</html>

As you can see, this demo uses the require() function to load the two module classes and initialize the page. Once we have our "friends" and "filter" controllers, the mediator links the two together through event binding:

// Bind to selection chagnes on the filters.
filter.events.filterSelected.bind( updateFriends );
filter.events.filterDeselected.bind( updateFriends );

Whenever the filters in the filter module are changed, the mediator gathers up the active filters and invokes the displayUsingFilter() method on the list module. This bridge keeps the two UI modules both decoupled and in visual harmony.

While not the primary focus of this exploration, here is the Signal module that I was using to create the event binding surface:

signal.js (Our Signal Class For Event Binding)

// Define the Signal class.
define(
	[ /* No dependencies. */ ],
	function(){


		// Define the Signal class. Each signal will be responsible
		// for a specific event beacon attached to an object.
		function Signal( context, eventType ){

			// Store the context (the object on which this event
			// is being attached).
			this.context = context;

			// Store the event type - the name of the event.
			this.eventType = eventType;

			// Create a queue of callbacks for this event.
			this.callbacks = [];

		}

		// Define the class methods.
		Signal.prototype = {

			// I bind an event handler.
			bind: function( callback ){

				// Add this callback to the queue.
				this.callbacks.push( callback );

			},


			// I dispatch the event with the given parameters.
			dispatch: function(){

				// Create an event object.
				var event = {
					type: this.eventType,
					target: this.context,
					dispatched: new Date()
				};

				// Create a clean array of arguments.
				var dispatchArguments = Array.prototype.slice.call(
					arguments
				);

				// Push the event onto the front of the arguments
				// that are being used to trigger the event.
				dispatchArguments.unshift( event );

				// Invoke the callbacks with the given arguments.
				for (var i = 0 ; i < this.callbacks.length ; i++){

					this.callbacks[ i ].apply(
						this.context,
						dispatchArguments
					);

				}

			},


			// I unbind an event handler.
			unbind: function( callback ){

				// Map the callbacks to remove the given callback.
				// Keep in mind that a given callback might be bound
				// more than once (no idea why).
				this.callbacks = $.map(
					this.callbacks,
					function( boundCallback ){

						// Check to see if the given callback is the
						// one that we want to unbind.
						if (boundCallback === callback){

							// Return NULL to remove from queue.
							return( null );

						} else {

							// Return the callback to keep bound.
							return( boundCallback );

						}

					}
				);

			}

		};


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

		// Return the class constructor.
		return( Signal );


	}
);

The idea of keeping all communication event-based is a nice one. It lets you imagine randomly adding modules to an application without having to update more than a line or two of code; bind-to or announce some globally-unique event type and it "just works." The hurdle that I keep running into, however, is that applications aren't just the assembly or random modules; they are the complex and cohesive synthesis of business rules. As such, it just makes more sense in my mind that mediators (or controllers - I'm not sure which terminology is accurate) perform a more active role, allowing event binding to be used only when it truly makes sense.

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

Reader Comments

2 Comments

Hey Ben!

Very interesting piece. I was reminded of some of the concepts I've been trying to encourage people to start using in http://addyosmani.com/largescalejavascript/ and it's great to see more examples of AMD modules being used as a part of this type of application structure.

Pardon my ignorance: I see that you're storing references to your own 'dom' namespace and was wondering if you were trying out DOM manipulation abstraction (so, you perhaps had your own convenience methods in 'dom' that piped back to jQuery or were just jQuery) or if that was there for a simpler purpose.

Although it can be difficult sometimes to grok where the mediator pattern should best be used here, I get what you mean when you say it can be a challenge to always build things in this decoupled a manner. One gets to a point where you ask yourself 'this is a really small feature - does it really need its own distinct module that publishes and subscribes messages?.

The answer is really up to you - if you want to store as much of a component's logic in a single module (with all the business rules in place) you can of course do that, but this pattern makes the most sense where other components really do rely on other independent modules to get things done. Having a module just to announce the page title has changed for example, might be overboard :)

Keep on writing the good stuff.

Addy

15,848 Comments

@Addy,

The "dom" stuff is just a name-space; I'm not using any kind of abstraction for DOM manipulation. Mostly, I started doing that because I found that I was occasionally getting naming conflicts between the DOM node references and the the data that populated them. I know I can always use perhaps more descriptive names like "friendsList" or "friendsData"; but, sometimes, just putting all the DOM-based stuff in its own namespace just makes my life easier.

I'll agree that some things are probably too small to be complete modules (effort vs. payoff); all I'm really saying is that *event-only* communication seems unintuitive to me. I love events. But, I also love explicit invocation as well. I feel in my gut that these two baby beasts should be playing nicely with one another.

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