A historical introduction to the Compose reactive state model
Jetpack Compose offers a completely new way to write, and to think about, UI code. One of its key features is that Compose code is reactive, which is to say it automatically updates in response to state changes. What really makes this feature magic, however, is that there is no explicit “reactive API”.
Background
Some time in the 10 years before this post was written in 2021, RxJava became the de facto standard way to write reactive UI code. You would design your APIs around streams (Observable
s) and some infrastructure code would glue streams together and provide other wiring like automatic subscription management. Streams could signal events or hold state and notify listeners about changes to that state. Business logic tended to be written as functional transforms on streams (shoutout to flatMap
).
RxJava was a major step up from manually implementing the observer pattern by creating your own Listener
interfaces and all the related boilerplate. Observables support sophisticated error handling and handle all the messy thread-safety details for you. But not all the grass was greener on the Rx side of the fence. Large apps with many streams can quickly become hard to reason about. APIs were tightly coupled to the reactive libraries, since the only way to express reactivity was to expose stream types.
- Does this stream emit immediately or do I need to provide an initial value?
- How do I combine multiple streams in the right way –
combineLatest
,concat
,merge
,switchMap
, oh my. - How do I make a mutable property? I can’t use a Kotlin property because the getter needs to return a stream, but the setter needs to take a single, non-stream value.
- If I need to expose multiple state values, do I combine them into a single stream that emits all values at once or expose multiple streams?
- Do I need to
observeOn
or am I already on the right thread? - How do I integrate all these nice async streams with this one legacy synchronous API?
- How do I provide both async and sync, or push-based streams and pull-based getter APIs, without almost-duplicating methods (
val currentTime: Date
vsval times: Observable<Date>
)?
Roughly ten years after introducing RxJava into the codebase I work in, @pyricau is still finding code that leaks because it’s not handling subscriptions just right.
As the industry adopted Kotlin, a lot of codebases started to migrate from RxJava to Flow – a similar stream library built around coroutines. Flows solved some of the problems of RxJava – structured concurrency is a much safer way to manage subscription logic – but a stream is still a stream. While it’s possible to get into the habit of thinking of everything in terms of streams, it’s one more layer of conceptual overhead to learn. It’s not intuitive to a lot of new developers, and even experienced developers get tripped up regularly. If only there were a better way.
Example
Consider the following hypothetical implementation of a special button:
class Counter {
var value: Int = 0
private set
fun increment() { value++ }
}
class CounterButton(val counter: Counter) : Button() {
fun initialize() {
this.text = “Counter: ${counter.value}”
setOnClickListener {
counter.increment()
}
}
}
The
initialize()
function used in theCounterButton
is not from either classic Android Views or Compose – for the sake of these examples, it is meant to be called by some glue code elsewhere in the app. If that’s unsatisfyingly vague, you can imagine it could be called from aninit
block oronAttachedToWindow
. There is another reason for defining a separate function, which I’ll explain once we get to the Compose content later in the post.
Can you tell what the programmer’s intent was? They wanted to make a button that shows the current value of a counter, and when you click the button, the counter is incremented. But this code is very broken. The text is only set once, when the button is initialized, and is never updated. Let’s fix that bug:
class CounterButton(val counter: Counter) : Button() {
fun initialize() {
this.text = “Counter: ${counter.value}”
setOnClickListener {
counter.increment()
this.text = “Counter: ${counter.value}”
}
}
}
Now the text will be updated when the counter is incremented! But let’s say we want to decrement the value when the user long-presses on the button.
class Counter {
// …
fun decrement() { value-- }
}
class CounterButton(val counter: Counter) : Button() {
fun initialize() {
this.text = “Counter: ${counter.value}”
setOnClickListener {
counter.increment()
this.text = “Counter: ${counter.value}”
}
setOnLongClickListener {
counter.decrement()
this.text = “Counter: ${counter.value}”
}
}
}
This works, but there’s some duplication. Following, the Rule of Three, let’s factor the text update out:
class CounterButton(val counter: Counter) : Button() {
fun initialize() {
updateText()
setOnClickListener {
counter.increment()
updateText()
}
setOnLongClickListener {
counter.decrement()
updateText()
}
}
private fun updateText() {
this.text = “Counter: ${counter.value}”
}
}
Unfortunately, any time this button gets another feature, the developer still has to remember to call updateText. Ideally we’d like to express that the text should be updated whenever the counter value changes. Let’s try using RxJava:
class Counter {
private val _value = BehaviorSubject.createDefault(0)
val value: Observable<Int> = _value
fun increment() { _value.value++ }
fun decrement() { _value.value— }
}
class CounterButton(val counter: Counter) : Button() {
fun initialize() {
counter.value.subscribe { value ->
this.text = “Counter: $value”
}
setOnClickListener {
counter.increment()
}
setOnLongClickListener {
counter.decrement()
}
}
}
This looks like it works in testing, but turns out we’re leaking that subscription to counter.value
(which we might only realize after shipping this code). There are many ways to solve this, but since this blog post is supposed to be about Compose and not RxJava, I’ll leave that as an exercise for the reader. We’ve managed to keep the intent fairly clear, but the Counter
class has gained some boilerplate and leaves some open questions: What if we want to add another state value to the counter? Do we combine all the state values into a single stream, or expose multiple streams? Let’s try the latter:
class Counter {
private val _value = BehaviorSubject.createDefault(0)
val value: Observable<Int> = _value
fun increment() { _value.value++ }
fun decrement() { _value.value— }
private val _label = BehaviorSubject.createDefault(“”)
val label: Observable<String> = _label
fun setLabel(label: String) { _label.value = label }
}
class CounterButton(val counter: Counter) : Button() {
fun initialize() {
combineLatest(counter.label, counter.value) { label, value ->
Pair(label, value)
}
.subscribe { (label, value) ->
this.text = “$label: $value”
}
setOnClickListener {
counter.increment()
}
setOnLongClickListener {
counter.decrement()
}
}
}
Now there’s more boilerplate in CounterButton
– we had to start using RxJava APIs to combine streams, but this can get messy if there are more than a few streams. And although I’ve been specifically referencing RxJava, this problem isn’t unique to that particular library – any library that implements reactive programming via a stream or subscription-based API has the same issues (Project Reactor, Kotlin Flows, etc.). It looks like Android developers are doomed to spend the rest of their days tying streams in knots.
A better way
Compose introduces a mechanism for managing state that eliminates the vast majority of boilerplate. Let’s update the above sample to take advantage of it:
class Counter {
var value: Int by mutableStateOf(0)
private set
fun increment() { value++ }
fun decrement() { value— }
var label: String by mutableStateOf(“”)
}
class CounterButton(val counter: Counter) : Button() {
fun initialize() {
this.text = “${counter.label}: ${counter.value}”
setOnClickListener {
counter.increment()
}
setOnLongClickListener {
counter.decrement()
}
}
}
This looks a lot more like the code we started with! The only difference is the introduction of mutableStateOf
, which effectively makes the counter’s properties observable. State values that are managed by things like mutableStateOf
are generally referred to as “snapshot state”, for reasons that I will get into later. There are various types of state that all behave similarly, including mutableStateListOf
and friends, so I will use the term “snapshot state” to refer to this set of concepts.
You may have heard that Compose makes use of a compiler plugin. That is true, however none of the snapshot state infrastructure described here relies on that plugin. It’s all done with regular, vanilla Kotlin.
Snapshot state: Observation
Readers familiar with Compose might point out that widgets in Compose aren’t classes, they’re functions, and none of this looks very Compose-y at all. They would be right, but this highlights a great design feature of Compose: the state management infrastructure is completely decoupled from the rest of the “composable” concepts. For example, you could, theoretically, use snapshot state with classic Android View
s.
It’s important to note that this isn’t actually magic, and this code change wouldn’t actually work automatically: it assumes that whatever glue code calls initialize
supports Compose’s state management. Adding the wiring to make initialize
reactive could be as simple as this:
snapshotFlow { initialize() }
.launchIn(scope)
snapshotFlow
creates a Flow
that executes a lambda, tracks all the snapshot state values that are read inside the lambda, and then re-executes it any time any of those values are changed. The Compose documentation explains in more detail here. It might not be immediately obvious in such a simple example, but this is a huge improvement over the RxJava approach because the code to wire up initialize
only needs to be written once (e.g. in a base class or factory function) and it will automatically work for all code using that infrastructure.
The logic for “observing” changes to state only needs to exist in shared infrastructure code, not everywhere that wants to read observable values.
The UI code (or whatever other business-specific code you’re writing) doesn’t need to think about how to observe multiple state values, how to manage subscription lifecycles, or any of that other messy stream stuff. We could factor an interface out of Counter
that would declare regular properties, and they would still be observable when backed by snapshot state.
Composable functions already have this implicit observation logic wired up, which is why code like this would just work:
@Composable fun CounterButton(counter: Counter) {
Text(“${counter.label}: ${counter.value}”)
}
The Compose compiler wraps the body of this CounterButton
function with code that effectively observes any and all MutableState
s that happen to be read inside the function.
Snapshot state: Thread safety
Another advantage of using snapshot state is that it makes it much easier and safer to reason about mutable state across threads. If seeing “mutable state” and “thread” in the same sentence sets off alarm bells, you’ve got good instincts. Mutating state across threads is so hard to do well, and the cause of so many hard-to-reproduce bugs, that many programming languages forbid it. Swift’s new actor library includes thread isolation, following in the footsteps of actor-based languages like Erlang. Dart (the language used by Flutter) uses separate memory spaces for “isolates”, its version of threads. Functional languages like Haskell often brag that they are safe for writing parallel code because all data is deeply immutable. Even in Kotlin, the initial memory model for Kotlin Native requires all objects shared between threads to be “frozen” (i.e. made deeply immutable).
Compose’s snapshot state mechanism is revolutionary for UI programming in a way because it allows you to work with mutable state in a safe way, across multiple threads, without race conditions. It does this by allowing glue code to control when changes made by one thread are seen by other threads. While not as clear a win as implicit observation, this feature will allow Compose to add parallelism to its execution in the future, without affecting the correctness of code (as long as that code follows the documented best practices, at least).
Conclusion
Jetpack Compose is an incredibly ambitious project that changes many of the ways we think about and write UI code in Kotlin. It allows us to write fully reactive apps with less boilerplate and hopefully less cognitive overhead than we’ve been able to do in the past. Simple, clear code that is easy to read and understand will (usually) just work as intended. In particular, Compose makes mutable state not be scary anymore. I expect this will have a very positive impact on the general quality of Android apps since there are fewer opportunities for hard-to-troubleshoot classes of bugs, and complex behavior is easy to get right.
Please let me know what you thought in the comments! I know there are questions I haven’t answered.
Digging deeper
This post hopefully demonstrated the practical and ergonomic advantages to Compose’s state model, and maybe even sparked some new questions: How the heck does all this stuff actually work? The answer to that question deserves its own article, so check out the next article in the series, Introduction to the Compose Snapshot system.
On the other hand, if you’re just trying to figure out how to use these APIs in your UI code, you might find my cheat sheet on remember { mutableStateOf() }
useful.
Also, check out my other articles about Compose state.
Huge thanks to Mark Murphy and Jossi for helping review and edit this post!