In the first post I introduced Flowik, a small MobX-style state library for JVM UIs.
Today I built something with real state in it: an Anki-style flashcard app on Vaadin Flow. Decks, cards, a study loop with Anki's SM-2 scheduler, undo, daily limits, a card browser. And along the way I ended up borrowing the one idea from Jetpack Compose I like most — state hoisting — for a UI toolkit that has never heard of it.
This post is about why those two ideas, derived state and hoisted state, fit together so well.
Why Anki is a good test
Spaced repetition looks simple. You see a question, you reveal the answer, you rate yourself: Again, Hard, Good or Easy. The card comes back in ten minutes, or in four days, or in three months.
The state behind it is not simple at all. Every deck shows three counts — New, Learn, Due — and each of them depends on:
- the schedule of every card in the deck,
- what you already studied today, because of the daily limits,
- the deck's own limits, which you can edit,
- and the clock. A learning card due in ten minutes is not due now.
Answer one card and the counts on three screens are wrong. Undo it and they are wrong again. This is exactly the kind of state that, done the traditional way, ends up as a web of listeners that nobody dares to touch.
Derive, don't sync
There is no list of due cards anywhere in the app. Nothing adds a card to a "learning" queue or removes it from a "review" one. The queues are a computed:
val queues: Computed<Map<Long, DeckQueue>> = computed {
val today = today
val doneToday = log.items.filter { localDate(it.time, zone) == today }
val cardsByDeck = cards.items.groupBy { it.deckId }
decks.items.associate { deck ->
deck.id to buildQueue(deck, cardsByDeck[deck.id].orEmpty(), doneToday.filter { it.card.deckId == deck.id })
}
}
buildQueue is plain Kotlin: group the deck's cards by state, sort, cut to the limits, pick the next card — learning cards that are due first, then the most overdue reviews, then new cards. It reads card schedules, deck limits, the review log and the clock, and Flowik records every one of those reads.
So answering is just this:
fun answer(card: ObservableEntity<Card>, answer: Answer) = action {
val before = card[Card::schedule]
card[Card::schedule] = scheduler.answer(before, answer, now, zone)
log.add(ReviewEntry(card, now, answer, before))
tick()
}
Write the new schedule, append to the log, read the clock. Every count on every screen follows, because every count was derived from those three things. Undo is the same in reverse: put the old schedule back, drop the log entry. Nobody has to remember which counters to fix.
The scheduler itself is a pure function from a schedule and a time to a new schedule. It has no idea a UI exists, which made it the easiest part of the app to test.
State down, events up
The other half is the one I want to talk about, because it changed how the components look.
In Compose, a well-behaved composable doesn't own the state it shows. It receives values and reports events through lambdas — "state flows down, events flow up". The state lives with whoever actually needs it, usually higher up the tree.
The Vaadin version looks almost the same. Here is the deck overview — the screen with the three counts and the Study Now button:
class DeckOverview(
deck: ObservableEntity<Deck>,
queue: Supplier<DeckQueue>,
onStudy: () -> Unit,
onBack: () -> Unit,
onAddCards: () -> Unit,
onBrowse: () -> Unit,
onOptions: () -> Unit
) : Div(), Bindings by Bindings() {
init {
val due = Div(stats, studyNow).apply {
visible { !queue.get().isFinished }
}
val finished = register(Congratulations(queue, onBack)).apply {
visible { queue.get().isFinished }
}
// …
}
}
It doesn't know there is a store. It doesn't know where Study Now leads. It shows a deck and a queue, and it says when a button was pressed.
There is one difference from Compose, and it is the interesting part. A composable function is called again whenever its inputs change, so it can take a plain DeckQueue. A Vaadin component is built once. If I passed it a DeckQueue value, it would show the counts as they were when the screen opened, forever.
So the state goes down as a Supplier<DeckQueue> — a way to read the queue — and the component reads it inside its bindings. visible { !queue.get().isFinished } is a reaction: Flowik sees which observables queue.get() touched and re-runs it when any of them change. The reactivity travels through the lambda. The component stays stateless, and it is still live.
That is the whole trick, and I find it rather elegant: hoisting in Compose relies on recomposition; here it relies on auto-tracking, and the component code reads the same.
One class knows the store
If components only take state and callbacks, somebody has to hand them out. In the app that is AnkiView, the routed page, and it is the only class that ever sees AnkiStore:
is Screen.Study -> StudyScreen(
deck = screen.deck,
queue = { store.queueOf(screen.deck) },
resume = screen.resume,
intervalLabels = store::intervalLabels,
canUndo = store::canUndo,
onAnswer = store::answer,
onUndo = store::undo,
onEdit = ::openEditCard,
onForget = store::forgetCard,
onDelete = store::deleteCard,
onBack = { store.showOverview(screen.deck) },
onDecks = store::showDecks
)
Read it as a wiring diagram. Property references like store::canUndo convert to suppliers, so most of it is one line per input. Events map onto store actions. The view also opens the dialogs, so they are wired the same way.
Navigation is one more atom: the view switches its content on store.screen with childSwitch, and the screen it leaves is disposed with all its bindings. Going somewhere is writing a value.
Not everything goes up
My first version of this had the store hold the study session too: the current card, whether its answer was showing, the labels on the answer buttons. It worked. It was also wrong, and it took me
a while to see why.
Compose has a precise rule for this: hoist state to the lowest common ancestor of everything that reads it — no higher. Who reads "the answer is showing"? The study screen. Nobody else. The store
only held it because answer() took no card and fished the current one out of its own state.
So the session moved into the screen:
private var answerShown by observable(false, name = "answerShown")
/** Shown before the queue's pick until it is answered — the card an undo brought back. */
private var pinned by observableRef(resume, name = "pinned")
private val card = register(computedRef { pinned ?: queue.get().next })
private val answerLabels by computed { card.value?.let(intervalLabels).orEmpty() }
The card on screen is derived, locally, from local state and the hoisted queue. computedRef notifies only when a different card comes up, not every time the queue is recomputed, so the card
face isn't rebuilt for nothing. Showing the answer never leaves the component. Answering does:
private fun answer(answer: Answer) {
val current = card.value ?: return
if (!answerShown) return
answerShown = false
pinned = null
onAnswer(current, answer)
}
The screen resets its own state and reports which card was rated. Rescheduling is the store's business. Until the store does it, the screen keeps showing the same card — which is precisely what
happens when a learning card is the only one left and comes straight back.
The store got smaller and more honest: decks, cards, the log, the clock, and which screen is showing. Things more than one screen cares about.
Dialogs report, they don't write
Dialogs follow the same rule. The card editor edits through a buffer —
createViewModel(card), which reads through to the card until you type — but it never writes the
card itself:
Button("Save") {
onSave(form[Card::front], form[Card::back])
close()
}
Cancel needs no code at all: the buffer is simply dropped.
Validation is derived like everything else. The "create deck" dialog takes a way to ask whether a name is taken, and the error message, the invalid state and the button all follow one computed:
private val deckName by observable("", name = "deckName")
private val nameError by computed {
deckName.trim().takeIf(isNameTaken)?.let { "A deck named '$it' already exists." }
}
private val name = TextField("Deck name").apply {
value(::deckName.asObservable())
errorMessage(::nameError)
}
isNameTaken reads the store's decks, so the check is as reactive as everything else — but the dialog doesn't know that. It just calls a function it was given.
Testing without a store
This is where hoisting pays for itself. A component that only takes state and callbacks can be built in a test from a couple of observables. No store, no scheduler, no Spring:
val current = observableRef(queue(cat, dog))
val answers = mutableListOf<Pair<String, Answer>>()
val screen = StudyScreen(
deck = deck,
queue = current,
resume = null,
intervalLabels = { mapOf(Answer.GOOD to "10m") },
canUndo = { false },
onAnswer = { card, answer -> answers += card[Card::front] to answer },
// the remaining callbacks are no-ops
)
shown(screen, "anki-show-answer").single().simulateClick()
shown(screen, "anki-answer-good").single().simulateClick()
assertEquals(listOf("cat" to Answer.GOOD), answers)
action { current.value = queue(dog) }
assertEquals("dog", shown(screen, "anki-card-front").single().element.text)
The test plays the store: it hands the screen a queue, checks what gets reported, then moves the queue on by itself. It checks both halves of the contract — the screen shows what it's given, and it reports instead of acting.
The honest part
It isn't completely free.
-
Parameter lists get long.
StudyScreentakes twelve arguments. Named arguments keep the call site readable, and I prefer an explicit list to a component quietly reaching into a store, but it is not pretty. -
A supplier must be read inside a binding. Call
queue.get()once ininit, outside any reaction, and you have a snapshot that will never update. The types don't catch it. - Time is an atom I tick by hand. The clock moves on every action and navigation, not on its own, so a card due in ten minutes doesn't appear while you stare at the screen. A proper observable clock is on my list.
- Every deck's queue is recomputed when any card changes. Fine for a few hundred cards; a computed per deck would be better, and Flowik doesn't have that yet.
Try it
The app is in the repo under
flowik-vaadin-demo/.../anki:
./gradlew :flowik-vaadin-demo:bootRun
# then open http://localhost:8080/anki
Press Next day in the top bar to time-travel and watch tomorrow's reviews fall due.
What I like most about this approach is how little each piece knows. The scheduler doesn't know there is a UI. The screens don't know there is a store. The store doesn't know there is Vaadin. And the counts are right everywhere, because nobody is responsible for keeping them right.
Source: github.com/jreznot/flowik

Top comments (0)