Skip to main content
Ben Nadel at the jQuery Conference 2011 (Cambridge, MA) with: Andrew Maurer
Ben Nadel at the jQuery Conference 2011 (Cambridge, MA) with: Andrew Maurer ( @maurerdotme )

Replacing RxJS With A State Machine In JavaScript

By
Published in

I have a lot of trouble working with reactive streams. It's just not how my brain works best. Give me a simple event-stream, and I can mostly hold that in my head. But, start to combine streams together, and my brain freezes up like a deer in headlights - my eyes darting from operator to operator, desperately trying to build-up a mental model of what is actually happening. As such, I recently removed RxJS from an application and replaced the reactive streams with state machines. It's definitely more code; but, I find it easier to reason about and maintain.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

ASIDE: My desire to replace RxJS in this particular context was driven primarily by the sheer amount of "vendor" code that was being pulled-in. This is an old app with no tree-shaking and it was pulling in literally hundreds-of-kilobytes of RxJS and only being used in two places. My main objective was in reducing the vendor payload. Making the code easier to reason about (for me personally) was a side-effect.

I won't reproduce the entirety of the RxJS code here, but I'll give you a high-level sense of how the streams were being combined. The context for this code is a mouse-drag observer (following by some window-scrolling, not shown). Hopefully I didn't break the code here too much by trying to pare it down.

// Get the three major events
var mouseup   = Rx.Observable.fromEvent(document, 'mouseup');
var mousemove = Rx.Observable.fromEvent(document, 'mousemove');
var mousedown = Rx.Observable.fromEvent(dragTarget, 'mousedown');
var intervalSource = Rx.Observable.interval(60, Rx.Scheduler.requestAnimationFrame);

var mousedrag = mousedown.flatMap(function(md) {
	return intervalSource
		.takeUntil(mouseup)
		.withLatestFrom(mousemove, function(s1, s2) {
			return s2.clientY;
		})
		.scan(md.clientY, function( initialClientY, clientY ) {
			return( clientY - initialClientY );
		})
	;
});

this.subscription = mousedrag.subscribe(function( delta ) {
	console.log( "Drag delta from origin:", delta );
});

I'm sure - or, rather, I assume - that if you were proficient in RxJS, this code would make sense at a glance. But, I'm not very familiar with the RxJS operator APIs (which is part of why I find RxJS so confusing). So, when I look at this code, it's not immediately obvious how it all fits together. Clearly, it has something to do with mouse movements; but, if I had to describe the overall intent of the code, it would take me a while to construct a meaningful mental model.

After adding a number of console.log() statements to these stream callback, I was able to piece together the general control flow:

  1. User mouses-down.
  2. We start an interval stream that emits an event ever 60ms.
  3. We read from the interval stream until the user mouses-up.
  4. When the user moves their mouse, we emit an event with the current mouse position.
  5. We combine the interval event with the mouse movement event and calculate the distance the mouse has moved relative to the original mouse-down event.

In order to translate this RxJS into code that I could understand and maintain more easily, I started to think about the states that these streams represent. I came up with:

  • Default State: The user is just sitting there staring at the screen.

  • Pending State: The user has moused-down, but hasn't moved their mouse yet. At this point, we don't know if they are "clicking"; or, if they are about to start "dragging".

  • Dragging State: The user is moving their mouse while the button is still pressed.

None of these states exists in isolation. The overall interaction works by transitioning from one state to another based on some sort of event:

  • DefaultmousedownPending
  • PendingmouseupDefault
  • Pendingmousemove (beyond threshold) → Dragging
  • DraggingmouseupDefault

There are all manner of libraries out there for defining and consuming state machines. But, for this exploration, I'm going to keep it simple. Each state is defined by a series of Functions whose names are prefixed with the current state. For example, default_setup(), is the initialization method for the "Default" state.

Each state has both a setup and a teardown method that is called when entering and exiting a given state, respectively. So, the Default state has both a default_setup() and a default_teardown() method. Each state defines its own event-handlers, which is how one state knows when and why to transition to the next state.

The following code is my attempt to recreate the RxJS event streams with this 3-state system:

NOTE: In my approach, the "pending" state has to pass a given drag-threshold before moving into the "dragging" state. This constraint was not part of the RxJS event streams; but, I think it should have been. That said, it's not obvious to me how I would have added it to the given RxJS code.

<!doctype html>
<html lang="en">
<body>

	<h1>
		Replacing RxJS With A State Machine In JavaScript
	</h1>

	<p>
		If you mouse-down and then start dragging (vertically), you get console logging.
	</p>

	<script type="text/javascript">

		default_setup();

		// ---
		// DEFAULT STATE: At this point, the user is just viewing the page, but has not
		// yet interacted with it. Once they mousedown, we'll move into the pending state.
		// ---

		function default_setup() {

			console.info( "Default: Setup" );
			document.addEventListener( "mousedown", default_handleMousedown );

		}

		function default_teardown() {

			console.info( "Default: Teardown" );
			document.removeEventListener( "mousedown", default_handleMousedown );

		}

		function default_handleMousedown( event ) {

			event.preventDefault();

			default_teardown();
			pending_setup( event.clientY );

		}

		// ---
		// PENDING STATE: The user has moused-down on the page, but we don't yet know if
		// they intend to drag or just click. If they do start to drag (and pass a minimum
		// threshold), we'll move into the dragging state.
		// ---

		function pending_setup( clientY ) {

			console.info( "Pending: Setup" );
			document.addEventListener( "mouseup", pending_handleMouseup );
			document.addEventListener( "mousemove", pending_handleMousemove );

			pending_setup.initialClientY = clientY;

		}

		function pending_teardown() {

			console.info( "Pending: Teardown" );
			document.removeEventListener( "mouseup", pending_handleMouseup );
			document.removeEventListener( "mousemove", pending_handleMousemove );

		}

		function pending_handleMouseup( event ) {

			pending_teardown();
			default_setup();

		}

		function pending_handleMousemove( event ) {

			// Only move onto next state if dragging threshold is passed.
			// --
			// CAUTION: This concept was not present in the RxJS version; but, I think it
			// should have been. And, in the state-based approach (for me) it is easier to
			// reason about this update using states vs. streams.
			if ( Math.abs( pending_setup.initialClientY - event.clientY ) > 10 ) {

				pending_teardown();
				dragging_setup( pending_setup.initialClientY );

			}

		}

		// ---
		// DRAGGING STATE.
		// ---

		function dragging_setup( clientY ) {

			console.info( "Dragging: Setup" );
			document.addEventListener( "mousemove", dragging_handleMousemove );
			document.addEventListener( "mouseup", dragging_handleMouseup );

			dragging_setup.initialClientY = clientY;
			dragging_setup.currentClientY = null;
			dragging_setup.timer = setInterval( dragging_handleInterval, 60 );

		}

		function dragging_teardown() {

			console.info( "Dragging: Teardown" );
			document.removeEventListener( "mousemove", dragging_handleMousemove );
			document.removeEventListener( "mouseup", dragging_handleMouseup );
			clearInterval( dragging_setup.timer );

		}

		function dragging_handleMousemove( event ) {

			dragging_setup.currentClientY = event.clientY;

		}

		function dragging_handleMouseup( event ) {

			dragging_teardown();
			default_setup();

		}

		function dragging_handleInterval() {

			console.log(
				"Drag delta from origin:",
				( dragging_setup.currentClientY - dragging_setup.initialClientY )
			);

		}

	</script>

</body>
</html>

So, we've gone from 21-lines of RxJS event streams up to 123-lines of State Machines. That's almost 6x the amount of code. It might seem like we're moving in the wrong direction. But - for me personally - when I look at this code, it's much easier to understand. Furthermore, I don't even have to fully understand all of the code at one time in order to think about effectively; all I have to do is look at the current state and think about how it transitions to the next state.

If we run this code in the browser and drag the mouse around, we get the following output:

State machine logging to the console as it passes from state to state.

As you can see, we're logging the setup and teardown events for each state. And, we're logging-out the delta calculated during the dragging state. This state machine code works as intended.

This is not an argument against RxJS. I know that many people absolutely love RxJS and feel that it greatly simplifies the code that they write. I'm just not one of those people. Reactive event streams overload my brain. I find it much easier to think in terms of "states"; and, I don't mind that it takes more code to make that happen.

Epilogue on Unsubscribing

In the RxJS version of the code, a subscription object is created. When the current application View is "destroyed", that subscription object can be "disposed", which will, in turn, automatically stop all the upstream events from firing. That's pretty nice!

In my state machine approach, when the current application View is "destroyed", I would simply call all three of the *_teardown() methods (since I might not know which state is currently active). This is slightly less elegant; but, again, I'm OK with that.

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

Reader Comments

Post A Comment — I'd Love To Hear From You!

Post a Comment

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