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.
- 🎯 Type-safe state management with Redux-like architecture
- 🚀 Kotlin Multiplatform Works across Android, iOS, JVM, watchOS, tvOS, Linux, Windows, and WebAssembly targets
- ⚡ Built-in async support with customizable lifecycle actions
- 🔄 Saga pattern for complex workflow orchestration
- đź’ľ Flexible persistence with multiple strategies
- đź§© Composable middleware for extensibility
- 🎨 Compose integration with optimized recomposition
Add Duks to your project by including it in your Gradle build file:
dependencies {
implementation("io.github.crowded-libs:duks:0.2.5")
}data class AppState(
val counter: Int = 0,
val user: User? = null,
val isLoading: Boolean = false
) : StateModelsealed class AppAction : Action {
data object Increment : AppAction()
data object Decrement : AppAction()
data class SetUser(val user: User) : AppAction()
data object LoadUser : AppAction(), AsyncAction<User>
}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 AsyncAction.Processing -> state.copy(isLoading = true)
is AsyncAction.Result -> when (action.initiatedBy) {
is AppAction.LoadUser -> state.copy(user = action.data as User, isLoading = false)
else -> state
}
else -> state
}
}val store = KStore(
initialState = AppState(),
reducer = appReducer,
middleware = listOf(
exceptionHandling(),
logging(),
async())
)@Composable
fun CounterScreen(store: KStore<CounterState>) {
// Access store state in a Compose-friendly way
val state by store.state.collectAsState()
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Count: ${state.count}")
Button(onClick = { store.dispatch(Increment()) }) {
Text("Increment")
}
Button(onClick = { store.dispatch(Increment(5)) }) {
Text("Increment by 5")
}
}
}Here's a complete example showing a todo app with Duks and Compose:
// 1. Define the state
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)
// 2. Define actions
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
data class LoadTodos : AsyncAction<List<TodoItem>> {
override suspend fun execute(): Result<List<TodoItem>> {
return try {
// Simulate loading todos from a repository
val todos = todoRepository.getAllTodos()
Result.success(todos)
} catch (e: Exception) {
Result.failure(e)
}
}
}
// 3. Create the reducer
val todoReducer: Reducer<TodoState> = { state, action ->
when (action) {
is UpdateInputText -> state.copy(inputText = action.text)
is AddTodo -> state.copy(
items = state.items + TodoItem(UUID.randomUUID().toString(), action.text),
inputText = "" // Clear input after adding
)
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 AsyncInitiatedByAction -> {
if (action.initiator is LoadTodos) {
state.copy(isLoading = true)
} else state
}
is AsyncSuccessAction<*, *> -> {
if (action.initiator is LoadTodos && action.result is List<*>) {
@Suppress("UNCHECKED_CAST")
state.copy(
items = action.result as List<TodoItem>,
isLoading = false
)
} else state
}
else -> state
}
}
// 4. Create the Compose UI
@Composable
fun TodoApp() {
// Create the store
val store = remember {
createStore(TodoState()) {
middleware {
async()
logging()
}
reduceWith(todoReducer)
}
}
// Load todos when the screen first appears
LaunchedEffect(Unit) {
store.dispatch(LoadTodos())
}
TodoScreen(store)
}
@Composable
fun TodoScreen(store: KStore<TodoState>) {
// Access the state from the store
val state by store.state.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// Input field and add button
Row(modifier = Modifier.fillMaxWidth()) {
TextField(
value = state.inputText,
onValueChange = { store.dispatch(UpdateInputText(it)) },
modifier = Modifier.weight(1f),
placeholder = { Text("Add a todo") }
)
Button(
onClick = {
if (state.inputText.isNotBlank()) {
store.dispatch(AddTodo(state.inputText))
}
},
modifier = Modifier.padding(start = 8.dp)
) {
Text("Add")
}
}
// Loading indicator
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally).padding(16.dp)
)
}
// Todo list
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp)
) {
items(state.items) { todo ->
TodoItem(
todo = todo,
onToggle = { store.dispatch(ToggleTodo(todo.id)) },
onDelete = { store.dispatch(DeleteTodo(todo.id)) }
)
}
}
}
}
@Composable
fun TodoItem(todo: TodoItem, onToggle: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = todo.completed,
onCheckedChange = { onToggle() }
)
Text(
text = todo.text,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
textDecoration = if (todo.completed) TextDecoration.LineThrough else null,
color = if (todo.completed) Color.Gray else Color.Black
)
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}Sagas provide powerful workflow orchestration for complex async scenarios. Each saga maintains its own independent state throughout its lifecycle:
// Define saga-specific state
data class OnboardingSagaState(
val userId: String,
val profileComplete: Boolean = false,
val tutorialComplete: Boolean = false,
val currentStep: String = "started"
)
// Define actions that interact with the saga
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
// Create the onboarding saga
class OnboardingSaga : SagaDefinition<OnboardingSagaState> {
override val name = "onboarding"
override fun configure(saga: SagaConfiguration<OnboardingSagaState>) {
// Start saga when user signs up
saga.startsOn<UserSignedUp> { action ->
SagaTransition.Continue(
OnboardingSagaState(
userId = action.userId,
currentStep = "profile_setup"
),
effects = listOf(
SagaEffect.Dispatch(ShowProfileSetupScreen(action.userId))
)
)
}
// Handle profile completion
saga.on<ProfileCompleted>(
condition = { action, state -> action.userId == state.userId }
) { action, state ->
val newState = state.copy(
profileComplete = true,
currentStep = "tutorial"
)
// If tutorial is already done, complete onboarding
if (state.tutorialComplete) {
SagaTransition.Complete(
effects = listOf(
SagaEffect.Dispatch(OnboardingCompleted(state.userId))
)
)
} else {
SagaTransition.Continue(
newState,
effects = listOf(
SagaEffect.Dispatch(ShowTutorialScreen(state.userId))
)
)
}
}
// Handle tutorial completion
saga.on<TutorialFinished>(
condition = { action, state -> action.userId == state.userId }
) { action, state ->
val newState = state.copy(
tutorialComplete = true,
currentStep = "completed"
)
// If profile is already complete, finish onboarding
if (state.profileComplete) {
SagaTransition.Complete(
effects = listOf(
SagaEffect.Dispatch(OnboardingCompleted(state.userId))
)
)
} else {
SagaTransition.Continue(
newState,
effects = listOf(
SagaEffect.Dispatch(ShowProfileSetupScreen(state.userId))
)
)
}
}
}
}
// Add saga middleware to store
val store = createStore(AppState()) {
middleware {
sagas {
register(OnboardingSaga())
// Or define inline
saga<PaymentSagaState>(
name = "payment",
initialState = { PaymentSagaState() }
) {
startsOn<InitiatePayment> { action ->
SagaTransition.Continue(
PaymentSagaState(orderId = action.orderId),
effects = listOf(
SagaEffect.Dispatch(ProcessPayment(action.orderId)),
SagaEffect.Delay(30000), // 30 second timeout
SagaEffect.Dispatch(PaymentTimeout(action.orderId))
)
)
}
}
}
}
}Create specialized async actions with custom lifecycle:
// Define custom async interface
interface NetworkAction<T> : AsyncAction<T> {
data class Loading(override val initiatedBy: Action) : NetworkAction<Nothing>, AsyncAction.Processing
data class Success<T>(override val initiatedBy: Action, override val data: T) : NetworkAction<T>, AsyncAction.Result<T>
data class Failure(override val initiatedBy: Action, val error: Throwable) : NetworkAction<Nothing>, AsyncAction.Error
data class Retry(override val initiatedBy: Action) : NetworkAction<Nothing>
}
// Implement in your action
data class FetchPosts(val userId: String) : AppAction(), NetworkAction<List<Post>> {
override fun createProcessingAction() = NetworkAction.Loading(this)
override fun createResultAction(data: List<Post>) = NetworkAction.Success(this, data)
override fun createErrorAction(error: Throwable) = NetworkAction.Failure(this, error)
}
// Handle in reducer
val reducer: Reducer<AppState> = { state, action ->
when (action) {
is NetworkAction.Loading -> state.copy(isLoading = true)
is NetworkAction.Success<*> -> when (action.initiatedBy) {
is FetchPosts -> state.copy(
posts = action.data as List<Post>,
isLoading = false
)
else -> state
}
is NetworkAction.Failure -> state.copy(
error = action.error.message,
isLoading = false
)
is NetworkAction.Retry -> {
// Re-dispatch original action
store.dispatch(action.initiatedBy)
state
}
else -> state
}
}Flexible persistence with multiple strategies:
// Create storage implementation
class FileStateStorage : StateStorage<AppState> {
override suspend fun save(state: AppState) {
File("app_state.json").writeText(Json.encodeToString(state))
}
override suspend fun load(): AppState? {
return try {
Json.decodeFromString(File("app_state.json").readText())
} catch (e: Exception) {
null
}
}
}
// Add persistence middleware with strategy
val persistenceMiddleware = PersistenceMiddleware(
storage = FileStateStorage(),
strategy = PersistenceStrategy.Debounced(500.milliseconds)
)
// For saga persistence
val sagaStorage = InMemorySagaStorage()
val sagaMiddleware = SagaMiddleware(
sagaDefinitions = setOf(OnboardingSaga()),
sagaStateSerializer = JsonSagaSerializer(),
sagaStorage = sagaStorage,
persistenceStrategy = SagaPersistenceStrategy.Combined(
SagaPersistenceStrategy.OnCheckpoint,
SagaPersistenceStrategy.OnCompletion
)
)Optimize performance by caching expensive operations:
data class SearchProducts(val query: String) : AppAction(), CacheableAction {
override val cacheKey = "search_$query"
override val cacheDuration = 5.minutes
}
// Add caching middleware
val cacheMiddleware = CachingMiddleware<AppState>(
cache = MapActionCache()
)- State Design: Keep state immutable and normalized
- Action Design: Use sealed classes for type-safe action hierarchies
- Performance: Use
mapToPropsAsStatefor Compose to minimize recomposition - Persistence: Choose appropriate strategy (Debounced for frequent updates, OnAction for critical state)
Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the MIT License - see the LICENSE file for details.
