States and Events¶
Overview¶
The core state and event interfaces in Circuit are CircuitUiState
and CircuitUiEvent
. All state and event types should implement/extend these marker interfaces.
data class CounterState(val count: Int, val eventSink: (Event) -> Unit) : CircuitUiState
sealed interface CounterEvent : CircuitUiEvent {
data object Increment : CounterEvent
data object Decrement : CounterEvent
data object Reset : CounterEvent
}
Presenters are simple classes or functions (usually the former) that compute state and handle events sent to them.
@Composable
fun CounterPresenter(): CounterState {
var count by remember { mutableIntStateOf(0) }
return CounterState(count) { event ->
when (event) {
Increment -> count++
Decrement -> count--
Reset -> count = 0
}
}
}
UIs are simple classes or functions (usually the latter) that render states. UIs can emit events via eventSink
properties in state classes.
@Composable
fun Counter(state: CounterState, modifier: Modifier = Modifier) {
Column(modifier) {
Text("Count: ${state.count}")
Button("Increment", onClick = { state.eventSink(Increment) })
Button("Decrement", onClick = { state.eventSink(Decrement) })
Button("Reset", onClick = { state.eventSink(Reset) })
}
}
These are the core building blocks! States should be @Stable
; events should be @Immutable
.
Wait, event callbacks in state types?
Yep! This may feel like a departure from how you’ve written UDF patterns in the past, but we really like it. We tried different patterns before with event Flow
s and having Circuit internals manage these for you, but we found they came with tradeoffs and friction points that we could avoid by just treating event emissions as another aspect of state. The end result is a tidier structure of state + event flows.
- Simpler cognitive overheads due to not always using
Flow
for events, which comes with caveats in compose (wrapping operators inremember
calls, pipelining nested event flows, etc) - Simple event-less UIs – state just doesn’t have an event sink.
- Simpler testing – no manual event flow needed. You end up writing more realistic tests where you tick along your presenter by emitting with its returned states directly.
- Different state types can have different event handling (e.g.
Click
may not make sense forLoading
states). - No internal ceremony around setting up a
Channel
and multicasting event streams. - No risk of dropping events (unlike
Flow
).
Note
Currently, while functions are treated as implicitly Stable
by the compose compiler, they’re not skippable when they’re non-composable Unit
-returning lambdas with equal-but-unstable captures. This may change though, and would be another free benefit for this case.
A longer-form writeup can be found in this PR.
FAQ¶
Doesn’t this break my state data classes’ ability to use
equals()
in tests?
Yes, but that’s ok! We found there are two primary solutions to this.
- Granularly assert expected state property values, rather than the whole object at once.
- Split your model into a separate class that is itself a property, if you really want/need to use
equals()
on the whole object. For example:data class StateData(val name: String, val age: Int) data class State(val data: StateData, val eventSink: (Event) -> Unit)
If neither of those satisfy your needs, there are alternative state designs described in alternative designs that avoid storing the event sink as a property.
Don’t lambdas break equality/stability checks? Do I need to wrap them in
remember
calls first?
Lambdas are automatically remembered in compose via lambda memoization, so you don’t need to manually remember them first.
Alternative Designs¶
The above docs describe how we conventionally write Circuit states. You’re not limited to this however, and may want to write them differently depending on your project’s needs. This section describes a few patterns we’ve explored. You can also mix-and match different aspects of these.
Using Poko¶
Poko is a neat library for generating hashCode/equals/toString impls without needing to use data
classes. Aside from its documented benefits over data classes, it has a neat @Poko.Skip
feature that allows for exclusion of annotated properties from equals/hashCode.
@Poko
class CounterState(val count: Int, @Poko.Skip val eventSink: (Event) -> Unit) : CircuitUiState
When to Use
Use this pattern if you want to limit API surface area from what data classes and want to just exclude event sinks from equals/hashCode.
Poko with a shared event interface¶
If you want to take Poko a step further and avoid denoting it as a property at all, you can create a base interface that handles events.
@Stable
interface EventSink<UiEvent : CircuitUiEvent> {
fun onEvent(event: UiEvent)
}
/**
* Creates an [EventSink] that calls the given [body] when [EventSink.onEvent] is called.
*
* Note this inline function + [InlineEventSink] return type are a bit of bytecode trickery to avoid
* creating a new class for every lambda passed to this function. The end result should be that the
* lambda is inlined directly to the field in the implementing class and the inlined
* [EventSink.onEvent] method impl is inlined directly as well to call it.
*/
@Suppress("NOTHING_TO_INLINE")
inline fun <UiEvent : CircuitUiEvent> eventSink(
noinline body: (UiEvent) -> Unit
): EventSink<UiEvent> = InlineEventSink(body)
/** @see eventSink */
@PublishedApi
@JvmInline
internal value class InlineEventSink<UiEvent : CircuitUiEvent>(private val body: (UiEvent) -> Unit) : EventSink<UiEvent> {
override fun onEvent(event: UiEvent) {
body(event)
}
}
Tip
In this case, access from the UI is now state.onEvent(<event>)
. You could change this function name to whatever you want, or even make it syntactically shorter with operator fun invoke
.
With this, you can then define your state without marking the eventSink as a property.
@Poko
class CounterState(
val count: Int,
eventSink: (Event) -> Unit
) : CircuitUiState, EventSink<Event> by eventSink(eventSink)
When to Use
- You want to exclude event sinks from equals/hashCode without relying on
@Poko.Skip
.
Using interfaces¶
Instead of defining a class for your state, you could define them in a more conventional compose-like state interface. Then, your presenters would return implementations of this interface that are backed directly by its internal State
variables. Then, events are denoted as callable functions on the interface.
interface CounterState : CounterState {
val count: Int
fun increment() {}
fun decrement() {}
}
Then its implementation in the presenter would look like so.
@Composable
fun CounterPresenter(): CounterState {
return remember {
object : CounterState {
override var count: Int by mutableIntStateOf(0)
private set
override fun increment() {
count++
}
override fun decrement() {
count--
}
}
}
}
When to Use
- You want to limit API surface area from what data classes
- Want to exclude event sinks from equals/hashCode
- Want to limit state object allocations (only one state instance is ever created then remembered).
- Only do this if you have actually measured performance.
- You want to bridge to another UDF architecture that uses event interfaces (super helpful for interop!)