MediumTaking Compose State Beyond the UI with Jetpack AppState: An Introduction to Transform | by Kavyasri Koduru | Nov, 2026 | Medium
Sitemap

Taking Compose State Beyond the UI with Jetpack AppState: An Introduction to Transform

12 min read5 days ago

--

How the Compose Runtime can help manage application-level state through headless Composition, automatic state observation, and the Transform APIs.

Modern Android applications manage much more state than what is visible on the screen.

We use a combination of StateFlow, SharedFlow, Compose State, coroutines, and custom state-management logic to manage the state across various components.

But what if we could use the same reactive state model that powers Jetpack Compose UI in other layers of our application as well?

This is the idea behind Jetpack AppState.

Introducing Jetpack AppState

Jetpack AppState is an AndroidX library initiative that brings the Compose state model beyond the UI layer.

To understand why this is interesting, let’s revisit something fundamental about Jetpack Compose.

When we write a composable function, it reads state values. When those values change, Compose identifies the affected parts of the composition and schedules recomposition.

For example:

@Composable
fun UserProfile(user: User) {
Text(text = user.name)
}

Here, the UI depends on the user.name value. When the observed state changes, Compose can recompose the relevant part of the UI.

However, state management is not exclusively a UI problem.

An application may need to observe state, derive values, and react to changes even without UI.

The interesting part is that the Compose Runtime is not inherently tied to rendering UI. Its state observation and recomposition capabilities can be used without producing visible UI elements.

Jetpack AppState builds on this capability and provides APIs to manage application-level state using the Compose Runtime.

In simple terms:

Jetpack AppState brings Compose’s reactive state model beyond the UI, making it useful for managing and reacting to state across an application.

The two libraries in AppState

Jetpack AppState currently consists of two main libraries: Transform and StateStore.

Although they complement each other, they address different responsibilities.

Transform

Transform allows us to execute composable logic without rendering UI.

It provides APIs to:

  • Observe Compose State values.
  • Collect asynchronous streams through Compose APIs.
  • Derive new state from existing state.
  • React to state changes using composition-aware effects.

The two important APIs we’ll explore are transform and listener.

StateStore

StateStore provides a type-safe way to hold and share application state across different components.

It exposes state as Compose State, allowing consumers to observe changes using the Compose Runtime’s state-observation mechanism.

It also provides integration with DataStore for persisting supported application state.

We’ll explore StateStore in a separate article.

Project status: AppState is still in alpha

One important thing to keep in mind before exploring these APIs is their maturity.

Jetpack AppState is currently in the alpha stage. This means its APIs and recommended usage patterns may evolve before reaching a stable release.

If you’re planning to experiment with it in a project, check the official documentation and AndroidX release notes for the latest API versions and setup instructions.

This is particularly important when evaluating a new library for production use. We should understand not only what an API offers but also how stable its contract is.

Official resources:

What is Transform?

Transform is a library built on top of the Compose Runtime.

It allows us to execute an @Composable block without creating or rendering any visible UI.

The block can read input state, collect Flow values through Compose APIs, and calculate a result. Transform then exposes that result as Compose State<R>.

The important point here is that Transform does not introduce a separate reactive engine. It reuses the existing Compose Runtime mechanisms for:

  • Composition
  • State-read tracking
  • Invalidation
  • Recomposition

The difference is that the composition is used without a UI tree.

This is what we call a headless Composition.

What does headless Composition mean?

To understand headless Composition, let’s first look at how a regular Compose UI works.

Normal UI Composition

In a typical Compose application, the flow looks something like this:

A simplified flow:

        ViewModel / State Sources
|
| State values
v
Composable Screen
|
| Composition
v
Compose UI Tree
|
| Apply changes
v
Visible UI

The Compose Runtime executes composable functions and maintains the composition. The UI infrastructure applies the resulting changes to the UI tree, which is eventually rendered on the screen.

Headless Composition

Now imagine that we want the same state observation and recomposition capabilities, but we don’t need to display anything.

We only want to execute some logic, observe its inputs, and calculate an output.

That’s where headless Composition comes in.

      Input State / Flow Sources
|
| Read / collect
v
@Composable Block
|
| Headless Composition
v
Compose Runtime
(Tracks State Reads)
|
| Latest calculation
v
Output State

Notice what’s missing: there is no UI tree and no visible UI rendering.

The Compose Runtime is still executing the composable block and tracking the state values it reads. It simply isn’t using that composition to produce UI.

Headless does not mean that Compose Runtime is not running.

It means that the composition is being used without producing visible UI.

This is the foundation on which Transform is built.

How does Transform know when to run again?

One of the most useful capabilities of the Compose Runtime is automatic state-read tracking.

Whenever a composable reads a Compose State value, the runtime tracks that read as a dependency of the current composition.

Transform uses this same mechanism.

Consider the following simplified example:

val count = mutableStateOf(10)
val result = transform(
initialValue = 0,
scope = viewModelScope,
) {
count.value * 2
}

The transformation reads count.value.

The Compose Runtime tracks that dependency. When count changes, the composition can be invalidated, and the block can be executed again to calculate the latest result.

       Input State Changes
|
v
Snapshot Change Applied
|
v
Composition Invalidated
|
v
Recomposition Scheduled
|
v
onUpdate Executes Again
|
v
Output State Updated

We don't have to manually subscribe to every state object or write custom observers for each dependency.

The Compose Runtime handles dependency tracking and schedules recomposition when relevant state changes.

There is one important detail: every state write does not necessarily result in exactly one recomposition.

Compose can coalesce changes, skip unnecessary work, and use the latest state values when recomposition happens.

Transform benefits from these existing runtime optimizations rather than implementing its own recomposition mechanism.

Dependencies can change dynamically

Another interesting aspect of Compose state tracking is that dependencies are determined by what the composable block actually reads.

Consider this example:

val dashboardState = transform(
initialValue = DashboardUiState(),
scope = viewModelScope,
) {
val online by networkMonitor.isOnline

val tracks = if (online) {
mediaRepository.allTracksFlow
.collectAsState(initial = emptyList()).value
} else {
mediaRepository.downloadedTracksFlow
.collectAsState(initial = emptyList()).value
}

DashboardUiState(
tracks = tracks,
isOnline = online,
)
}

Here, the block reads the online state and then chooses which Flow to collect.

  • When online is true, it collects allTracksFlow.
  • When online is false, it collects downloadedTracksFlow.

When the online state changes, the block recomposes and takes a different branch.

The set of state dependencies can therefore change based on the currently executed code path.

This is useful because the runtime tracks the dependencies that are relevant to the current execution rather than requiring us to manually maintain a fixed dependency list.

The two Transform APIs

Transform provides two overloads of the transform API.

Both execute a composable block and expose its result as State<R>. The main difference is how the lifetime of the transformation is controlled.

Conceptually, their signatures look like this:

Scope-based overload

fun <R> transform(
initialValue: R,
scope: CoroutineScope,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable (R) -> R,
): State<R>

Composable overload

@Composable
fun <R> transform(
initialValue: R,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable (R) -> R,
): State<R>

Let's understand the difference.

Scope-based overload

The first overload accepts a CoroutineScope from the caller.

This means the caller explicitly controls how long the transformation should remain active.

For example:

class LibraryViewModel(
private val repository: LibraryRepository,
) : ViewModel() {

val uiState: State<LibraryUiState> =
transform(
initialValue = LibraryUiState.Loading,
scope = viewModelScope,
) {
val books by repository.booksFlow.collectAsState(
initial = emptyList()
)

LibraryUiState.Content(books)
}
}

Here, we pass viewModelScope.

The transformation is therefore associated with the ViewModel's lifecycle. When the ViewModel is cleared and its scope is cancelled, the transformation is cancelled as well.

This overload is useful when we want to use Transform from non-composable code, such as:

  • ViewModels
  • Application-level state holders
  • Managers
  • Other non-UI components

Composable overload

The second overload does not require us to provide a CoroutineScope.

Instead, it is designed to be called from an existing Composition. Transform manages the required scope internally, tying the transformation's lifetime to the calling composition.

Get Kavyasri Koduru’s stories in your inbox

Join Medium for free to get updates from this writer.

For example:

@Composable
fun SearchScreen(
searchResults: List<SearchResult>,
) {
val filteredResults = transform(
initialValue = emptyList<SearchResult>(),
) {
searchResults.filter { it.isRelevant }
}

SearchResultsList(filteredResults.value)
}

This is useful when the transformation is local to a composable call site and should follow that composition's lifecycle.

Comparing the two overloads

Press enter or click to view image in full size

What about the dispatcher?

Both overloads accept a CoroutineDispatcher, which defaults to Dispatchers.Default.

This controls the dispatcher used for Transform's composition work.

It is worth remembering that this does not automatically move every operation inside the block to that dispatcher. Individual APIs and coroutine contexts can have their own threading requirements.

Why is onUpdate marked @Composable?

This is probably one of the most interesting aspects of the Transform API.

The scope-based overload can be called from a ViewModel, but its onUpdate parameter is marked @Composable.

Why?

Doesn't @Composable mean that the function needs to be called from a UI?

Not exactly.

@Composable means that a function participates in a Compose Composition. It does not necessarily mean that the function must render UI.

Remember our earlier discussion about headless Composition?

Transform creates and manages the Composition in which the onUpdate block executes.

So, even though the transform() function is called from a ViewModel, the ViewModel is not directly invoking a composable function like a regular Kotlin method.

Instead, Transform arranges for that composable block to execute inside its own headless Composition.

This allows us to use Compose Runtime APIs inside the block.

For example:

remember

To retain intermediate values across recompositions.

collectAsState

To collect a Flow and expose its current value as Compose State.

LaunchedEffect

To launch coroutine work associated with keys and the composition lifecycle.

Consider a simplified search example:

val results: State<List<SearchResult>> =
transform(
initialValue = emptyList(),
scope = viewModelScope,
) {
var currentResults by remember {
mutableStateOf(emptyList<SearchResult>())
}

LaunchedEffect(searchQuery) {
if (searchQuery.isBlank()) {
currentResults = emptyList()
} else {
delay(300)
currentResults = repository.search(searchQuery)
}
}

currentResults
}

Here:

  1. remember retains currentResults across recompositions.
  2. LaunchedEffect runs when the composition enters this block.
  3. When searchQuery changes, the effect is cancelled and restarted with the new key.
  4. The block returns the latest currentResults.
  5. Transform exposes that result through its output State.

This is the benefit of having a composable block: we can use the Compose Runtime's state and effect APIs even though we aren't rendering a UI.

What does Transform manage internally?

So far, we've seen that Transform runs a composable block without a UI, tracks state dependencies, and updates its output when relevant state changes.

But what actually makes this possible?

A useful mental model is that Transform coordinates a few responsibilities:

                 Transform
|
+----------+----------+
| | |
v v v
Composition Recomposer Coroutine
| | Scope
| |
v v
Executes Schedules
onUpdate Recomposition
|
v
Output State

At a conceptual level, Transform manages:

  1. TransformScope — coordinates the transformation.
  2. Composition — provides the environment in which the composable block executes.
  3. Recomposer — handles subsequent recompositions when observed state changes.
  4. Output State — holds the latest calculated result.
  5. Coroutine Scope — controls the lifetime of the transformation.

These are conceptual responsibilities, not necessarily five independent public objects or implementation classes.

The important lifecycle relationship is that the transformation remains active while its owning scope or composition remains active. When that owner is cancelled or leaves composition, the associated work is stopped.

Introducing listener

So far, we've discussed transform, which observes state and produces a derived State<R>.

But what if we don't need to produce a new state value?

What if we simply want to observe some state and perform an action when it changes?

For example:

  • Start a timer when a session begins.
  • Cancel the timer when a session is paused.
  • Trigger an operation when a particular state is reached.
  • Keep some background work active only while a condition is true.

This is where the listener API comes in.

What is listener?

listener is another API provided by the Transform library.

It executes a composable block in a headless Composition and observes the Compose State values read by that block.

When those values change, the block can recompose.

Unliketransform, however, listener does not produce a derived State<R>.

Its purpose is to observe state and coordinate composition-aware effects.

Conceptually, its signature looks like this:

suspend fun listener(
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable () -> Unit,
)

It is a suspend function and continues running until the calling coroutine is cancelled.

Example: A focus-session timer

Imagine an application that supports focus sessions.

A session can be in one of three states:

       +---------+
| Running |
+---------+
|
Pause / End
|
v
+---------+
| Paused |
+---------+
|
Resume
|
v
+---------+
| Running |
+---------+

When a session starts, we want to start a timer.

When the user pauses the session, the timer should stop.

When the session resumes, the timer should start again.

When the timer finishes, we should mark the session as complete and show a notification.

We can use listener for this:

scope.launch {
listener {
when (val current = session.value) {
is FocusSession.Running -> {
LaunchedEffect(current.endTimeMillis) {
val remaining =
current.endTimeMillis - System.currentTimeMillis()

if (remaining > 0) {
delay(remaining)
}

stateStore.setState(
FocusSessionKey,
FocusSession.Idle,
)

notificationManager.notify(
NOTIFICATION_ID,
buildCompletionNotification(current.taskId),
)
}
}

FocusSession.Paused,
FocusSession.Idle -> Unit
}
}
}

Let's understand what happens here.

When the session is Running

The listener enters the Running branch and creates a LaunchedEffect.

The effect waits until the session's end time.

When the session is Paused

The listener observes the state change and recomposes.

The Running branch is no longer part of the composition. As a result, the associated LaunchedEffect is cancelled.

When the session resumes

The listener recomposes again, enters the Running branch, and starts the effect with the new end time.

When the timer completes

The effect updates the session state and posts a notification.

This is a good example of how listener can coordinate work based on the current state of an application component.

Why use LaunchedEffect inside listener?

An important detail is that the listener block itself can recompose multiple times.

We should not place an unguarded side effect directly in the composable body if we need it to start, stop, or restart based on composition lifecycle.

Instead, APIs like LaunchedEffect provide that lifecycle-aware behavior.

In the example, the effect is associated with the Running branch and keyed by endTimeMillis.

That gives us automatic cancellation and restarting when the relevant state changes.

Transform vs listener

Both APIs use the same general idea: execute composable logic in a headless Composition and observe state changes.

But their purposes are different.

Press enter or click to view image in full size

A simple way to remember the difference:

Transform answers: What is the current derived value?

Listener answers: Given the current state, what work should be active?

A few practical considerations

Before wrapping up, here are a few things worth keeping in mind when working with Transform.

  • It is state-oriented. Transform is designed to expose the latest state, not guarantee processing of every intermediate event.
  • Dependencies are based on state reads. Make sure the values that should trigger updates are read inside the composable block.
  • Choose the right lifetime. Use a scope that matches the lifetime of the component owning the transformation.
  • Keep side effects lifecycle-aware. Use LaunchedEffect or other appropriate effect APIs for work that should start, stop, or restart with composition.
  • Be mindful of dispatchers. Expensive calculations and asynchronous operations should respect the threading requirements of the APIs involved.
  • Configure the module for Compose. A module using Transform's composable APIs needs the appropriate Compose setup, even if it does not render UI.

Wrapping up

Jetpack AppState extends the Compose state model beyond the UI layer.

In this article, we explored the Transform library and how it uses the Compose Runtime to execute composable logic without rendering visible UI.

The key concepts to take away are:

  • A headless Composition uses the Compose Runtime without producing a UI tree.
  • Transform tracks state reads automatically and relies on the runtime's invalidation and recomposition mechanisms.
  • transform derives a value and exposes it as Compose State<R>.
  • listener observes state and coordinates composition-aware effects without producing a derived state value.
  • The lifetime of a transformation is controlled by either the supplied coroutine scope or the calling composition, depending on the overload.

The interesting part is that we are reusing a state model that Android developers already know from Jetpack Compose, but applying it to a broader part of the application.

--

--