Routed Reducers
redux-kotlin-routing provides routed (model, action) dispatch over
ModelState. It replaces the when(action) {} cascade with
exact-leaf-class routing: an action only visits the handlers registered for
its concrete class, and only the models a handler changes are rebuilt — the
rest keep === identity, so the
granular subscription layer stays precise.
implementation("org.reduxkotlin:redux-kotlin-routing:<version>")
(Already included if you use redux-kotlin-bundle.)
The DSL
import org.reduxkotlin.routing.createModelStore
val store = createModelStore {
model(UserModel()) {
on<LoggedIn> { s, a -> s.copy(user = a.user) }
on<LoggedOut> { s, _ -> s.copy(user = null) }
}
model(CartModel()) {
on<AddItem> { s, a -> s.copy(items = s.items + a.item) }
}
onAction<Checkout> { reads, _ ->
val cart = reads.get<CartModel>()
writeSet { set(cart.copy(closed = true)) }
}
onBroadcast<Logout> { model, _ -> /* reset each model */ model }
install(SomeFeatureModule)
}
model(initial) { on<Action> { … } }— declares a state slot with its initial value and per-action handlers(model, action) -> model.onAction<A>— a multi-model handler: read any models viareads.get<M>(), return awriteSet { set(…) }of the models it changes.onBroadcast<A>— runs(model, action) -> modelagainst every declared model; the place for cross-cutting actions likeLogout.install(module)— composes a reusableReduxModule(hand-written or generated) into the store.
Semantics
- Exact-leaf matching.
on<Open>matchesOpen, not subtypes of a shared sealed parent. Register each leaf, or useonBroadcastfor cross-cutting actions. - Structural init. A model's starting value is its
model(initial)declaration. There is no INIT-action fan-out. - Order fixed at creation. Handlers for the same action run in
registration order;
install(module)order is the composition point. - Last-write-wins on same-model writes within one dispatch.
- Immutability is required. Return a new instance to signal a change, the
same instance for "no change". Enable
devChecks = trueto fail fast on wasteful structurally-equal copies. - Handlers must be pure.
on/onAction/onBroadcasthandlers compute the next model(s) from their inputs only — never calldispatchor read the store from inside a handler (side effects belong in middleware). The same applies to theonWriteobserver. - All-or-nothing. A handler that throws aborts the whole dispatch; no partial commit.
- Rehydration at construction. The optional
preloadedState: ModelState?parameter overlays restored/persisted models onto the declared defaults (its key set must be a subset of the declared slots), so the firstgetState()already reflects restored state — no post-paint dispatch. See Bundles — rehydrating withpreloadedState.
Threading
createModelStore builds a plain (not thread-safe) store. On multi-threaded
platforms use redux-kotlin-bundle's createConcurrentModelStore,
which builds the same routed store and adopts it as a
concurrent store. The routing layer composes with
granular subscriptions and the
Compose bindings unchanged.
Code generation: @Reduce / @ReduxInitial
redux-kotlin-routing-codegen is a KSP processor that generates a
ReduxModule from annotated handler functions, so you annotate functions
instead of writing the DSL by hand:
@ReduxInitial fun userInitial(): UserModel = UserModel()
@Reduce fun onLoggedIn(s: UserModel, a: LoggedIn): UserModel = s.copy(user = a.user)
@Reduce fun onLoggedOut(s: UserModel, a: LoggedOut): UserModel = s.copy(user = null)
The processor generates object MyFeature : ReduxModule, installed like any
hand-written module:
val store = createModelStore { install(MyFeature) }
:::caution Not yet on Maven Central
The processor is currently consumed as an in-repo project(...) dependency —
publishing it is a pre-release follow-up. The setup below shows the wiring as
used by the repository's
codegen sample.
:::
Setup (consumer module build.gradle.kts)
plugins {
kotlin("multiplatform") // your KMP setup
id("com.google.devtools.ksp")
}
dependencies {
add("kspCommonMainMetadata", project(":redux-kotlin-routing-codegen"))
}
ksp {
arg("routing.moduleName", "MyFeature") // REQUIRED — names the generated object
arg("routing.generatedPackage", "com.example.gen") // optional, defaults to org.reduxkotlin.routing.generated
}
kotlin {
sourceSets.commonMain { kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") }
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
if (name != "kspCommonMainKotlinMetadata") dependsOn("kspCommonMainKotlinMetadata")
}
Rules
@Reducemust be a top-level function(M, A) -> M(returns the model type). Model and action types must be non-generic, non-nullable, public/internal classes. Matching is by the action's exact leaf class (not subtypes).@ReduxInitialis a top-level() -> Mprovider. Exactly one per model type, in the same module as that model's@Reducehandlers. A model with handlers but no in-module@ReduxInitialis a compile error — for models shared across modules, register handlers with the hand-written DSL instead.- Handlers must live in
commonMain(onlykspCommonMainMetadatais wired).
Ordering with the hand-written DSL
install(MyFeature) registers its handlers at that point in the
createModelStore { } sequence. A hand-written handler for the same action
placed before/after the install(...) runs before/after the generated ones
(registration order fixes dispatch order, and last-write-wins applies within a
dispatch). If mixing, install generated modules first unless you intend
otherwise.
v1 limitations
Single-model @Reduce handlers only. Multi-model (onAction) and broadcast
(onBroadcast) handlers, and handlers in platform source sets, are not
generated — use the hand DSL for those.
See also
- MultiModel — the
ModelStatecontainer the routing layer drives. - Bundles —
createConcurrentModelStore, the routed store with thread safety. - Granular subscriptions — why preserved
===identity matters.