Declarative API
Formula is a mix of functional, reactive and declarative programming. One aspect that might seem quite unusual is the way that it handles asynchronous actions such as RxJava observables, Kotlin Flows, etc. Most developers are used to explicitly managing subscription lifecycle.
val fetchUserObservable = repository.fetchUser()
disposables += fetchUserObservable.subscribe { userResult ->
// Do something
}
Formula does things a bit differently. It manages the lifecycle of the asynchronous actions for you. Instead of manually subscribing and unsubscribing, you define the conditions for which the asynchronous action should run and the listener which handles events produced by the action.
val fetchUserAction = RxAction.fromObservable { repository.fetchUser() }
fetchUserAction.onEvent { userResult ->
// Do something
}
The logic looks very similar to the first option, but the key difference here is that Observable.subscribe
hasn't run yet - the execution
is deferred. It might not be clear why this is useful just from this example, but deferring execution allows us to provide a declarative API.
For example, we can add conditional logic to only fetch user when user id is set.
if (state.userId != null) {
val fetchUserAction = RxAction.fromObservable { repository.fetchUser(state.userId) }
fetchUserAction.onEvent { userResult ->
// Do something with the result
}
}
What if we want to fetch user information only after user clicks on some button to enable this. We can just expand on the conditional logic.
if (state.isUserFetchEnabled && state.userId != null) {
// Logic here is the same as the previous example
}
Here, we don’t care what controls isUserFetchEnabled
boolean. Formula will start execution when these
conditions are met and will dispose of the action if state.isUserFetchedEnabled
becomes false
again.
What if for some unusual reason the userId could change and we would want to refetch? We can
define this behavior using key
parameter
if (state.isUserFetchEnabled && state.userId != null) {
val fetchUserAction = RxAction.fromObservable(key = state.userId) { repository.fetchUser(state.userId) }
fetchUserAction.onEvent { userResult ->
// Do something with the result
}
}
To understand how all this works, we will make some simplified assumptions about Formula APIs:
- Each
Formula
will define an immutableState
data class. - We use
State
object in theevaluate(state: State)
function which defines thePair<UIModel, Actions>
- Any time there is a
State
change, the Formula runtime will callevaluate
again.
In code, this might look like this
val actionCache: MutableMap<Key, RunningAction> = mutableMapOf()
fun onStateChanged(state: State) {
val (uiModel, actions) = formula.evaluate(state)
// Stop actions
actionCache.forEach { (key, runningAction) ->
if (!actions.contains(key)) {
actionCache.remove(key)
runningAction.stop()
}
}
// Start actions
actions.forEach { action ->
if (!actionCache.contains(key)) {
actionCache[key] = action.start()
}
}
}
It's worth mentioning that this is an approximate and not the actual implementation. Within our
assumptions we didn't discuss formula inputs (passed to configure Formula) and child
formulas (enables re-use and composition) which also have an affect on evaluation. Similarly,
to how we used State
to control the lifecycle of the action, we can use formula input or child
formula outputs.
To expand on the previous assumptions, let's define how Input
interacts
with Formula
. Input is passed by the outside world to configure Formula
instance. Similarly
to State
:
Input
is usually an immutable data class- We use
Input
object in theevaluate(input, state)
function to createPair<UIModel, Actions>
- Any time there is an
Input
change, the Formula runtime will callevaluate
again.
This means that similarly to State
, we can also use Input
to define action conditions.
if (input.userId != null) {
val fetchUserAction = RxAction.fromObservable { repository.fetchUser(input.userId) }
fetchUserAction.onEvent { userResult ->
// Do something with the result
}
}