Flowik: MobX-style reactive state for Vaadin Flow (a community project)

Hi everyone!

I’d like to share a small experiment of mine: Flowik, a Kotlin library that brings
MobX-style reactive state to Vaadin Flow (and some other UI toolkits on JVM).

I built it purely out of academic interest. I wanted to see how far the MobX model can go on the
JVM, and how it compares to the new Vaadin Signals. It’s not a product or a competitor to anything,
just an exploration that I think the community might find interesting.

MobX in one sentence

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

MobX is a popular state-management library from the JavaScript
world, built on three ideas:

  • Observable state: mutable values that track who reads them.
  • Derivations: computed { } for cached values and autoRun { } for side effects such as UI
    updates. They re-run when, and only when, something they read changes.
  • Actions: action { } blocks that batch writes, so each derivation runs once at the end.

There are no listeners, no .subscribe() calls and no dependency graph to maintain by hand. Reading
an observable inside a derivation subscribes you to it.

The demo

The demo is a port of the master/detail example from the Vaadin docs’ “Compose with Components”
page: an employee directory with a list, search, a tabbed detail pane, add/edit dialogs and two more
pages (Teams and Locations) that group the same people. Everything sits under one router layout,
demo.vaadin.employees.MainLayout.

What it shows:

  • One store, stateless components. EmployeeStore (@UIScope) is the only place state changes.
    The list, the detail pane and the editor dialog never reference the store or each other. Each gets
    the slices of state it shows plus a callback for every event it raises: state goes down, events
    come up.
  • Fine-grained entities. Each employee is an ObservableEntity<Employee> with one atom per
    property. Edit someone’s phone number and only the labels that show the phone re-render, on every
    page.
  • Derived pages for free. Teams and Locations are a single GroupsView fed by
    store.groupedBy { ... }. Change someone’s title on the Employees page and the Teams page regroups,
    with no code connecting the two.
  • Edit buffers. The editor works on a ViewModel<Employee>. Save is enabled { form.isDirty },
    and Cancel needs no code because nothing reaches the model until submit().

Some code

The store is a plain class:

@SpringComponent
@UIScope
class EmployeeStore : Store {
    val employees = observables(*sampleEmployees().toTypedArray())

    var search by observable("", name = "search")
    var selected by observableRef<ObservableEntity<Employee>?>(null, name = "selected")

    val visibleEmployees by employees.filter { employee ->
        val query = search.trim().lowercase()
        query.isEmpty()
                || employee.fullName.lowercase().contains(query)
                || employee[Employee::title].lowercase().contains(query)
    }

    fun select(employee: ObservableEntity<Employee>?) = action { selected = employee }
    // ...
}

The route view wires the store to children that know nothing about it:

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 {
        addClassName("detail-pane")
        childSwitch(store::selected) { employee ->
            if (employee == null) NoSelection() else detail(employee)
        }
    },
)

Bindings are just lambdas. Whatever they read, they follow:

H2().apply { text { employee.fullName } }

Paragraph("No one matches the search.").apply {
    visible { employees.get().isEmpty() }
}

Button("Save") { onSave(); close() }.apply {
    enabled { form.isDirty }
}

Derived helpers are written once as extensions, and each caller is subscribed to exactly the
properties they touch (here firstName and lastName):

val ObservableEntity<Employee>.fullName: String
    get() = "${this[Employee::firstName]} ${this[Employee::lastName]}".trim()

And the Teams and Locations pages are just two groupings of the same store:

class TeamsView(store: EmployeeStore) : GroupsView(
    title = "Teams",
    groups = store.groupedBy { it[Employee::title] },
    onOpen = store.openOnEmployeesPage(),
)

class LocationsView(store: EmployeeStore) : GroupsView(
    title = "Locations",
    groups = store.groupedBy { it.nested(Employee::address)[Address::city] },
    onOpen = store.openOnEmployeesPage(),
)

How it compares to Vaadin Signals

Signals and Flowik share the core idea (automatic dependency tracking) but make different
trade-offs. Signals are Vaadin-native, thread-safe, and can be shared across sessions and cluster
nodes, which Flowik doesn’t try to do. Flowik focuses on modelling the state of large screens:

  • Components take Supplier<T>, not a framework type. Every observable and computed is a
    Supplier, and so is any lambda that reads them, so a caller can pass a store property, an inline
    derivation (isSelected = { selected.get() === employee }) or a constant, and the component can’t
    tell the difference. get() works everywhere, including click handlers, so there’s no
    get()/peek() choice to make.
  • Fine-grained objects out of the box. observable(obj) splits a data class into one atom per
    property. With Signals, a record in a ListSignal is usually one ValueSignal, and any field
    update re-runs every binding that reads that record.
  • Cached derivations by default. computed { } caches until a dependency changes, and
    computedStruct notifies only on a real change.
  • Actions batch writes, so derivations run once at the end of the outermost action.
  • Lifetimes follow the composition tree. register(child), items { } and childSwitch { }
    dispose of the children they drop.
  • Plain JUnit testing. Bindings run without a UI or attaching, so you can construct a component,
    push state into it and assert, with no browser harness.

In short: Signals let a component follow state, and Flowik tries to let you design the state
(entities, derived slices, edit buffers) so it can be handed to components that don’t know where it
comes from. The README has a full
side-by-side comparison.

Try it

Source, docs and the demo: GitHub - jreznot/flowik: Robust MobX for mutable UI on JVM: Swing, Vaadin, IntelliJ Platform · GitHub

The demo is a Spring Boot app in flowik-vaadin-demo. Releases are on JitPack
(com.github.jreznot.flowik:flowik-vaadin).

I’d love to hear what you think, especially from anyone who has tried Signals on a larger UI. Which
patterns worked for you, and where did it get awkward?

P.S. I promised Marcus Hellberg I’d post it at some point during Spring I/O 2026 in Barcelona.

2 Likes

Thank you for sharing! I’m always interested in seeing alternative approaches and understanding the reasons that lead to making different choices.

This is the most significant difference from my perspective. It’s also something we couldn’t do without the help of Kotlin since Java has no similar usable mechanism without making application code quite ugly.

I haven’t stress tested it with a super complex view but my experience is that the performance impact is relatively small from coarse-grained observability of business objects. That approach is also much more ergonomic in Java with the help of ValueSignal.updater and ValueSignal.modifier.

This a conscious choice. Dependency tracking can’t work with an arbitrary Supplier so there’s a risk of confusion if you configure a component with a supplier that doesn’t read any signals. We partially “fixed” this late in the design process by adding a check that every binding must read at least one signal but we still kept the dedicated type.

We had it that way to begin with but it led to way too many silly mistakes. The problem is that you accidentally read the signal once instead of creating a binding, e.g. new Span("Hello " + signal.get()) instead of the intended new Span(() -> "Hello " + signal.get()). It’s a bit annoying that you must use peek() e.g. when checking something in a click listener but I think that’s a smaller problem than the accidental omissions that we saw before.

Also a conscious choice since each cached signal has a non-trivial memory cost (especially in our case where the caching mechanism also has to play nice with potential concurrent updates and transactions for shared signals). Most computed signals are just trivial derivations like reading an object property or concatenating a string where caching offers little benefit.