Skip to content

Commit 69f1d60

Browse files
authored
Flesh out state docs more + different designs (#2061)
Pulled from #1077
1 parent 3efed9a commit 69f1d60

File tree

4 files changed

+171
-4
lines changed

4 files changed

+171
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Unreleased
55
----------
66

77
- Fix the provided `Modifier` not being used in `NavigatorDefaults.EmptyDecoration`
8+
- [docs] Add more alternative state designs.
89

910
0.27.1
1011
------

docs/states-and-events.md

Lines changed: 168 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,51 @@
11
States and Events
22
=================
33

4+
## Overview
5+
46
The core state and event interfaces in Circuit are `CircuitUiState` and `CircuitUiEvent`. All state and event types should implement/extend these marker interfaces.
57

6-
Presenters are simple functions that determine and return composable states. UIs are simple functions that render states. Uis can emit events via `eventSink` properties in state classes, which presenters then handle. These are the core building blocks!
8+
```kotlin
9+
data class CounterState(val count: Int, val eventSink: (Event) -> Unit) : CircuitUiState
10+
11+
sealed interface CounterEvent : CircuitUiEvent {
12+
data object Increment : CounterEvent
13+
data object Decrement : CounterEvent
14+
data object Reset : CounterEvent
15+
}
16+
```
17+
18+
Presenters are simple classes or functions (usually the former) that compute state and handle events sent to them.
19+
20+
```kotlin
21+
@Composable
22+
fun CounterPresenter(): CounterState {
23+
var count by remember { mutableIntStateOf(0) }
24+
return CounterState(count) { event ->
25+
when (event) {
26+
Increment -> count++
27+
Decrement -> count--
28+
Reset -> count = 0
29+
}
30+
}
31+
}
32+
```
33+
34+
UIs are simple classes or functions (usually the latter) that render states. UIs can emit events via `eventSink` properties in state classes.
735

8-
States should be [`@Stable`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/Stable); events should be [`@Immutable`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/Immutable).
36+
```kotlin
37+
@Composable
38+
fun Counter(state: CounterState, modifier: Modifier = Modifier) {
39+
Column(modifier) {
40+
Text("Count: ${state.count}")
41+
Button("Increment", onClick = { state.eventSink(Increment) })
42+
Button("Decrement", onClick = { state.eventSink(Decrement) })
43+
Button("Reset", onClick = { state.eventSink(Reset) })
44+
}
45+
}
46+
```
47+
48+
These are the core building blocks! States should be [`@Stable`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/Stable); events should be [`@Immutable`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/Immutable).
949

1050
> Wait, event callbacks in state types?
1151
@@ -19,6 +59,131 @@ Yep! This may feel like a departure from how you’ve written UDF patterns in th
1959
* No risk of dropping events (unlike `Flow`).
2060

2161
!!! note
22-
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.
62+
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.
2363

2464
A longer-form writeup can be found in [this PR](https://github.com/slackhq/circuit/pull/146).
65+
66+
## FAQ
67+
68+
> Doesn't this break my state data classes' ability to use `equals()` in tests?
69+
70+
Yes, but that's ok! We found there are two primary solutions to this.
71+
72+
1. Granularly assert expected state property values, rather than the whole object at once.
73+
2. 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:
74+
```kotlin
75+
data class StateData(val name: String, val age: Int)
76+
data class State(val data: StateData, val eventSink: (Event) -> Unit)
77+
```
78+
79+
If neither of those satisfy your needs, there are alternative state designs described in [alternative designs](#alternative-designs) that avoid storing the event sink as a property.
80+
81+
> Don't lambdas break equality/stability checks? Do I need to wrap them in `remember` calls first?
82+
83+
Lambdas are automatically remembered in compose via [lambda memoization](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping#lambda-memoization), so you don't need to manually remember them first.
84+
85+
## Alternative Designs
86+
87+
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.
88+
89+
### Using [Poko](https://github.com/drewhamilton/Poko)
90+
91+
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.
92+
93+
```kotlin
94+
@Poko
95+
class CounterState(val count: Int, @Poko.Skip val eventSink: (Event) -> Unit) : CircuitUiState
96+
```
97+
98+
!!! tip "When to Use"
99+
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.
100+
101+
### Poko with a shared event interface
102+
103+
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.
104+
105+
```kotlin
106+
@Stable
107+
interface EventSink<UiEvent : CircuitUiEvent> {
108+
fun onEvent(event: UiEvent)
109+
}
110+
111+
/**
112+
* Creates an [EventSink] that calls the given [body] when [EventSink.onEvent] is called.
113+
*
114+
* Note this inline function + [InlineEventSink] return type are a bit of bytecode trickery to avoid
115+
* creating a new class for every lambda passed to this function. The end result should be that the
116+
* lambda is inlined directly to the field in the implementing class and the inlined
117+
* [EventSink.onEvent] method impl is inlined directly as well to call it.
118+
*/
119+
@Suppress("NOTHING_TO_INLINE")
120+
inline fun <UiEvent : CircuitUiEvent> eventSink(
121+
noinline body: (UiEvent) -> Unit
122+
): EventSink<UiEvent> = InlineEventSink(body)
123+
124+
/** @see eventSink */
125+
@PublishedApi
126+
@JvmInline
127+
internal value class InlineEventSink<UiEvent : CircuitUiEvent>(private val body: (UiEvent) -> Unit) : EventSink<UiEvent> {
128+
override fun onEvent(event: UiEvent) {
129+
body(event)
130+
}
131+
}
132+
```
133+
134+
!!! tip
135+
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`.
136+
137+
With this, you can then define your state without marking the eventSink as a property.
138+
139+
```kotlin
140+
@Poko
141+
class CounterState(
142+
val count: Int,
143+
eventSink: (Event) -> Unit
144+
) : CircuitUiState, EventSink<Event> by eventSink(eventSink)
145+
```
146+
147+
!!! tip "When to Use"
148+
- You want to exclude event sinks from equals/hashCode without relying on `@Poko.Skip`.
149+
150+
### Using interfaces
151+
152+
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.
153+
154+
```kotlin
155+
interface CounterState : CounterState {
156+
val count: Int
157+
fun increment() {}
158+
fun decrement() {}
159+
}
160+
```
161+
162+
Then its implementation in the presenter would look like so.
163+
164+
```kotlin
165+
@Composable
166+
fun CounterPresenter(): CounterState {
167+
return remember {
168+
object : CounterState {
169+
override var count: Int by mutableIntStateOf(0)
170+
private set
171+
172+
override fun increment() {
173+
count++
174+
}
175+
176+
override fun decrement() {
177+
count--
178+
}
179+
}
180+
}
181+
}
182+
```
183+
184+
!!! tip "When to Use"
185+
- You want to limit API surface area from what data classes
186+
- Want to exclude event sinks from equals/hashCode
187+
- Want to limit state object allocations (only one state instance is ever created then remembered).
188+
- Only do this if you have actually measured performance.
189+
- You want to bridge to another UDF architecture that uses event interfaces (super helpful for interop!)

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ nav:
8484
'Effects': circuitx/effects.md
8585
'Gesture Navigation': circuitx/gesture-navigation.md
8686
'Overlays': circuitx/overlays.md
87+
- 'Deep Linking': deep-linking-android.md
8788
- 'API': api/0.x/index.html
8889
- 'Discussions ⏏': https://github.com/slackhq/circuit/discussions
8990
- 'Change Log': changelog.md

samples/star/benchmark/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ android {
2020
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2121
}
2222

23-
testOptions.managedDevices.devices {
23+
testOptions.managedDevices.allDevices {
2424
create<ManagedVirtualDevice>(mvdName) {
2525
device = "Pixel 6"
2626
apiLevel = mvdApi

0 commit comments

Comments
 (0)