Event handling
Learn about events, event handling, and observers to watch for property changes.
Overview
In a Desk app, events are used to communicate between different managed objects. Once an object emits an event, it can be handled either by a listener function (callback), an observer (see below), or an attached parent object.
Why? — Events allow for a loosely coupled architecture, where objects can communicate without needing to know about each other’s implementation details. This makes it easier to maintain and extend the application, and to test individual parts in isolation.
Implementation — Events are represented by ManagedEvent
objects, created by the emitting ManagedObject
object. The event object contains information about the event, such as the event name, the emitting object, and any additional data.
- class ManagedEventAn object that represents an event, to be emitted on a ManagedObject.
Event names are case-sensitive, and should start with a capital letter — e.g. Click
and ButtonClick
, not click
or button-click
. This makes it possible to distinguish handler methods with an event name (e.g. onButtonClick
), which are used in activity classes and composite views.
Emitting events
To emit an event, use the ManagedObject.emit() method. This method can be used in two ways:
- Emitting an event using an instance of ManagedEvent.
- Emitting an event using just the event name and additional data.
- emit(event)Emits an event, immediately calling all event handlers.
const myObject = new ManagedObject();
myObject.emit("MyEvent", { someData: "Hello" });
// same as this:
const myEvent = new ManagedEvent("MyEvent", myObject, { someData: "Hello" });
myObject.emit(myEvent);
Emitting change events
The ManagedChangeEvent event subclass is used to emit change events. There’s nothing special about the event class itself, but change events are handled differently from other events. Notably:
- Change events can be handled using a callback function, when emitted by an attached object or a service (see below).
- Change events on bound objects will trigger a forced update of the bound value.
As with other events, change events can be emitted using the emit() method, but to emit a change event using just the event name, you should use the emitChange() method instead. Without any parameters, this method emits a change event with the name Change
and no additional data.
- class ManagedChangeEventAn object that represents an event, emitted on a ManagedObject when its internal state changes.
- emitChange(name?, data?)Emits a change event, an instance of ManagedChangeEvent.
const myObject = new ManagedObject();
myObject.emitChange();
// same as this:
const myEvent = new ManagedChangeEvent("Change", myObject);
myObject.emit(myEvent);
Handling change events from attached objects
With an architecture that’s based on composition, parent objects are often interested in changes to attached objects — for example, to keep their own internal state up to date.
You can simply provide a callback (or an Observer instance, see below) to the attach() or autoAttach() methods to listen for change events. When a change event is emitted by an attached object, the parent object calls the callback automatically.
// Handle change events on an attached object:
class ParentObject extends ManagedObject {
readonly object = this.attach(new MyObject(), (object, event) => {
if (event) {
// ... handle change event here
}
});
}
Handling change events from services
Shared functionality and data is commonly encapsulated in a service, accessible by name (e.g. Auth
or Settings
) using the service context. Refer to the documentation for services to learn more.
Services can be observed by activities and other services. A callback function (or an Observer instance, see below) can be used to listen for change events that are emitted by a service, along with service registration, replacement, and unlinking, when provided to the observeService()
method.
- observeService(id, observer?) protectedObserves a particular service by ID, until the activity is unlinked.
class AuthService extends Service {
id = "Auth";
// ... service implementation
}
class MyActivity extends Activity {
// Observe service changes:
auth = this.observeService<AuthService>("Auth", (service, event) => {
if (service) {
// ... handle service registered, updated OR changed here
} else {
// ... handle unlinked service here
}
});
// If necessary, use the observed service elsewhere too:
exampleMethod() {
const authService = this.auth.observed;
// ... use authService here (may be undefined)
}
}
Handling events using listeners
Listeners provide the simplest mechanism for handling events. A listener adds a callback function that’s invoked whenever any event is emitted by the target object. The listener stops automatically when the target object is unlinked, but is otherwise never removed.
- listen(handler)Adds a handler for all events emitted by this object.
const myObject = new MyObject();
myObject.listen((event) => {
if (event.name === "SomeEvent") {
// ... do something
}
});
Note: Since listeners can’t be removed, don’t add a listener to an object that’s intended to ‘outlive’ the listener. For example, from the Activity.ready() method, don’t add a listener to an object that stays around during the entire lifetime of the application. That may end up adding multiple listeners, creating a memory leak. In that case, use a (service) observer, or find a way to attach the target object to the activity.
Async event streams
Rather than using a callback function, you can also use an async iterable to listen for events. This allows for a ‘loop’-like syntax, and ensures that each event is handled in sequence, even if the event is emitted while the listener is still processing a previous event.
for await (let event of myObject.listen()) {
if (event.name === "SomeEvent") {
// ...handle SomeEvent
}
}
// ... (code here runs after object is unlinked, or `break`)
Observers
The Desk framework provides an even more powerful mechanism for handling events, in the form of observers.
Observers are classes that are instantiated to observe a single object at a time. They can be used to handle all events, property changes, and attachment changes (i.e. attaching or unlinking) of any ManagedObject instance.
To create an observer, write a subclass of the Observer class, and override its methods or add your own. Then, create an instance and use the observe() method to start observing a target object.
- class ObserverThe base class for an observer that watches a particular ManagedObject.
- observe(observed)Starts observing the specified managed object.
The lifecycle of an observer instance extends beyond that of the target object, so that a single observer can be used to observe multiple objects. Once a target object is unlinked, the observer stops automatically. You can also stop the observer manually using the stop() method. After that, the observer can be used again to observe a new object.
- stop()Stops observing events and properties.
Handling events using observers
Once the observer has started observing a target object, its handleEvent() method is called for every event emitted by the target object.
- handleEvent(event) protectedA method that’s called when an event is emitted on the observed object, may be overridden.
By default, this method tries to find a specific method for each event using its name (e.g. onConnected()
for a Connected
event). You can also override this method to handle events in a different way.
// Handle change events using an observer:
class MyObserver extends Observer<MyObject> {
protected handleEvent(event: ManagedEvent) {
// ... handle any event here
// `this.observed` is the object being observed
}
// If handleEvent is not overridden, handle events by name:
onSomeEvent(event: ManagedEvent) {
// ... handle SomeEvent here
}
}
let myObject = new MyObject();
new MyObserver().observe(myObject);
myObject.emit("SomeEvent");
You can pass an observer instance to the ManagedObject.attach() or ManagedObject.autoAttach() methods to observe attached objects. In the case of autoAttach
, the observer is started and stopped automatically when a new object is attached or detached.
// Handle change events using an observer:
class MyObjectObserver extends Observer<MyObject> {
onSomeEvent(event: ManagedEvent) {
// ... handle SomeEvent here
}
}
class ParentObject extends ManagedObject {
readonly object = this.attach(new MyObject(), new MyObjectObserver());
}
In addition, observers can be used to handle unlinking, and attachment changes (i.e. moving the observed object to a different attached parent). Use the following methods to handle these events:
- handleUnlink() protectedA method that’s called when the observed object is unlinked, may be overridden.
- handleAttachedChange(origin) protectedA method that’s called when the observed object has been attached to another object, may be overridden.
Handling property changes using observers
With the help of an Observer instance, you can handle property changes of a managed object. In the observer, you can either handle all property changes in a single method, or handle each property change individually — either synchronously or asynchronously.
Each property is observed individually. Under the hood, this adds a property getter and setter for each observed property, the same way bindings are implemented. This allows the observer to intercept changes.
Observed properties and maintainability
Although being able to observe any property directly seems like a powerful and convenient feature, it’s best not to rely on this mechanism unnecessarily. From the part of your code where a property is set, it may not be clear that a handler will be called. This could cause unexpected side effects, or ‘magic’ that’s difficult to unravel for other developers later on.
Where possible, consider using change events (see above) to communicate state changes, or use methods to get and set data. Only use observers where side effects are expected and well documented — for example, built-in renderers use observers to handle property changes of UI components and update or re-render on-screen elements when needed, which is clearly an ‘expected’ side effect.
To start observing a property, call the observeProperty() or observePropertyAsync() methods. Since you’ll likely want to start observing properties as soon as the observer starts, you can add these calls to your implementation of the observe(...)
method — refer to the example below.
- observeProperty(…properties) protectedStarts observing one or more properties.
- observePropertyAsync(…properties) protectedStarts observing one or more properties, asynchronously.
Once a property is observed, the observer’s handlePropertyChange() method is called whenever the property is set, or when a managed object referenced by the property emits a change event. By default, this method calls a specific handler method for each property change, using the property name (e.g. onNameChange()
for a name
property). You can override the handlePropertyChange method to handle property changes in a different way, or implement a handler method for each property change.
class MyObject extends ManagedObject {
foo = "bar";
doSomething() {
this.foo = "baz";
}
}
class MyObjectObserver extends Observer<MyObject> {
// Override observe() to observe foo:
observe(observed: MyObject) {
return super.observe(observed).observeProperty("foo");
}
// Handle foo changes:
onFooChange(foo: string) {
// ...
}
}
// create object and observer:
let myObject = new MyObject();
new MyObjectObserver().observe(myObject);
Handling view events
In practice, the most common source of events in a front-end application are view objects: UI components and other view instances emit events when the user interacts with them.
When a view is attached to an activity or view composite, these events are handled by a single method. By default, this method tries to find a specific handler method for each event using the event name (e.g. onButtonClick()
for a ButtonClick
event). You can override this method to handle events in a different way, or implement a handler method for each event.
- delegateViewEvent(event) protectedDelegates events from the current view.
To declare types of events that are emitted by a view object, you can use the ViewEvent type. This type is based on the ManagedEvent class, but narrows down the source
property to a specific View type (refer to the example code below).
- type ViewEventType definition for an event that’s emitted on a view object.
Note: In an event handler, you can access the rendered output element (e.g. the DOM element) of a UI component using the UIComponent.lastRenderOutput property, if needed.
Since views are typically defined using ui
methods or JSX tags, which allow you to alias events (e.g. onClick: "Foo"
), adding event handlers to an activity or view composite class is as simple as adding a handler method with the appropriate name (like onFoo()
).
// Use event names in the view:
const View = ui.cell(
// ...
ui.row(
ui.textField({
placeholder: "Enter text",
onFocusIn: "InputFocusIn",
onEnterKeyPress: "ConfirmInput",
}),
ui.button("Confirm", "ConfirmInput"),
),
);
// Handle events in the activity, by name:
class MyActivity extends Activity {
// ...
onInputFocusIn(event: ViewEvent<UITextField>) {
// ...
// => event.source is typed as UITextField
}
onConfirmInput(event: ViewEvent) {
// ...
// => or otherwise, as a View object
}
}
In the case of view composites, if the handler does not return true or if a handler doesn’t exist, the event is also delegated to the parent object (see next section).
Handling delegated view events
There are a few situations where being able to find the emitting view object using the event source
property may not be enough — notably when the view is contained by another view object (or composite) that’s important to understanding the source of the event.
- For events that are emitted by a view object within a list (i.e. a UIListView instance), handling the event often requires access to the list item object (or value) that’s associated with the view object.
- For events that are emitted from within a form, access to the form context object (i.e. UIFormContext instance) is often useful.
- For events that are emitted from within a view composite, and not handled by the composite itself, access to the composite object allows for retrieving view composite properties or its associated view model.
In these cases, the event is delegated by the containing view object or composite — emitting a new event object, that references both the containing view and the original event object. The new object has its delegate property set to the containing view object, and the inner property set to the original event object.
You can use the DelegatedEvent generic type to describe such events. For events that are emitted from within a list, you can use the more specific UIListView.ItemEvent type.
- type DelegatedEventA generic type definition for an event that has been propagated by a delegate object.
- type UIListView.ItemEvent staticType alias for events delegated by UIListView.ItemControllerView.
The following example shows how to handle an event that’s emitted from within a list.
// Use events from within a list:
const View = ui.cell(
// ...
ui.list(
{ items: bound("items") },
ui.row(
// ...
ui.button("Delete", "DeleteItem"),
),
),
);
// Handle delegated events in the activity
class MyActivity extends Activity {
// ...
onDeleteItem(event: UIListView.ItemEvent<MyItem>) {
// ...
// => event.delegate is a UIList.ItemController
// => event.delegate.item is typed as MyItem
// => event.source is still UIButton
}
}
Further reading
For more information on views, UI components, view composites, and list views, see the following topic:
- ViewsThis article is not yet available.
For more information on data structures, which are often used together with events, see the following topic:
- Data structuresUse managed lists and records to model hierarchical data in your application.