Skip to content

How It Works

Formula has a similar mental model to Jetpack Compose — it has a declarative evaluate() function which describes what should exist right now. It produces output and actions based on current input, state, and child outputs. It re-runs when inputs, state, or child hierarchy state changes. For every change, the root formula re-evaluates all the way down to the changed area.

Transitions

Formula provides a mechanism to declare event listeners which respond to events by producing transitions. A transition can update the formula's state and optionally execute side effects. When state changes, it propagates up to the root, which re-runs evaluate() down to the changed area.

Maintaining components across evaluations

evaluate() is called many times over a formula's lifetime. Actions, child formulas, and listeners need to persist across these evaluations — actions should keep running, children should keep their state, and listeners should maintain stable identity. Formula uses a key-based mechanism to match components across evaluations and manage their lifecycle.

Action Lifecycle

Actions declared during evaluation are started by the runtime. If an action is no longer declared in a subsequent evaluation, the runtime cancels it. If an action's key changes, the runtime cancels the old one and starts a new one.

To match actions across evaluations, the runtime uses a composite key (positional key based on anonymous class type + optional user-provided key). During evaluation, each action declaration looks up its key in LifecycleCache — if a matching action exists, it is reused; if not, a new one is started. Actions not declared during an evaluation are cancelled afterward.

// Conditional: action only runs when userId is set.
// If userId becomes null, the runtime cancels it.
if (state.userId != null) {
    // Key: if userId changes, runtime cancels the old action and starts a new one.
    Action.fromFlow(key = state.userId) {
        repository.observeUser(state.userId)
    }.onEvent { user ->
        transition(state.copy(user = user))
    }
}

Child Formula Lifecycle

Child formulas declared via context.child() during evaluation are started by the runtime. If a child is no longer declared in a subsequent evaluation, the runtime terminates it.

To match child formulas across evaluations, the runtime uses a composite key of formula type + formula.key(input). If key(input) changes, the runtime terminates the old instance and starts a new one with initialState.

A child only re-evaluates if its input changed or state changed within its hierarchy. Otherwise, context.child() returns the previously computed output.

val headerOutput = context.child(headerFormula, HeaderInput(title = state.title))

val dialog = if (state.showDialog) {
    context.child(dialogFormula, Unit)
} else {
    null
}

Listener Lifecycle

Listeners created via context.onEvent and context.callback maintain the same instance across evaluations. This is important because listeners are placed on outputs and passed to child formulas via input — if a new instance were created every evaluation, equality checks would always fail, triggering unnecessary re-evaluation.

Like actions, listeners are matched across evaluations using a composite key (positional key + optional user-provided key). The listener's internal transition is updated each evaluation to reference the latest state, but the instance itself stays the same.

Listeners not requested during an evaluation are disabled and cleaned up.

Key Collisions and Indexing

When multiple components share the same positional key — for example, creating listeners inside a map loop — Formula disambiguates them by index. The first occurrence gets index 0, the second gets index 1, and so on.

This is fragile: if item order changes or items are removed, indexes shift and components can get matched to wrong instances. To avoid this, provide stable, explicit keys such as a unique identifier for the object.