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) }
)