Use Compose State outside the UI

Jetpack Compose State<T> and MutableState<T> are general-purpose value holders backed by the Compose snapshot system. Although they're most commonly used to drive UI recomposition, the snapshot system itself isn't tied to UI rendering.

With the AppState libraries—Transform and StateStore—you can hold, derive, observe, and persist Compose State across ViewModels, managers, repositories, and background services.

Why use Compose State beyond the UI?

When you read Compose State inside a state-observing block—such as a UI composable, transform, or derivedStateOf—the Compose runtime provides two key benefits across any layer of your architecture:

  • Automatic and dynamic dependency tracking: Reading a state's .value (or using by property delegation) automatically registers that state as a dependency of the block. Because tracking is dynamic, dependencies are determined at runtime by which code paths execute. If a conditional branch isn't taken (for example, after an if check or early return), states inside that branch aren't read and changes to them don't trigger unnecessary recomputations.
  • Consistency and granularity: You can declare separate, fine-grained MutableState properties and wrap multi-property writes in Snapshot.withMutableSnapshot. Intermediate updates are isolated to the mutable snapshot and applied atomically when the block completes, so observers reading inside a snapshot never see inconsistent intermediate state and only recompute when the specific properties they read change. Unlike StateFlow, you don't have to choose between a single monolithic StateFlow<UiState> (which invalidates every collector when any field changes) and separate StateFlow properties (which can't update together in a single atomic transaction).

Choose the right state API across your architecture

Choose how to hold and persist Compose State based on how long a piece of state should live and which components need to access it:

State category Lifetime and scope Examples Recommended Compose State APIs
Live state Always stored in memory; queried fresh on startup and not persisted across process death. Network connection status, Bluetooth connection status, temporary UI state such as item selection.
Transient UI state Survives configuration changes and system-initiated process death, not stored indefinitely; scoped to a single screen or activity. Expanded or collapsed UI elements, scroll position, screen search query or filter toggles.
Transient session state Survives background process death, not stored indefinitely; shared across activities, services, or widgets. Active focus timer, multi-step checkout cart, or in-progress form draft.
Permanent state Kept on disk indefinitely. User preferences and settings (DataStore), structured app data and media libraries (Room). DataStore or Room flows collected inside transform using collectAsState

Use Compose State in a ViewModel

Inside a ViewModel, you can declare independent screen inputs as granular MutableState properties, persist them across process death with SavedStateHandle.saveable, update multiple properties atomically with Snapshot.withMutableSnapshot, and combine them with repository Flow streams inside a transform:

sealed interface TaskListState {
    data object Loading : TaskListState
    data class Success(
        val tasks: List<Task>,
        val selectedTaskIds: Set<String>,
        val isOnline: Boolean = true,
    ) : TaskListState
}

@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {

    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set

    fun updateSearchQuery(query: String) {
        Snapshot.withMutableSnapshot {
            searchQuery = query
            selectedTaskIds = emptySet()
        }
    }

    val tasksState: State<TaskListState> = transform<TaskListState>(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading

        TaskListState.Success(
            tasks = tasks,
            selectedTaskIds = selectedTaskIds,
        )
    }
}

Key patterns in this ViewModel:

  • Use SavedStateHandle.saveable for reactive saved state: Delegating searchQuery to SavedStateHandle.saveable with mutableStateOf (from androidx.lifecycle:lifecycle-viewmodel-compose) persists the value across process death as a MutableState, so transform tracks reads automatically. Avoid bare SavedStateHandle.saved, which returns a plain Kotlin property rather than Compose State and doesn't trigger recomposition when changed.
  • Atomic multi-property writes with Snapshot.withMutableSnapshot: Updating searchQuery and selectedTaskIds inside Snapshot.withMutableSnapshot isolates the writes and commits both changes in a single atomic snapshot transaction, so observers (including transform) see both updates together in one recomposition pass rather than observing the new searchQuery paired with stale selectedTaskIds.
  • Reuse flows across recompositions with remember(key): Wrapping tasksRepository.getTasks(searchQuery) in remember with searchQuery as a key ensures that when selectedTaskIds changes and recomposes the transform block, Compose reuses the existing database Flow rather than restarting the query.

Share live system state across your app

For live system state shared across multiple ViewModels, activities, or services—such as network or Bluetooth connection status—you don't want to persist stale status to disk across process death. Instead, query the current system state on startup, hold it in a private mutableStateOf property inside an application-scoped singleton, and expose a read-only State<T>:

interface NetworkMonitor {
    val isOnline: State<Boolean>
}

@Singleton
class LiveNetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) : NetworkMonitor {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)

    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    override val isOnline: State<Boolean> = _isOnline

    private val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onCapabilitiesChanged(
            network: Network,
            networkCapabilities: NetworkCapabilities,
        ) {
            _isOnline.value = networkCapabilities.hasCapability(
                NetworkCapabilities.NET_CAPABILITY_INTERNET
            )
        }

        override fun onLost(network: Network) {
            _isOnline.value = false
        }
    }

    init {
        connectivityManager.registerDefaultNetworkCallback(callback)
    }

    private fun checkCurrentConnectivity(): Boolean {
        val network = connectivityManager.activeNetwork ?: return false
        val capabilities =
            connectivityManager.getNetworkCapabilities(network) ?: return false
        return capabilities.hasCapability(
            NetworkCapabilities.NET_CAPABILITY_INTERNET
        )
    }
}

MutableState supports consistent reads and updates from any thread, so NetworkCallback can update _isOnline.value directly from its background callback thread. If a background callback updates multiple MutableState properties together, wrap the writes in Snapshot.withMutableSnapshot so they're applied atomically.

Any component in your app can read networkMonitor.isOnline.value—whether as a one-off check, inside a UI composable, or inside a state-observing block such as listener, derivedStateOf, or transform. For example, TaskListViewModel can read networkMonitor.isOnline.value inside its transform block:

val tasksState: State<TaskListState> = transform<TaskListState>(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(
        tasks = tasks,
        selectedTaskIds = selectedTaskIds,
        isOnline = networkMonitor.isOnline.value,
    )
}

Because transform tracks networkMonitor.isOnline.value alongside searchQuery, selectedTaskIds, and the repository Flow, any change to connectivity automatically recomputes TaskListState.Success without re-querying the database.