Let’s start with a bug you’ve probably had, in Compose, with or without Redux: you change one field, and the whole screen recomposes.
Here is a ReduxKotlin-flavored version of the cause. Compose’s stability inference treats interfaces as unstable. Store<S> is an interface. So a composable that takes a Store<S> parameter is never skippable. Every recomposition of the parent recomposes it, forever, no matter how carefully you scoped your state reads.
The fix is one wrapper:
@Stable@JvmInlinepublic value class StableStore<S>(public val value: Store<S>)
@Composablepublic fun <S> rememberStableStore(store: Store<S>): StableStore<S>val s = rememberStableStore(store).valueIf you take nothing else from this post, take that one.
The whole API
redux-kotlin-compose gives you three things:
@Composable fun <S, F> Store<S>.selectorState(selector: (S) -> F): State<F>@Composable fun <S, F> Store<S>.fieldState(property: KProperty1<S, F>): State<F>@Composable fun <S> rememberStableStore(store: Store<S>): StableStore<S>And if you use multi-model state, redux-kotlin-compose-multimodel adds two more. It’s a separate artifact, though both ship inside -bundle-compose:
@Composable inline fun <reified M : Any, F> Store<ModelState>.fieldState(property: KProperty1<M, F>): State<F>@Composable fun <M : Any, F> Store<ModelState>.fieldStateOf(modelClass: KClass<M>, selector: (M) -> F): State<F>Dispatch is store.dispatch(action).
That’s the entire integration. There’s no StoreProvider, no CompositionLocal, and no hook. You pass the store to the components that need it, as a parameter, like any other value. I know that is unfashionable. It also means scope is lexical, visible in the signature, and impossible to get wrong by accident.
The binding doesn’t cache, on purpose
State.value re-reads selector(store.state) on every read. The store subscription doesn’t push a value into the State at all. It just bumps an internal counter to schedule a recomposition.
This looks wasteful and isn’t. It means you can never read a stale value, even mid-frame, even if a dispatch lands between two reads in the same composable. There’s no cached copy to go out of date.
Under the hood these are granular subscriptions with a Compose adapter on top: selectorState is subscribeTo plus a recomposition trigger. The subscription is installed in a DisposableEffect that subscribes first and re-samples second. That order closes the window where a dispatch lands between first composition and subscription install. It reads like a nitpick. It’s the difference between a binding you can trust and one that goes stale once a month.
Bind the narrowest slice that’s worth the surgery
Here is TaskFlow’s board. A column binds only its own visible card IDs and its own WIP count:
val visibleCardIds by s.selectorState { ms -> deriveVisibleCardIds(ms.get<BoardModel>(), ms.get<FilterModel>(), colId)}val wip by s.selectorState { ms -> val c = ms.get<BoardModel>().board?.columnById(colId) WipState(c?.cardIds?.size ?: 0, c?.wipLimit ?: wipLimit)}And a card binds only its own Card and its own optimistic flag:
@Composableprivate fun CardCell(s: Store<ModelState>, cardId: CardId, ...) { val card by s.fieldStateOf(BoardModel::class) { it.board?.cards?.get(cardId) } val optimistic by s.fieldStateOf(SyncModel::class) { cardId in it.inFlight } card?.let { KanbanCard(card = it, isOptimistic = optimistic, ...) }}Notice what is not there: nothing selects the board wholesale, and there’s no .filter or .count in a composable body. Every derivation happens inside a selectorState { } or in a reducer.

Move a card between columns on that board and only the two affected columns recompose. I know that because TaskFlow has a test that counts recompositions per column across a card move and asserts the untouched column stays flat.
That test is the part I actually care about. “Bind narrowly” is advice, and advice decays. A recomposition-counting test is a rule, and a rule can be enforced: by CI, by a reviewer, or by a coding agent that has no intuition for Compose performance but can absolutely read a failing assertion. If you want an agent to maintain your render performance, don’t write it a style guide. Write it a test.
When not to do this
I said “worth the surgery” deliberately.
In another app of mine, the root composable does this:
val state by store.selectorState { it } // the entire AppStateThe whole state tree, in one binding, recomposing everything on every dispatch. By the rule above this is heresy. It’s also completely fine: the screens are small, the state is small, and the app is fast. There’s a comment in that file naming the upgrade path for the day it stops being fine.
Bind narrowly when narrow buys you something. TaskFlow’s board has dozens of cards and drag interactions, so it buys a lot. A settings screen with six rows buys nothing, and the surgery is just cost.
Sharing state across components
Two shapes, both supported. Pick by size.
One store, one state class, one root reducer. This is classic Redux, it’s what the app above does, and it’s the right default until it isn’t.
Multi-model. One store, many feature model slots in a ModelState, each component binding a field out of one slot. TaskFlow does this, and it’s why the snippets above say ms.get<BoardModel>() instead of reaching into a single giant state class.
For genuinely separate lifetimes, there’s a store registry. TaskFlow keeps a root store for accounts, settings, and auth, and then an isolated per-account store created on login and torn down on logout:
StoreRegistry<AccountId, ModelState>Components take whichever store they need (appStore or accountStore) as a parameter. When an account logs out, its store, its sync engine, and its DevTools session all go away together. You get that almost for free once stores are values you pass around rather than singletons you reach for.
Full details in the Compose integration doc.
Happy composing. Write your agent a test, not a style guide.
Sources of truth: reduxkotlin.org · github.com/reduxkotlin/redux-kotlin · the TaskFlow sample
Part 5 of ReduxKotlin 1.0.