AbstractAs most sophisticated web apps, Orbeon Forms has to deal with lots of asynchronous events in the browser, like Ajax requests, timers, and user interactions. We'll go over a real-life example of logic involving several asynchronous events, see the difficulty involved in implementing it with observers, and explore 3 alternatives: state machines,
await, and Functional Reactive Programming (FRP). We'll see why, based on our experience, we don't consider state machines to be a good solution, and through an example explore the two remaining alternatives,
Example of the loading indicatorOrbeon Forms can show on the page a "loading indicator" to inform users that something is happening: data was sent to the server, and we're waiting for a response, maybe because we need to validate user input, save data, or call a service. Its logic works is as follows:
- In essence, when a request is in progress, we want the loading indicator to show.
- However, if the request takes very little time, we don't want to show the loading indicator.
- Also, when a request comes back, we don't want to hide the indicator if we already know we'll right away send another request to the server.
The problem with observersThe browser gives us events, and we can run code when those events happen. Code running upon some event is called an observer. For the loading indicator, we would have an observer when an Ajax request starts, and another one when it ends. Since we don't want to show the indicator right away, the observer on the "Ajax request started" starts a timer for say 200 ms. So we would have an observer on that timer, which on completion shows the indicator if the request is still in progress. But to know that, we need to have state (
isRequestInProgress), shared between the two observers.
But a boolean isn't enough. Say you start a first request at
t = 0 ms. At that point, you also start a timer for 200 ms. Say that first request ends at
t = 100 ms. Now say a second request starts at
t = 150 ms. At
t = 200 ms, the first timer ends: a request is still in progress, so it shows the loading indicator. Bug! At that point the request has been going on for only 50 ms, so we shouldn't show the loading indicator just yet. One way to solve this problem is to use a counter, instead of a boolean, an increment/decrement that counter at the right time.
I won't go here through all the details of the implementation, but the end result is that the above logic is spread across a number of observers that share and update some global state. Logic written this way is surprisingly hard to maintain, and subtle bugs can lead to a loading indicator that stays "stuck", i.e. shown even if there no Ajax request in progress, or that doesn't show anymore.
Possible solutionsWe're aware of 3 alternatives to observers:
State machinesIn Form Builder, we started using a state machine about 2 years ago, and had a mixed experience with that approach. A state machine solves part of the observer problem, by putting the high level logic in a single place, in a fairly declarative form. However, we found that complex state machines are hard to debug, as you can't debug the declaration of the state machine itself, but instead have to debug the generic implementation of the state machine.
Before going for
await and FRP
awaitor FRP in production code, we wanted to do some experimentation. For this, we took the example described in the excellent paper Deprecating the Observer Pattern. The example uses the canvas and the
mouseupevents to let the user draw on the canvas. You'll find below the CoffeeScript code for 3 implementations:
See and run the code
isDown = false canvas.on 'mousedown', (e) -> isDown = true startLine(e) canvas.on 'mousemove', (e) -> if isDown continueLine(e) canvas.on 'mouseup', (e) -> isDown = false
See and run the code
while true await canvas.one('mousedown', defer(e)) startLine(e) until e.type == 'mouseup' rv = new iced.Rendezvous canvas.one('mousemove', rv.id().defer(e)) canvas.one('mouseup' , rv.id().defer(e)) await rv.wait(defer()) continueLine(e)
See and run the code
downStream = canvas.asEventStream('mousedown') upStream = canvas.asEventStream('mouseup') moveStream = canvas.asEventStream('mousemove') isDownProp = downStream.map(true) .merge(upStream.map(false)) .toProperty(false) moveWhenDownStream = moveStream.filter(isDownProp) downStream .onValue(startLine) moveWhenDownStream .onValue(continueLine)
ConclusionWe believe that both
awaitand FRP are significant improvement over observers or a state machine. At this point, we have a slight preference for
await, as we think that:
- Most developers will find it easier to understand code using
awaitthan code using FRP, and we believe in Erik Meijer's mantra: "write baby code".
- We think that code using
awaitwill be easier to debug than code using FRP, as it will be easier to put breakpoints in the right place, and follow the code execution.