Event Handling
Event handling in Formula is based on event listeners. A listener is just a function
that is called when an event happens and could be described by a simple (Event) -> Unit
function type. We pass listeners to other parts of the codebase such as the view layer by
adding the listener to the Render Model.
UI Events
To handle UI events, declare a Listener on the Render Model for each type of UI event you care about.
data class FormRenderModel(
// A listener where event contains no information (We use kotlin.Unit type).
val onSaveSelected: Listener<Unit>,
// A listener where name string is passed as part of the event.
val onNameChanged: Listener<String>,
)
To create a listener use FormulaContext.onEvent. Note: All listeners should be created within Formula.evaluate block.
override fun Snapshot<Input, State>.evaluate(): Evaluation<FormRenderModel> {
return Evaluation(
output = FormRenderModel(
onNameChanged = context.onEvent<String> { newName ->
// Use "newName" to perform a transition
transition(state.copy(name = newName))
},
onSaveSelected = context.onEvent<Unit> {
// No state change, performing side-effects.
transition {
userService.updateName(state.name)
analytics.trackNameUpdated(state.name)
}
}
)
)
}
This example is dense, but it shows almost every kind of scenario. Let's go over it.
To create a listener, we pass a function that returns a Transition<State>. Formula
uses transitions to update internal state and/or perform side-effects to other components.
Listeners are scoped to the current state. Any time we transition to a new state, evaluate
is called again and the listeners are recreated.
// Updating state
context.onEvent { newName: String ->
// We use kotlin data class copy function
// to create a new state with new name
transition(state.copy(name = newName))
}
// Updating onSaveSelected to include validation
context.onEvent {
if (state.name.isBlank()) {
// A transition which performs a side-effect.
transition {
input.showNotification("Name cannot be empty!")
}
} else {
// No state change, performing side-effects as part of the transition
transition {
userService.updateName(state.name)
analytics.trackNameUpdated(state.name)
}
}
}
To ensure safe execution, all side-effects should be performed within transition {} block which
will be executed after the state change is performed.
Sending messages to the parent
To pass events to the parent, first define the listener on the Formula.Input class.
data class ItemListInput(
val onItemSelected: Listener<ItemId>,
)
Also, lets make sure that Input type is declared at the top of our formula.
class ItemListFormula() : Formula<ItemListInput, ..., ...>
Now, we can use the input passed to us in Formula.evaluate to communicate with the parent.
override fun evaluate(input: ItemListInput, state, context): ... {
return Evaluation(
output = state.items.map { item ->
context.key(item.id) {
ItemRow(
name = item.name,
onClick = context.onEvent {
// Notifying parent that item was selected.
transition {
input.onItemSelected(item.id)
}
}
)
}
}
)
}
Formula events
There are a few events that every formula can listen to and respond.
Evaluation(
output = ...,
actions = context.actions {
// Performs a side effect when formula is initialized
Action.onInit().onEvent {
transition { analytics.trackScreenOpen() }
}
// Performs a side effect when formula is terminated
Action.onTerminate().onEvent {
transition { analytics.trackClose() }
}
// Performs a side-effect when data changes
Action.onData(state.itemId).onEvent {
// This will call api.fetchItem for each unique itemId
transition { api.fetchItem(state.itemId) }
}
}
)
Formula retains listeners
Listeners retain equality across re-evaluation (such as state changes). The first time formula requests a listener, we create it and persist it in the map. Subsequent calls will re-use this instance. The instance is disabled and removed when your formula is removed or if you don't request this listener within Formula.evaluate block.
By default, we generate a key for each listener based on the listener type. Usually, this
is an anonymous class which is associated with the position in code where it is called. There are
a couple of cases when this is not sufficient and you need to explicitly provide a unique key.
Case 1: Declaring listeners within a loop
For example, if you are mapping list of items and creating a listener within the map function.
// This will not work unless your list of items never changes (removal of item or position change).
ItemListRenderModel(
items = state.items.map { item ->
ItemRenderModel(
name = item.name,
onSelected = context.onEvent {
// perform a transition
}
)
}
)
To fix it, you should wrap ItemRenderModel creation block in context.key where you pass it an item id.
context.key(item.id) {
ItemRenderModel(
name = item.name,
onSelected = context.onEvent {
// perform a transition
}
)
}
Case 2: Delegating to another function
There is an issue with listeners when passing FormulaContext to another function.
Let's say you have a function that takes FormulaContext and creates a ChildRenderModel.
fun createChildRenderModel(context: FormulaContext<...>): ChildRenderModel {
return ChildRenderModel(
onClick = context.onEvent {}
)
}
There is no problem calling it once, but there will be key collisions if you call it multiple times:
RenderModel(
// First child is created with no problem
first = createChildRenderModel(context),
// Calling it again will crash
second = createChildRenderModel(context)
)
To fix it, wrap createChildRenderModel with context.key block.
RenderModel(
first = context.key("first") { createChildRenderModel(context) },
second = context.key("second") { createChildRenderModel(context) }
)