
This is a Servoy tutorial on the Events Manager API and the declarative UI Events feature that shipped in Servoy 2025.06. If you have ever wired up a cross-form event listener in onShow and then spent twenty minutes debugging why your handler fired twice (or not at all) after a tab switch, this tutorial is for you.
This tutorial assumes you are comfortable with modern JavaScript in Servoy. If you want a refresher on const, let, arrow functions, and template literals in a Servoy context, I recommend starting with Modern JavaScript in Servoy before continuing here.
The problem
Let me paint a picture. You have a customer-search module: a popup or embedded panel that lets the user find a customer and select one. Several forms in your application need to react when that selection happens: an invoice_edit form, a support-ticket form, a contact form. All of them need to know which customer was chosen.
The obvious wrong answer is to reach across to each consumer form directly from the search module: forms.invoice_edit.setCustomer(oCustomer), and the same for every other consumer. That is tight coupling. The search module now has to know the names of every form that cares about its selections, and every new consumer requires a change to the module. Not great.
The slightly-less-wrong answer is a global variable. The search module writes the selected customer into globals.oSelectedCustomer and fires off a broadcast-style notification. The consumers read it. This works until you need context (which form triggered the search?) or until two consumers need different handling.
The correct answer is the Observer pattern: the search module fires a custom event; any interested form registers a handler. The module does not know its consumers, and adding a new consumer requires zero changes to the module.
Servoy has had a programmatic API for exactly this pattern for some time. Servoy 2025.06 added a declarative layer on top of it called UI Events. Let’s walk through both. The “before” is what makes the “after” worth having.
The programmatic Events Manager API
The Events Manager is accessed via the global eventsManager object. Event types are accessed via the global EventType object. You define custom event type names in your solution’s eventTypes property, and they become available as EventType.<yourEventName> [1].
The programmatic API has three core operations: registering a listener, firing an event, and removing a listener [1].
Registering a listener
/**@type {Function}*/var _fnCustomerSelectedDeregister = null;
/** * Registers a handler for the customerSelected event in the invoice_edit form. * Called from onShow so the form listens while visible. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * @param {Boolean} bFirstShow Whether this is the first time the form has shown. * @param {JSEvent} event The Servoy event object. * @return {void} */function onShow(bFirstShow, event) { try { /**@type {Function}*/ const fnDeregister = eventsManager.addEventListener( EventType.customerSelected, onCustomerSelectedFromSearch );
/**@type {Function}*/ _fnCustomerSelectedDeregister = fnDeregister; } catch (oError) { application.output( 'invoice_edit.onShow: failed to register customerSelected listener. ' + oError.message, LOGGINGLEVEL.ERROR ); plugins.dialogs.showErrorDialog( 'Startup Error', 'Could not initialize the customer event listener. Please reload the form.', 'OK' ); }}
/** * Deregisters the customerSelected handler when the form hides. * Paired with onShow to avoid listener accumulation. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * @param {JSEvent} event The Servoy event object. * @return {Boolean} */function onHide(event) { try { if (_fnCustomerSelectedDeregister) { _fnCustomerSelectedDeregister(); _fnCustomerSelectedDeregister = null; } } catch (oError) { application.output( 'invoice_edit.onHide: failed to deregister customerSelected listener. ' + oError.message, LOGGINGLEVEL.ERROR ); plugins.dialogs.showErrorDialog( 'Cleanup Error', 'Could not release the customer event listener. Please reload the application if you notice duplicate updates.', 'OK' ); } return true;}
/** * Handles the customerSelected event fired by the customer-search module. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * @param {JSEvent} event The Servoy event object (event.data holds the argument array). * @param {String} sCustomerId The selected customer ID, expanded from the argument array. * @return {void} */function onCustomerSelectedFromSearch(event, sCustomerId) { try { /**@type {QBSelect<db:/myserver/crm_customer>}*/ const query = datasources.db.myserver.crm_customer.createSelect(); query.result.addPk(); query.where.add(query.columns.cust_id.eq(sCustomerId)); foundset.loadRecords(query); } catch (oError) { application.output( 'invoice_edit.onCustomerSelectedFromSearch: error loading customer. ' + oError.message, LOGGINGLEVEL.ERROR ); plugins.dialogs.showErrorDialog( 'Load Error', 'Could not load the selected customer. Please try again.', 'OK' ); }}Notice _fnCustomerSelectedDeregister. The addEventListener call returns a deregister function [1]. I store it in a form variable (the _ prefix signals a form variable per Servoy conventions) so that onHide can call it to clean up. The alternative is calling eventsManager.removeEventListener(EventType.customerSelected, onCustomerSelectedFromSearch, null) directly [1], but the returned deregister function is cleaner because you do not have to repeat the event type and callback reference.
This approach works well. The pattern: register in onShow, store the deregister function, call it in onHide. A minor discipline issue is that you have to remember to pair them on every consumer form. Forget the onHide step and your listener accumulates: navigate away, navigate back, and the handler fires twice. Navigate away three times and it fires three times. The platform does not warn you; it just calls the handler as many times as you registered it.
The module side: firing the event
The search module fires the event programmatically. This is true whether or not you use UI Events, so I want to be clear about it upfront: firing is always programmatic [2]. The 2025.06 declarative layer changes how consumers register handlers, not how events are triggered.
/** * Fires the customerSelected event when the user confirms a selection * in the customer-search module. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * @param {String} sCustomerId The ID of the selected customer record. * @return {void} */function fireCustomerSelected(sCustomerId) { try { eventsManager.fireEventListeners( EventType.customerSelected, null, [sCustomerId] ); } catch (oError) { application.output( 'customerSearch.fireCustomerSelected: error firing event. ' + oError.message, LOGGINGLEVEL.ERROR ); plugins.dialogs.showErrorDialog( 'Selection Error', 'The customer selection could not be broadcast to the open forms. Please try selecting again.', 'OK' ); }}The three-argument form of fireEventListeners takes the event type, an optional context (null means all listeners), and an array of arguments to pass to each callback [1]. Those arguments show up in the callback as the event.data array, and also as expanded positional parameters after the JSEvent argument, which is why onCustomerSelectedFromSearch above receives sCustomerId as a second parameter directly.
The full fireEventListeners signature family
The API has five overloads worth knowing [1]:
// Fire all listeners, returns Boolean (logical AND of all listener return values)eventsManager.fireEventListeners(EventType.customerSelected);
// Fire with context filter (only listeners registered for this context)eventsManager.fireEventListeners(EventType.customerSelected, forms.invoice_edit);
// Fire with arguments, no context filtereventsManager.fireEventListeners(EventType.customerSelected, [sCustomerId]);
// Fire with arguments and context filtereventsManager.fireEventListeners(EventType.customerSelected, null, [sCustomerId]);
// Fire with explicit return value aggregationeventsManager.fireEventListeners( EventType.customerSelected, null, [sCustomerId], EVENTS_AGGREGATION_TYPE.RETURN_VALUE_ARRAY);That last overload is particularly useful when you need each listener’s individual return value rather than the AND-combined Boolean. The EVENTS_AGGREGATION_TYPE constant has two values: RETURN_VALUE_BOOLEAN (the default, logical AND of all return values) and RETURN_VALUE_ARRAY (an array of each listener’s individual return value) [4]. More on that in a moment.
The gate pattern: coordinating a save across embedded forms
fireEventListeners returns the logical AND of all listener return values by default [1]. This behavior enables a save-coordination pattern that I find elegant. Imagine a complex order entry form with three embedded sections: line items, shipping details, and billing information. Each section validates independently. The orchestrating form can fire a pre-save event and block the save if any section objects:
/** * Handles the save action button on the order entry form. * Fires a pre-save event to all embedded sections; only saves if all pass. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * @param {JSEvent} event The Servoy event object. * @return {void} */function onActionSave(event) { try { /**@type {Boolean}*/ const bAllSectionsValid = eventsManager.fireEventListeners( EventType.onBeforeSave, null );
if (bAllSectionsValid) { /**@type {Boolean}*/ const bSaved = databaseManager.saveData(); if (!bSaved) { databaseManager.rollbackEditedRecords(); plugins.dialogs.showErrorDialog( 'Save Failed', 'The save was rejected by the database. Your changes have been rolled back.', 'OK' ); return; } eventsManager.fireEventListeners(EventType.onSave, null); } else { eventsManager.fireEventListeners(EventType.onSaveFailed, null); } } catch (oError) { application.output( 'orderEntry.onActionSave: unexpected error. ' + oError.message, LOGGINGLEVEL.ERROR ); plugins.dialogs.showErrorDialog('Error', 'An unexpected error occurred during save.', 'OK'); }}
/** * Validates the shipping section before save. * Returns false to block the save if validation fails. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * @param {JSEvent} event The Servoy event object. * @return {Boolean} True if valid; false to block the save. */function onBeforeSave(event) { try { /**@type {Date}*/ const dShippedDate = foundset.getSelectedRecord()?.shipped_date ?? null; /**@type {Date}*/ const dOrderDate = foundset.getSelectedRecord()?.order_date ?? null;
if (dShippedDate !== null && dOrderDate !== null && dShippedDate < dOrderDate) { elements.shipped_date.addStyleClass('field-invalid'); return false; } elements.shipped_date.removeStyleClass('field-invalid'); return true; } catch (oError) { application.output( 'shippingSection.onBeforeSave: validation error. ' + oError.message, LOGGINGLEVEL.ERROR ); plugins.dialogs.showErrorDialog( 'Validation Error', 'The shipping section could not be validated. The save has been blocked to protect your data.', 'OK' ); return false; }}Each embedded section registers its validator exactly the way the invoice_edit form registered its handler earlier: a single eventsManager.addEventListener(EventType.onBeforeSave, onBeforeSave) call in the section’s onShow, with the returned deregister function stored in a form variable and called from onHide. When the save button fires the event, every listener runs, and bAllSectionsValid is true only if all of them return true. Any one returning false blocks the save. This is a clean Observer-pattern gate, and it works without the sections knowing anything about each other.
Note that EventType.onBeforeSave, EventType.onSave, and EventType.onSaveFailed in this example are custom event types I defined in the solution’s eventTypes property. The programming guide examples show this pattern using custom-named events [2]. The confirmed built-in event types in the API reference are EventType.onShow, EventType.onHide, and EventType.onBeforeHide [1]. I am not claiming an exhaustive list of built-ins here, as the API reference page does not enumerate all of them.
Enter UI Events: the 2025.06 upgrade
Okay. We have the programmatic pattern down. Register in onShow, deregister in onHide, store the deregister function. It works, but it is boilerplate that every consumer form has to replicate, and forgetting the onHide pairing is the kind of bug that only shows up in production after a user has navigated through the same form fifteen times.
Servoy 2025.06 added a way to eliminate that boilerplate for custom events that module authors want to be IDE-visible. The release notes describe it this way [3]:
“The Events Manager with the solution’s EventsType property now supports creating UI Events. Modules can define UI Events for forms or solutions. Developers see these events in the Properties view and can declaratively attach functions (similar to button click handlers). The system automatically handles event registration and deregistration when forms are created or destroyed” [3].
The ticket number is SVY-20147 [3].
Let me unpack what “UI Event” means in practice.
Defining a UI Event
There is no scripting API for creating UI Events. The designation happens in the IDE. Here is the step-by-step from the programming guide [2]:
- In Solution Explorer, select your solution (the root node).
- Open the Properties panel and locate the
eventTypesfield. - Click the
[]button to open the Event Types Editor. - Add an event with the name you want consumers to see (for example,
customerSelected). - Set the event’s
UI Eventproperty toSolution(app-wide binding) orForm(per-form binding).
That’s it from the module author’s perspective. Once you flag an event as a UI Event, consumers will see it in their form’s Properties view under “Custom Events,” exactly the way onClick appears for a button.
The module still fires the event the same way it always did [2]:
/** * Fires the customerSelected event after the user confirms a selection. * Called from the customer-search module's accept action. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * @param {String} sCustomerId The selected customer ID. * @return {void} */function fireCustomerSelected(sCustomerId) { try { eventsManager.fireEventListeners( EventType.customerSelected, null, [sCustomerId] ); } catch (oError) { application.output( 'customerSearch.fireCustomerSelected: error. ' + oError.message, LOGGINGLEVEL.ERROR ); plugins.dialogs.showErrorDialog( 'Selection Error', 'The customer selection could not be broadcast to the open forms. Please try selecting again.', 'OK' ); }}Nothing changes on the firing side. Firing is always programmatic.
Consuming a UI Event declaratively
On the consumer side, everything changes. A form consuming a UI Event does not write addEventListener in onShow or removeEventListener in onHide. Instead, the developer opens the consumer form in the Properties view and sees customerSelected listed under Custom Events. They click it, select a handler function from the autocomplete, and they are done [2].
Servoy registers the handler when the form is created and deregisters it when the form is destroyed. The lifecycle management that was previously the developer’s responsibility becomes the platform’s responsibility.
For the invoice_edit form, the result is that the onShow and onHide functions shown in the earlier example either disappear or shrink to contain only unrelated logic. The customer event registration is gone. The deregister function variable is gone. There is nothing to forget.
The before-and-after in one table
| Concern | Scripted (pre-2025.06) | Declarative UI Events (2025.06+) |
|---|---|---|
| Consumer registers | addEventListener in onShow | Properties view, no code |
| Consumer deregisters | removeEventListener in onHide | Automatic on form destroy |
| Leak risk | Yes, easy to forget pairing | None |
| Module fires | fireEventListeners() | Same: fireEventListeners() |
| IDE visibility | None | Event appears in Properties view |
| Works since | Always | 2025.06 |
Bottom line: the module author defines the event, fires it programmatically, and flags it as a UI Event in the editor. Consumers attach handlers declaratively in the Properties view. Servoy handles the rest.
Can you mix declarative and programmatic registration?
The programming guide shows scripted addEventListener as an “alternative” to declarative binding for UI Events [2], which implies both approaches can work for the same event type. The underlying mechanism is the same eventsManager infrastructure either way. However, the documentation does not explicitly confirm whether a UI Event can have BOTH a declarative Properties view handler AND a scripted addEventListener handler active simultaneously on the same form. I would not rely on that combination without testing it in your Servoy version.
Which pattern to use
If you are writing a module that you want other developers to consume without reading your source code, mark your events as UI Events. The Properties view becomes self-documenting: consumers see the event name and can attach a handler the same way they attach a button’s onClick. This is pretty sweet for team environments where not everyone knows the Events Manager scripting API.
If you need programmatic control, such as registering a listener conditionally based on runtime state, or registering with a context filter so only listeners for a specific form instance receive the event, stick with the scripted API. The context parameter on addEventListener and fireEventListeners gives you precision that the declarative Properties view binding does not expose.
If you are on a Servoy version before 2025.06, UI Events do not exist. The scripted pattern is the only option.
The 2025.06 declarative theme
It is worth stepping back for a moment. The UI Events feature is one of three 2025.06 additions that push Servoy form development in a more declarative direction. The pattern in the Data-Driven UI tutorial (article #21, not yet published) showed how visibleDataprovider and enabledDataprovider move component visibility logic from onShow and onRecordSelection handlers into data bindings. UI Events move cross-form event wiring from onShow and onHide into the Properties view. There is also a form-scoped LESS feature that moves stylesheet scoping from global class-name conventions into the form editor itself.
All three reduce the amount of boilerplate in form event handlers, and all three make form intent more readable to someone who opens the form later and is not familiar with its internal wiring. I find this trend encouraging. Servoy forms have always been readable at the component level; these features extend that readability to the behavioral and visual layers.
Conclusion
The Events Manager is the Observer pattern for Servoy: custom events flow from module to consumers without the module knowing who is listening. The programmatic API (addEventListener, fireEventListeners, removeEventListener) has been available for some time and is worth knowing well. The gate pattern in particular, where a pre-save event’s AND-aggregated return value controls whether a save proceeds, is an elegant way to coordinate validation across embedded forms.
The 2025.06 UI Events addition builds on that foundation. By marking a custom event as a UI Event in the Event Types Editor, module authors make it visible in the Properties view. Consumer forms can then bind handlers declaratively, the same way they bind a button’s onClick. Servoy manages registration and deregistration automatically.
The important accuracy point to carry away is that firing always stays in code. The module author always calls eventsManager.fireEventListeners(). The declarative layer is purely on the consumer side.
That concludes this Servoy tutorial on the Events Manager. I hope you enjoyed it, and I look forward to bringing you more Servoy tutorials in the future.
The Series
This is Part 6 of the 6-part Modern Servoy Language and Platform Features sub-series:
- Modern JavaScript in Servoy: ES6+ syntax,
const/let, arrow functions, template literals, and destructuring in the Rhino runtime - Promises and Async in Servoy: asynchronous data APIs, Promise.all() patterns, and two-level error handling
- Typed Query Builder: QBSelect, typed column classes, and type-safe foundset queries
- Typed Solution Model: JSForm.NAMES, JSValueList.NAMES, JSPermission, and F3 navigation for solution references
- Data-Driven UI: visibleDataprovider and enabledDataprovider: declarative component visibility and enabled state via data bindings
- Events Manager: Declarative Custom Events in Servoy (this article): the programmatic Observer-pattern API and the 2025.06 UI Events declarative binding layer
References
[1] Servoy B.V., “Events Manager,” Servoy Dev API Reference, 2025. https://docs.servoy.com/reference/servoycore/dev-api/events-manager
[2] Servoy B.V., “Events Manager Programming Guide,” Servoy Documentation, 2025. https://docs.servoy.com/guides/develop/programming-guide/scripting-the-ui/events-manager
[3] Servoy B.V., “Servoy 2025.06 Release Notes,” Servoy Documentation, June 2025. https://docs.servoy.com/release-notes/release-notes/2025.06
[4] Servoy B.V., “EVENTS_AGGREGATION_TYPE,” Servoy Dev API Reference, 2025. https://docs.servoy.com/reference/servoycore/dev-api/events-manager/events_aggregation_type
Building production AI, or modernizing a legacy system?
That is the kind of work we do at Dotzlaw Consulting. Book a free 20-minute intro call and tell us what you are trying to build, or what is slowing you down.