| name | floschu-store |
| description | Use when implementing, debugging, or testing code that uses the floschu/store library. Triggers on tasks involving Store, Reducer, Actions, Effects, or Environment types from at.florianschuster.store; UDF state management in Kotlin Multiplatform projects; or migrating to or from the store library. |
floschu/store - Kotlin State Management
A skill for working with the store library - an opinionated Kotlin Coroutines multiplatform library for unidirectional data flow (UDF) state management.
Core Concepts
The library follows a Redux/Flux-like pattern:
- State: Immutable data held in a
StateFlow<State>
- Actions: Events dispatched to trigger state changes
- Reducers: Pure functions that process actions and return new state
- Effects: Async side effects (network, database, etc.)
- Environment: Dependency container for effects
dispatch(Action)
view ▶──────────────────────┬◀─────────────────────────────┐
│ │
│ │
┏━━━━━━━━━━━━━━━━━━━━━━▼━━━━━━━━━━━━━━━━━━━━━┓ │
┃ Store │ actions ┃ │
┃ with Environment │ ┃ │
┃ ┏━━━━━▼━━━━━┓ ┃ ┏━━━━━━━━━━━━━━┓
┃ ┌────────────▶┃ reducer ┃ ─ ─ ─ ─ ─ ─ ─ - ─▶ effect ┃
┃ │ ┗━━━━━━━━━━━┛ can produce ┃ ┗━━━━━━━━━━━━━━┛
┃ │ │ ┃ uses Environment
┃ │ previous │ ┃
┃ │ state │ new state ┃
┃ │ │ ┃
┃ │ ┏━━━━━▼━━━━━┓ ┃
┃ └─────────────┃ State ┃ ┃
┃ ┗━━━━━━━━━━━┛ ┃
┃ │ ┃
┗━━━━━━━━━━━━━━━━━━━━━━▼━━━━━━━━━━━━━━━━━━━━━┛
│
view ◀─────────────────────┘
Implementation Patterns
1. Define the Components
class LoginEnvironment(
val authService: AuthenticationService,
val tokenRepo: TokenRepository,
)
sealed interface LoginAction {
data class EmailChanged(val value: String) : LoginAction
data class PasswordChanged(val value: String) : LoginAction
data object Login : LoginAction
data class LoginResult(val result: Result<Token>) : LoginAction
}
data class LoginState(
val email: String = "",
val password: String = "",
val loading: Boolean = false,
val error: Boolean = false,
) {
val isInputValid = email.isNotEmpty() && password.isNotEmpty()
}
2. Create the Reducer
val LoginReducer = Reducer<LoginEnvironment, LoginAction, LoginState> { previousState, action ->
when (action) {
is LoginAction.EmailChanged -> previousState.copy(email = action.value)
is LoginAction.PasswordChanged -> previousState.copy(password = action.value)
is LoginAction.Login -> {
if (!previousState.isInputValid) return@Reducer previousState
cancelEffect(id = LoginAction.Login)
effect(id = LoginAction.Login) {
val result = environment.authService.authenticate(
previousState.email,
previousState.password,
)
dispatch(LoginAction.LoginResult(result))
}
previousState.copy(loading = true, error = false)
}
is LoginAction.LoginResult -> {
action.result.onSuccess { token ->
effect { environment.tokenRepo.store(token) }
}
previousState.copy(loading = false, error = action.result.isFailure)
}
}
}
3. Create the Store
Use Kotlin's delegation pattern:
class LoginStore(
effectScope: CoroutineScope,
environment: LoginEnvironment,
) : Store<LoginEnvironment, LoginAction, LoginState> by Store(
initialState = LoginState(),
environment = environment,
effectScope = effectScope,
reducer = LoginReducer,
)
4. Use in UI (Compose)
@Composable
fun LoginScreen(store: LoginStore) {
val state by store.state.collectAsState()
TextField(
value = state.email,
onValueChange = { store.dispatch(LoginAction.EmailChanged(it)) }
)
Button(
onClick = { store.dispatch(LoginAction.Login) },
enabled = state.isInputValid && !state.loading
) {
if (state.loading) CircularProgressIndicator() else Text("Login")
}
}
Effect Patterns
Restarting and Debouncing (e.g., search)
is SearchAction.QueryChanged -> {
cancelEffect(id = SearchAction.QueryChanged::class)
effect(id = SearchAction.QueryChanged::class) {
delay(300.milliseconds)
val items = environment.searchRepo.search(action.query)
dispatch(SearchAction.ItemsLoaded(items))
}
previousState.copy(loading = true)
}
Initial Effect (runs on store creation)
val NavigationReducer = Reducer<NavEnv, NavAction, NavState>(
initialEffect = effect {
environment.tokenRepo.token.collect { token ->
if (token != null) dispatch(NavAction.GoTo(Route.Home))
}
}
) { previousState, action -> }
Accessing Current State in Effects
effect {
val currentState = state.value
val result = environment.api.fetch(currentState.query)
dispatch(Action.Result(result))
}
Store Composition (Delegation)
Compose multiple stores into a parent:
val appStore = Store(
initialState = AppState(),
environment = appEnv,
effectScope = scope,
delegates = listOf(
loginStore.delegate(
scopeAction = scopeAction(AppAction.Login::action),
expandState = { state, loginState -> state.copy(login = loginState) }
),
searchStore.delegate(
scopeAction = scopeAction(AppAction.Search::action),
expandState = { state, searchState -> state.copy(search = searchState) }
),
)
)
Debugging
Enable logging with StoreEvents:
val store = Store(
initialState = State(),
environment = env,
effectScope = scope,
reducer = reducer,
events = StoreEvents.Println("MyStore"),
)
Testing
Test Reducers Directly
@Test
fun `email changed updates state`() = runTest {
val store = LoginStore()
store.dispatch(LoginAction.EmailChanged("test@example.com"))
assertEquals("test@example.com", store.state.value.email)
}
Test with Turbine (StateFlow testing)
@Test
fun `login flow`() = runTest {
val store = LoginStore()
store.state.test {
assertEquals(LoginState(), awaitItem())
store.dispatch(LoginAction.Login)
assertEquals(true, awaitItem().loading)
assertEquals(false, awaitItem().loading)
}
}
Mock Environment for Testing
val testLoginEnvironment = LoginEnvironment(
authService = object : AuthenticationService {
override suspend fun authenticate(email: String, password: String) =
Result.success(Token("test-token"))
},
tokenRepo = FakeTokenRepository(),
)
Common Mistakes to Avoid
- Mutating state directly - Always use
copy() to create new state
- Blocking in reducers - Reducers must be synchronous; use
effect {} for async
- Forgetting effect IDs - Use IDs when you need cancellation or deduplication
- Not handling all actions - Use exhaustive
when to handle all sealed cases
- Leaking coroutine scope - Pass a lifecycle-aware scope (e.g.,
viewModelScope)
Installation
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("at.florianschuster.store:store:$version")
}
}
}
}
See the changelog for versions.