Transform Part of Android Jetpack.
Transform is a library built on the Compose runtime that runs a @Composable
block without a UI to transform inputs into Compose State.
Whenever any State read inside the block changes, the block automatically
recomposes and updates the returned State—letting you use Compose's
dependency tracking and recomposition model in ViewModels, repositories, or
background services.
Use cases
- Reactive data transformation: Convert raw data streams (such as network responses or database queries) into UI-ready state.
- Combine multiple inputs: Merge multiple independent state sources (such as
user input, settings, and network status) into a single cohesive state without
complex
Flowoperators. - Debouncing and throttling: Implement time-based behavior (such as
debouncing search queries) using standard Compose side-effects
like
LaunchedEffect. - Conditional operations: Run different state-gathering logic based on
current conditions using standard
if-elseorwhenexpressions.
API overview
The androidx.appstate.transform package provides the following functions:
package androidx.appstate.transform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable (R) -> R,
): State<R>
@Composable
public fun <R> transform(
initialValue: R,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable (R) -> R,
): State<R>
public suspend fun listener(
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable () -> Unit,
)
Create a transform
You can create transforms either on their own (using a provided
CoroutineScope) or within an existing Composition. The key parameters for any
transform are initialValue and onUpdate. The onUpdate lambda receives the
previous output value R as its parameter (initialized to initialValue on the
first composition). You can return this parameter to keep the last emitted state
instead of resetting to a loading state, such as
{ previous -> flow.collectAsState(initial = null).value ?: previous }.
For example, you can use transform to observe changes to a search query string
and debounce expensive computations like network calls:
var query: String by mutableStateOf("") val searchResults: List<String> by transform<List<String>>( initialValue = emptyList(), scope = coroutineScope, ) { var results by remember { mutableStateOf(emptyList<String>()) } LaunchedEffect(query) { if (query.isBlank()) { results = emptyList() return@LaunchedEffect } delay(300.milliseconds) results = api.search(query) } results }
In this snippet:
queryis backed by aMutableState, which a search bar UI composable can read and update.transformtakes aninitialValueof an empty list, aCoroutineScope, andonUpdateas a trailing lambda.- Inside the
onUpdateblock,rememberkeeps theresultsstate across recompositions whileLaunchedEffectruns. LaunchedEffecttakesqueryas a key. Becausequeryis backed by aMutableState, the effect is cancelled and relaunched every timequerychanges.- To avoid calling the API on every keystroke, the effect body waits 300 ms. If the query changes before that delay completes, the effect is cancelled and no network call is made.
Inside a @Composable function, you can also use the @Composable transform
overload, which remembers a CoroutineScope internally and runs onUpdate on
Dispatchers.Default by default. This is useful when you want to offload an
expensive state derivation (such as filtering or searching a large list) off the
main UI thread:
@Composable fun LogViewerScreen( entriesState: State<List<LogEntry>>, filterQueryState: State<String>, ) { // Calls rememberCoroutineScope() internally and runs onUpdate on // Dispatchers.Default so main-thread UI recomposition isn't blocked val matchingEntries by transform<List<LogEntry>>( initialValue = emptyList(), dispatcher = Dispatchers.Default, ) { val entries by entriesState val filterQuery by filterQueryState if (filterQuery.isBlank()) { entries } else { entries.filter { entry -> entry.matchesQuery(filterQuery) } } } // Render matchingEntries... }
Observe state with listener
When you want to react to Compose State changes in a non-UI component (such as
a state manager, repository, or background service) without producing a derived
State<R>, use listener.
listener is a suspend function that runs a @Composable () -> Unit block
until the calling coroutine is cancelled, automatically recomposing whenever any
state read inside onUpdate changes:
class FocusSessionManager( private val stateStore: StateStore, private val notificationManager: NotificationManager, scope: CoroutineScope, ) { val session: State<FocusSession> = stateStore.getState(FocusSessionKey) init { scope.launch { listener { when (val current = session.value) { is FocusSession.Running -> { LaunchedEffect(current.endTimeMillis) { val remainingMillis = current.endTimeMillis - System.currentTimeMillis() if (remainingMillis > 0) { delay(remainingMillis) } stateStore.setState(FocusSessionKey, FocusSession.Idle) notificationManager.notify( NOTIFICATION_ID, buildSessionCompleteNotification(current.taskId), ) } } FocusSession.Idle, is FocusSession.Paused -> Unit } } } } // startFocus, pauseFocus, resumeFocus, endFocus... }
In this example, listener observes session.value. When the session is
FocusSession.Running, LaunchedEffect waits until endTimeMillis before
resetting the session to FocusSession.Idle and posting a completion
notification. If the user pauses or ends the session early, the Running branch
leaves composition and cancels the timer automatically. When scope is
cancelled, listener stops observing.
Common patterns
The following patterns show how to use transform for conditional logic,
combining multiple streams, and producing UI state in a ViewModel.
Conditional state production
Use standard Kotlin if-else or when expressions inside transform to
switch between upstream state sources based on current conditions. Because read
tracking is dynamic, transform only observes the branch that runs on the
current recomposition:
var searchQuery by mutableStateOf("") val displayedTasks: State<List<Task>> = transform( initialValue = emptyList(), scope = coroutineScope, ) { if (searchQuery.isBlank()) { val recentTasks by tasksRepository.recentTasks.collectAsState( initial = emptyList(), ) recentTasks } else { val matchingTasks by remember(searchQuery) { tasksRepository.searchTasks(searchQuery) }.collectAsState(initial = emptyList()) matchingTasks } }
Combine multiple state sources
Because Compose automatically tracks state reads, you can combine any number of
State<T> properties and Flow streams in a single transform block. Unlike
Flow.combine, which only provides typed overloads for up to
five flows before falling back to an untyped Array<Any?>, adding another input
in transform is always a single type-safe read:
var showCompleted by mutableStateOf(false) var selectedCategory by mutableStateOf<Category?>(null) val dashboardState: State<DashboardUiState> = transform( initialValue = DashboardUiState(), scope = coroutineScope, ) { val tasks by tasksRepository.tasksFlow.collectAsState(initial = emptyList()) val sortOrder by userPrefsRepository.sortOrderFlow.collectAsState( initial = SortOrder.BY_DEADLINE, ) val focusSession by focusSessionManager.session val isOnline by networkMonitor.isOnline val visibleTasks = tasks .filter { task -> (showCompleted || !task.isCompleted) && (selectedCategory == null || task.category == selectedCategory) } .sortedWith(sortOrder.comparator) DashboardUiState( tasks = visibleTasks, selectedCategory = selectedCategory, focusSession = focusSession, isOnline = isOnline, ) }
Use transform in a ViewModel
The non-composable transform overload runs reactive logic in non-UI layers
(such as ViewModels or repositories) while using Compose's state model. It runs
as long as the provided CoroutineScope is active, so you can tie it to a
component's lifecycle by passing viewModelScope inside a
ViewModel:
class HomeViewModel( private val mediaRepository: MediaRepository, private val networkMonitor: NetworkMonitor, ) : ViewModel() { val uiState: State<HomeUiState> = transform<HomeUiState>( initialValue = HomeUiState.Loading, scope = viewModelScope, ) { val isOnline by networkMonitor.isOnline val tracks = if (isOnline) { mediaRepository.allTracksFlow.collectAsState(initial = null).value } else { mediaRepository.downloadedTracksFlow .collectAsState(initial = null).value } ?: return@transform HomeUiState.Loading HomeUiState.Success( tracks = tracks, isOnline = isOnline, ) } }
In this ViewModel, uiState reads networkMonitor.isOnline and dynamically
switches between collecting mediaRepository.downloadedTracksFlow when offline
and mediaRepository.allTracksFlow when online, exposing a single
State<HomeUiState> that UI composables can read directly. For more details on
combining transform with MutableState and SavedStateHandle.saveable, see
Use Compose State in a ViewModel.
Choose between transform, derivedStateOf, and Flow
Use the following guidelines to choose between transform, derivedStateOf,
and Kotlin Flow:
derivedStateOf versus transform
Both derivedStateOf and transform produce a derived
State<T> that automatically tracks the Compose states read inside its block.
Choose between them based on whether you're computing a value directly from
existing State<T> objects or need @Composable features such as
collectAsState, produceState, and LaunchedEffect:
| Capability | derivedStateOf |
transform |
|---|---|---|
| Lambda type | Standard Kotlin lambda (() -> T) |
@Composable lambda (@Composable (R) -> R) |
| Supported inputs | Synchronous State<T> reads only |
State<T>, Kotlin Flow (using collectAsState), suspend functions, and callback APIs (using produceState) |
| Execution and consistency | Lazy and synchronous when .value is read; updates atomically within the same snapshot without an initialValue or CoroutineScope |
Runs an active Composition in a CoroutineScope and recomposes asynchronously after a snapshot commits; requires an initialValue |
| State and side-effects | Can't call remember or side-effect APIs |
Supports remember, LaunchedEffect, produceState, and reusable @Composable functions |
Use derivedStateOf when all inputs are already State<T> objects and you
don't need @Composable APIs:
- Synchronous, same-snapshot derivations: Computing a value or filtered list
from existing
State<T>properties without managing aCoroutineScopeor providing a placeholderinitialValue. BecausederivedStateOfevaluates synchronously when.valueis read, callers reading.valueimmediately after updating an input state always observe the updated result in the same snapshot. - Reducing how often observers update: Computing a value that changes less
often than its source
State<T>(such as checking whether a scroll offset exceeds a threshold). For more details, see thederivedStateOfdocumentation.
Use transform when your derivation requires the Compose runtime:
- Combining
State<T>with asynchronous streams: Collecting a KotlinFlowfrom a repository or database usingcollectAsStatealongsideState<T>inputs. - Restarting upstream work when inputs change: Using
remember(key)inside the block to recreate a parameterizedFlowonly when a specificState<T>input updates. - Running side-effects or remembering intermediate state: Using
LaunchedEffect(for example, to debounce inputs or run timers),produceState(to load data from asuspendfunction or wrap a callback withawaitDispose), orrememberto keep state across recompositions.
The following example compares derivedStateOf with a transform block that
combines State<T> and a repository Flow:
class TaskListViewModel( private val tasksRepository: TasksRepository, private val preferencesManager: TaskPreferencesManager, ) : ViewModel() { var searchQuery by mutableStateOf("") private set // Computing a value from existing State<T>: use derivedStateOf val hasSearchQuery: State<Boolean> = derivedStateOf { searchQuery.isNotBlank() } // Combining State<T> with a Flow and remember(key): use transform val uiState: State<TaskListUiState> = transform<TaskListUiState>( initialValue = TaskListUiState.Loading, scope = viewModelScope, ) { val showCompleted by preferencesManager.showCompleted val tasks by remember(showCompleted) { tasksRepository.observeTasks(showCompleted) }.collectAsState(initial = null) val currentTasks = tasks ?: return@transform TaskListUiState.Loading val filteredTasks = currentTasks.filter { task -> task.title.contains(searchQuery, ignoreCase = true) } TaskListUiState.Success(filteredTasks) } }
In this example, hasSearchQuery only inspects the searchQuery state, so
derivedStateOf computes it lazily without a CoroutineScope. By contrast,
uiState combines preferencesManager.showCompleted and searchQuery with an
asynchronous Flow from tasksRepository and uses remember(showCompleted) to
avoid recreating the Flow when only searchQuery changes.
When to use Flow instead of transform
transform is designed to represent the current value of state, not a
stream of events. If a state changes multiple times before the next
recomposition, Compose only uses the latest value, and it skips updates when the
new value equals (==) the previous value. Use Kotlin Flow
instead when your logic must process every value in order:
- Running totals and averages: Calculations like moving averages, cumulative
sums, or undo history (
runningFoldorscan) need every value to be counted once. In atransformblock, rapid updates before recomposition are skipped, two identical readings in a row (such as a sensor emitting10.0twice) don't trigger recomposition, and recompositions triggered by other states in the same block can count the current value twice. Compute the running value in aFlowfirst, then collect the result intransformif you need to combine it with other state. - Event logging and queues: Pipelines that log user interactions, process
queued network requests, or record transactions where every intermediate
change (
A -> B -> CorA -> B -> A) must be recorded. - One-time events: Actions like playing a sound or sending a hardware
command, where sending the same command twice in a row must run twice.
transformrequires aninitialValueand skips consecutive equal outputs. - Backpressure-sensitive streams: Streams (such as file or socket reads) where a fast producer must suspend when a consumer falls behind. Compose state writes don't suspend and overwrite previous values instead.
Test transforms
Because transforms rely on the Compose runtime and Kotlin Coroutines, testing
them requires coordinating asynchronous execution and state recomposition. Use
the androidx.appstate.transform:transform-testing artifact to control the
coroutine scheduler and trigger recompositions in unit tests.
The androidx.appstate.transform.testing package provides the following APIs:
package androidx.appstate.transform.testing
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.time.Duration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.TestResult
public fun transformTest(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend TransformTestScope.() -> Unit,
): TestResult
public sealed interface TransformTestScope : CoroutineScope {
public val testCoroutineScheduler: TestCoroutineScheduler
public val testDispatcher: TestDispatcher
public val backgroundScope: CoroutineScope
public fun advance()
public fun advanceTimeBy(duration: Duration)
public fun advanceUntilIdle()
}
transformTest wraps runTest and executes your test
block with a TransformTestScope receiver:
backgroundScope: ACoroutineScopefor running transforms that shouldn't block test completion. It's automatically cancelled when the test finishes, which stops the transform.testDispatcher: ATestDispatcherlinked totestCoroutineScheduler. Pass this totransform'sdispatcherparameter so recomposition runs on the test scheduler instead of defaulting toDispatchers.Default.advance: Runs pending coroutines on the test scheduler, sends Compose snapshot apply notifications (Snapshot.sendApplyNotifications), and recomposes thetransformso the outputStateupdates.advanceTimeBy(duration)andadvanceUntilIdle: Advance the virtual clock ontestCoroutineSchedulerfor time-dependent operations.
Test state updates
To test a transform, pass backgroundScope and testDispatcher from
TransformTestScope, then call advance after updating an input state or
Flow:
@Test fun testStateUpdate() = transformTest { var sourceState by mutableStateOf(0) val state = transform( initialValue = 0, scope = backgroundScope, dispatcher = testDispatcher, ) { sourceState } assertThat(state.value).isEqualTo(0) // Modify the source state and advance to trigger recomposition. sourceState = 1 advance() assertThat(state.value).isEqualTo(1) }
Test time-based operations
When a transform uses time-based side-effects like LaunchedEffect and
delay (such as debouncing search queries), combine advanceTimeBy(duration)
with advance:
- After modifying an input state, call
advanceso thetransformblock recomposes and starts theLaunchedEffect. - Call
advanceTimeBy(duration)to move the virtual clock forward, and then calladvanceto run tasks scheduled at that timestamp and recompose thetransform.
@Test fun testDebouncedSearch() = transformTest { var query by mutableStateOf("") val searchResults = transform<List<String>>( initialValue = emptyList(), scope = backgroundScope, dispatcher = testDispatcher, ) { var results by remember { mutableStateOf(emptyList<String>()) } LaunchedEffect(query) { if (query.isBlank()) { results = emptyList() return@LaunchedEffect } delay(300.milliseconds) results = api.search(query) } results } query = "compose" // Recompose to start the LaunchedEffect for the new query. advance() // Results don't update before the 300ms debounce delay completes. advanceTimeBy(200.milliseconds) advance() assertThat(searchResults.value).isEmpty() // Advance past the remaining 100ms and trigger recomposition. advanceTimeBy(100.milliseconds) advance() assertThat(searchResults.value).containsExactly("Compose State", "Compose UI") }
Test a ViewModel or state holder
Because transform runs its Composition in the provided scope and defaults
its dispatcher parameter to Dispatchers.Default, classes that create a
transform should accept a CoroutineScope and a CoroutineDispatcher as
constructor parameters. In a ViewModel, pass the injected
CoroutineScope to the ViewModel(coroutineScope) constructor and forward the
CoroutineDispatcher to transform:
class HomeViewModel( private val mediaRepository: MediaRepository, private val networkMonitor: NetworkMonitor, coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), dispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ViewModel(coroutineScope) { val uiState: State<HomeUiState> = transform<HomeUiState>( initialValue = HomeUiState.Loading, scope = viewModelScope, dispatcher = dispatcher, ) { val isOnline by networkMonitor.isOnline val tracks = if (isOnline) { mediaRepository.allTracksFlow.collectAsState(initial = null).value } else { mediaRepository.downloadedTracksFlow .collectAsState(initial = null).value } ?: return@transform HomeUiState.Loading HomeUiState.Success( tracks = tracks, isOnline = isOnline, ) } } @Test fun testHomeViewModelOfflineState() = transformTest { val mediaRepository = FakeMediaRepository() val networkMonitor = FakeNetworkMonitor() val viewModel = HomeViewModel( mediaRepository = mediaRepository, networkMonitor = networkMonitor, coroutineScope = backgroundScope, dispatcher = testDispatcher, ) val downloadedTrack = Track(id = "1", isDownloaded = true) val streamingTrack = Track(id = "2", isDownloaded = false) mediaRepository.emitAllTracks(listOf(downloadedTrack, streamingTrack)) mediaRepository.emitDownloadedTracks(listOf(downloadedTrack)) advance() assertThat(viewModel.uiState.value).isEqualTo( HomeUiState.Success( tracks = listOf(downloadedTrack, streamingTrack), isOnline = true, ) ) // Switch offline and verify it switches to downloadedTracksFlow. networkMonitor.setOnline(false) // First advance() recomposes into the offline branch (where collectAsState // initially returns null -> Loading) and starts collecting downloadedTracksFlow. advance() // Second advance() applies the collected downloadedTracksFlow value and // recomposes uiState with the offline Success state. advance() assertThat(viewModel.uiState.value).isEqualTo( HomeUiState.Success( tracks = listOf(downloadedTrack), isOnline = false, ) ) }
In this test, passing backgroundScope and testDispatcher into
HomeViewModel binds its viewModelScope and transform Composition to the
test scheduler, so each advance call deterministically processes emissions
from FakeMediaRepository and FakeNetworkMonitor. For more patterns on using
SavedStateHandle.saveable and Snapshot.withMutableSnapshot in a ViewModel,
see Use Compose State in a ViewModel.