Skip to content
jreznotPublic

About

Robust MobX for mutable UI on JVM: Swing, Vaadin, IntelliJ Platform

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

Flowik

dev.to License team JetBrains project

Flowik is a small Kotlin library that brings MobX-style reactive state to JVM UI toolkits. You define plain observable values and computed expressions; the UI subscribes itself and re-renders automatically when anything it reads changes — no listeners, no manual rebinding.

The library is split into a framework-agnostic core and three UI-binding modules:

Module Purpose
flowik-core Reactive primitives: observables, computed, autoRun, reactions, actions
flowik-swing Bindings for Swing components
flowik-vaadin Bindings for Vaadin Flow components
flowik-intellij Bindings for the IntelliJ Platform Kotlin UI DSL

Most users only need one of flowik-swing, flowik-vaadin or flowik-intellij — all three pull flowik-core in transitively.

The MobX concept

Flowik is directly inspired by MobX. The core idea, in one sentence:

Anything that can be derived from the application state should be derived. Automatically.

In practice this means three building blocks:

  • Observable state — mutable values that track who reads them. Created with observable(...), observableEntity(...), observables(...), observableSet(...).
  • Derivations — pure functions over observables. Either computed { ... } for cached values or autoRun { ... } for side effects (typically UI updates). Both are self-wiring: they re-evaluate when — and only when — something they read has changed.
  • Actions — action { ... } blocks that batch writes so derivations fire once at the end.

There is no event bus, no dependency graph you maintain by hand, no .subscribe() boilerplate at every call site. Read an observable from inside a derivation, and you are subscribed; stop reading it and you are not. For background and rationale, the MobX docs are the best introduction — the mental model carries over directly.

Getting it via JitPack

Releases are published through JitPack. The first time a version is requested, JitPack builds it from the git tag — the badge page above shows the latest available tag and triggers a build for new ones.

Gradle (Kotlin DSL)

repositories {
    mavenCentral()
    maven("https://jitpack.io")
}

dependencies {
    // pick one of these:
    implementation("com.github.jreznot.flowik:flowik-swing:v0.8.0")
    // OR
    implementation("com.github.jreznot.flowik:flowik-vaadin:v0.8.0")
}

Maven

<repositories>
    <repository>
        <id>jitpack.io</id>
        <url>https://jitpack.io</url>
    </repository>
</repositories>

<dependency>
    <groupId>com.github.jreznot.flowik</groupId>
    <artifactId>flowik-swing</artifactId> <!-- or flowik-vaadin, flowik-intellij -->
    <version>v0.8.0</version>
</dependency>

flowik-core is pulled in transitively. You only need to declare it explicitly if you want the reactive primitives without any UI bindings.

To follow the latest commit instead of a tagged release, use main-SNAPSHOT as the version.

A minimal example

A typical Flowik app has two parts: a store (plain Kotlin class holding observables and computed values) and a view that reads from the store. The view never registers listeners — it just reads, and Flowik does the rest.

import flowik.core.*

class CounterStore {
    val count = observable(0)
    val doubled = computed { count.value * 2 }

    fun inc() = action { count.value += 1 }
}

Using it from Swing (flowik-swing)

import flowik.core.*
import flowik.layout.uiFrame
import flowik.swing.*

fun main() {
    val store = CounterStore()
    val bindings = Bindings()                 // owns the reactions this window creates

    uiFrame("Counter", width = 240, height = 120, bindings = bindings) {
        center {
            vbox(gap = 4) {
                Label { "Count: ${store.count.value} (×2 = ${store.doubled.value})" }
                Button("Increment") { store.inc() }
            }
        }
    }.disposeOnClose(bindings)                // and releases them with the window
}

The Label lambda reads store.count and store.doubled, so the label re-renders whenever either changes. No explicit subscription — but the reactions behind it do have an owner, which the next section is about.

Lifetimes in Swing: Bindings and BindingsPanel

Every Swing binding registers with a Bindings group, which it takes as a context parameter. Nothing watches the component hierarchy: a panel that is hidden behind a CardLayout, moved between containers or re-parented keeps working, and reactions end only where the code says so.

A context parameter is satisfied by any receiver in scope, which is what keeps the group out of the way. Two things provide it:

  • the builder scope inside uiFrame { } — so Label { }, TextField(store::name) and visible { } all find the frame's group with nothing passed around;
  • BindingsPanel, a JPanel that is a group — so a component class binds inside its own members and is disposed as one unit:
class Sidebar(notes: Computed<List<Note>>, filter: MutableObservable<String>) : BindingsPanel(BorderLayout()) {
    // register() takes over a child's lifetime and returns it
    private val field = register(FilterField(filter))

    init {
        add(field, BorderLayout.NORTH)
        add(JLabel().apply { text { "${notes.value.size} notes" } }, BorderLayout.SOUTH)
        autoRun("sidebar.title") { … }
    }
}

Disposal is then one call at whichever level owns the lifetime — panel.dispose() for a panel the code throws away, disposeOnClose(bindings) for the top of the tree. A container that builds and drops children as it runs (a tabbed area, a ForEach, a Switch) disposes what it removes, so the rule is simply whoever creates a component owns it.

Context parameters are a Kotlin 2.2 preview feature, so a module using flowik-swing compiles with -XXLanguage:+ContextParameters — see Bindings: where the subscriptions live.

Using it from Vaadin (flowik-vaadin)

import com.vaadin.flow.component.html.Span
import com.vaadin.flow.component.orderedlayout.VerticalLayout
import com.vaadin.flow.router.Route
import flowik.core.*
import flowik.vaadin.*

@Route("counter")
class CounterView : VerticalLayout(), Bindings by Bindings() {
    private val store = CounterStore()

    init {
        val label = Span()
        label.text { "Count: ${store.count.value} (×2 = ${store.doubled.value})" }
        add(label, Button("Increment") { store.inc() })
        disposeOnDetach()          // the route is gone -> so are its reactions
    }
}

The Vaadin bindings also expose two-way property binding for inputs — for example TextField().apply { value(store::filter) } keeps a TextField in sync with an observable string property.

Lifetimes work exactly as in Swing: the bindings take a Bindings group as a context parameter, and the view is the group — Bindings by Bindings() is all the Swing module's BindingsPanel does, and Vaadin views are classes anyway, so there is no base class to extend. Nothing subscribes to detach events behind your back:

  • disposeOnDetach() opts a component in, and is the right hook for a routed view or a dialog you do not reuse. Aim it at the root you know is discarded — Vaadin also detaches a component that is merely moved between layouts, and that must not kill a binding;
  • register(child) hands a nested component's group to its owner, so one dispose() releases the tree;
  • items { } and childSwitch { } dispose the children they drop, so a row that owns bindings stops following its item when it leaves the list — and the children still showing go with the group they were bound in.

Events reach the store the same way they do from a Button: as an action, so a handler that writes several observables re-renders once.

  • onClick { } for anything clickable, and onDomEvent("keydown", filter = "event.key === 'Enter'") { } for any other DOM event — the filter is JavaScript the browser evaluates, so events nobody wants never make the round trip;
  • onAnimationEnd("collapse") { } lets a CSS exit animation finish before the model changes — add the class that starts it, and close the tab in the handler;
  • shortcut(Key.KEY_N, KeyModifier.ALT) { } is a keyboard shortcut that lives as long as the component it is declared on.

Two small bindings cover what lists and tabs keep needing: activeWhen { } toggles the active class and aria-selected together, and focusWhen { } puts the caret in a field each time a condition turns true — a search box that a button reveals.

Data components and forms have bindings of their own:

  • items { store.visibleCards } on a Grid, Select, ComboBox or any other component with a list data view replaces the items when the list changes, and refreshes just the one row whose entity changed — renderers run outside any reaction, so this is what keeps a column current. The selection survives as long as its items are still in the list;
  • errorMessage { if (taken) "This name is already used." else null } marks a field invalid while there is a message, with the field's own validation switched off so it cannot overrule it;
  • menu.item("Delete") { store.delete(card) } adds a menu item whose click runs as an action, and checked(store::showDone.asObservable()) binds a checkable item two-way.

For tests, testImplementation(testFixtures("com.github.jreznot.flowik:flowik-vaadin:…")) brings server-side helpers: find<T>(), isShown(), simulateClick(), fireDomEvent(…), and setValueFromClient(…) — the client edit that a two-way binding writes back.

Using it from a plugin (flowik-intellij)

The IntelliJ Platform has its own declarative panel builder, the Kotlin UI DSL. Flowik does not replace it — it plugs into it, so panel { row { ... } } stays exactly as the platform documents it and only the binding changes:

import com.intellij.openapi.ui.DialogPanel
import com.intellij.ui.dsl.builder.*
import flowik.core.*
import flowik.intellij.*

fun counterPanel(): DialogPanel {
    val store = CounterStore()
    return context(Bindings()) {
        panel {
            row { label("").text { "Count: ${store.count.value} (×2 = ${store.doubled.value})" } }
            row { button("Increment") { store.inc() } }
        }
    }
}

The Bindings context argument collects the subscriptions the panel creates so they can be released together; the section below covers it, along with live vs. Apply-time binding.

Core API at a glance

From flowik-core:

val name    = observable("Alice")              // ObservableValue<String>
val person  = observable(Person("Bob", 30))    // ObservableEntity<Person> — each property reactive
val items   = observables<TodoItem>()          // ObservableEntityList<TodoItem>
val roles   = observableSet("admin")           // ObservableSet<String>
val drafts  = observableMap(42L to "Hi")       // ObservableMap<Long, String>

val greeting = computed { "Hello, ${name.value}" }
val report   = lazyObservable(emptyList<Row>()) { sink -> load { sink(it) } }  // runs on first read

autoRun { println(greeting.value) }            // re-runs whenever greeting changes
reaction(supply = { name.value }, effect = { newName -> println("name -> $newName") })  // on change only
whenThen(check = { items.size > 10 }, effect = { println("Over the limit!") })

action {                                       // batch — derivations fire once at the end
    name.value = "Carol"
    person[Person::age] = 31
}

Deep state: nested objects and lists

observable(obj) decomposes one level: every property becomes its own atom. When a property holds another object — or a list of them — you decide whether it stays atomic or gets decomposed in turn:

data class Address(val city: String, val zip: String)
data class Member(val name: String, val active: Boolean = true)
data class Team(val name: String, val address: Address, val members: List<Member>, val tags: List<String>)

val team = observable(Team("A-Team", Address("Munich", "80331"), listOf(Member("Alice")), listOf("core")))

team[Team::name]                    // String   — reads the scalar atom, and tracks it
team[Team::name] = "B-Team"         // writes it: only name-readers re-run
team[Team::address]                 // Address  — the whole object, kept in one atom
team[Team::tags]                    // List<String> — the list as one atomic value

team.property(Team::name)           // ObservableValue<String>      — the atom itself, e.g. to bind to a UI
team.list<String>("tags")           // ObservableList<String>       — reactive list of plain values
team.nested(Team::address)          // ObservableEntity<Address>    — city and zip are separate atoms
team.nestedList(Team::members)      // ObservableEntityList<Member> — each element is an ObservableEntity

nested / nestedList compose, so a tree of any depth is reachable:

team.nested(Team::address)[Address::city] = "Berlin"
company.nested(Company::team).nested(Team::address)[Address::city] = "Berlin"

val alice = team.nestedList(Team::members)[0]
alice[Member::active] = false                             // fine-grained: only active-readers re-run

Deep access is explicit rather than automatic because a property's type cannot tell the compiler whether it holds a value to keep atomic or an object to decompose — KProperty1<T, Address> and KProperty1<T, String> look the same to overload resolution. It is the same deep/shallow pair the top-level factories already offer (observables vs observablesShallow).

Two rules follow from the containers being created lazily and cached:

  • One access pattern per property. A field is exposed through exactly one container; asking for team[Team::address] after team.nested(Team::address) fails with an IllegalStateException rather than handing out two independent copies of the same state.
  • value is the original snapshot. Writes go into the containers, not back into the wrapped instance, so read current state through the containers — team.value.address.city stays as it was.

Reactions stay as fine-grained as the atom they read, at any depth. subscribe works the other way round: a change anywhere in the tree — including inside a list element — is forwarded up to the root's subscribers. Element property changes deliberately do not invalidate readers of a list's contents; a reaction that must re-run on them reads the element property itself:

val activeNames = team.nestedList(Team::members)
    .filter { it[Member::active] }            // property-reactive, because the predicate reads the atom
    .map { it[Member::name] }

Nullable object properties (Address?) have nothing to decompose, so they are rejected at compile time — keep them atomic with get(...). A Map-typed property stays atomic too: the core has no keyed reactive container to decompose it into.

Sets: observableSet

The equivalent of MobX's observable.set — a reactive, insertion-ordered set. Every read (items, size, isEmpty(), in, iteration) auto-tracks, and only mutations that really change the contents notify anybody:

val selection = observableSet<String>()

autoRun { println("selected ${selection.size}, alice in: ${"alice" in selection}") }

selection.add("alice")            // fires
selection.add("alice")            // duplicate — nobody is notified
selection.toggle("alice")         // removes it, fires
selection.setAll(listOf("bob"))   // replace wholesale, one notification

val upperNames = selection.map { it.uppercase() }        // Computed<List<String>>
val shortNames = selection.filterToSet { it.length < 4 } // Computed<Set<String>>

addAll / removeAll / setAll batch internally, so a bulk change re-runs dependents once. It also works as a property delegate, handing out a plain MutableSet view whose mutations stay reactive:

class Store {
    var tags: MutableSet<String> by observableSet("new")
}

Elements are stored as plain values (a shallow container — there is no ObservableEntity-wrapping variant, since hashing would key each element on its initial snapshot). For a keyed reactive collection use observables(...). Fine-grained SetChange.Add / Remove / Clear events are available through onChange { }, mirroring ObservableList.

Lists: addAll, move, sort, setAll

ObservableList emits one ListChange per edit, and the bulk operations are built to keep that stream small, so a container bound with items(list) touches only the rows that changed:

val tasks = ObservableList(listOf("write", "test", "ship"))

tasks.addAll(1, listOf("review", "fix"))   // one Insert each, reactions run once
tasks.move(0, 2)                           // one ListChange.Move — the row moves, it is not rebuilt
tasks.sortBy { it.length }                 // stable, in place, through Moves
tasks.setAll(fetched)                      // diffed against the current contents

setAll (MobX's replace) diffs the new contents against the current ones: items equal to one already there stay put — instance and row included — and only the rest are inserted, removed, or updated in place. Bound rows keep their state, a JList keeps its selection, and replacing a list with equal contents notifies nobody. ObservableEntityList.setAll(values) is the exception: it wraps every value afresh, because an existing wrapper may have been edited since it was created.

ListChange.Move(from, to, item) is new in this release: a when over ListChange needs a branch for it. The Swing and Vaadin items(list) bindings move the existing component.

Maps: observableMap

The equivalent of MobX's observable.map — entries with dynamic keys, for lookup tables, caches and selections keyed by id. As in MobX, a lookup tracks its own key:

val drafts = observableMap<Long, String>()

autoRun { println("draft of 42: ${drafts[42L]}") }   // re-runs for key 42 only
autoRun { println("${drafts.size} draft(s)") }       // re-runs on any change

drafts[42L] = "Hello"      // both run
drafts[7L] = "Other"       // only the second one runs
drafts[42L] = "Hello"      // equal value — nobody is notified

get and containsKey track one key; items, size, keys, values and iteration track the whole map. putAll, setAll (MobX's replace) and clear batch, onChange { } delivers MapChange.Add / Update / Remove / Clear, and var drafts: MutableMap<Long, String> by observableMap() hands out a plain MutableMap view that tracks and writes through the same way.

Change detection: ref and struct

observable(...) decomposes an arbitrary object into one atom per property, and ObservableValue decides "did it change?" with equals. Two wrappers cover the cases where that is not what you want — the equivalents of MobX's observable.ref and observable.struct:

class Store {
    // One atom holding the whole value, compared by identity (===).
    // Reassigning an equal-but-distinct instance still notifies.
    var session: Session by observableRef(Session.Anonymous)

    // Compared deeply and structurally — arrays and lists included, so a write
    // with the same content notifies nobody.
    var matrix: Array<IntArray> by observableStruct(emptyArray())

    // Or bring your own policy.
    val temperature = observable(20.0, Comparer { a, b -> abs(a - b) < 0.5 })
}

Both are MutableObservables, so they work with two-way bindings (TextField(store::session)) exactly like a plain observable.

The same idea applies to derivations. A plain computed propagates invalidation: every upstream write re-runs dependents, even when the derived result is identical. computedStruct / computedRef notify only on an actual change:

val isOverLimit = computedStruct { items.size > 10 }   // fires on false <-> true, not on every add
val reading     = computed(Comparer<Double> { a, b -> abs(a - b) < 0.1 }) { sensor.value }  // custom policy

Deciding whether the result changed means evaluating it, so these derivations are eager: they re-evaluate when an upstream observable changes, or once at the end of the enclosing action.

Finally, untracked { } reads observables without subscribing to them — useful inside a reaction that must consult a value it should not re-run for.

Reactions: options, awaitUntil, now()

reaction follows MobX: the effect runs when the supplied value changes, not every time a dependency does. The options are MobX's too:

reaction(supply = { count.value > 5 }) { over -> warn(over) }       // only on false <-> true

reaction(
    supply = { store.query.value },
    fireImmediately = true,                                         // also run on creation
    equals = Comparer { a, b -> a.trim() == b.trim() },             // what counts as a change
    delay = 300.milliseconds,                                       // coalesce typing, run on mainContext
) { query -> runSearch(query) }

Before this release the effect ran on every invalidation. To keep that for a reaction that relied on it — one whose supply returns the same mutable object every time, for instance — pass equals = Comparer { _, _ -> false }.

awaitUntil is the coroutine form of whenThen — MobX's await when(…). It suspends until the predicate holds, returns false if a timeout elapses first, and releases the predicate however the wait ends:

val openWhenReady = flowAction {
    if (awaitUntil(timeout = 5.seconds) { store.isReady }) openEditor() else showTimeout()
}

now(interval) is the current time as an observable, after mobx-utils: a reaction or computed that reads it re-runs every interval. The timer runs only while something observes it, and readers of the same interval share one:

Label { "edited ${Duration.between(note.edited, now(1.minutes)).toMinutes()} min ago" }

Ticks are written on mainContext — Dispatchers.Main, the EDT, by default; on Vaadin pass ui.asCoroutineDispatcher(), as you would for delay.

Editing as a transaction: createViewModel

The equivalent of mobx-utils createViewModel — a buffered view over a model, so an edit becomes a transaction that only reaches the model when it is committed:

data class FormData(val name: String = "<Unnamed>", val email: String = "")

val model = observable(FormData())          // ObservableEntity<FormData>
val form = createViewModel(model)

autoRun { println("${form[FormData::name]} / ${model[FormData::name]} / dirty=${form.isDirty}") }

form[FormData::name] = "Pavan"              // Pavan / <Unnamed> / dirty=true
form.reset()                                // <Unnamed> / <Unnamed> / dirty=false
form[FormData::name] = "Flowik"
form.submit()                               // Flowik / Flowik / dirty=false

Until a property is written, the view model reads through to the model — a change to the model is still visible. The first write buffers the value and marks that property, and the view model, dirty; from then on reads return the buffer and the model is left alone. submit() flushes every buffered edit in one action, reset() throws them away, and neither has to happen at all — abandoning the form restores the previous state for free.

Member Meaning
form[Prop::name] read / write the buffered value, auto-tracking
form.property(Prop::name) the buffered atom itself — a MutableObservable to bind a component to
form.isDirty any edit buffered? isPropertyDirty(Prop::name) asks about one property
form.changedValues the buffered edits by property name, in the order they were made
form.submit() / reset() commit / discard everything; resetProperty(Prop::name) reverts one
form.model the model being edited

Reads stay as fine-grained as the property, so binding a form field to form.property(...) re-renders that field alone, and an OK/Cancel dialog is submit() on OK and nothing at all on Cancel:

row("Host:") { 
    textField().bindText(form.property(Settings::host)) 
}

The model can be an ObservableEntity — the usual way to edit a data class — or a store holding atoms of its own, in which case both shapes are found:

class Settings {
    var host: String by observable("localhost")   // delegated — the property is the value
    val port = observable(8080)                   // the property is the atom
}

val settings = Settings()
val form = createViewModel(settings)

form[Settings::host] = "example.org"              // typed by the property reference
form.property(settings.port).value = 9090         // typed by the atom, since `Settings::port` is the atom

A view model buffers values, so it covers the properties that hold one. A computed has nothing to submit into, and a reactive collection (ObservableList, ObservableSet) is a container rather than a value — a buffer would hand out a live view of the model's own contents. Both are rejected on access with a message saying so; edit collections directly.

Deferred work: lazyObservable

The equivalent of mobx-utils lazyObservable — a value that is computed the first time somebody reads it, and updated by pushes afterward. It is the one primitive started by a read: computed re-derives from atoms it tracks and cannot fetch, flowAction has to be invoked by hand.

class PlanetStore(scope: CoroutineScope) : Store {
    val planets = lazyObservableAsync(emptyList<Planet>(), scope) { sink ->
        val page1 = withContext(Dispatchers.IO) { api.page(1) }
        sink(page1)                                              // partial result, the UI paints it
        sink(page1 + withContext(Dispatchers.IO) { api.page(2) })
    }
    val summary by computed { "${planets.value.size} planet(s)" }
}

The computation receives a sink and may call it as many times as it likes, so a progressive load is the normal case. Because a LazyObservable is an ordinary ReadableObservable, every one-way binding takes it — and displaying it is what triggers the load:

Label(store.summary)                                    // this read starts the fetch
Panel(visible = store.planets.pending) { progressBar() }
Button("Reload") { store.planets.refresh() }
current() / value starts the computation, returns initial until the first push, auto-tracks
subscribe { } does not start it — laziness is read-driven
pending observable flag: set while a started computation has not pushed yet
error observable: what the computation failed with, cleared by refresh / reset
refresh() re-invokes it, cancelling a run in flight; does nothing before the first read
reset() back to initial and unstarted — the next read recomputes
dispose() cancels, ignores later pushes, freezes the value; registers with Bindings

lazyObservableAsync launches under the scope you give it: only one run exists at a time, refresh cancels the previous one, and pushes that arrive from it afterward are dropped. The body starts on context and each push is applied on mainContext as an action — the same pair flowAction takes, defaulting to Dispatchers.Main (the EDT under Swing), so no runInAction is needed inside. The scope is bound at construction rather than at the call site, because a lazy observable has no call site: it starts from a read, which may happen inside a binding, a computed, or a repaint.

For a callback or listener API, use the plain flavor — its computation returns immediately and pushes whenever it has something, from the UI thread:

val size = lazyObservable(0L) { sink -> watcher.onChange { sink(dir.size()) } }

A Flow is one line away, collected on first read and restarted by refresh:

val ticks = clockFlow.toLazyObservable(0, scope)

Observables read inside the computation are not dependencies of whoever read the lazy observable — the body runs untracked, so a fetch that reads query.value re-runs only when you refresh it. Anything the body throws shows up in error — the rejected state of mobx-utils' fromPromise — and also goes to onError, or to the global handler or the log when no handler was given, exactly as for the reactions below:

Label { store.planets.error.value?.let { "Failed: ${it.message}" } ?: store.summary }

A computed per key: computedFn

A computed caches one value. When the value depends on an argument — a deck, an id — you want one cached computation per argument, so that a change to one deck recomputes that deck only. That is computedFn, after the mobx-utils function of the same name:

class Store {
    val queueOf = computedFn { deck: ObservableEntity<Deck> -> buildQueue(deck) }
}

Span().apply { text { "${store.queueOf(deck).size} due" } }   // cached per deck

Called inside a reaction or a computed, each key gets a Computed of its own, shared by every reader of that key. An entry lives only while something reads it: once its last reader is disposed it is dropped, together with everything it read, so a computedFn over a long-lived store does not pile up entries for keys nobody shows anymore. Called outside any reaction, an unknown key is simply evaluated and nothing is cached — the same rule as for an unobserved computed.

computedFn(equals = Comparer.Structural) { … } gives each entry computedStruct semantics: its readers are notified only when that key's result actually changes. That is the tool for keeping a shared input — a list every key filters, a log every key counts — from recomputing every key. keepAlive = true keeps entries cached until you dispose() the function; there is also a two-argument form, computedFn { a: A, b: B -> … }.

Observed and unobserved

Atoms, computeds, observable lists and sets — and the wrappers built on them — can say when they start and stop being read, as MobX's onBecomeObserved / onBecomeUnobserved:

val now = observable(Instant.now())
var timer: Timer? = null
now.onBecomeObserved { timer = startTicking { now.value = Instant.now() } }
now.onBecomeUnobserved { timer?.cancel() }

Observed means read by at least one reaction or computed. Losing the last reader only counts once the current action, flush of reactions or reaction run is over, so a value that is dropped and read again in the same pass — a reaction re-running, a screen rebuilt in the same flush — fires neither hook. Listeners run untracked. The free functions onBecomeObserved(observable) { } and onBecomeUnobserved(observable) { } accept anything observable, including what observableRef and computedStruct return.

This is the same lifecycle that lets a computed suspend when nobody reads it — it disconnects from its sources and drops its cache — and that computedFn uses to drop entries.

Errors in reactions

As in MobX, an exception thrown inside a reaction is logged, but not re-thrown. Writing an observable never fails because some far-away reaction did, and a failing reaction does not stop the others scheduled alongside it:

autoRun { 
    label.text = store.title.value.substring(0, 10) 
}   // throws for short titles — logged, dropped

Tracking survives the failure, so the reaction runs again on the next change and recovers on its own. Pass onError to handle the failure yourself instead of logging it — autoRun, reaction and whenThen all accept it:

autoRun(onError = { status.value = it.message }) { render(store.report.value) }

reaction(
    supply = { store.query.value },
    onError = { log.warn("search failed", it) },
    effect = { runSearch(it) },
)

whenThen(check = { store.ready.value }, onError = { showFatal(it) }) { start() }

The handler sees exceptions from both parts of a reaction — the tracked expression and the effect. A CancellationException is the one exception that is always re-thrown, so coroutine cancellation is never swallowed.

For an application-wide policy, install a global handler — MobX's onReactionError. It receives every exception a reaction, observation hook or lazy observable without an onError of its own fails with, together with the source that threw it, and replaces the logging while installed:

val uninstall = onReactionError { error, source -> crashReporter.report(error, "$source") }

Logging goes through SLF4J, under the flowik.core category. flowik-core depends on the SLF4J API only — the application picks the binding (logback, slf4j-simple, …); with no binding on the classpath, SLF4J discards the messages.

The IntelliJ Platform and the Kotlin UI DSL

flowik-intellij is a thin adapter between Flowik observables and the platform's own binding abstractions. The platform builds panels with the Kotlin UI DSL and binds cells to ObservableMutableProperty (live) or MutableProperty (deferred, Apply-time); Flowik supplies both from an observable, so a plugin can keep a MobX-style store and still write ordinary panel { row { textField() } } code.

Nothing here reimplements the UI DSL. Every function is an extension on Cell, Row or Placeholder, so platform features — align, comment, validationOnApply, groups, collapsible rows — keep working unchanged.

Bindings: where the subscriptions live

A UI DSL panel is built: panel { } returns a DialogPanel and the builder receivers are gone. There is no place to hang the reactions a binding creates, so the bindings take a Bindings (from flowik-core) as a context parameter and register into it.

class MyToolWindow(parent: Disposable) {
    private val store = ContactStore()
    private val bindings = Bindings()

    val panel: DialogPanel = context(bindings) {
        panel {
            row("Name:") { textField().bindText(store::name) }
        }
    }

    init {
        Disposer.register(parent) { bindings.dispose() }   // tool window closed -> reactions released
    }
}

bindings.dispose() disposes every reaction and eager derivation the panel created. Tying it to a platform Disposable — the tool window's, the Configurable's, the DialogWrapper's — is what keeps a closed panel from being kept alive by the store it read.

For a panel whose lifetime is the whole IDE session, context(Bindings()) { panel { ... } } (as in the demo) is fine: the throwaway Bindings still satisfies the context parameter, it just never gets disposed.

Bindings is an interface, so the thing that owns the lifetime can be the group — class Sidebar : JPanel(), Bindings by Bindings(), which is all flowik-swing's BindingsPanel is, and the same line works on a Vaadin view. Being a receiver, it then satisfies the context parameter inside every one of its members. Groups also nest: register(child) hands a child group over, so one dispose() at the top releases the tree.

Context parameters are a Kotlin 2.2 preview feature, so both the module and its consumers compile with:

val compileKotlin: KotlinCompile by tasks
compileKotlin.compilerOptions {
    freeCompilerArgs.set(listOf("-XXLanguage:+ContextParameters"))
}

The binding surface

Everything below requires a Bindings in scope.

Binding Direction Notes
Cell<JTextComponent>.bindText(observable) two-way also takes store::name (a delegated property)
Cell<JTextComponent>.bindIntText(observable) two-way platform's Int parsing
Cell<JToggleButton>.bindSelected(observable) two-way checkbox / radio button; also takes store::flag
Cell<ComboBox<T>>.bindItem(observable) two-way
Cell<JLabel>.bindText(observable) one-way read-only sources (Computed) are accepted
Cell<JLabel>.text { } one-way derives from an expression — no intermediate computed
Cell.visibleIf { } / enabledIf { } one-way Row has both too
Cell.bindIn(read, update) one-way any aspect with no property: icon, foreground, tooltip …
Cell.autoRun { } one-way read and write in one block, like a plain autoRun
Placeholder.bindContent { } one-way rebuilds a sub-panel — the DSL's ForEach / SwitchPanel
context(Bindings()) {
    panel {
        row("Name:") { textField().bindText(store::name) }
        row("Email:") {
            textField()
                .bindText(store::email)
                .enabledIf { store.name.isNotBlank() }        // predicate, not a property
        }
        row { checkBox("Subscribe").bindSelected(store::subscribed) }
        row { label("").text(store::greeting) }
        row { button("Reset") { store.reset() } }.visibleIf { store.isValid }
    }
}

The visibleIf / enabledIf predicates are the shape worth noticing: no ObservableProperty to build, no computed to declare and dispose. They read the store directly and re-apply only on an actual false <-> true transition — internally computedStruct, not computed, so an unrelated write does not trigger a layout pass.

bindIn and autoRun are the escape hatches for aspects the UI DSL has no bindXxx for:

icon(AllIcons.General.Warning).bindIn({ store.errors.size }) { isVisible = it > 0 }
cell(myChart).autoRun { model = store.series.value; repaint() }

They differ in threading. bindIn splits the two halves — the tracked read runs on whichever thread wrote the store, update is applied on the EDT — so it is safe when a background task mutates the store. autoRun runs the whole block on the writing thread (deferring it would leave the tracking scope and lose the dependencies), so it requires EDT mutation, which runInAction guarantees.

Placeholder.bindContent rebuilds a whole region when the observables it reads change, giving each rebuild a fresh child Disposable so the discarded sub-panel's bindings are released rather than accumulating:

lateinit var body: Placeholder
val root = panel {
    row { body = placeholder() }
}
body.bindContent {                    // after the panel is built: `panel` is the top-level
    panel {                           // builder here, so the lambda returns a DialogPanel
        for (item in store.items) {
            row { label("").bindText(item.property(Item::title)) }
        }
    }
}

Live or on Apply

The two contracts the platform offers are both available, and the choice is about semantics rather than convenience:

  • asProperty(bindings) → ObservableMutableProperty. The store is written on every keystroke and click, and store changes reach the component immediately. This is what a tool window or an inspector wants — there is no OK button to wait for. All the bindXxx extensions above use it.
  • asMutableProperty() → MutableProperty. The deferred contract: the component is filled from the store by DialogPanel.reset(), the store is written by apply(), and isModified() compares the two. This is what a Configurable needs for OK/Apply/Cancel to mean anything.
row("Timeout:") { intTextField().bindIntText(settings.timeout.asMutableProperty()) }  // Apply-time
row { checkBox("Live preview").bindSelected(viewState::preview) }                     // immediate

Both can be mixed in one panel: bind the persisted settings deferred and the ephemeral view state (filters, expanded nodes, previews) live. asProperty also works one-way, on any ReadableObservable — hand a Computed to Cell.visibleIf, Row.enabledIf, or any other platform API that takes an ObservableProperty.

Writes through either wrapper are wrapped in action, so one UI event produces one batch, and reads go through untracked: the platform reads properties from arbitrary places, including from inside a derivation that is rebuilding a panel, and those reads must not silently become dependencies of it.

Flowik vs. Vaadin Signals

Vaadin Flow now ships its own reactive state, Signals. The two share the core idea — reads inside a reactive block are tracked automatically, writes re-run whatever depends on them — but they make different trade-offs. Signals are a Vaadin-native, server-wide (even cluster-wide) state layer. Flowik is a MobX-style state model that is not tied to any one toolkit, with Vaadin as one of several bindings.

Aspect Flowik (MobX-style) Vaadin Signals
Scope Toolkit-agnostic core; bindings for Swing, Vaadin Flow and the IntelliJ UI DSL Vaadin Flow only, built into the framework
Language Kotlin-first: delegates (var name by observable("")), property refs (store::name), context params Java API, usable from Kotlin
State primitives observable, observableRef / observableStruct, observables (lists), observableSet Local ValueSignal, ListSignal; shared SharedValueSignal, SharedNumberSignal, SharedListSignal, SharedMapSignal, SharedNodeSignal
Object state observable(obj) splits a data class into one atom per property; nested / nestedList go deeper A signal holds one value, usually an immutable record replaced with update(...); modify(...) for mutable objects
Reading .value / get anywhere; tracked inside a derivation, a plain read outside one get() is tracked and only allowed in a reactive context (effect, computed, transaction); peek() everywhere else
Derived values computed { } caches while observed and lets go of its sources once nothing reads it; computedStruct / computedRef notify only on a real change Signal.computed(...) re-runs on every read; Signal.cached(...) caches; map(...), Signal.not(...)
Side effects autoRun, reaction(supply, effect), whenThen(check, effect) Signal.effect(component, ...) with an EffectContext (isInitialRun, isBackgroundChange); Signal.unboundEffect(...)
Untracked reads untracked { } peek(), Signal.untracked(...)
Writes inside derivations Allowed in autoRun / reactions; computed should stay pure Effect and computed callbacks run in a read-only transaction; Signal.runWithoutTransaction(...) to opt out
Batching action { } — derivations run once, at the end of the outermost action Signal.runInTransaction(...) — atomic, all-or-nothing, with verifyValue / verifyPosition checks; shared signals only
Component binding Extension functions over a lambda: text { }, visible { }, enabled { }, className, style, value(store::prop) Built-in methods: bindText, bindVisible, bindEnabled, bindValue(signal, setter), bindCssClassNames, …; new Span(signal)
Lists and dynamic children items(list) { } (disposes dropped rows), childSwitch(observe) { } List rendering bound to ListSignal / SharedListSignal; moveTo repositions DOM instead of rebuilding
Lifecycle Explicit: every binding registers with a Bindings group; disposeOnDetach() opts a view in; moving a component keeps its bindings Implicit: an effect is active while its component is attached and paused while detached; Registration.remove() ends it
Threading UI-thread confined; leave it with UI.flowAction { } / lazyObservableAsync, which come back through ui.access Thread-safe; local signals can be written from any thread without ui.access() (needs @Push to reach the browser)
Multi-user / cluster Not built in: state lives where you put it (session, singleton) and is single-node Shared signals sync across sessions and cluster nodes, with compare-and-set update()
Async loading lazyObservable / lazyObservableAsync (load on first read, progressive pushes, pending, refresh), Flow.toLazyObservable Write to a signal from a background thread
Edit buffering createViewModel(model) — buffered edits with isDirty, submit(), reset() Binder integration
Change detection equals by default; identity (observableRef), deep structural (observableStruct) or a custom policy Objects.equals by default; custom equality checker in the constructor
Error handling Reaction errors are logged and not re-thrown; onError per reaction MissingSignalUsageException when an effect reads no signal
Reusable component inputs Supplier<T> plus callbacks; any tracked expression works, and get() is valid anywhere Signal<T> plus callbacks; the component must choose get() or peek() depending on where it reads
Testing a component Construct it and assert, in plain JUnit; bindings run without a UI or attaching Bindings are active only while attached, so the component has to be put into a UI first

Why the Flowik approach scales to larger UIs

Signals answer how does a component follow a value. Large UIs are mostly about a different question: how do you split a screen into small parts that know nothing about each other, and reuse them. The master/detail page in flowik-vaadin-demo, a port of the Vaadin docs' "Compose with Components" example, shows the Flowik answer.

One store, stateless parts. EmployeeStore is the only place state changes. Its setters are private, and only the route view sees it. The view hands each child the slices it shows and a callback for each event it raises — state goes down, events come up:

class EmployeesView(private val store: EmployeeStore) : Div(), Bindings by Bindings() {
    init {
        add(
            register(EmployeeMaster(
                employees = store::visibleEmployees,          // a computed slice
                query = store::search,
                onQueryChange = store::updateSearch,
                selected = store::selected,
                onSelect = store::select,
                onAddNew = ::addNew,
            )),
            Div().apply {
                childSwitch(store::selected) { employee ->
                    if (employee == null) NoSelection() else detail(employee)
                }
            },
        )
        disposeOnDetach()
    }
}

EmployeeMaster and EmployeeDetail never reference the store or each other. Both follow store.selected, so clicking a row, adding someone and deleting someone all move the detail pane the same way, and there is no code that keeps the two panes in sync.

This works because of what a component receives:

  • A Supplier<T>, not a framework type. EmployeeMaster takes Supplier<List<ObservableEntity<Employee>>>. Every observable and computed is a Supplier, and so is any lambda that reads them. A caller can pass a store property (store::visibleEmployees), derive a value inline (isSelected = { selected.get() === employee }, manager = { store.managerOf(employee) }) or pass a constant (tab = { DetailTab.PERSONAL }), and the child cannot tell which. Tracking goes straight through the lambda, so derived inputs need no wrapper type and nothing to dispose. get() also works anywhere, so a component reads its inputs the same way in a binding and in a click handler. In Signals, get() throws outside a reactive context, so every reusable component has to know where each read happens and choose peek() there.

  • A live view of an entity, not a snapshot. A row, a PersonCard, the detail tabs and the manager card all receive the same ObservableEntity<Employee>. Each Field reads only the property it shows (employee[Employee::workPhone]), so an edit to one property re-renders only the places that show it, on every page that shows it. Derived helpers are written once, as extensions on the entity (val ObservableEntity<Employee>.fullName), and every component that calls them is subscribed to exactly firstName and lastName. In Signals, a person in a ListSignal is one ValueSignal holding an immutable record, and any change replaces the whole record:

    ValueSignal<Employee> cody = employees.insertLast(codyRecord);
    name.bindText(() -> cody.get().firstName() + " " + cody.get().lastName());
    phone.bindText(() -> cody.get().workPhone());
    cody.update(e -> e.withTitle("Lead"));   // both bindings re-run: they read the same signal

    You can narrow this back down with one Signal.cached(...) per field. In practice that means modeling the record field by field, by hand, in every component that needs it, which is what observable(obj) already does.

  • An edit buffer it does not own. EmployeeEditor takes a ViewModel<Employee> and an onSave. The caller decides what Save means, so the same dialog edits an existing person (createViewModel(employee), onSave = form::submit) and creates a new one: a draft that joins the list only on Save. The Save button is enabled { form.isDirty }. Cancel needs no callback, because nothing reaches the model until submit().

  • Its own lifetime. Every part is Bindings by Bindings(). register(child), items { } and childSwitch { } dispose the children they drop, so lifetimes follow the composition tree: whoever creates a component owns it. Moving a component to another layout does not change its bindings.

Derived state is shared in the same way. The Teams and Locations pages are one GroupsView that takes a Supplier<List<Group>> and an onOpen. Each route only chooses the grouping (store.groupedBy { it[Employee::title] }). Rename someone's title on the Employees page and the Teams page regroups, with no code that connects the two pages.

Each part can be tested on its own. Because the parts depend only on suppliers and callbacks, and their bindings run without a UI, EmployeesViewTest tests them in plain JUnit, with no store and no browser harness:

val people = observables(*sampleEmployees().take(3).toTypedArray())
val query = observable("")
val selected = observableRef<ObservableEntity<Employee>?>(null)

val master = EmployeeMaster(
    employees = { people.items.filter { it.fullName.contains(query.value) } },
    query = query,
    onQueryChange = { events += "query $it" },
    selected = selected,
    onSelect = { events += "select ${it.fullName}" },
    onAddNew = { events += "add" },
)

master.rows()[1].click()                        // only reported: the master does not select by itself
action { selected.value = people.items[1] }     // its owner does, and the row follows
assertTrue(master.rows()[1].hasClass("selected"))

A Signals component tested the same way stays inert, because its effects run only while it is attached.

Signals let a component follow state. Flowik lets you design the state — fine-grained entities, derived slices, edit buffers — so it can be handed to components that do not know where it comes from. The more components a screen has, the more that matters.

Full examples and source

Real, runnable demos live in the repo:

  • flowik-swing-demo — a Todo app showing list filtering, computed totals, keyboard navigation, and conditional visibility.
  • flowik-vaadin-demo — the same Todo app on Vaadin Flow with property delegates and two-way input bindings, a master/detail Employees page, and an Obsidian-like note editor ported from the Swing demo: sliding sidebars, animated tabs and keyboard shortcuts.
  • flowik-vaadin-anki — an Anki-style flashcard app on Vaadin Flow whose study queues are derived from the cards, the review log and the clock, over blocking backend services that load each deck's cards only when they are needed.
  • flowik-intellij-demo — an IDE plugin with a tool window built from the Kotlin UI DSL: live two-way bindings, a computed label, and a conditionally visible row

Library sources:

License

Apache License 2.0 — see LICENSE.

About

Robust MobX for mutable UI on JVM: Swing, Vaadin, IntelliJ Platform

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages