
Lightweight, type-safe state management inspired by Redux, featuring predictable unidirectional data flow, middleware support, and seamless integration with Compose UI. Offers action caching, saga pattern support, and minimal dependencies.
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.
mapToPropsAsState (equality-filtered slices)Related libraries in the ecosystem (separate artifacts): duks-routing, duks-storage-lmdb, duks-ga4.
dependencies {
implementation("io.github.crowded-libs:duks:0.4.0")
}data class AppState(
val counter: Int = 0,
val user: User? = null,
val isLoading: Boolean = false,
val error: String? = null
) : StateModelsealed class AppAction : Action {
data object Increment : AppAction()
data object Decrement : AppAction()
data class SetUser(val user: User) : AppAction()
data class LoadUser(val id: String) : AppAction(), AsyncAction<User> {
override suspend fun getResult(stateAccessor: StateAccessor): Result<User> =
runCatching { userRepository.getUser(id) }
}
}Async middleware emits default lifecycle actions: AsyncProcessing, AsyncResultAction, AsyncError, and AsyncComplete (unless you override the create*Action methods).
val appReducer: Reducer<AppState> = { state, action ->
when (action) {
is AppAction.Increment -> state.copy(counter = state.counter + 1)
is AppAction.Decrement -> state.copy(counter = state.counter - 1)
is AppAction.SetUser -> state.copy(user = action.user)
is AsyncProcessing -> state.copy(isLoading = true, error = null)
is AsyncResultAction<*> -> when (action.initiatedBy) {
is AppAction.LoadUser -> state.copy(
user = action.result as User,
isLoading = false
)
else -> state
}
is AsyncError -> state.copy(isLoading = false, error = action.error.message)
is AsyncComplete -> state.copy(isLoading = false)
else -> state
}
}Use createStore (the store constructor is internal):
val store = createStore(AppState()) {
middleware {
exceptionHandling(
onError = { error, action -> /* metrics / logging hooks */ },
errorAction = { error, action -> /* optional Action for the reducer */ null }
)
logging()
async()
}
reduceWith(appReducer)
}
// Fire-and-forget (UI):
store.dispatch(AppAction.Increment)
// Await this action's middleware + reducer (not nested async/saga work):
// store.dispatchAsync(AppAction.LoadUser("id"))
// When the store is no longer needed (tests, multi-window, etc.):
// store.close()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.
Order is the order you register them (outermost first):
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 -> /* ... */ }data class TodoState(
val items: List<TodoItem> = emptyList(),
val inputText: String = "",
val isLoading: Boolean = false
) : StateModel
data class TodoItem(val id: String, val text: String, val completed: Boolean = false)
data class UpdateInputText(val text: String) : Action
data class AddTodo(val text: String) : Action
data class ToggleTodo(val id: String) : Action
data class DeleteTodo(val id: String) : Action
class LoadTodos : AsyncAction<List<TodoItem>> {
override suspend fun getResult(stateAccessor: StateAccessor): Result<List<TodoItem>> =
runCatching { todoRepository.getAllTodos() }
}
val todoReducer: Reducer<TodoState> = { state, action ->
when (action) {
is UpdateInputText -> state.copy(inputText = action.text)
is AddTodo -> state.copy(
items = state.items + TodoItem(Uuid.random().toString(), action.text),
inputText = ""
)
is ToggleTodo -> state.copy(
items = state.items.map {
if (it.id == action.id) it.copy(completed = !it.completed) else it
}
)
is DeleteTodo -> state.copy(items = state.items.filterNot { it.id == action.id })
is AsyncProcessing ->
if (action.initiatedBy is LoadTodos) state.copy(isLoading = true) else state
is AsyncResultAction<*> ->
if (action.initiatedBy is LoadTodos) {
@Suppress("UNCHECKED_CAST")
state.copy(items = action.result as List<TodoItem>, isLoading = false)
} else state
is AsyncError, is AsyncComplete ->
if ((action as AsyncInitiatedByAction).initiatedBy is LoadTodos) {
state.copy(isLoading = false)
} else state
else -> state
}
}
@Composable
fun TodoApp() {
val store = remember {
createStore(TodoState()) {
middleware {
exceptionHandling()
logging()
async()
}
reduceWith(todoReducer)
}
}
LaunchedEffect(Unit) {
store.dispatch(LoadTodos())
}
TodoScreen(store)
}
data class TodoScreenProps(
val items: List<TodoItem>,
val inputText: String,
val isLoading: Boolean
)
@Composable
fun TodoScreen(store: KStore<TodoState>) {
val props by store.state.mapToPropsAsState {
TodoScreenProps(items, inputText, isLoading)
}
// Build UI from props; unrelated AppState changes will not recompose this screen.
}Sagas orchestrate multi-step workflows with their own state:
data class OnboardingSagaState(
val userId: String,
val profileComplete: Boolean = false,
val tutorialComplete: Boolean = false,
val currentStep: String = "started"
)
data class UserSignedUp(val userId: String, val email: String) : Action
data class ProfileCompleted(val userId: String) : Action
data class TutorialFinished(val userId: String) : Action
data class OnboardingCompleted(val userId: String) : Action
class OnboardingSaga : SagaDefinition<OnboardingSagaState> {
override val name = "onboarding"
override fun configure(saga: SagaConfiguration<OnboardingSagaState>) {
saga.startsOn<UserSignedUp> { action ->
SagaTransition.Continue(
OnboardingSagaState(
userId = action.userId,
currentStep = "profile_setup"
),
effects = listOf(
SagaEffect.Dispatch(ShowProfileSetupScreen(action.userId))
)
)
}
saga.on<ProfileCompleted>(
condition = { action, state -> action.userId == state.userId }
) { _, state ->
val newState = state.copy(profileComplete = true, currentStep = "tutorial")
if (state.tutorialComplete) {
SagaTransition.Complete(
effects = listOf(SagaEffect.Dispatch(OnboardingCompleted(state.userId)))
)
} else {
SagaTransition.Continue(
newState,
effects = listOf(SagaEffect.Dispatch(ShowTutorialScreen(state.userId)))
)
}
}
saga.on<TutorialFinished>(
condition = { action, state -> action.userId == state.userId }
) { _, state ->
val newState = state.copy(tutorialComplete = true, currentStep = "completed")
if (state.profileComplete) {
SagaTransition.Complete(
effects = listOf(SagaEffect.Dispatch(OnboardingCompleted(state.userId)))
)
} else {
SagaTransition.Continue(
newState,
effects = listOf(SagaEffect.Dispatch(ShowProfileSetupScreen(state.userId)))
)
}
}
// Fail removes the instance, clears persistence, and runs effects:
// SagaTransition.Fail(error, effects = listOf(SagaEffect.Dispatch(...)))
}
}
// Multi-step fan-in (wait for several load completions):
// afterFanInStep(allSteps, completedSteps, step, updateState = { ... }) {
// dispatch(AllLoaded); SagaTransition.Complete()
// }
val store = createStore(AppState()) {
middleware {
exceptionHandling()
sagas {
register(OnboardingSaga())
saga("payment") {
startsOn<InitiatePayment> { action ->
SagaTransition.Continue(
PaymentSagaState(orderId = action.orderId),
effects = listOf(
SagaEffect.Dispatch(ProcessPayment(action.orderId))
)
)
}
}
}
async()
}
reduceWith(appReducer)
}Override the lifecycle factories for domain-specific loading/error actions:
interface AccountAsyncAction<T : Any> : AsyncAction<T> {
override fun createProcessingAction(): Action = AccountLoading
override fun createErrorAction(error: Throwable): Action = AccountError(error)
override fun createCompleteAction(): Action = AccountComplete
}
data class SignIn(val email: String, val password: String) :
Action, AccountAsyncAction<Account> {
override suspend fun getResult(stateAccessor: StateAccessor): Result<Account> =
accountService.signIn(email, password)
override fun createResultAction(result: Account): Action = AccountSignedIn(result)
}class FileStateStorage : StateStorage<AppState> {
override suspend fun save(state: AppState) {
File("app_state.json").writeText(Json.encodeToString(state))
}
override suspend fun load(): AppState? = try {
Json.decodeFromString(File("app_state.json").readText())
} catch (_: Exception) {
null
}
override suspend fun clear() {
File("app_state.json").delete()
}
override suspend fun exists(): Boolean = File("app_state.json").exists()
}
val store = createStore(AppState()) {
middleware {
exceptionHandling()
persistence(
storage = FileStateStorage(),
strategy = PersistenceStrategy.Debounced(500) // milliseconds
)
async()
}
reduceWith(appReducer)
}Strategies: OnEveryChange, Debounced(delayMs), OnAction(setOf(...)), Conditional { ... }, Combined(...).
Saga instances can be persisted via sagas(storage = ..., persistenceStrategy = ...) on the middleware builder.
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.
AsyncAction for IOmapToPropsAsState with data-class propsOnAction for critical checkpointsstore.close() when disposing a store (tests, secondary windows)Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the Apache License 2.0 — see the LICENSE file for details.
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.
mapToPropsAsState (equality-filtered slices)Related libraries in the ecosystem (separate artifacts): duks-routing, duks-storage-lmdb, duks-ga4.
dependencies {
implementation("io.github.crowded-libs:duks:0.4.0")
}data class AppState(
val counter: Int = 0,
val user: User? = null,
val isLoading: Boolean = false,
val error: String? = null
) : StateModelsealed class AppAction : Action {
data object Increment : AppAction()
data object Decrement : AppAction()
data class SetUser(val user: User) : AppAction()
data class LoadUser(val id: String) : AppAction(), AsyncAction<User> {
override suspend fun getResult(stateAccessor: StateAccessor): Result<User> =
runCatching { userRepository.getUser(id) }
}
}Async middleware emits default lifecycle actions: AsyncProcessing, AsyncResultAction, AsyncError, and AsyncComplete (unless you override the create*Action methods).
val appReducer: Reducer<AppState> = { state, action ->
when (action) {
is AppAction.Increment -> state.copy(counter = state.counter + 1)
is AppAction.Decrement -> state.copy(counter = state.counter - 1)
is AppAction.SetUser -> state.copy(user = action.user)
is AsyncProcessing -> state.copy(isLoading = true, error = null)
is AsyncResultAction<*> -> when (action.initiatedBy) {
is AppAction.LoadUser -> state.copy(
user = action.result as User,
isLoading = false
)
else -> state
}
is AsyncError -> state.copy(isLoading = false, error = action.error.message)
is AsyncComplete -> state.copy(isLoading = false)
else -> state
}
}Use createStore (the store constructor is internal):
val store = createStore(AppState()) {
middleware {
exceptionHandling(
onError = { error, action -> /* metrics / logging hooks */ },
errorAction = { error, action -> /* optional Action for the reducer */ null }
)
logging()
async()
}
reduceWith(appReducer)
}
// Fire-and-forget (UI):
store.dispatch(AppAction.Increment)
// Await this action's middleware + reducer (not nested async/saga work):
// store.dispatchAsync(AppAction.LoadUser("id"))
// When the store is no longer needed (tests, multi-window, etc.):
// store.close()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.
Order is the order you register them (outermost first):
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 -> /* ... */ }data class TodoState(
val items: List<TodoItem> = emptyList(),
val inputText: String = "",
val isLoading: Boolean = false
) : StateModel
data class TodoItem(val id: String, val text: String, val completed: Boolean = false)
data class UpdateInputText(val text: String) : Action
data class AddTodo(val text: String) : Action
data class ToggleTodo(val id: String) : Action
data class DeleteTodo(val id: String) : Action
class LoadTodos : AsyncAction<List<TodoItem>> {
override suspend fun getResult(stateAccessor: StateAccessor): Result<List<TodoItem>> =
runCatching { todoRepository.getAllTodos() }
}
val todoReducer: Reducer<TodoState> = { state, action ->
when (action) {
is UpdateInputText -> state.copy(inputText = action.text)
is AddTodo -> state.copy(
items = state.items + TodoItem(Uuid.random().toString(), action.text),
inputText = ""
)
is ToggleTodo -> state.copy(
items = state.items.map {
if (it.id == action.id) it.copy(completed = !it.completed) else it
}
)
is DeleteTodo -> state.copy(items = state.items.filterNot { it.id == action.id })
is AsyncProcessing ->
if (action.initiatedBy is LoadTodos) state.copy(isLoading = true) else state
is AsyncResultAction<*> ->
if (action.initiatedBy is LoadTodos) {
@Suppress("UNCHECKED_CAST")
state.copy(items = action.result as List<TodoItem>, isLoading = false)
} else state
is AsyncError, is AsyncComplete ->
if ((action as AsyncInitiatedByAction).initiatedBy is LoadTodos) {
state.copy(isLoading = false)
} else state
else -> state
}
}
@Composable
fun TodoApp() {
val store = remember {
createStore(TodoState()) {
middleware {
exceptionHandling()
logging()
async()
}
reduceWith(todoReducer)
}
}
LaunchedEffect(Unit) {
store.dispatch(LoadTodos())
}
TodoScreen(store)
}
data class TodoScreenProps(
val items: List<TodoItem>,
val inputText: String,
val isLoading: Boolean
)
@Composable
fun TodoScreen(store: KStore<TodoState>) {
val props by store.state.mapToPropsAsState {
TodoScreenProps(items, inputText, isLoading)
}
// Build UI from props; unrelated AppState changes will not recompose this screen.
}Sagas orchestrate multi-step workflows with their own state:
data class OnboardingSagaState(
val userId: String,
val profileComplete: Boolean = false,
val tutorialComplete: Boolean = false,
val currentStep: String = "started"
)
data class UserSignedUp(val userId: String, val email: String) : Action
data class ProfileCompleted(val userId: String) : Action
data class TutorialFinished(val userId: String) : Action
data class OnboardingCompleted(val userId: String) : Action
class OnboardingSaga : SagaDefinition<OnboardingSagaState> {
override val name = "onboarding"
override fun configure(saga: SagaConfiguration<OnboardingSagaState>) {
saga.startsOn<UserSignedUp> { action ->
SagaTransition.Continue(
OnboardingSagaState(
userId = action.userId,
currentStep = "profile_setup"
),
effects = listOf(
SagaEffect.Dispatch(ShowProfileSetupScreen(action.userId))
)
)
}
saga.on<ProfileCompleted>(
condition = { action, state -> action.userId == state.userId }
) { _, state ->
val newState = state.copy(profileComplete = true, currentStep = "tutorial")
if (state.tutorialComplete) {
SagaTransition.Complete(
effects = listOf(SagaEffect.Dispatch(OnboardingCompleted(state.userId)))
)
} else {
SagaTransition.Continue(
newState,
effects = listOf(SagaEffect.Dispatch(ShowTutorialScreen(state.userId)))
)
}
}
saga.on<TutorialFinished>(
condition = { action, state -> action.userId == state.userId }
) { _, state ->
val newState = state.copy(tutorialComplete = true, currentStep = "completed")
if (state.profileComplete) {
SagaTransition.Complete(
effects = listOf(SagaEffect.Dispatch(OnboardingCompleted(state.userId)))
)
} else {
SagaTransition.Continue(
newState,
effects = listOf(SagaEffect.Dispatch(ShowProfileSetupScreen(state.userId)))
)
}
}
// Fail removes the instance, clears persistence, and runs effects:
// SagaTransition.Fail(error, effects = listOf(SagaEffect.Dispatch(...)))
}
}
// Multi-step fan-in (wait for several load completions):
// afterFanInStep(allSteps, completedSteps, step, updateState = { ... }) {
// dispatch(AllLoaded); SagaTransition.Complete()
// }
val store = createStore(AppState()) {
middleware {
exceptionHandling()
sagas {
register(OnboardingSaga())
saga("payment") {
startsOn<InitiatePayment> { action ->
SagaTransition.Continue(
PaymentSagaState(orderId = action.orderId),
effects = listOf(
SagaEffect.Dispatch(ProcessPayment(action.orderId))
)
)
}
}
}
async()
}
reduceWith(appReducer)
}Override the lifecycle factories for domain-specific loading/error actions:
interface AccountAsyncAction<T : Any> : AsyncAction<T> {
override fun createProcessingAction(): Action = AccountLoading
override fun createErrorAction(error: Throwable): Action = AccountError(error)
override fun createCompleteAction(): Action = AccountComplete
}
data class SignIn(val email: String, val password: String) :
Action, AccountAsyncAction<Account> {
override suspend fun getResult(stateAccessor: StateAccessor): Result<Account> =
accountService.signIn(email, password)
override fun createResultAction(result: Account): Action = AccountSignedIn(result)
}class FileStateStorage : StateStorage<AppState> {
override suspend fun save(state: AppState) {
File("app_state.json").writeText(Json.encodeToString(state))
}
override suspend fun load(): AppState? = try {
Json.decodeFromString(File("app_state.json").readText())
} catch (_: Exception) {
null
}
override suspend fun clear() {
File("app_state.json").delete()
}
override suspend fun exists(): Boolean = File("app_state.json").exists()
}
val store = createStore(AppState()) {
middleware {
exceptionHandling()
persistence(
storage = FileStateStorage(),
strategy = PersistenceStrategy.Debounced(500) // milliseconds
)
async()
}
reduceWith(appReducer)
}Strategies: OnEveryChange, Debounced(delayMs), OnAction(setOf(...)), Conditional { ... }, Combined(...).
Saga instances can be persisted via sagas(storage = ..., persistenceStrategy = ...) on the middleware builder.
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.
AsyncAction for IOmapToPropsAsState with data-class propsOnAction for critical checkpointsstore.close() when disposing a store (tests, secondary windows)Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the Apache License 2.0 — see the LICENSE file for details.