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.
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 orautoRun { ... }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.
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.
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")
}<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 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 }
}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.
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 { }— soLabel { },TextField(store::name)andvisible { }all find the frame's group with nothing passed around; BindingsPanel, aJPanelthat 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.
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 onedispose()releases the tree;items { }andchildSwitch { }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, andonDomEvent("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 aGrid,Select,ComboBoxor 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, andchecked(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.
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.
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
}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 ObservableEntitynested / 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-runDeep 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]afterteam.nested(Team::address)fails with anIllegalStateExceptionrather than handing out two independent copies of the same state. valueis the original snapshot. Writes go into the containers, not back into the wrapped instance, so read current state through the containers —team.value.address.citystays 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.
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.
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 contentssetAll (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.
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 notifiedget 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.
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 policyDeciding 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.
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.
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=falseUntil 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 atomA 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.
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 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 deckCalled 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 -> … }.
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.
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, droppedTracking 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.
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.
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"))
}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)) }
}
}
}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 thebindXxxextensions above use it.asMutableProperty()→MutableProperty. The deferred contract: the component is filled from the store byDialogPanel.reset(), the store is written byapply(), andisModified()compares the two. This is what aConfigurableneeds for OK/Apply/Cancel to mean anything.
row("Timeout:") { intTextField().bindIntText(settings.timeout.asMutableProperty()) } // Apply-time
row { checkBox("Live preview").bindSelected(viewState::preview) } // immediateBoth 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.
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 |
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.EmployeeMastertakesSupplier<List<ObservableEntity<Employee>>>. Every observable andcomputedis aSupplier, 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 choosepeek()there. -
A live view of an entity, not a snapshot. A row, a
PersonCard, the detail tabs and the manager card all receive the sameObservableEntity<Employee>. EachFieldreads 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 exactlyfirstNameandlastName. In Signals, a person in aListSignalis oneValueSignalholding 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 whatobservable(obj)already does. -
An edit buffer it does not own.
EmployeeEditortakes aViewModel<Employee>and anonSave. 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 isenabled { form.isDirty }. Cancel needs no callback, because nothing reaches the model untilsubmit(). -
Its own lifetime. Every part is
Bindings by Bindings().register(child),items { }andchildSwitch { }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.
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:
flowik-core— reactive primitives.flowik-swing— Swing component bindings.flowik-vaadin— Vaadin Flow component bindings.flowik-intellij— Kotlin UI DSL bindings.
Apache License 2.0 — see LICENSE.