DEV Community

loading...

A historical introduction to the Compose reactive state model

Zach Klippenstein
Opinions are my own. He/him.
・9 min read

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”.

This post is part of a series that attempts to explain how Compose does this via its snapshot state system. Stay tuned for the sequel!

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 (Observables) 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 vs val 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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The initialize() function used in the CounterButton 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 an init block or onAttachedToWindow. 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}
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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}
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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}
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 Views.

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)
Enter fullscreen mode Exit fullscreen mode

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})
}
Enter fullscreen mode Exit fullscreen mode

The Compose compiler wraps the body of this CounterButton function with code that effectively observes any and all MutableStates 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 blog post, so stay tuned for a follow-up!

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.

Huge thanks to Mark Murphy and @jossiwolf for helping review and edit this post!

Discussion (7)

Collapse
mr3ytheprogrammer profile image
M R 3 Y

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).

Isn't that something compose is already doing? for example when you have a LaunchedEffect & any other composable as children of parent composable, AFAIK they will be executed in parallel:

@Composable
fun Component() {
       var state by remember{ mutableStateOf(0) }
       LaunchedEffect(Unit) {
             // do some work which utilizes state's value
       }
       Box() {
             // Use the value of state, too.
       }
}
Enter fullscreen mode Exit fullscreen mode

From my experience with compose I will say that LaunchedEffect & Box will be executed in parallel

Collapse
zachklipp profile image
Zach Klippenstein Author

That’s true, and my wording wasn’t as precise as it should have been. You are correct that effects can already be used to run code in parallel to composition.

What I intended that sentence to mean was that Compose can perform composition of multiple composables in parallel. It does not currently do this - when preparing a frame, all invalidated composable functions are currently recomposed in a loop on the main thread. Experimental support for parallel recomposition is already present but you have to opt in to it using fairly low level APIs.

So, for example, in this code, when state is changed, the lambdas passed to box 1 and box 2 would both be invalidated, and then they could both be re-executed on different background threads. Right now they’d be executed serially on the main thread.

@Composable fun Root() {
  val state by remember { mutableStateOf() }
  Column {
    // Box 1
    Box {
      println(state)
    }
    // Box 2
    Box {
      println(state)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
mr3ytheprogrammer profile image
M R 3 Y

Oh! Thanks for clarifying, I got it. I can imagine how powerful this technique could be

Collapse
aartikov profile image
Artur Artikov

Should we use mutableStateOf in ViewModels? Official documentation recommends to use LiveData or StateFlow for some reason: developer.android.com/jetpack/comp...

Collapse
zachklipp profile image
Zach Klippenstein Author

It depends how much you want to couple your ViewModels to the Compose runtime and mental model. I do wish the snapshot stuff was in a separate artifact so this would be an easier call. I would see no reason to not use snapshot state in view models in a 100% compose app. I also think that in such an app, "view model" is basically interchangeable with/just another word for "hoisted state class".

Collapse
aartikov profile image
Artur Artikov

Should we use Compose state management outside of presentation layer? I mean some stateful observable objects in domain or data layers.

Collapse
zachklipp profile image
Zach Klippenstein Author

I don't personally have a strong opinion here. If it makes sense, and it makes the code cleaner/more testable/easier to maintain, then sure.

Forem Open with the Forem app