diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 91dd970..74b3de7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: push: branches: - "*" + pull_request: concurrency: group: "build-${{ github.ref }}" @@ -26,6 +27,23 @@ jobs: run: | cd lib ./gradlew build + + api-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: "adopt" + java-version-file: .java-version + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: wrapper + add-job-summary: on-failure + - name: "check api compatibility" + run: | + cd lib + ./gradlew checkLegacyAbi build-example: runs-on: macos-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0f325..d9fe14b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # changelog +## `[0.5.0]` - 2025-01-22 + +- Update kotlin to `2.3.0` +- Fix concurrency bug in `Implementation.kt` +- Add agent skill for AI coding assistants (`.opencode/skills/floschu-store/SKILL.md`) + ## `[0.4.0]` - 2025-11-21 - Add `val state: StateFlow` to `EffectExecution.Context` diff --git a/README.md b/README.md index 94319f1..024895e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ see [changelog](https://github.com/floschu/store/blob/main/CHANGELOG.md) for ver Go to [store](https://github.com/floschu/store/blob/main/lib/src/commonMain/kotlin/at/florianschuster/store/Store.kt) as entry point for more information. +Check out the [store skill](skills/floschu-store.md) to implement and test `store` with your **AI agents**. + ```kotlin class LoginEnvironment( val authenticationService: AuthenticationService, diff --git a/example/composeApp/build.gradle.kts b/example/composeApp/build.gradle.kts index 17fcdea..358f3f7 100644 --- a/example/composeApp/build.gradle.kts +++ b/example/composeApp/build.gradle.kts @@ -9,12 +9,12 @@ plugins { alias(libs.plugins.composeCompiler) } +val jenvContent = File("../.java-version").readText().trim() + kotlin { androidTarget { compilerOptions { - jvmTarget.set( - JvmTarget.fromTarget(File("../.java-version").readText()) - ) + jvmTarget.set(JvmTarget.fromTarget(jenvContent)) } } @@ -95,8 +95,9 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + val javaVersion = JavaVersion.toVersion(jenvContent) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion } } diff --git a/example/composeApp/src/androidMain/AndroidManifest.xml b/example/composeApp/src/androidMain/AndroidManifest.xml index 70b8dba..927ee19 100644 --- a/example/composeApp/src/androidMain/AndroidManifest.xml +++ b/example/composeApp/src/androidMain/AndroidManifest.xml @@ -10,6 +10,7 @@ android:theme="@android:style/Theme.Material.Light.NoActionBar"> diff --git a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/App.kt b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/App.kt index 5cce056..f35c835 100644 --- a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/App.kt +++ b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/App.kt @@ -49,8 +49,15 @@ fun App() { AnimatedContent( targetState = state.navigationState.route, transitionSpec = { fadeIn() togetherWith fadeOut() }, - ) { route -> - when (val route = state.navigationState.route) { + contentKey = { route -> + when (route) { + is Login -> "login" + is Search -> "search" + is Detail -> "detail-${route.id}" + } + }, + ) { targetRoute -> + when (targetRoute) { is Login -> { LoginView( paddingValues = paddingValues, @@ -70,7 +77,7 @@ fun App() { is Detail -> DetailView( paddingValues = paddingValues, - state = DetailState(route.id), + state = DetailState(targetRoute.id), ) } } diff --git a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/AppStore.kt b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/AppStore.kt index 5b7e0b0..afc5f23 100644 --- a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/AppStore.kt +++ b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/AppStore.kt @@ -53,7 +53,7 @@ internal class AppStore( environment = Unit, delegates = listOf( NavigationReducer.delegate( - initialState = NavigationState(), + initialState = initialState.navigationState, environment = navigationEnvironment, effectScope = effectScope, scopeAction = scopeAction(AppAction.Navigation::action), diff --git a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/login/LoginStore.kt b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/login/LoginStore.kt index f4c7907..e601722 100644 --- a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/login/LoginStore.kt +++ b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/login/LoginStore.kt @@ -75,10 +75,13 @@ internal class LoginStore( // This effect has an Id, so it will not be executed if this authentication // effect is already in progress. effect(id = LoginAction.Authenticate) { + // Use state.value to get the current state at execution time, + // not the captured previousState which could be stale + val currentState = state.value runCatching { environment.authenticationService.authenticate( - checkNotNull(previousState.email), - checkNotNull(previousState.password), + checkNotNull(currentState.email), + checkNotNull(currentState.password), ) }.fold( onSuccess = { token -> diff --git a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/search/SearchReducer.kt b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/search/SearchReducer.kt index a5cebab..9b6fffd 100644 --- a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/search/SearchReducer.kt +++ b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/search/SearchReducer.kt @@ -4,8 +4,10 @@ import androidx.compose.runtime.Immutable import at.florianschuster.store.Reducer import at.florianschuster.store.cancelEffect import at.florianschuster.store.effect +import at.florianschuster.store.example.Log import at.florianschuster.store.example.service.SearchRepository import at.florianschuster.store.example.service.TokenRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.milliseconds @@ -31,12 +33,31 @@ internal data class SearchState( internal val SearchReducer = Reducer { previousState, action -> when (action) { is SearchAction.QueryChanged -> { - // if a new query is entered, we cancel the previous effect + // Cancel any previous search effect cancelEffect(id = SearchAction.QueryChanged::class) + + // Don't trigger search for empty queries - just clear results + if (action.query.isEmpty()) { + return@Reducer previousState.copy( + query = "", + items = emptyList(), + loading = false, + ) + } + effect(id = SearchAction.QueryChanged::class) { delay(300.milliseconds) // we debounce the search query - val items = environment.searchRepository.loadQueryItems(action.query) - dispatch(SearchAction.ItemsLoaded(items)) + runCatching { environment.searchRepository.loadQueryItems(action.query) }.fold( + onSuccess = { items -> dispatch(SearchAction.ItemsLoaded(items)) }, + onFailure = { error -> + Log.e(error) + // On error, dispatch empty results to clear loading state + // and avoid leaving the UI in a loading state forever + if (error !is CancellationException) { + dispatch(SearchAction.ItemsLoaded(emptyList())) + } + } + ) } previousState.copy( query = action.query, @@ -57,6 +78,7 @@ internal val SearchReducer = Reducer( is EffectExecution -> { val effectId = effect.id - // only launch effect if it is not already launched - if (effectId != null && executionJobList.isActive(effectId)) { - continue - } - // launch new effect - val newJob = launch { effect.block(executionContext) } - events?.emit(StoreEvent.Effect.Launch(effectId)) - newJob.invokeOnCompletion { cause -> - if (cause is CancellationException && effectId != null) { - events?.emit(StoreEvent.Effect.Cancel(effectId)) - } else { - events?.emit(StoreEvent.Effect.Complete(effectId)) + // If effect has an ID, use atomic check-and-add to prevent race conditions + if (effectId != null) { + val newJob = executionJobList.launchIfNotActive(effectId) { + launch { effect.block(executionContext) } + } + if (newJob != null) { + events?.emit(StoreEvent.Effect.Launch(effectId)) + newJob.invokeOnCompletion { cause -> + if (cause is CancellationException) { + events?.emit(StoreEvent.Effect.Cancel(effectId)) + } else { + events?.emit(StoreEvent.Effect.Complete(effectId)) + } + } + } + // If newJob is null, effect was already active - skip + } else { + // No effect ID - just launch without tracking + val newJob = launch { effect.block(executionContext) } + events?.emit(StoreEvent.Effect.Launch(effectId)) + newJob.invokeOnCompletion { cause -> + if (cause is CancellationException) { + events?.emit(StoreEvent.Effect.Cancel(effectId)) + } else { + events?.emit(StoreEvent.Effect.Complete(effectId)) + } } - } - // only track job if it has id and has not already completed - if (effectId != null && !newJob.isCompleted) { - val item = ExecutionJobList.JobItem(effectId = effectId, job = newJob) - executionJobList.add(item) } } } @@ -192,17 +201,32 @@ internal class EffectHandler( private val mutex = Mutex() internal val items = mutableListOf() - suspend fun isActive(id: Any): Boolean = mutex.withLock { - val item = items.firstOrNull { it.effectId == id } - item != null && item.job.isActive - } - - suspend fun add(jobItem: JobItem) { - suspend fun remove() = mutex.withLock { items.remove(jobItem) } - mutex.withLock { - items.add(jobItem) - jobItem.job.invokeOnCompletion { effectScope.launch { remove() } } + /** + * Atomically checks if an effect with the given ID is already active. + * If not active, launches the job and adds it to tracking. + * Returns the launched Job if successful, or null if the effect was already active. + * + * This method prevents race conditions by combining the check and add + * operations within a single mutex lock. + */ + suspend fun launchIfNotActive(id: Any, createJob: () -> Job): Job? = mutex.withLock { + // Check if already active while holding lock + val existingItem = items.firstOrNull { it.effectId == id } + if (existingItem != null && existingItem.job.isActive) { + return@withLock null // Already active, don't create new job + } + // Create and add the job while still holding lock + val job = createJob() + if (!job.isCompleted) { + val item = JobItem(effectId = id, job = job) + items.add(item) + job.invokeOnCompletion { + effectScope.launch { + mutex.withLock { items.remove(item) } + } + } } + job } suspend fun cancel(ids: List) { diff --git a/lib/src/commonMain/kotlin/at/florianschuster/store/Store.kt b/lib/src/commonMain/kotlin/at/florianschuster/store/Store.kt index 2761765..5b79cc9 100644 --- a/lib/src/commonMain/kotlin/at/florianschuster/store/Store.kt +++ b/lib/src/commonMain/kotlin/at/florianschuster/store/Store.kt @@ -5,6 +5,30 @@ import kotlinx.coroutines.flow.StateFlow /** * A store that holds [State] and allows dispatching [Action]s. + * + * ``` + * dispatch(Action) + * view ▶──────────────────────┬◀──────────────────────────────┐ + * │ │ + * │ │ + * ┏━━━━━━━━━━━━━━━━━━━━━━▼━━━━━━━━━━━━━━━━━━━━━┓ │ + * ┃ Store │ actions ┃ │ + * ┃ with Environment │ ┃ │ + * ┃ ┏━━━━━▼━━━━━┓ ┃ ┏━━━━━━━━━━━━━━┓ + * ┃ ┌────────────▶┃ reducer ┃ ─ ─ ─ ─ ─ ─ ─ - ─▶ effect ┃ + * ┃ │ ┗━━━━━━━━━━━┛ can produce ┃ ┗━━━━━━━━━━━━━━┛ + * ┃ │ │ ┃ uses Environment + * ┃ │ previous │ ┃ + * ┃ │ state │ new state ┃ + * ┃ │ │ ┃ + * ┃ │ ┏━━━━━▼━━━━━┓ ┃ + * ┃ └─────────────┃ State ┃ ┃ + * ┃ ┗━━━━━━━━━━━┛ ┃ + * ┃ │ ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━▼━━━━━━━━━━━━━━━━━━━━━┛ + * │ + * view ◀───────────────────────┘ + * ``` */ interface Store { val state: StateFlow diff --git a/lib/src/commonTest/kotlin/at/florianschuster/store/ExecutionJobsTest.kt b/lib/src/commonTest/kotlin/at/florianschuster/store/ExecutionJobsTest.kt index aa601c2..50e5297 100644 --- a/lib/src/commonTest/kotlin/at/florianschuster/store/ExecutionJobsTest.kt +++ b/lib/src/commonTest/kotlin/at/florianschuster/store/ExecutionJobsTest.kt @@ -1,33 +1,139 @@ package at.florianschuster.store +import kotlinx.coroutines.CompletableJob import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class ExecutionJobsTest { @Test - fun `ExecutionJobs can be added and cancelled`() = runTest { + fun `ExecutionJobs can be added via launchIfNotActive and cancelled`() = runTest { val sut = EffectHandler.ExecutionJobList(backgroundScope) assertTrue(sut.items.isEmpty()) + // Cancel on empty list should be safe sut.cancel(listOf(1)) assertTrue(sut.items.isEmpty()) val itemId = 1 - val item = EffectHandler.ExecutionJobList.JobItem(effectId = 1, job = Job()) - sut.add(item) - assertEquals(item, sut.items.single()) - assertTrue(item.job.isActive) - assertTrue(sut.isActive(itemId)) + var createdJob: CompletableJob? = null + + // Launch a new job via launchIfNotActive + val job = sut.launchIfNotActive(itemId) { + Job().also { createdJob = it } + } + + assertNotNull(job) + assertEquals(createdJob, job) + assertEquals(1, sut.items.size) + assertTrue(job.isActive) + // Trying to launch again with same ID should return null (already active) + val duplicateJob = sut.launchIfNotActive(itemId) { + Job() // This should not be called + } + assertNull(duplicateJob) + assertEquals(1, sut.items.size) // Still only 1 item + + // Cancel should remove and cancel the job sut.cancel(listOf(itemId)) assertTrue(sut.items.isEmpty()) - assertFalse(item.job.isActive) - assertFalse(sut.isActive(itemId)) + assertFalse(job.isActive) + + // After cancellation, a new job can be launched with same ID + val newJob = sut.launchIfNotActive(itemId) { + Job() + } + assertNotNull(newJob) + assertEquals(1, sut.items.size) + } + + @Test + fun `ExecutionJobs allows launching with different IDs`() = runTest { + val sut = EffectHandler.ExecutionJobList(backgroundScope) + + val job1 = sut.launchIfNotActive(1) { Job() } + val job2 = sut.launchIfNotActive(2) { Job() } + val job3 = sut.launchIfNotActive(3) { Job() } + + assertNotNull(job1) + assertNotNull(job2) + assertNotNull(job3) + assertEquals(3, sut.items.size) + + // Cancel just one + sut.cancel(listOf(2)) + assertEquals(2, sut.items.size) + assertTrue(job1.isActive) + assertFalse(job2.isActive) + assertTrue(job3.isActive) + + // Cancel remaining + sut.cancel(listOf(1, 3)) + assertTrue(sut.items.isEmpty()) + } + + @Test + fun `concurrent launchIfNotActive with same ID only creates one job`() = runTest { + val sut = EffectHandler.ExecutionJobList(backgroundScope) + val results = mutableListOf() + val resultsMutex = Mutex() + val effectId = "concurrent-test-id" + + // Launch multiple concurrent attempts with the same ID + // This tests that the mutex properly serializes access and prevents duplicates + repeat(50) { + launch { + val job = sut.launchIfNotActive(effectId) { Job() } + resultsMutex.withLock { + results.add(job) + } + } + } + + advanceUntilIdle() + + // Only one should have succeeded (non-null), rest should be null + val successfulLaunches = results.filterNotNull() + assertEquals(1, successfulLaunches.size, "Expected exactly one successful launch, got ${successfulLaunches.size}") + assertEquals(1, sut.items.size, "Expected exactly one item tracked") + + // The successful job should be active + assertTrue(successfulLaunches.first().isActive) + } + + @Test + fun `concurrent launchIfNotActive with different IDs all succeed`() = runTest { + val sut = EffectHandler.ExecutionJobList(backgroundScope) + val results = mutableListOf() + val resultsMutex = Mutex() + + // Launch concurrent attempts with different IDs + repeat(20) { id -> + launch { + val job = sut.launchIfNotActive("id-$id") { Job() } + resultsMutex.withLock { + results.add(job) + } + } + } + + advanceUntilIdle() + + // All should succeed since they have different IDs + val successfulLaunches = results.filterNotNull() + assertEquals(20, successfulLaunches.size, "All launches should succeed with unique IDs") + assertEquals(20, sut.items.size, "All items should be tracked") } } diff --git a/lib/src/commonTest/kotlin/at/florianschuster/store/StoreTest.kt b/lib/src/commonTest/kotlin/at/florianschuster/store/StoreTest.kt index 3267007..ee691c3 100644 --- a/lib/src/commonTest/kotlin/at/florianschuster/store/StoreTest.kt +++ b/lib/src/commonTest/kotlin/at/florianschuster/store/StoreTest.kt @@ -2,7 +2,9 @@ package at.florianschuster.store import kotlinx.coroutines.delay import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -214,4 +216,105 @@ internal class StoreTest { ) assertFailsWith { sut.dispatch(1) } } + + @Test + fun `destructuring operators work as expected`() = runTest { + val sut: Store = Store( + initialState = 42, + effectScope = backgroundScope, + environment = Unit, + reducer = Reducer { previousState, action -> previousState + action }, + ) + + val (state, dispatch) = sut + + assertEquals(42, state.value) + + dispatch(8) + assertEquals(50, state.value) + + dispatch(10) + assertEquals(60, state.value) + } + + @Test + fun `multiple effects can be cancelled at once`() = runTest { + var effect1Running = false + var effect2Running = false + var effect3Running = false + + val sut: Store = Store( + initialState = 0, + effectScope = backgroundScope, + environment = Unit, + reducer = Reducer { previousState, action -> + when (action) { + "start" -> { + effect(id = "effect1") { + effect1Running = true + try { + delay(10.seconds) + } finally { + effect1Running = false + } + } + effect(id = "effect2") { + effect2Running = true + try { + delay(10.seconds) + } finally { + effect2Running = false + } + } + effect(id = "effect3") { + effect3Running = true + try { + delay(10.seconds) + } finally { + effect3Running = false + } + } + previousState + } + "cancelMultiple" -> { + cancelEffects(ids = listOf("effect1", "effect2")) + previousState + } + else -> previousState + } + }, + ) + + sut.dispatch("start") + runCurrent() + assertEquals(true, effect1Running) + assertEquals(true, effect2Running) + assertEquals(true, effect3Running) + + sut.dispatch("cancelMultiple") + runCurrent() + assertEquals(false, effect1Running) + assertEquals(false, effect2Running) + assertEquals(true, effect3Running) // effect3 should still be running + } + + @Test + fun `concurrent dispatches are handled safely`() = runTest { + val sut: Store = Store( + initialState = 0, + effectScope = backgroundScope, + environment = Unit, + reducer = Reducer { previousState, action -> previousState + action }, + ) + + // Dispatch many actions concurrently + repeat(100) { + launch { + sut.dispatch(1) + } + } + advanceUntilIdle() + + assertEquals(100, sut.state.value) + } } diff --git a/skills/floschu-store.md b/skills/floschu-store.md new file mode 100644 index 0000000..d3f6f2b --- /dev/null +++ b/skills/floschu-store.md @@ -0,0 +1,281 @@ +--- +name: floschu-store +description: Implement, debug, and test floschu/store - a unidirectional data flow state management kmp library with coroutines +--- + +# floschu/store - Kotlin State Management + +A skill for working with the [store](https://github.com/floschu/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` +- **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 + +```kotlin +// Environment: holds dependencies for side effects +class LoginEnvironment( + val authService: AuthenticationService, + val tokenRepo: TokenRepository, +) + +// Actions: sealed interface for all possible events +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) : LoginAction +} + +// State: immutable data class +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 + +```kotlin +val LoginReducer = Reducer { 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 + // Cancel any in-flight login, then start new one + 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: + +```kotlin +class LoginStore( + effectScope: CoroutineScope, + environment: LoginEnvironment, +) : Store by Store( + initialState = LoginState(), + environment = environment, + effectScope = effectScope, + reducer = LoginReducer, +) +``` + +### 4. Use in UI (Compose) + +```kotlin +@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) + +```kotlin +is SearchAction.QueryChanged -> { + cancelEffect(id = SearchAction.QueryChanged::class) + effect(id = SearchAction.QueryChanged::class) { + delay(300.milliseconds) // Debounce + val items = environment.searchRepo.search(action.query) + dispatch(SearchAction.ItemsLoaded(items)) + } + previousState.copy(loading = true) +} +``` + +### Initial Effect (runs on store creation) + +```kotlin +val NavigationReducer = Reducer( + initialEffect = effect { + environment.tokenRepo.token.collect { token -> + if (token != null) dispatch(NavAction.GoTo(Route.Home)) + } + } +) { previousState, action -> /* ... */ } +``` + +### Accessing Current State in Effects + +```kotlin +effect { + val currentState = state.value // StateFlow + val result = environment.api.fetch(currentState.query) + dispatch(Action.Result(result)) +} +``` + +## Store Composition (Delegation) + +Compose multiple stores into a parent: + +```kotlin +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`: + +```kotlin +val store = Store( + initialState = State(), + environment = env, + effectScope = scope, + reducer = reducer, + events = StoreEvents.Println("MyStore"), // Logs all events +) +// Output: MyStore > init > (initialState="...", ...) +// Output: MyStore > dispatch > "..." +// Output: MyStore > reduce > ("...", "...") -> "..." +``` + +## Testing + +### Test Reducers Directly + +```kotlin +@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) + +```kotlin +@Test +fun `login flow`() = runTest { + val store = LoginStore() + store.state.test { + assertEquals(LoginState(), awaitItem()) // Initial + + store.dispatch(LoginAction.Login) + assertEquals(true, awaitItem().loading) // Loading + + assertEquals(false, awaitItem().loading) // Complete + } +} +``` + +### Mock Environment for Testing + +```kotlin +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 + +1. **Mutating state directly** - Always use `copy()` to create new state +2. **Blocking in reducers** - Reducers must be synchronous; use `effect {}` for async +3. **Forgetting effect IDs** - Use IDs when you need cancellation or deduplication +4. **Not handling all actions** - Use exhaustive `when` to handle all sealed cases +5. **Leaking coroutine scope** - Pass a lifecycle-aware scope (e.g., `viewModelScope`) + +## Installation + +```groovy +kotlin { + sourceSets { + commonMain { + dependencies { + implementation("at.florianschuster.store:store:$version") + } + } + } +} +``` + +See the [changelog](https://github.com/floschu/store/blob/main/CHANGELOG.md) for versions.