Patrick Jackson · blog

Granular Subscriptions: Stop Diffing By Hand

938 words5 min read#kotlin#kotlin-multiplatform#redux#reduxkotlin#api-design

Redux gives you one subscription primitive: store.subscribe { }. It fires after every dispatch, and it tells you nothing about what changed.

So everybody writes this:

var lastName = store.state.user.name
val unsub = store.subscribe {
val name = store.state.user.name
if (name != lastName) {
lastName = name
render(name)
}
}

Re-read the value. Compare it to the last one. Remember the new one. Do not forget to remember the new one.

I’ve written that block more times than I want to count, and I’ve gotten it wrong more than once. Here is the same thing now:

val unsub = store.subscribeTo({ it.user.name }) { _, name -> render(name) }

That’s the whole post, really. The rest is detail.

Selectors aren’t going anywhere

Let me kill a misunderstanding before it starts, because I’ve seen it twice already.

Granular subscriptions do not replace selectors. A selector is the function you pass in: { it.user.name } up there is a selector. Nothing about selectors is deprecated, and the Compose bindings take them too. What is replaced is the subscribe-to-everything-and-diff-by-hand pattern, which was never an API so much as a habit.

Your existing selector functions keep working. They just moved inside the parentheses.

The API

Three forms, depending on what reads best at the call site.

A lambda selector. Works everywhere, including from Swift and JS:

store.subscribeTo({ it.user.name }) { _, new -> render(new) }

A property reference. Kotlin-only, because a KProperty1 isn’t constructible from Swift:

store.subscribeTo(AppState::user) { _, new -> render(new) }

A batch DSL, when a component cares about several fields:

val sub = store.subscribeFields {
on(AppState::user) { _, new -> refreshProfile(new) }
on(AppState::feed) { _, new -> refreshFeed(new) }
on(AppState::theme) { _, new -> applyTheme(new) }
}

Three fields, but one underlying store.subscribe. A batch of N entries costs one subscriber, not N.

If you’re using multi-model state, the model is inferred from the property reference’s receiver, so the call site never has to name ModelState:

store.subscribeFields {
on(LoggedInUserModel::displayName) { _, name -> userHeader.title = name }
on(CartModel::itemCount) { _, n -> cartBadge.count = n }
on(ThemeModel::palette) { _, p -> applyPalette(p) }
}

Behavior worth knowing

It fires only when the value actually moved. The comparison is a === identity fast path, then a structural ==. If a dispatch doesn’t change user.name, your callback doesn’t run.

triggerOnSubscribe defaults to true. On subscribe you get one immediate (current, current) call. This is deliberate: it matches StateFlow, LiveData, and RxJava’s BehaviorSubject, and it deletes the “render once, then subscribe for changes” two-step that every UI binding otherwise needs. If you don’t want it, pass triggerOnSubscribe = false.

A throwing selector can’t take down its neighbors. Every selector and listener invocation is wrapped. A bad entry forwards to an optional handler and is skipped; the others still fire:

val sub = store.subscribeFields(
onSelectorError = { cause -> crashReporter.recordNonFatal(cause) },
) { scope ->
scope.on({ it.user!!.profile.displayName }) { _, name -> header.title = name }
scope.on(AppState::count) { _, n -> tickCounter.update(n) }
}

The nullable chain in the first selector will NPE the day user is null. It won’t stop count from updating.

It holds no locks of its own. No Mutex, no synchronized, no atomics, just a @Volatile var last per entry, riding the serial-dispatch contract. That’s precisely why ConcurrentStore’s NotificationContext must deliver serially. If notifications could arrive concurrently, these diffs would tear.

The race I had to fix

Worth telling, because it’s subtle and it was silently wrong.

When you registered a subscription, the code sampled each selector’s current value, and then installed the underlying store.subscribe. A dispatch landing in the gap between those two steps had no notification of its own: the sample was already taken, and the subscriber wasn’t yet attached. That change was silently missed. The only entries that got anything were the triggerOnSubscribe ones, and they got a stale (current, current).

The fix re-diffs every entry immediately after the subscription is installed. If the value moved during registration, you get one real (prev, next) callback. If it didn’t, you get the documented (current, current) trigger. It subsumes the trigger rather than double-firing it.

The Compose bindings needed the same fix, in the same order: subscribe first, re-sample second. It’s load-bearing in both.

The bug class this deletes

This one is a bit of a hobby horse, so bear with me.

The hand-rolled diff block at the top of this post is boilerplate: a forgotten lastName = assignment, a comparison against the wrong field, a subscription that re-renders on every dispatch because someone dropped the check entirely. It’s the sort of code that is easy to write, easy to write slightly wrong, and almost impossible to notice is wrong, because the symptom is “the UI updates a bit too often” or “occasionally it doesn’t.”

A coding agent writes that block the same way we do, with the same failure modes, and (unlike us) it has no user complaining that the screen flickers. It can’t see the symptom at all.

subscribeTo removes the opportunity. There’s no last variable to forget to update, because the library owns it. That’s a smaller and less glamorous thing than any AI feature, and I think it matters more than most of them: the best way to help a machine write correct code is to delete the places where incorrect code fits.

Full API in the granular subscriptions doc.

Happy subscribing. That’s one fewer bug class for you or your agent to write.


Sources of truth: reduxkotlin.org · github.com/reduxkotlin/redux-kotlin · the TaskFlow sample

Part 4 of ReduxKotlin 1.0.