Simple Finite State Machine and Event Emitter in Vanilla JavaScript


There are two common challenges when building stateful JavaScript components: managing state and communicating with consumers. In this post we'll solve both issues with some simple vanilla JavaScript.

Managing State Transitions

Let's say you are writing a widget which retrieves a stock price. It may of ready (online), offline, or waiting for a server response. Here's how the states can transition from one to another:

	ready
		-> offline
		-> awaiting-response

	offline
		-> ready

	awaiting-response
		-> ready

One simple way to do this is with conditional logic:

	
/*
	Stock Widget
	a stateful component
*/
var StockWidget = function() {
	this.state = "ready";
	this.tickerSymbol = "";
	this.price = null;
};

StockWidget.prototype.goOffline = function() {
	this.state = "offline";
	console.log("system is OFFLINE");
};

StockWidget.prototype.goOnline = function () {
	this.state = "ready";
	console.log("system is READY");
}

StockWidget.prototype.getPrice = function(tickerSymbol){
	if(this.state === "awaiting-response"){
		// only allow one pending AJAX call
		return;
	}
	if(this.state === "offline"){
		// don't waste time querying the service if it's down
		return;
	}
	if(this.state === "ready"){
		console.log("looking up price for "+tickerSymbol);
		this.state = "awaiting-response";
		this.randomAsyncStockQuote(tickerSymbol, function(quote){
			this.tickerSymbol = quote.tickerSymbol;
			this.price = quote.price;
			console.log(this.tickerSymbol +" is currently $"+this.price);
			this.state = "ready";
		}.bind(this));
	}
};

StockWidget.prototype.randomAsyncStockQuote = function(tickerSymbol, callback){
	// simulate AJAX
	setTimeout(function(){
		callback({
			tickerSymbol: tickerSymbol.toUpperCase(), 
			price: (Math.random() * 100).toFixed(2)
		});
	}, 1000);
};

/*
	Example Usage
*/
var st = new StockWidget();
st.goOffline();
st.goOnline();
st.getPrice("appl");
	

This works and it's simple to read but it will get much uglier over time as the number of states/transitions increases.

To make future state logic cleaner, consider using a simple Finite State Machine to transition between states in a more declarative way.

	
/*
	Finite State Machine
	responsible for handling state transitions
*/
var MyStateMachine = function(transitions, currentStateName, onStateChange){
	this.currentStateName = currentStateName;
	this.transitions = transitions;
	this.current = this.transitions[this.currentStateName];
	this.onStateChange = onStateChange;
}

MyStateMachine.prototype.handle = function(eventName){
   if (this.current[eventName]) {
        this.currentStateName = this.current[eventName];
        this.current = this.transitions[this.currentStateName];
		if(this.onStateChange){
	    	this.onStateChange(this.currentStateName);
	    }
    }
}

/*
	Stock Widget
	a stateful component
*/
var StockWidget = function() {
	this.tickerSymbol = "";
	this.price = null;

	function logStateChange(newState){
		console.log("state changed to "+newState);
	}

	// Cool: declarative state transitions!!!
	this.fsm = new MyStateMachine({
		// state
		"ready" : {
			// event name : new state
			"server-went-down": "offline",
			"price-requested" : "awaiting-response"
		},
		"offline" : {
			"server-came-up" : "ready"
		},
		"awaiting-response" : {
			"price-received" : "ready"
		}
	}, 
	"ready", 
	logStateChange.bind(this));
};

StockWidget.prototype.goOffline = function() {
	this.fsm.handle("server-went-down");	
};

StockWidget.prototype.goOnline = function () {
	this.fsm.handle("server-came-up");	
}

StockWidget.prototype.getPrice = function(tickerSymbol){
	if(this.fsm.currentStateName === "ready"){
		console.log("looking up price for "+tickerSymbol);
		this.fsm.handle("price-requested");
		this.randomAsyncStockQuote(tickerSymbol, function(quote){
			this.tickerSymbol = quote.tickerSymbol;
			this.price = quote.price;
			console.log(this.tickerSymbol +" is currently $"+this.price);
			this.fsm.handle("price-received");
		}.bind(this));
	}
};

StockWidget.prototype.randomAsyncStockQuote = function(tickerSymbol, callback){
	// simulate AJAX
	setTimeout(function(){
		callback({
			tickerSymbol: tickerSymbol.toUpperCase(), 
			price: (Math.random() * 100).toFixed(2)
		});
	}, 1000);
};

/*
	Example Usage
*/
var st = new StockWidget();
st.goOffline();
st.goOnline();
st.getPrice("appl");
	

Notice how we're using events (strings) to signal the state transitions, allowing us to simply map states to events.

Messaging

State is great, but you probably also want your consumers to know when things happen. We could pass a "onStateChanged" callback to the StickWidget constructor function to achieve this, but it only allows us to have one listener and what if our consumer wants to know about all those nifty events from our FSM?

Instead, let's build a simple event emitter and compose it into our StockWidget.

	
/*
	Event Emitter: resposible for notifying listeners
*/
var MyEventEmitter = function(){
	this.events = {};
}

// register an event listener
MyEventEmitter.prototype.on = function(event, callback){
	if(!this.events[event]){
		this.events[event] = [];
	}
	this.events[event].push(callback);
};
// calls all listeners for an event
MyEventEmitter.prototype.fire = function(event, data){
	if(!this.events[event]){
		return;
	}
	for(var i=0; i< this.events[event].length; i++){
		this.events[event][i](data);
	}
};
// remove a listener
MyEventEmitter.prototype.off = function(event, callback){
	if(!this.events[event]){
		return;
	}
	this.events[event] = this.events[event].filter(function(c){
		return c !== callback;
	});
};

/*
	Finite State Machine
	responsible for handling state transitions
*/
var MyStateMachine = function(transitions, currentStateName, onStateChange){
	this.currentStateName = currentStateName;
	this.transitions = transitions;
	this.current = this.transitions[this.currentStateName];
	this.onStateChange = onStateChange;
}

MyStateMachine.prototype.handle = function(eventName, data){
	if (this.current[eventName]) {
	    this.currentStateName = this.current[eventName];
	    this.current = this.transitions[this.currentStateName];
	    if(this.onStateChange){
	    	this.onStateChange(this.currentStateName);
	    }
	}
}


/*
	Stock Widget
	a stateful component
*/
var StockWidget = function() {
	this.tickerSymbol = "";
	this.price = null;
	this.events = new MyEventEmitter();

	function triggerStateChangeEvent(newState){
		this.events.fire("state-change", newState);
	}

	this.fsm = new MyStateMachine({
		"ready" : {
			"server-went-down": "offline",
			"price-requested" : "awaiting-response"
		},
		"offline" : {
			"server-came-up" : "ready"
		},
		"awaiting-response" : {
			"price-received" : "ready"
		}
	}, 
	"ready", 
	triggerStateChangeEvent.bind(this));
};

StockWidget.prototype.__trigger = function(eventName, data) {
	this.fsm.handle(eventName);
	this.events.fire(eventName, data);
};

StockWidget.prototype.goOffline = function() {
	this.__trigger("server-went-down");
};

StockWidget.prototype.goOnline = function () {
	this.__trigger("server-came-up");	
}

StockWidget.prototype.getPrice = function(tickerSymbol){
	if(this.fsm.currentStateName === "ready"){
		this.__trigger("price-requested");
		this.randomAsyncStockQuote(tickerSymbol, function(quote){
			this.tickerSymbol = quote.tickerSymbol;
			this.price = quote.price;
			this.__trigger("price-received", quote);
		}.bind(this));
	}
};

StockWidget.prototype.randomAsyncStockQuote = function(tickerSymbol, callback){
	// simulate AJAX
	setTimeout(function(){
		callback({
			tickerSymbol: tickerSymbol.toUpperCase(), 
			price: (Math.random() * 100).toFixed(2)
		});
	}, 1000);
};


/*
	Example Usage
*/

var priceHistory = [];

var logStateChange = function(newState){
    console.log("state changed to "+newState);
};

var logPriceReceived = function(priceInfo){
    console.log(priceInfo.tickerSymbol +" is currently $"+priceInfo.price);
};

var addToPriceHistory = function(priceInfo){
    priceHistory.push(priceInfo.price);
};

var ohNoes = function(){
    console.log("UH NOES!!!!!!!");
};

var st = new StockWidget();

// the consumer can subscribe to FSM events
st.events.on("server-went-down", ohNoes);

// we can register multiple event listeners
st.events.on("price-received", logPriceReceived);
st.events.on("price-received", addToPriceHistory);


// log all state changes, which is not a FSM event
st.events.on("state-change", logStateChange);

st.goOffline();
st.goOnline();
st.getPrice("appl");
	

Now the consuming application can listen for any event coming from the FSM, as well as other events defined by the StockWidget like the "state-changed". The Event Emitter allows the consumer to have as many listeners for each event as they want.

Notice how clean the StickWidget code is compared to the first example.

Extra Credit

If you like the idea of a FSM and want more rich functionality, check out machina.js. Be careful not to go overboard though, in theory every stateful thing could have a FSM, so only go there if you really think you need to.

Event emitters are a solid way to provide a nice API for your components. There are many event emitter libraries out there. Lucid is a nice one with lots of cool features. Node.js has an emitter baked in.

Summary

With just a little vanilla JavaScript you can build spiffy state management and messaging features into your component while keeping it clean.

[ Archive · Home ]