
Library enhances navigation in compose-based applications with multi-layer navigation, authentication, responsive design, state restoration, and feature toggles, supporting device-aware and type-safe routing.
Kotlin Multiplatform routing for Compose apps built on duks.
Requires duks 0.4.0. Targets: JVM, Android, iOS, wasmJs.
dependencies {
implementation("io.github.crowded-libs:duks-routing:0.3.1")
}HasRouterState and withRouterState.StoreBuilder.routing { } registers middleware and a router reducer.routeTo, goBack, …) update state.routerState through that reducer.store.state / routerState (or RouterMiddleware.state, which mirrors the same stack).Domain reducers should not handle routing actions.
@Serializable
data class AppState(
val user: User? = null,
override val routerState: RouterState = RouterState()
) : HasRouterState {
override fun withRouterState(routerState: RouterState) = copy(routerState = routerState)
}
val store = createStore(AppState()) {
routing(
authConfig = AuthConfig(
authChecker = { it.user != null },
unauthenticatedRoute = "/login"
)
) {
initialRoute("/home")
content("/home") { HomeScreen() }
content("/login") { LoginScreen() }
content("/profile", requiresAuth = true) { ProfileScreen() }
modal("/settings") { SettingsModal() }
}
reduceWith(::appReduce)
}Render the current route, for example:
val route by store.state.mapToPropsAsState { routerState.primaryRoute() }
route?.Content()Use DeviceContextProvider(store) { … } so device-based RenderConditions receive size/orientation updates.
| Layer | DSL | Behavior |
|---|---|---|
| Scene | scene(path) { } |
Full-screen stack. Navigating to a scene clears content and modal stacks. |
| Content | content(path) { } |
Content stack (e.g. main chrome). |
| Modal | modal(path) { } |
Overlay stack. |
Optional config on a route is available as RouteInstance.config / RouterState.primaryConfig<T>().
Groups share auth/config/prefix:
group(requiresAuth = true, config = ScaffoldConfig(showBack = true)) {
content("/account") { AccountScreen() }
content("/billing") { BillingScreen() }
}store.routeTo("/products")
store.routeTo("/product", param = productId)
store.routeTo("/login", clearHistory = true) // same as mode = ClearHistory
store.routeTo("/item", param = id, mode = NavigationMode.SingleTop)
store.switchScene("/home") // scene ReplaceLayer; clears content + modals
store.goBack() // no-op at root; does not set lastRouteType
store.showModal("/filter", param = options)
store.dismissModal()
store.popToRoute("/home") // modal → content → scene
store.dispatch(Routing.DeepLink("myapp://host/item/42"))| Mode | Behavior |
|---|---|
Push (default) |
Append on the target layer. Scene still clears content + modals. |
SingleTop |
If the top of that layer has the same path, replace it; otherwise push. |
ReplaceLayer |
Replace that layer only. Scene also clears content + modals. Content clears modals and keeps scenes. |
ClearHistory |
Clear all layers; destination alone. |
routerState.primaryRoute() // last content, else last scene
routerState.canGoBack()
routerState.primaryConfig<MyConfig>()
route.pathEquals("/home") // normalized path equalityrequiresAuth = true on a route: unauthenticated navigation is redirected to AuthConfig.unauthenticatedRoute (that route’s layer).onAuthFailure is invoked when a protected route is blocked.revalidateOnSessionLoss (default true): if auth goes true → false while protected routes are active, navigates to the unauthenticated route with history cleared.content("/item/{id}") {
val id = routeParam<String>()
ItemScreen(id)
}
store.routeTo("/item/42") // param = "42"Multiple path segments become a Map<String, String> when no explicit param is passed.
Typed DSL:
content<String>("/item/{id}") { id -> ItemScreen(id) }
modal<FilterOptions>("/filter") { options -> FilterModal(options) }When RouterState is serialized (e.g. with app state), params are encoded if a codec exists:
String, Int, Long, Boolean, Float, Double
@Serializable typerouting {
registerParamSerializer<ProductId>()
// …
}
// or: RouteParamRegistry.Default.register<ProductId>()Unregistered / non-serializable params: path is kept, param is dropped on serialize.
With duks persistence, restored routerState is rehydrated against the route table (paths → live Route content). Configure in the DSL:
routing {
restoration {
restoreAll() // default base
// restoreOnly(RouteType.Scene, RouteType.Content)
// restoreSpecific { scenes("/home"); content("/list") }
conditionalDefaults(mode = ConditionalDefaultsMode.OnlyIfEmpty) {
`when` { it.user == null } then "/login"
`when` { it.user?.isOnboarded == true } then "/home"
otherwise("/onboarding")
}
}
}| Mode | When defaults apply |
|---|---|
OverrideAlways (default) |
Matching condition (or otherwise) replaces restored stacks |
OnlyIfEmpty |
Only if the restored stack is empty |
OnlyIfInvalid |
If empty, or any restored path is not a registered route |
content(
"/dashboard",
whenCondition = RenderCondition.DeviceType(setOf(DeviceClass.Desktop, DeviceClass.Tablet))
) {
DesktopDashboard()
}
content(
"/dashboard",
whenCondition = RenderCondition.DeviceType(setOf(DeviceClass.Phone))
) {
MobileDashboard()
}Also: RenderCondition.ScreenSize, Orientation, Custom, FeatureEnabled, and and / or.
Device class breakpoints (smallest edge, prefer dp): watch ≤320, phone <600, tablet <900, else desktop — see DeviceClassHeuristics.
Implement FeatureToggleEvaluator and pass it to featureToggles(...). Routes can require a feature:
routing {
featureToggles(AppFeatureEvaluator()) // reevaluateOnAppStateChange = true by default
content("/beta", requiredFeature = "beta_access") {
BetaScreen()
}
}RouterState.enabledFeatures is the set of feature names declared as requiredFeature on routes that currently evaluate to true. It is updated on navigation/device changes and, by default, after other actions when that set would change.
UI helpers (they read enabledFeatures, so the feature name must appear as some route’s requiredFeature if you rely on the cache):
if (state.isFeatureEnabled("beta_access")) { /* … */ }
FeatureGate(store, "beta_access", fallback = { LockedScreen() }) {
BetaScreen()
}routing {
onNavigation { previous, current, action ->
// analytics, logging
}
// routes…
}Called after the store has committed a new routerState.
Register later (e.g. analytics middleware after routing { }) via the returned middleware:
val router = routing { /* routes */ }
router.addNavigationListener { previous, current, action -> /* … */ }
router.removeNavigationListener(listener)Recommended order matches duks docs: exception handling → logging/cache → persistence → routing → sagas → async. Call routing { } so it sits with domain middleware after persistence.
Apache 2.0
Kotlin Multiplatform routing for Compose apps built on duks.
Requires duks 0.4.0. Targets: JVM, Android, iOS, wasmJs.
dependencies {
implementation("io.github.crowded-libs:duks-routing:0.3.1")
}HasRouterState and withRouterState.StoreBuilder.routing { } registers middleware and a router reducer.routeTo, goBack, …) update state.routerState through that reducer.store.state / routerState (or RouterMiddleware.state, which mirrors the same stack).Domain reducers should not handle routing actions.
@Serializable
data class AppState(
val user: User? = null,
override val routerState: RouterState = RouterState()
) : HasRouterState {
override fun withRouterState(routerState: RouterState) = copy(routerState = routerState)
}
val store = createStore(AppState()) {
routing(
authConfig = AuthConfig(
authChecker = { it.user != null },
unauthenticatedRoute = "/login"
)
) {
initialRoute("/home")
content("/home") { HomeScreen() }
content("/login") { LoginScreen() }
content("/profile", requiresAuth = true) { ProfileScreen() }
modal("/settings") { SettingsModal() }
}
reduceWith(::appReduce)
}Render the current route, for example:
val route by store.state.mapToPropsAsState { routerState.primaryRoute() }
route?.Content()Use DeviceContextProvider(store) { … } so device-based RenderConditions receive size/orientation updates.
| Layer | DSL | Behavior |
|---|---|---|
| Scene | scene(path) { } |
Full-screen stack. Navigating to a scene clears content and modal stacks. |
| Content | content(path) { } |
Content stack (e.g. main chrome). |
| Modal | modal(path) { } |
Overlay stack. |
Optional config on a route is available as RouteInstance.config / RouterState.primaryConfig<T>().
Groups share auth/config/prefix:
group(requiresAuth = true, config = ScaffoldConfig(showBack = true)) {
content("/account") { AccountScreen() }
content("/billing") { BillingScreen() }
}store.routeTo("/products")
store.routeTo("/product", param = productId)
store.routeTo("/login", clearHistory = true) // same as mode = ClearHistory
store.routeTo("/item", param = id, mode = NavigationMode.SingleTop)
store.switchScene("/home") // scene ReplaceLayer; clears content + modals
store.goBack() // no-op at root; does not set lastRouteType
store.showModal("/filter", param = options)
store.dismissModal()
store.popToRoute("/home") // modal → content → scene
store.dispatch(Routing.DeepLink("myapp://host/item/42"))| Mode | Behavior |
|---|---|
Push (default) |
Append on the target layer. Scene still clears content + modals. |
SingleTop |
If the top of that layer has the same path, replace it; otherwise push. |
ReplaceLayer |
Replace that layer only. Scene also clears content + modals. Content clears modals and keeps scenes. |
ClearHistory |
Clear all layers; destination alone. |
routerState.primaryRoute() // last content, else last scene
routerState.canGoBack()
routerState.primaryConfig<MyConfig>()
route.pathEquals("/home") // normalized path equalityrequiresAuth = true on a route: unauthenticated navigation is redirected to AuthConfig.unauthenticatedRoute (that route’s layer).onAuthFailure is invoked when a protected route is blocked.revalidateOnSessionLoss (default true): if auth goes true → false while protected routes are active, navigates to the unauthenticated route with history cleared.content("/item/{id}") {
val id = routeParam<String>()
ItemScreen(id)
}
store.routeTo("/item/42") // param = "42"Multiple path segments become a Map<String, String> when no explicit param is passed.
Typed DSL:
content<String>("/item/{id}") { id -> ItemScreen(id) }
modal<FilterOptions>("/filter") { options -> FilterModal(options) }When RouterState is serialized (e.g. with app state), params are encoded if a codec exists:
String, Int, Long, Boolean, Float, Double
@Serializable typerouting {
registerParamSerializer<ProductId>()
// …
}
// or: RouteParamRegistry.Default.register<ProductId>()Unregistered / non-serializable params: path is kept, param is dropped on serialize.
With duks persistence, restored routerState is rehydrated against the route table (paths → live Route content). Configure in the DSL:
routing {
restoration {
restoreAll() // default base
// restoreOnly(RouteType.Scene, RouteType.Content)
// restoreSpecific { scenes("/home"); content("/list") }
conditionalDefaults(mode = ConditionalDefaultsMode.OnlyIfEmpty) {
`when` { it.user == null } then "/login"
`when` { it.user?.isOnboarded == true } then "/home"
otherwise("/onboarding")
}
}
}| Mode | When defaults apply |
|---|---|
OverrideAlways (default) |
Matching condition (or otherwise) replaces restored stacks |
OnlyIfEmpty |
Only if the restored stack is empty |
OnlyIfInvalid |
If empty, or any restored path is not a registered route |
content(
"/dashboard",
whenCondition = RenderCondition.DeviceType(setOf(DeviceClass.Desktop, DeviceClass.Tablet))
) {
DesktopDashboard()
}
content(
"/dashboard",
whenCondition = RenderCondition.DeviceType(setOf(DeviceClass.Phone))
) {
MobileDashboard()
}Also: RenderCondition.ScreenSize, Orientation, Custom, FeatureEnabled, and and / or.
Device class breakpoints (smallest edge, prefer dp): watch ≤320, phone <600, tablet <900, else desktop — see DeviceClassHeuristics.
Implement FeatureToggleEvaluator and pass it to featureToggles(...). Routes can require a feature:
routing {
featureToggles(AppFeatureEvaluator()) // reevaluateOnAppStateChange = true by default
content("/beta", requiredFeature = "beta_access") {
BetaScreen()
}
}RouterState.enabledFeatures is the set of feature names declared as requiredFeature on routes that currently evaluate to true. It is updated on navigation/device changes and, by default, after other actions when that set would change.
UI helpers (they read enabledFeatures, so the feature name must appear as some route’s requiredFeature if you rely on the cache):
if (state.isFeatureEnabled("beta_access")) { /* … */ }
FeatureGate(store, "beta_access", fallback = { LockedScreen() }) {
BetaScreen()
}routing {
onNavigation { previous, current, action ->
// analytics, logging
}
// routes…
}Called after the store has committed a new routerState.
Register later (e.g. analytics middleware after routing { }) via the returned middleware:
val router = routing { /* routes */ }
router.addNavigationListener { previous, current, action -> /* … */ }
router.removeNavigationListener(listener)Recommended order matches duks docs: exception handling → logging/cache → persistence → routing → sagas → async. Call routing { } so it sits with domain middleware after persistence.
Apache 2.0