Skip to content

event-observer-state

Typed screen state connecting repository operations to the View through SavedStateHandle. The state outlives individual requests; the handle's owner controls restoration.

kotlin {
    sourceSets.commonMain.dependencies {
        implementation("io.github.matheus-corregiari:event-observer-state:2.3.0")
    }
}

The module supports Android (API 23+), JVM, iOS ARM64/simulator ARM64, JS and WasmJS.

Minimal ViewModel

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import br.com.arch.toolkit.eventObserver.state.saveResponseState
import br.com.arch.toolkit.eventObserver.state.saveState
import kotlinx.coroutines.Job
import kotlinx.serialization.Serializable

@Serializable
data class User(val name: String)

class UsersViewModel(handle: SavedStateHandle) : ViewModel() {
    val users by handle.saveResponseState<List<User>>()
    val filter by handle.saveState<String>(default = "")

    fun refresh(repository: UsersRepository): Job {
        val query = filter.get().orEmpty() // Read saved state on the owner thread.
        return users.load { repository.users(query) } // Flow<DataResult<List<User>>>
    }

    fun changeFilter(value: String, repository: UsersRepository) {
        filter.set(value)
        refresh(repository)
    }
}

Observe users.flow() as state from the View. It is the same StateFlow before, during and after every request. A completed producer keeps its last value; refresh always starts a new operation. Calling load again cancels the previous operation and rejects its late results.

For a continuous repository stream, use the same load call. Call cancel() to disconnect it; the current saved data remains observable. The ViewModel scope also cancels collection on disposal. No loading event is invented: the source supplies DataResult loading/error/success statuses.

Without DataResult

saveState<T>() exposes Regular<T>. Use set(value) for an object, list or map, or bind(repository.flow) to collect a one-shot or continuous Flow<T>. bind returns a Job. On failure the old data remains; pass onError = { ... } to handle failure explicitly. By default, the exception reaches the supplied scope's exception handler. Cancellation is never an error result.

You can construct ViewModelState.Regular or ViewModelState.Result directly with a handle, serializer and main-thread scope. The delegates simply supply the property key and ViewModel scope. No Koin setup is required.

For property syntax, var query by handle.value<String>() infers the key from the property name. StateValue.Companion.required and default provide fallbacks for property reads and their flows; they do not persist the fallback. Assignments still save the assigned value. default = value initializes an absent saved entry.

Transform a repository payload

users.loadMapped(transform = { dtos: List<UserDto> -> dtos.map { User(it.displayName) } }) {
    repository.usersDto()
}

For plain values use state.bindMapped(repository.flow) { input -> viewValue(input) }. The transformed value is what gets serialized and restored. Transformations may suspend; operation replacement is checked again before committing the transformed value.

For pagination or accumulation, define the rule yourself:

users.loadReducing(reduce = { current, page: List<User> -> current.orEmpty() + page }) {
    repository.nextPage()
}

Plain values have the equivalent bindReducing(source) { current, input -> next }. Reducers run for non-null result payloads only. Each new operation replaces the previous one; there is no parallel queue or implicit merge policy. For filters, use replacement rather than append.

A response feeding several smaller states

Persist a serializable screen model when fields must update together:

@Serializable
data class Screen(val users: List<User>, val total: Int)

class ScreenViewModel(handle: SavedStateHandle) : ViewModel() {
    val screen by handle.saveState<Screen>()
    val users = screen.select { it?.users.orEmpty() }
    val total = screen.select { it?.total ?: 0 }
}

Map one repository response into Screen with bindMapped, or use saveResponseState<Screen>() and loadMapped when it carries DataResult. select returns distinct immediate state; it does not serialize another copy. Observe the full screen state when an atomic snapshot of all fields is required. A separate editable filter or selection can have its own saved entry.

Compose flows using standard operators

Use combine(a, b) { left, right -> Screen(left, right) } to build a screen model from the latest values of two sources, then screen.bind(combined). Both sources must emit before combine produces its first value. Use merge(first, second) with bindReducing to apply updates from several sources; define ordering/conflict rules in your reducer. merge does not guarantee ordering between sources. Reconnecting a holder replaces its previous operation.

Reducers read the current holder before each emission and may suspend on the worker. A direct write during a suspended reducer is not implicitly merged with its output. Use immutable values and route edits through one reducer when those edits must participate in the same merge rule.

Observe from a lifecycle-bound collector, for example in an Android View:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.users.flow().collect { result -> render(result) }
    }
}

Use collectAsStateWithLifecycle in Compose. Stopping UI collection does not stop a holder's operation: the ViewModel scope owns it. Call cancel() or dispose its owner to stop repository work. StateFlow retains the latest state and can skip intermediate emissions for slow collectors; use an event stream for actions that must each be delivered.

Persistence contract

  • Keys contain JSON strings. A stored "null" is different from an absent key: defaults initialize only absent keys. invalidate() and set(null) clear data without removing the key or its observers.
  • get(), flow().value and select(...).value read one immutable in-memory snapshot. Payload and transient status are published together, after saving the prepared JSON.
  • The key is private to one holder. Do not write/remove it or create another active holder or handle flow for it. Raw handle writes do not update an active holder; use set or setAsync. Existing JSON is decoded once when the holder is created.
  • Do not call SavedStateHandle.remove() on an active key: AndroidX detaches its existing flows. Use the holder's clearing API. Use one holder per key.
  • Primitive types and typed lists/maps use built-in serializers. Complex objects require @Serializable or an explicit KSerializer<T>. Nested collection elements and map keys must also be serializable. Complex map keys require the appropriate Json configuration, such as allowStructuredMapKeys; arbitrary Any values are not automatically serializable.
  • Apply the Kotlin serialization compiler plugin in the module declaring @Serializable models, using the same version as that module's Kotlin plugin.
  • Supply json = Json { ... } and/or serializer = ... when needed. Encoding completes before writing; an exception leaves the old value intact. Invalid restored JSON fails explicitly during holder creation (or first delegate access), without replacing the stored content.
  • Saving a DataResult persists its non-null payload. Loading/error/none results without data retain the last payload; use invalidate() to clear it. On restoration, non-null data becomes Success, while saved null becomes None. Throwable and in-progress work are not persisted.
  • Run access and operations on the main thread with a main-thread scope. Replace collections instead of mutating them in place. Projections must be pure and inexpensive.

Expensive work

bind/load and their mapping/reduction variants run producers, transformations and JSON preparation on workerDispatcher (default Dispatchers.Default). Each emission commits on the owner scope before the producer proceeds. Capture filters and other saved values before entering a producer lambda; worker code must not access the UI or SavedStateHandle.

Use state.setAsync(largeValue) for a direct expensive write. It returns a Job and follows the same latest-operation rule. Use state.selectAsync(initialValue = emptyList<Row>()) { ... } for an expensive projection; reads reuse the last completed result. set, property assignments, constructors/restoration, and select remain synchronous. Do not use them for unbounded work on the UI thread.

See performance tests and limits, including JS/Wasm event-loop constraints.

Shared code and tests

All production code lives in commonMain. Repository binding, serialization, restoration snapshots, refresh, filters, cancellation and projection tests live in commonTest and run on every test target. JVM-only responsiveness tests use real executors to detect UI starvation. A separate Android integration test verifies SavedStateRegistry and a real Bundle/Parcel round trip; those Android APIs cannot run in commonTest.

See the compiled usage examples.

Platform boundaries

This module uses only the supplied SavedStateHandle; it creates no disk or browser storage. Recreation works to the extent the owner saves and restores that handle. Merely constructing SavedStateHandle() does not install restoration into an application's lifecycle. Android saved state follows the owner's save/stop lifecycle and is not a guarantee after force-stop, task dismissal or arbitrary application termination. Other platforms require their own supported handle owner; this module does not promise persistence after closing the application.

Keep serialized screen state small. Large repository datasets belong in a database/cache; retain IDs, filters or a compact screen snapshot when the platform's saved-state budget would be exceeded.

See migration and API reference.