Wednesday, January 03, 2024

UI5 Tips: Buffering Events to avoid a request-storm

Standard UI5 event handling will usually go a long way. Yet sometimes, certain user actions can cause ui5 objects to generate a lot of similar events within a small period of time, and it is often not useful to handle each and all of them: only the last event needs handling. A very common scenario is doing a search in response to the liveChange event: if you'd attach a handler to handle the liveChange event, and do the backend query from there, then a backend request would be sent for each keystroke while the user is typing in the search field. This causes a storm of requests that the backend must somehow handle. But most of these requests will be for naught, as the user is only interested in the result of the query that matches the last complete search term they typed. So, rather than firing a query to the backend for each and every keystroke, it makes more sense to buffer these events, and react to only the last one. The bufferedEventHandler utility helps you to do just that in a generic and reusable way. This ui5tip describes the bufferedEventHandler utility. It is available on github under terms of the Apache 2.0 License. There's also a sample application so you can try it out yourself.

The BufferedEventHandler sample app

The bufferedEventHandler sample application illustrates the scenario from the introduction. It consists of a single page showing mockup company data in a sap.ui.table.Table. A screenshot is shown below: Screenshot of the BufferedEventHandler sample app. At the top left of the grid, there's a sap.m.SearchField labeled "Search in Name". The user can type some search term into the searchfield, and the grid will automatically refresh and show only the rows for which the CompanyName has a case-insensitive match with the entered search term. While the search happens automatically, it does not happen immediately as the search term changes at every keystroke. Rather, about 1 second after the user stops typing, the data grid is filtered. At the top right of the grid, there's a sap.m.ProgressIndicator labeled "Event buffer Timeout". The progress indicator reflects how much time has passed since the last keystroke. When the progress indicator reaches a 100%, the filter action is executed.

The bufferedEventHandler Utility

To buffer events we provide a bufferedEventHandler utility object with just one bufferEvents function. You can find this in the bufferedEventHandler file in the utils directory. To use it, we need to import it into the source file where we want to use it. This will usually be in a ui5 controller and in the sample app we do this in MainPage.controller.js:
sap.ui.define([
  "sap/ui/core/mvc/Controller",
  "sap/ui/table/Column",
  "sap/m/Text",
  "sap/ui/model/Filter",
  "sap/ui/model/FilterOperator",
  "sap/ui/model/FilterType",
  "ui5tips/utils/bufferedEventHandler"
], 
function(
  Controller,
  Column,
  Text,
  Filter,
  FilterOperator,
  FilterType,
  bufferedEventHandler
){
 "use strict";  
  var controller = Controller.extend("ui5tips.components.mainpage.MainPage", {
    ...
  });
  return controller;
}
We can now refer to the bufferedEventHandler utility through the local variable that is also called bufferedEventHandler. The controller uses the bufferedEventHandler utility in the initSearchField() method. This called from the controller's standard onInit() lifecycle method, which is called just once for the Controller instance:
  ...
  onInit: function() {
    this.initSearchField();
  },
  initSearchField: function(){
    var searchField = this.byId('searchField');
    bufferedEventHandler.bufferEvents(
      // event provider
      searchField,
      // timeInterval
      1000, 
      // eventId
      'liveChange', 
      // data
      null, 
      // handler
      this.doSearch, 
      // listener
      this,
      // progressHandler
      this.searchFieldProgress,
      // progressUpdateInterval
      50
    );
  },
  ...

The bufferEvents() Method

The meat of the initSearchField() method is the call to the bufferEvents method of the bufferedEventHandler utility. This method has the following arguments:
  • eventProvider: the 1st argument should be the object that emits the events - in our example this is the sap.m.SearchField. This object should be a subclass of sap.ui.base.EventProvider. (bufferEvents will throw an error if it's not!)
  • timeInterval: the 2nd argument is the timeout, in milliseconds. This is the amount of time that should pass between the occurrence of the last event and the call to the actual handler of the event. If a new event occurs during the wait period, the timeout is reset, and a new waiting period is started. In the example, we use a timeInterval of 1000 - that is, we will wait 1000 milliseconds (1 second) before handling the last event.
Choosing the timeInterval is a balancing act. In the case of the example, where the events are generated in response to user actions, the timeInterval should not be too short, as the user should be given enough time to type a meaningful searchterm before the actual query kicks in. But if the timeInterval is too long, the application may appear unresponsive to the user. If the application appears unresponsive, the user may try to retype their search term, which will only postpone the reaction even more. (There's more about this in the section about the ProgressIndicator). The next 4 arguments of bufferEvents correspond to sap.ui.base.EventProvider's attachEvent() method:
  • eventId: a string that identifies the event to listen to. In our example this is 'liveChange'. Some ui5 objects, (for example, sap.ui.base.ManagedObjects, which includes all sap.ui.core.Controls) expose the events they expose through their metadata. In these cases, bufferEvents will verify whether the passed eventId is in fact exposed by the object, and it will throw an error in case it doesn't. EventProviders that do not expose their events through metadata can still be used with the bufferedEventHandler, but you'll just need to make sure yourself the value for eventId is valid, as bufferEvent has no way of checking it.
  • data: an optional argument to pass any "extra" data that the event handler might need. In the example, we pass null as we have no need for any additional data.
  • handler: this should be the callback-function that will be called upon to actually handle the event. The callback function will receive an instance of an sap.ui.base.Event as single argument, which typically provides access to all relevant information pertaining to the event. In the example, we pass this.doSearch, which is a method of the controller that will perform the actual filtering of the data grid.
  • listener: this is an optional argument which you can use to specify the scope in which the handler will be called. Typically the handler will not be completely standalone, but it will refer to a this object, one way or another. If the handler function is not already bound (for example, by using the function's bind() method), then you should pass whatever object should act as this for the handler function via the listener argument. In the example, we simply use this which refers to the controller instance itself. This makes sense as the handler function is also a method of the controller. (Remember: we passed this.doSearch as handler.)
In the call to bufferEvents, these arguments will be used to create an actual handler for the event, and also automatically attach it to the eventProvider for the specified eventId. But rather then calling the passed handler, it will start a javascript timeout for a duration of the passed timeInterval. If the timeout was already initiated, it is cleared, thus canceling the previous event, and initiating a new waiting period.

Monitoring wait progress

The final 2 arguments to bufferEvents are optional, and may be used by the application to monitor the waiting period between the occurrence of the last event and the time when the handler will actually be called:
  • progressHandler: when passed, this should be a callback function which is to be called at the start and during the waiting period. If the progressHandler callback is called, it will be called using the listener as scope. The callback will be passed a floating point number between 0 and 1, indicating the fraction of the time that has passed between the last event and now. If a progressHandler is specified, it is always called at least once and passed 0 whenever a new waiting period is initiated. In this example we passed this.searchFieldProgress, which is a method of the controller that updates the sap.m.ProgressIndicator that sits in the right top of the data grid.
  • progressUpdateInterval: this should be in integer, indicating the number of milliseconds between the calls to the progressHandler. In our example it is 50, which means we will get 1000 / 50 = 20 updates during the waiting period, which ensures a smooth and regular update of the ProgressIndicator control.

The ProgressIndicator

The sample application provides a sap.m.ProgressIndicator to indicate when the entered search term will be used to filter the data. A progress indicator may not be necessary in case the timeInterval is so short that it will appear to the user as if the event is handled immediately. But when the timeInterval exceeds 200 or 250 milliseconds, most users will start to experience a noticeable lag. Now, there is this strange psychological phenomenon happening here - as the user is still typing their search term, they will be happy that the backend query is not already fired. It would make them feel rushed if the grid was constantly being updated while they were typing. But once the user is done typing their search term, they want to have the result as quickly as possible. Obviously, the software cannot read the user's mind (yet!), so once the user stops typing, the application needs to let the user know they acknowledged their action, and that it is 'working on it'. Hence the need for a progress indicator: by having a visual indicator that "something's happening", the user will be assured the application has acknowledged their input, and this will make the wait period before actually handling the event more acceptable. If the wait is sufficiently short, a simple busyIndicator might do the trick, but since the progressHandler gets passed an exacte estimate of how much longer the user will need to wait, our progress indicator can communicate this to the user. This will make the application's behavior more predictable and hopefully more satisfying to use. Of course, it is not absolutely necessary to use the sap.m.ProgressIndicator to give this kind of feedback to the user. It's just that for this sample, this was the easiest, most straighforward illustration of this principle. You can use the `progressHandler` callback to do anything you like to fit your need.

Detaching

The bufferEvents method will create and attach a handler to the eventProvider. bufferEvents will also return that generated handler so you can detach it explicitly from the eventProvider if you need to. As a convenience, the returned handler provides its own detach() method for this purpose:
var bufferedEventHandlerInstance = bufferedEventHandler.bufferEvents(...);
...
bufferedEventHandlerInstance.detach();
(Note that in a typical scenario, the eventHandler and the eventProvider will almost certainly be in the same scope and lifecycle, so there is rarely a need to explicitly do this.)

Other Use Cases

The liveSearch scenario may not always be a convincing use case. For example, if the query is done against a client model rather than a remote backend system then it might not actually be a problem to re-issue the query for every keystroke. But there are some other scenarios that benefit from event buffering. We will encounter one such case in the tip about Persisting UI State.

Finally

Did you like this tip? Do you have a better tip? Feel free to post a comment and share your approach to the same or similar problem. Want more tips? Find other posts with the ui5tips tag!

DuckDB bag of tricks: Processing PGN chess games with DuckDB - Rolling up each game's lines into a single game row (6/6)

DuckDB bag of tricks is the banner I use on this blog to post my tips and tricks about DuckDB . This post is the sixth installment of a s...