Duks - Kotlin Compose State Management and Control Flow
Duks is a lightweight, type-safe state management library for Kotlin Multiplatform applications, inspired by Redux. It provides a predictable, unidirectional data flow pattern with built-in support for middleware and Compose UI integration.

Features
- Type-safe state management with Redux-like architecture
- — Android, iOS (device + simulator), JVM, and WebAssembly (wasmJs)
Related libraries in the ecosystem (separate artifacts): duks-routing, duks-storage-lmdb, duks-ga4.
Installation
dependencies {
implementation("io.github.crowded-libs:duks:0.4.0")
}
Quick Start
1. Define Your State
data class AppState(
val counter: Int = 0,
val user: User? = null,
val isLoading: Boolean = false,
val error: String? = null
) : StateModel
2. Define Actions
sealed class AppAction : Action {
data object Increment : AppAction()
Decrement : AppAction()
( user: User) : AppAction()
( id: String) : AppAction(), AsyncAction<User> {
: Result<User> =
runCatching { userRepository.getUser(id) }
}
}
3. Create a Reducer
Async middleware emits default lifecycle actions: AsyncProcessing, AsyncResultAction, AsyncError, and AsyncComplete (unless you override the create*Action methods).
val appReducer: Reducer<AppState> = { state, action ->
(action) {
AppAction.Increment -> state.copy(counter = state.counter + )
AppAction.Decrement -> state.copy(counter = state.counter - )
AppAction.SetUser -> state.copy(user = action.user)
AsyncProcessing -> state.copy(isLoading = , error = )
AsyncResultAction<*> -> (action.initiatedBy) {
AppAction.LoadUser -> state.copy(
user = action.result User,
isLoading =
)
-> state
}
AsyncError -> state.copy(isLoading = , error = action.error.message)
AsyncComplete -> state.copy(isLoading = )
-> state
}
}
4. Create the Store
Use createStore (the store constructor is internal):
val store = createStore(AppState()) {
middleware {
exceptionHandling(
onError = { error, action -> },
errorAction = { error, action -> null }
)
logging()
async()
}
reduceWith(appReducer)
}
store.dispatch(AppAction.Increment)
Concurrency: only the reducer’s state write is serialized. Concurrent dispatch calls may interleave in middleware. Prefer dispatchAsync when you need to await completion of a single action’s chain.
Recommended middleware order
Order is the order you register them (outermost first):
- exceptionHandling — first, so failures in the chain are caught
- logging (optional)
- caching (optional)
- persistence — restore runs on store create; keep before heavy side effects
- domain middleware (routing, analytics, …)
5. Use in Compose
Prefer selecting a slice so unrelated state changes do not recompose the screen.
TProps should be equality-friendly (e.g. data classes).
@Composable
fun CounterScreen(store: KStore<AppState>) {
val counter by store.state.mapToPropsAsState { counter }
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Count: $counter")
Button(onClick = { store.dispatch(AppAction.Increment) }) {
Text("Increment")
}
}
}
For non-Compose collectors of a distinct slice:
store.state.mapToProps { user }.collect { user -> }
Complete Compose Example
Advanced Features
Sagas
Sagas orchestrate multi-step workflows with their own state:
Custom Async Actions
Override the lifecycle factories for domain-specific loading/error actions:
Persistence
Strategies: OnEveryChange, Debounced(delayMs), OnAction(setOf(...)), Conditional { ... }, Combined(...).
Saga instances can be persisted via sagas(storage = ..., persistenceStrategy = ...) on the middleware builder.
Action Caching
CacheableAction uses cacheKey (default: toString()) and expiresAfter for TTL.
MapActionCache removes expired entries on read and can cap size via MapActionCache(maxSize = …).
data class SearchProducts(val query: String) : Action, CacheableAction {
override val cacheKey: String = "search:$query"
override val expiresAfter: Instant =
Clock.System.now().plus(5, DateTimeUnit.MINUTE, TimeZone.currentSystemDefault())
}
val store = createStore(AppState()) {
middleware {
exceptionHandling()
caching(MapActionCache(maxSize = 256))
async()
}
reduceWith(appReducer)
}
Caching is best for pure sync transforms of the same action; it is not a substitute for memoizing async network results.
Best Practices
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
This project is licensed under the Apache License 2.0 — see the LICENSE file for details.