Apps foundation

Take Compose state beyond the UI with Jetpack AppState

Speakers

  • Ben Sagmoe

    Developer Relations Engineer

  • Elif Bilgin Morris

    Software Engineer

About

About the talk

Compose’s snapshot runtime, automatic dependency tracking, and atomic updates don’t have to stop at the UI layer. Jetpack AppState lets you move Compose state upstream—streamlining what reaches your screens by hoisting and deriving state with the snapshot system before it ever hits the UI.

In this session, you’ll learn about AppState’s two foundational libraries. We’ll dive into StateStore for hoisting state, and Transform for using composition to derive state with plain Kotlin control flow—eliminating complex Flow combine chains and keeping your UI effortlessly in sync.

Summary

  • Compose Runtime works as a general-purpose state management engine without Compose UI. Its snapshot system tracks reads dynamically and keeps concurrent updates consistent across properties.
  • Jetpack AppState Transform computes and exposes Compose State from headless composable blocks. It replaces complex Flow operator chains like combine while supporting early returns and nested state derivations.
  • Transform uses an eager headless frame clock to run transformations off the UI thread. The clock parks whenever watched states remain unchanged, avoiding wasted work.
  • Jetpack AppState StateStore shares Compose State across distinct components like activities and background services. Integrating it with DataStore lets individual keys survive process death without custom boilerplate.

Resources

Gemini Notebook

Topics

  • jetpack-compose
  • jetpack-appstate
  • state-management
  • android-architecture
  • compose-runtime

Transcript

Introduction

  • Hello, and welcome to Take Compose State Beyond the UI with Jetpack AppState.

  • My name is Ben, and I am a developer relations engineer working on that Jetpack architecture libraries.

  • And I'm Elif, and I'm one of the engineers who work on the app architecture libraries.

  • And today, we're here to talk to you about a future where Compose goes beyond the UI and into the data and domain layers of applications.

  • So, as I'm sure you're all well aware by now, Compose is the recommended toolkit for UI development on Android.

  • From tat-- phones and tablets to widgets, watches, TVs, and XR devices, Compose powers it all.

  • But as your app grows across these UI surfaces and background components, managing the state behind them gets harder, especially around two common pain points.

  • The first is deriving complex state.

  • Weaving together multiple data streams, local UI inputs, and ambient state, like network connectivity, that often turns into a tangle of operators like combine, flatMapLatest, and distinctUntilChanged.

  • And then the second is sharing state across components: keeping an activity, service, app widget, and more in sync, and making sure that that state can survive proc-ground-- process death when needed.

  • When we stepped back to look at these challenges, we came to look at how Compose itself is built.

  • Most people think of Compose as a UI toolkit, but it's actually two distinct layers: Compose UI on top and the Compose Runtime underneath.

  • While Compose UI handles layout, drawing, and input, the Compose Runtime is a general purpose engine for state management and recomposition.

  • It has no dependency on the UI.

  • And that's the realization that led us to build Jetpack AppState.

  • It's a family of libraries focused on making the most of Compose state beyond the UI.

What is Compose State?

  • But before we dive into AppState, let's make sure we're on the same page when we talk about what we mean by Compose state.

  • And the answer is androidx.compose.runtime.State.

    /**
     * A value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value.
     */
    @Stable
    public interface State<out T> {
        public val value: T
    }
  • And the interface you're probably more familiar with, MutableState.

    /**
     * A mutable value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
     * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
     * will be scheduled. If [value] is written to with the same value, no recompositions will be
     * scheduled.
     */
    @Stable
    public interface MutableState<T> : State<T> {
        override var value: T
        public operator fun component1(): T
        public operator fun component2(): (T) -> Unit
    }
  • Fundamentally, these are just bare-bones containers.

  • What makes them actually work is the Compose snapshot system.

  • So when we say Compose state, what we really mean is usually Snapshot State.

  • So, when you call mutableStateOf, you're making a container that routes reads and writes through the snapshot system.

  • And whenever your code runs inside of a snapshot observer, like a composition, you get two properties that a stream like a Flow doesn't give you.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
    }
  • The first is automatic and dynamic read tracking.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
  • So, automatic means that there's no subscribing or collecting.

  • Just reading .value is the subscription.

  • So, if we look at Counter here, reading count.value directly inside the composable reports to the that read to the composition, and it registers Counter in its list of readers.

  • And now look at AddButton.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
    
    @Composable
    fun AddButton(onClick: () -> Unit) {
        Button(onClick = onClick) {
            Text("Add")
        }
    }
  • It only writes to count on the click.

  • So, it isn't a reader.

  • When a user clicks AddButton and modifies count, the snapshot system tracks that write, which lets the composition know to invalidate Counter, but not AddButton.

  • And dynamic means that read tracking happens at runtime based on which code paths actually run.

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(true) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • So here, we're guarding the counter text behind an isVisible flag.

  • When isVisible is true, both isVisible and count are read.

  • So, Counter is registered as a reader of both of them.

  • And when isVisible is false, if the it-- the if branch doesn't run, count isn't read, so Counter is only registered as a reader of isVisible, not count.

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(false) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • So, when AddButton writes to count, there's nothing left to invalidate.

  • And now, the second property is not having to choose between consistency and granularity.

  • Consistency means that when you update multiple values in a snapshot, those changes stay isolated while your code runs, even if another thread is writing to them at the same time.

  • And those updates become visible all at once when the snapshot finishes.

  • Granularity means that separate properties can be dec-- declared and tracked individually, especially when they're unrelated.

  • So, updating one property only invalidates the readers that actually depend on it.

  • Let's take a look at this in some real code.

  • For example, we're going to look at a game that has three pieces of state: a level name, time survived, and score.

    val levelName: String = "Level 1 - Stay Alive!"
    val timeSurvived: Int = 0
    val score: Double = 0.0
  • So to start, let's look at a con-- a Flow implementation that chooses consistency at the expense of granularity by bundling everything into a GameUiState class.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
  • That means that every time updateScore runs, you copy the entire GameUiState so that both timeSurvived and score are able to update together in sync.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
  • In the UI, GameScreen collects the UI state and reads from it to pass the properties that are relevant down to Header and ScoreBadge.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state by viewModel.uiState.collectAsStateWithLifecycle()
        Column {
            Header(levelName = state.levelName)
            ScoreBadge(
                timeSurvived = state.timeSurvived,
                score = state.score
            )
        }
    }
  • Because GameScreen reads every state, every call to updateScore invalidates GameScreen even though the header's data never changed.

  • Now, you could split every property into its own Flow to get granularity, but then you would lose consistency because another thread could update a value at while the other update block is running.

  • Now let's consider an alternate state-based implementation.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
  • In this one, we use a GameUiState again.

  • But in this one, it's a little bit different because each property delegates to mutableStateOf.

  • Here, updateScore wraps its writes in withMutableSnapshot, and this is what gives us consistency across, writes.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
  • And then in the UI, GameScreen passes State straight through to Header and ScoreBadge.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state = viewModel.uiState
        Column {
            Header(state = state)
            ScoreBadge(state = state)
        }
    }
  • It doesn't read any of the properties, so when updateScore runs, GameScreen isn't a reader.

  • Only the actual component, in this case, ScoreBadge, is invalidated.

  • And lastly, notice that none of this is tied to the UI.

  • The UI just happens to be one place where these properties really shine.

  • And with that, thanks for coming to our talk.

  • Okay, jokes aside, I'll hand it over to Elif to take us through the first library, Transform.

Introducing Transform

  • Okay.

  • Thanks, Ben.

  • So, as Ben just noted, Compose snapshot state offers us automatic and dynamic read tracking, consistency, and granularity, which brings me to the question, what if we could have our data flows also driven by a Compose State Snapshot-- a Snapshot State?

  • And if state is our data model, then how do we actually compute and transform a state for all of its consumers?

  • This is where the new Jetpack AppState Transform library comes in.

  • Before we dive into the library itself, we need to talk about how the library came to be.

  • So, the idea of taking advantage of the Compose engine, which is the runtime and the compiler here, to produce state values in a Headless composition, is not a new one.

  • Many of you might already be using or might already be familiar with the Molecule library.

  • And by Headless here, we mean there's no UI.

  • So Molecule was pioneering in demonstrating the power of using Compose Runtime, and it did so by showing us in action that a composable function doesn't have to emit UI nodes, and it can actually be used to write composable function that return a pure data model.

  • In Molecule's case, this was a Kotlin Flow or a StateFlow.

  • This meant that instead of chaining react-- reactive operators like combine, you could write ordinary Kotlin.

  • But instead of dealing with flows, what if your data model was State, since your UI is already in Compose?

  • With Transform, your data model is the State.

  • Let's take a look at the actual API.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R>
  • This is the whole API.

  • It's very simple, one function.

  • It returns a plain observable State, not a Flow, not a StateFlow.

  • And whoever consumes it doesn't know or care that a Transform produced it.

  • What's really interesting here is this block.

  • It's Composable.

  • This means that everything you already know works inside of it.

  • Remember LaunchedEffect, collectAsState, early returns, plain ifs, anything you can read from inside of a Composable, you can derive from in a Transform.

  • We should note that all of the APIs we're seeing today in this presentation are all in alpha, and they're subject to change, but regardless, let's take it look take a look at an example.

  • So, let's take a very simple, small focus timer app.

  • You pick a task, you set a 25-minute timer, the clock ticks down.

  • Every time you're creating a single screen, even when you're creating a simple single screen like this, you probably will need to combine and manage multiple independent data streams to assemble your state under the hood.

  • For our focus screen, let's say we have two: the focus session itself, which is going to be what phase we're in, so paused or not, how many seconds are left, and all of this is going to be coming from a timer engine as a StateFlow that's going to tick let's say every one second.

  • The other data stream is going to be the current task that the user's working on that's going to come in from a Room database as a Flow.

  • So, two data streams and one screen state at the end.

  • The ViewModel is going to be where they get put together, and its output, which we're going to call the summary, is going to be the piece of code that we're going to write next.

  • Traditionally, you would, this would mean that in your ViewModel, you will have to combine these flows using chained operators.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: StateFlow<FocusSummary> =
            combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find { it.id == session.activeTaskId }
                    FocusSummary.Active(...)
                }
            }.stateIn(
                    scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = FocusSummary.Idle,
    )
    }
  • So, combine the two zip the two flows together into streams together, the branching inside the lambda, so if else right there, and then a state in at the end.

  • With Transform, we can drop this entire boilerplate code.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
    initialValue = FocusSummary.Idle,
    scope = viewModelScope,
    container = handle.getOrCreateContainer("transform")
    ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
  • This call looks kind of familiar because we have an initial value in a scope being passed in, but there's nothing upstream to chain, the derivation is the block itself.

  • And unlike stateIn when you would probably need to configure a WhileSubscribed time out to pause and resume upstream streams, a Transform runs continuously within the lifetime of that scope.

  • Again, our sources are flows, and flows are welcome.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
                initialValue = FocusSummary.Idle,
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                         FocusSummary.Active(...)
            }
    }
  • We're going to bridge each one with collectAsState, and the session is a StateFlow, so we don't need an initial value, but the Room Flow is cold, so we give it one.

  • And that's the whole subscription story.

  • The read is what subscribes us.

  • It's the automatic read tracking that Ben was talking about earlier in the example we saw, but in a ViewModel.

  • We can even introduce early returns like this here when we're in an idle state.

  • Let's say, we have no new tasks, we won't even process the rest.

  • And again, this is the dynamic read tracking paying off again.

  • While we're idle, the Room Flow is not collected at all.

  • We then finally emit the summary.

  • So, if the user pauses the task or the timer ticks, everything automat-- automatically gets updated.

Nested transforms

  • So, things get a bit more complicated when we step outside of the single ViewModel example.

  • Let's say, this same session needs to show up on two more additional surfaces, a mini-timer bar that lives at the bottom of the app's screen on every screen, and a home-screen widget.

  • Both of these will need the same derived information about the session summary, which phase, paused or not, what-- how much time left in the timer.

  • And this is going to mean that if every single surface were to assemble its own copy, that's the same derivation three times and three different places that all have to be in agreement with each other.

  • So, what we'll need to do instead is derive the summary once in the application scope and then everything downstream will consume it.

  • So, how do we actually go about implementing something like this?

  • Where our implementation is probably going to need three pieces.

    class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
        ...
    }
    
    class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
        ...
    }
    
    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(context: Context, id: GlanceId) {
            provideContent {
                ...
            }
        }
    }
  • So, a session manager in the application scope that's going to be where the summary gets derived, a mini-timer's ViewModel, and a Glance Widget, both of which is going to consume the summary derived in this focus session manager.

  • Let's build each one like the traditional way and then with Transform.

  • So, traditionally, again, we would start by deriving the summary once in the application scope as a StateFlow.

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: StateFlow<FocusSummary> =
            combine(
                sessionFlow,
                taskRepository.tasksFlow
            ) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find {
                        it.id == session.activeTaskId
                    }
                    FocusSummary.Active(...)
                }
            }.stateIn(
    scope = appScope,
    started = SharingStarted.WhileSubscribed(5_000),
    initialValue = FocusSummary.Idle
    )
    }
  • It's the same combine and stateIn calls that we saw earlier in the ViewModel example, but just moved up a level.

  • With Transform, we can also reuse the same declaration from the ViewModel we saw earlier.

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
               initialValue = FocusSummary.Idle,
    scope = scope,
    container = handle.getOrCreateContainer("transform")
            ) {
                val session by sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow
                    .collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
    }
  • The only thing that's changed is, again, it now lives in the application scope.

  • Next up, we have the mini-timer's ViewModel.

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: StateFlow<MiniTimerUiState> =
            combine(
                focusManager.summary,
                isExpanded
            ) { summary, expanded ->
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = MiniTimerUiState()
    )
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • Let's say, we want to give ourselves a little bit of a challenge here, too.

  • Let's say that in the mini-timer, in addition to the two data streams that we had already, the StateFlow and the Room Flow, we also want to track whether the timer bar is expanded or not.

  • And we use a MutableStateFlow for that.

  • To do this, we're going to have to use another combine to track the summary flow and the isExpanded StateFlow.

  • And we get a second state in.

  • With Transform, we don't have to use any of these chained operators again to achieve the syncing between data streams.

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: State<MiniTimerUiState> =
            transform(
                initialValue = MiniTimerUiState(),
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val summary by focusManager.summary
                       val expanded by isExpanded.collectAsState()
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • What we can do, and this is really cool, is that we can do nested Transforms.

  • So, here the ViewModel reads the summary, which is the output of the upstream Transform, just like any other state, and then it collects isExpanded with collectAsState.

  • So, we can mix an upstream state and a downstream flow in the StateFlow right in the same block.

  • And remember granularity.

  • So, if you were to toggle isExpanded, it's only going to rerun this block only.

  • The app-level block and the widget don't even have to notice.

  • Lastly, we have the widget.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                    .collectAsState()
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • Since Glance is Compose, we can collect the StateFlow with collectAsState inside a provideContent like any other Composable.

  • With Transform, this is even simpler.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • Our focus manager is providing us with a Compose State, so there's nothing that we need to convert here.

  • And ultimately, the mini-timer bar and the home-screen widget can never disagree because they're reading the same state.

  • And not only that, but any change made to that state will be reflected to all of the consumers of that state.

  • What does that mean?

  • So, if you were to go and hit pause on the widget here, then it's going to go through the manager into the session stream, back out through the summary, and all three of these screens are going to be updated in sync.

  • So, now that we've looked at how to use Transform a little bit, let's take a deeper dive under the hood.

  • The Transform API that we saw earlier is actually a helper function used to create a basic managed Transform.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R> {
            val transformScope = TransformScope()
        val transform = transformScope
    .launchTransform(initialValue, onUpdate)
        scope.launch(
    dispatcher,
    start = CoroutineStart.UNDISPATCHED
     ) {
            try { transformScope.run() } finally {
                transform.dispose()
            }
        }
        return transform
    }
  • In reality, it actually uses a TransformScope and a launchTransform extension function, which launches a Composable transformation in this TransformScope and returns a disposable state.

  • When you read it top to bottom, you're going to see that it does everything for you.

  • It creates the TransformScope and launches your Transform into it, which is going to be the state that you get back.

  • And then it also launches run in the TransformScope on the dispatcher that you provided, so that the scope's lifetime and the thread belong to you.

  • And then the finally disposes of your transformation when the scope ends.

  • This is the whole life-cycle story of Transform.

  • So, let's take a look at the TransformScope interface here really quickly.

    public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
    
    public abstract class TransformScope {
            public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
    public abstract suspend fun run()
    }
    
    public fun <T> TransformScope.launchTransform(
    initialState: T,
    onUpdate: @Composable (T) -> T,
    ): DisposableState<T>
    
    public interface DisposableState<T> : State<T> {
    public fun dispose()
    }
  • We can see that it has a createTransform function, which is going to create a Transform attached to this TransformScope.

  • And it also has a suspending run function, which starts the TransformScope and begins the process of updating the present state transformations.

  • What does that mean?

  • So, run actually sets up the Recomposer and the composition.

  • And every Transform in the scope lives inside that composition and has its very own recompose scope.

  • This is why we want to share scope and why we have TransformScope.

  • So that whenever a state changes, only the Transforms that read it are rerun.

  • As we noted earlier, run is a suspend function.

    override suspend fun run() {
            val context = currentCoroutineContext()
            val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
            ...
            val recomposer = Recomposer(context)
            val composition = Composition(UnitApplier, recomposer)
            try {
                withContext(clock) {
                    if (clock is HeadlessTransformClock) {
                        launch(start = CoroutineStart.UNDISPATCHED) {
                            clock.runClock()
                        }
                    }
                    ...
                }
            } finally {
                // dispose of composition, cancel the clock, close the recomposer
            }
        }
  • So, it runs on whatever coroutine that you call it from until that coroutine is canceled.

  • Now, you might be asking or wondering, a Recomposer needs a frame clock to tell it when it needs to produce a frame.

  • In Compose UI, that's the display, but as we mentioned earlier, we're in a headless environment, so there is no UI.

  • So, unless you provide us with a clock, we're going to create our own.

  • By decoupling recomposition from the display frame clock, Transform runs all of its transformations and state derivations off the UI thread.

  • To allow this, we built a dedicated HeadlessTransformClock.

  • Transform frame clock.

  • Since a widget, a service, or another Transform downstream might be waiting on a value from a Transform, our clock is eager.

  • This means that the moment any state a Transform is consuming changes, a new frame is produced immediately.

  • It's a small lock-free state machine, so requesting a frame is a simple compare-and-set operation.

  • And when there's nothing that has changed in the state, the clock parks.

  • So, a Transform that's consuming a state that hasn't been changed at all costs you nothing.

  • Before I hand it off back to Ben, we should also note that Transform now supports rememberSaveable to ensure that your data is saved across configuration changes.

  • It does so through the new SavedStateContainer API, which is now the building block on which Compose's SaveableStateRegistry, SavedStateHandle, and SavedStateRegistry are all built on.

  • You can turn any of these into a SavedStateContainer or create child containers and pass them into your Transform to ensure that your data is saved.

  • Finally, a reminder again that all the APIs that we're talking about today are all in alpha, so they are subject to change.

  • With that, handing it off back to Ben.

Compose State outside of the UI

  • All right.

  • All righty, thanks, Elif.

  • So, now that we've seen how Compose state is powerful and how Transform lets us derive it outside of the UI, one thing you might have noticed in the example so far is our inputs were Flows that we bridged using collectAsState.

  • But is that always necessary?

  • Why not use Compose state directly in our state holders?

  • So, to answer that question, I find it helpful to think about state within three broad categories.

  • The first is live state that's only ever stored in memory.

  • Think things like network or Bluetooth connection status.

  • You wouldn't want to use a persisted value.

  • And then, the second is transient state that should survive process death, but doesn't need to be stored indefinitely.

  • Think things like UI state, such as whether a card is expanded or not, and session state, like your navigation back stack or a form draft.

  • And then, the third is permanent state, which is stored indefinitely.

  • Think things like user preferences or a media library living in a database.

  • So, let's start with how you can use Compose state for both live and transient state.

  • Up until now, our architecture guidance kept Compose state in the UI layer, or at sometimes, in the ViewModels, and we told you to use Flow everywhere else.

  • But why was that?

  • And it's because as soon as you needed to combine a state property with a Flow, you had to convert it back using snapshotFlow.

  • But with Transform, that conversion isn't necessary.

  • So, let's look at what this looks or what this means, starting in a ViewModel.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(
        private val tasksRepository: TasksRepository,
        private val networkMonitor: NetworkMonitor,
        savedStateHandle: SavedStateHandle,
    ) : ViewModel() {
        ...
    }
  • For local screen inputs, like selectedTaskIds here, you can declare plain mutableStateOf property instead of MutableStateFlow.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    }
  • And because this is something that you does don't necessarily want to persist across process death, it's okay that it's not like that it's just using mutableStateOf.

  • For things that you do want to survive process death, like the search query, you can back its mutable state directly with savedStateHandle.saveable.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    }
  • And then, just like updateScore in the game example earlier, when a method updates multiple state values at once, wrapping the writes in withMutableSnapshot ensures consistency.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : 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()
            }
        }
    }
  • Then, to combine searchQuery with a Room Flow, you just read it directly inside the Transform, while collecting the Flow.

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • So, what about live state like network connectivity?

    @Singleton
    class NetworkMonitor @Inject constructor(
        @ApplicationContext context: Context,
    ) {
        private val connectivityManager =
            context.getSystemService(ConnectivityManager::class.java)
        ...
    }
  • This usually lives in a Singleton that's because it's read across multiple screens or even by multiple app components, like services.

  • So, but that doesn't mean you can't use Compose State to store it.

  • In fact, it's often simpler than a flow-based version.

  • You don't need a callback flow or anything to do the adaption, you just update dot value directly in the callbacks.

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    }
  • And, if a consumer still needs a flow, you can use snapshotFlow to bridge back.

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    
        private val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) { _isOnline.value = true }
            override fun onLost(network: Network) { _isOnline.value = false }
        }
    
        init {
            connectivityManager.registerDefaultNetworkCallback(callback)
        }
    }
  • So, let's take a look what that looks like in the ViewModel.

  • You can read isOnline directly in a Transform, and it'll recompose when the value changes.

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        if (!networkMonitor.isOnline.value) {
            return@transform TaskListState.Offline
        }
    
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • And, all this is just happening directly, there's no like, application scope, or, coroutine scope that you need to provide to update the state, it's just, you know, a direct, synchronous update.

  • So, with Transform, your ViewModel can combine State from Singleton managers, State in a ViewModel, and Flows from Repositories, all in a single Transform block.

  • And, now let's go back to transient State.

  • Earlier, I mentioned two types: UI state and session state.

  • For UI state, and even session state that lives inside of a single Activity, ViewModel and SavedState work great.

  • That's exactly what they were designed for.

  • But, when things start to break down is when that session state needs to be shared across a bunch of different UIs, and app components.

  • Once that state moves outside of a ViewModel, our existing APIs and guidance leave a bit of a gap.

  • So, first the SavedState APIs save into an Activity's Bundle, but that's not available in other app components, like a service.

  • Second, mutableStateOf in a Singleton, that only lives in memory, so it doesn't survive process death.

Introducing StateStore

  • And, that's the gap that Jetpack AppState, StateStore fills.

  • It's a type-safe map of Compose State that can be shared across app components and backed by DataStore to provide, to survive process death.

  • You could wire DataStore into every Singleton yourself, but that means writing save and restore boilerplate for every property.

  • With the StateStore DataStore Integration, you hook up the DataStore once, and then you opt in each little bit of state that you want to persist.

  • So, let's build out that in our focus timer app.

  • We're going to start in-memory first, and then add DataStore persistence afterwards.

  • To set the stage, we're modeling a FocusSession as a sealed interface with idle, running, and paused states.

    sealed interface FocusSession {
        data object Idle : FocusSession
        data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
  • And then, to store this state in StateStore, we defined a StateStoreKey with its type, FocusSession, and a default value, idle.

    sealed interface FocusSession { ... }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
  • And then, we provide a Singleton StateStore in Hilt that we can inject into different components where we need it, such as Focus Session Manager.

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(): StateStore = StateStore()
    }
    
    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        ...
    }
  • So, similar to how the ViewModel injects SavedStateHandle.

  • Okay.

  • So, now we've got a StateStore, but how do we use it?

  • Well, good thing, it's a small API.

  • It just has three methods, so we're going to go through them one by one.

  • The first is setState, which writes a new value for a key.

    fun startFocus(taskId: String, durationMinutes: Int = 25) {
        val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
        stateStore.setState(
            FocusSessionKey,
            FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
        )
    }
    
    fun endFocus() {
        stateStore.setState(FocusSessionKey, FocusSession.Idle)
    }
  • When a session starts, we set it to running, and when the session ends, we set it to idle.

  • That's it.

  • Second, we have, still have, updateState, which lets you compute a new value based off of the current value, such as calculating the time remaining when switching between running and paused.

    fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Running) return@updateState session
            FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
        }
    }
    
    fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Paused) return@updateState session
            FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
        }
    }
  • And lastly, there's getState, which returns a read-only State for a key, and it has the option to override the default value, if you want to change that up at a specific call site.

    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
        ...
    }
  • And, exposing the FocusSession State in this manager means that consumers never have to access StateStore or its keys directly.

  • You can in-- maintain write and read encapsulation.

  • And then, instead of consumers they just inject Focus Session Manager, which, well, they probably already have because they need to control the session, and then they can read the State.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() { ... }
    
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface WidgetEntryPoint {
        fun focusSessionManager(): FocusSessionManager
    }
  • And then, those readers, they can either directly re-expose the State, or they can use Transform to make a new, updated State kind of like we saw with the Mini-Timer ViewModel example earlier.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
  • And then, the UI Composables can read that State just like any other Compose State and recompose when necessary.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
    
    @Composable
    fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
        val session by viewModel.session
        if (session !is FocusSession.Idle) {
            TimerReadout(session = session)
        }
    }
  • But so far, the FocusSession only ever lives in memory.

  • So, let's look at what it makes takes to survive process death.

  • So, first, we have to do some one-time setup by launching the syncToDataStore when we create the store.

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(
            @ApplicationContext context: Context,
            @ApplicationScope scope: CoroutineScope,
        ): StateStore = StateStore().apply {
            val path = context.dataStoreFile("app_state.json").absolutePath
            scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
        }
    }
  • And then, after that, we opt in each key that we want to persist.

    @Serializable
    sealed interface FocusSession {
        @Serializable data object Idle : FocusSession
        @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
        PersistToDataStore
  • All we have to do is make sure that the type is serializable and that the key implements PersistToDataStore interface.

  • And then, you might be wondering, okay, that seems easy.

  • So, what does it actually do to persist the key?

  • So, under the hood, syncToDataStore uses Transform's listener API, which is basically the exact same thing as Transform, except it doesn't return anything, it's just used for observation and side effects.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener { … }
    }
  • And then, within that listener, it reads every single key within the store, and it checks if one is a PersistToDataStore key.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener {
            for (key in keys) {
                if (key is PersistToDataStore) {
                    val value = getState(key).value
    
                    LaunchedEffect(key) {
                        // Restore saved value from dataStore on first access
                    }
    
                    LaunchedEffect(key, value) {
                        // Flush updated value to dataStore on change
                    }
                }
            }
        }
    }
  • When it is, it uses LaunchedEffect to sync those values back to DataStore whenever they change.

  • And, the thing I want to point out here is that having a standard container like StateStore is what lets us provide these drop-in integrations.

  • DataStore is just the first one we're looking at.

Summary of Compose State categories

  • So, stepping back to our state categories, we now have a pretty clear picture of how you can use Compose State across your entire application.

  • From mutableStateOf for live state, to the SavedState APIs for screen-local presentation state, and StateStore for cross-component session state, and then lastly, we've got collectAsState for interop with Flows.

  • But really, interop isn't limited to any single category, you can just use those helpers like collectAsState whenever you need to bridge observable types into Compose State.

  • But for permanent state, one other thing we're looking at is ways to work with Compose or for DataStore and Room to directly expose Compose State, so you don't have to write your own like nicer syntax for doing that.

  • And with that, I'm happy to announce that Transform, StateStore, and the DataStore integration are all available in alpha today, and that we've also published all the documentation on developer.android.com, and we've got an AppState Recipes repo on GitHub that shows examples of using both StateStore and Transform.

    [versions]
    appstate = "1.0.0-alpha01"
    ...
    
    [libraries]
    ...
    transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
    transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
    statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
    statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }
  • And, yeah, please give them a try in your app.

  • Give us feedback.

  • Like, we're in alpha, so this is when we can really iterate and make changes that'll make sure that they work for your use cases.

Code

Code snippets

Kotlin snippet at 01:56 in "What is Compose State?"
/**
 * A value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value.
 */
@Stable
public interface State<out T> {
    public val value: T
}
Kotlin snippet at 02:00 in "What is Compose State?"
/**
 * A mutable value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
 * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
 * will be scheduled. If [value] is written to with the same value, no recompositions will be
 * scheduled.
 */
@Stable
public interface MutableState<T> : State<T> {
    override var value: T
    public operator fun component1(): T
    public operator fun component2(): (T) -> Unit
}
Kotlin snippet at 02:23 in "What is Compose State?"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
}
Kotlin snippet at 02:32 in "What is Compose State?"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}
Kotlin snippet at 02:52 in "What is Compose State?"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}

@Composable
fun AddButton(onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("Add")
    }
}
Kotlin snippet at 03:08 in "What is Compose State?"
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(true) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
Kotlin snippet at 03:26 in "What is Compose State?"
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(false) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
Kotlin snippet at 04:15 in "What is Compose State?"
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0
Kotlin snippet at 04:23 in "What is Compose State?"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)
Kotlin snippet at 04:33 in "What is Compose State?"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}
Kotlin snippet at 04:42 in "What is Compose State?"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    Column {
        Header(levelName = state.levelName)
        ScoreBadge(
            timeSurvived = state.timeSurvived,
            score = state.score
        )
    }
}
Kotlin snippet at 05:09 in "What is Compose State?"
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}
Kotlin snippet at 05:21 in "What is Compose State?"
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}
Kotlin snippet at 05:30 in "What is Compose State?"
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state = viewModel.uiState
    Column {
        Header(state = state)
        ScoreBadge(state = state)
    }
}
Kotlin snippet at 07:35 in "Introducing Transform"
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R>
Kotlin snippet at 09:20 in "Introducing Transform"
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: StateFlow<FocusSummary> =
        combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
            }
        }.stateIn(
                scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = FocusSummary.Idle,
)
}
Kotlin snippet at 09:37 in "Introducing Transform"
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find { it.id == session.activeTaskId }
            FocusSummary.Active(...)
Kotlin snippet at 10:01 in "Introducing Transform"
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
            initialValue = FocusSummary.Idle,
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
                     FocusSummary.Active(...)
        }
}
Kotlin snippet at 11:39 in "Nested transforms"
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
    ...
}

class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
    ...
}

class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            ...
        }
    }
}
Kotlin snippet at 12:03 in "Nested transforms"
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: StateFlow<FocusSummary> =
        combine(
            sessionFlow,
            taskRepository.tasksFlow
        ) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
        }.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}
Kotlin snippet at 12:17 in "Nested transforms"
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
           initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
        ) {
            val session by sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow
                .collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
            FocusSummary.Active(...)
        }
}
Kotlin snippet at 12:28 in "Nested transforms"
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: StateFlow<MiniTimerUiState> =
        combine(
            focusManager.summary,
            isExpanded
        ) { summary, expanded ->
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = MiniTimerUiState()
)

    fun toggleExpanded() { isExpanded.update { !it } }
}
Kotlin snippet at 12:56 in "Nested transforms"
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: State<MiniTimerUiState> =
        transform(
            initialValue = MiniTimerUiState(),
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val summary by focusManager.summary
                   val expanded by isExpanded.collectAsState()
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }

    fun toggleExpanded() { isExpanded.update { !it } }
}
Kotlin snippet at 13:37 in "Nested transforms"
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
                .collectAsState()
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
Kotlin snippet at 13:47 in "Nested transforms"
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
Kotlin snippet at 14:33 in "Nested transforms"
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R> {
        val transformScope = TransformScope()
    val transform = transformScope
.launchTransform(initialValue, onUpdate)
    scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
 ) {
        try { transformScope.run() } finally {
            transform.dispose()
        }
    }
    return transform
}
Kotlin snippet at 15:18 in "Nested transforms"
public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope

public abstract class TransformScope {
        public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}

public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>

public interface DisposableState<T> : State<T> {
public fun dispose()
}
Kotlin snippet at 16:02 in "Nested transforms"
override suspend fun run() {
        val context = currentCoroutineContext()
        val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
        ...
        val recomposer = Recomposer(context)
        val composition = Composition(UnitApplier, recomposer)
        try {
            withContext(clock) {
                if (clock is HeadlessTransformClock) {
                    launch(start = CoroutineStart.UNDISPATCHED) {
                        clock.runClock()
                    }
                }
                ...
            }
        } finally {
            // dispose of composition, cancel the clock, close the recomposer
        }
    }
Kotlin snippet at 19:12 in "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    private val networkMonitor: NetworkMonitor,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    ...
}
Kotlin snippet at 19:17 in "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set
}
Kotlin snippet at 19:33 in "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set
}
Kotlin snippet at 19:41 in "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : 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()
        }
    }
}
Kotlin snippet at 19:52 in "Compose State outside of the UI"
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
Kotlin snippet at 20:00 in "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)
    ...
}
Kotlin snippet at 20:17 in "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline
}
Kotlin snippet at 20:23 in "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline

    private val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) { _isOnline.value = true }
        override fun onLost(network: Network) { _isOnline.value = false }
    }

    init {
        connectivityManager.registerDefaultNetworkCallback(callback)
    }
}
Kotlin snippet at 20:32 in "Compose State outside of the UI"
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    if (!networkMonitor.isOnline.value) {
        return@transform TaskListState.Offline
    }

    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
Kotlin snippet at 22:07 in "Introducing StateStore"
sealed interface FocusSession {
    data object Idle : FocusSession
    data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
Kotlin snippet at 22:14 in "Introducing StateStore"
sealed interface FocusSession { ... }

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
Kotlin snippet at 22:23 in "Introducing StateStore"
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(): StateStore = StateStore()
}

@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    ...
}
Kotlin snippet at 22:42 in "Introducing StateStore"
fun startFocus(taskId: String, durationMinutes: Int = 25) {
    val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
    stateStore.setState(
        FocusSessionKey,
        FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
    )
}

fun endFocus() {
    stateStore.setState(FocusSessionKey, FocusSession.Idle)
}
Kotlin snippet at 22:52 in "Introducing StateStore"
fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Running) return@updateState session
        FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
    }
}

fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Paused) return@updateState session
        FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
    }
}
Kotlin snippet at 23:03 in "Introducing StateStore"
@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
    ...
}
Kotlin snippet at 23:22 in "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }

@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
    fun focusSessionManager(): FocusSessionManager
}
Kotlin snippet at 23:33 in "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}
Kotlin snippet at 23:45 in "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}

@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
    val session by viewModel.session
    if (session !is FocusSession.Idle) {
        TimerReadout(session = session)
    }
}
Kotlin snippet at 23:58 in "Introducing StateStore"
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(
        @ApplicationContext context: Context,
        @ApplicationScope scope: CoroutineScope,
    ): StateStore = StateStore().apply {
        val path = context.dataStoreFile("app_state.json").absolutePath
        scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
    }
}
Kotlin snippet at 24:04 in "Introducing StateStore"
@Serializable
sealed interface FocusSession {
    @Serializable data object Idle : FocusSession
    @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
    PersistToDataStore
Kotlin snippet at 24:22 in "Introducing StateStore"
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener { … }
}
Kotlin snippet at 24:33 in "Introducing StateStore"
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener {
        for (key in keys) {
            if (key is PersistToDataStore) {
                val value = getState(key).value

                LaunchedEffect(key) {
                    // Restore saved value from dataStore on first access
                }

                LaunchedEffect(key, value) {
                    // Flush updated value to dataStore on change
                }
            }
        }
    }
}
TOML snippet at 25:39 in "Summary of Compose State categories"
[versions]
appstate = "1.0.0-alpha01"
...

[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }