Norbi
06/05/2022, 2:43 PM@Immutate
interface TodoAppState {
val screenStack: List<Screen>
val todoLists: List<TodoList>
}
@Immutate
interface EditScreen : Screen {
val editedTodoListId: Long
}
@Immutate
interface TodoList {
val id: Long
val name: String
val items: List<TodoItem>
}
@Immutate
interface TodoItem {
val text: String
val isCompleted: Boolean
}
...
See: https://github.com/kotlinw/kotlinw/blob/main/kotlinw-immutator-example-webapp/src/jsMain/kotlin/kotlinw/immutator/example/webapp/State.kt
2.
A KSP processor will generate the corresponding immutable and mutable classes from the interface declarations.
Eg. interface TodoItem
will trigger the generation of:
• data class TodoItemImmutable
• interface TodoItemMutable
• class TodoItemMutableImpl
• some useful extension methods like TodoItemImmutable.toMutable()
and TodoItemMutable.toImmutable()
Converting between immutable and mutable objects is efficient, eg. immutable.toMutable().toImmutable() === immutable
.
3. A state store is defined holding the current immutable state ("single source of truth"):
val stateFlow = MutableStateFlow(createInitialState())
inline fun mutateState(mutator: (TodoAppStateMutable) -> Unit) = stateFlow.update { it.mutate(mutator) }
private fun createInitialState() = TodoAppStateImmutable(...)
See: https://github.com/kotlinw/kotlinw/blob/main/kotlinw-immutator-example-webapp/src/jsMain/kotlin/kotlinw/immutator/example/webapp/StateStore.kt
4.
The application is composed using the immutable model, eg.
@Composable
fun TodoListView(todoList: TodoListImmutable, isCompletedShown: Boolean) {
Div {
H3 {
Text(todoList.name)
}
Div {
val items by derivedStateOf { todoList.items.filter { if (isCompletedShown) true else it.isActive } }
items.forEach {
key(it) {
Div {
if (isCompletedShown) {
if (it.isCompleted) Text("☑ ") else Text("☐ ")
} else {
Text("• ")
}
Text(it.text)
}
}
}
}
}
}
5. State is mutated in a controlled manner using state operations (like "actions" in case of Javascript-based state stores).
fun TodoAppStateMutable.addNewTodoList() {
todoLists.add(TodoListImmutable(System.now().toEpochMilliseconds(), "", persistentListOf()).toMutable())
}
...
Button({
onClick { mutateState { it.addNewTodoList() } }
}) {
Text("Add new TODO list")
}
...
Button({
onClick {
mutateState {
it.getTodoListItems().add(TodoItemImmutable("", false).toMutable())
}
}
}) {
Text("Add item")
}
Sources: https://github.com/kotlinw/kotlinw/tree/main/kotlinw-immutator-example-webapp
(Please note: this is a demo application, some Compose best practices are not applied to make the concepts more clear.)Arjan van Wieringen
06/05/2022, 7:51 PMNorbi
06/05/2022, 9:37 PM... a project that strictly follows the principles and patterns it can be a very productive and testable pattern.
In general, use Redux when you have reasonable amounts of data changing over time, you need a single source of truth.Beside the same advantages, - it is more Kotlin-ish in my opinion (state is modified by calling functions instead of the less natural "action dispatching") - results in less error-prone, efficient Compose code (because at most places you work with immutable data structures, data modification must be "guarded by"
mutateState { ... }
calls)
- the automatic generation of boilerplate code makes the state model definition fairly readable
- and the usage of immutable data makes the code more safe (uncontrolled state modification is not possible).Casey Brooks
06/05/2022, 10:07 PMZach Klippenstein (he/him) [MOD]
06/06/2022, 12:52 AM@Immutable
annotations on the interfaces are incorrect if you've got a mutable implementation. You want @Stable
instead.Norbi
06/06/2022, 5:58 AM@Immutate
, for the KSP processor 😉Tash
06/06/2022, 5:25 PMmutableStateOf
fields (needed for cases of frequently changing state).Norbi
06/06/2022, 6:43 PMhow is the “mutable” part representedIt is represented by a simple "property name" - "property value" map, accessed by delegated properties. The difficult part of the job is handled by internal implementation classes, like the
MutableObjectState
class.
Eg., for the example webapp's TodoItem
interface the following code is generated (nothing special, as I mentioned, almost all functionality is implemented as a library to minimize the complexity of generated code):
package kotlinw.immutator.example.webapp
import androidx.compose.runtime.Immutable
import kotlinw.immutator.internal.ImmutableObject
import kotlinw.immutator.internal.MutableObjectImplementor
import kotlinw.immutator.internal.MutableObjectState
import kotlinw.immutator.internal.mutableValueProperty
import kotlin.reflect.KProperty1
fun TodoItem.toMutable(): TodoItemMutable {
return when (this) {
is TodoItemMutable -> this
is TodoItemImmutable -> _immutator_convertToMutable()
else -> throw IllegalStateException()
}
}
fun TodoItem.toImmutable(): TodoItemImmutable {
return when (this) {
is TodoItemImmutable -> this
is TodoItemMutable -> (this as TodoItemMutableImpl)._immutator_convertToImmutable()
else -> throw IllegalStateException()
}
}
interface TodoItemMutable : TodoItem,
MutableObjectImplementor<TodoItemMutable, TodoItemImmutable> {
override var text: String
override var isCompleted: Boolean
}
@Immutable
data class TodoItemImmutable(
override val text: String,
override val isCompleted: Boolean,
) : TodoItem, ImmutableObject<TodoItemMutable> {
override fun _immutator_convertToMutable(): TodoItemMutable = TodoItemMutableImpl(this)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || other !is TodoItem) {
return false
}
if (text != other.text) {
return false
}
if (isCompleted != other.isCompleted) {
return false
}
return true
}
override fun hashCode(): Int {
var result = text.hashCode()
result = 31 * result + (isCompleted.hashCode())
return result
}
override fun toString(): String = """TodoItem(text=$text, isCompleted=$isCompleted)"""
}
class TodoItemMutableImpl(
private val source: TodoItemImmutable,
) : TodoItemMutable {
private val _objectState: MutableObjectState<TodoItemMutable, TodoItemImmutable> =
MutableObjectState(
source,
properties
)
override val _immutator_isModified: Boolean
get() = _objectState.isModified
override var text: String by mutableValueProperty(_objectState)
override var isCompleted: Boolean by mutableValueProperty(_objectState)
override fun _immutator_convertToImmutable(): TodoItemImmutable =
if (_immutator_isModified) {
TodoItemImmutable(text, isCompleted)
} else {
source
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || other !is TodoItem) {
return false
}
if (text != other.text) {
return false
}
if (isCompleted != other.isCompleted) {
return false
}
return true
}
override fun hashCode(): Int {
var result = text.hashCode()
result = 31 * result + (isCompleted.hashCode())
return result
}
override fun toString(): String = """TodoItem(text=$text, isCompleted=$isCompleted)"""
companion object {
val properties: Map<String, KProperty1<TodoItemImmutable, *>> = listOf(
TodoItemImmutable::text, TodoItemImmutable::isCompleted
).associateBy { it.name }
}
}
Please note that this is more of a proof of concept than a usable library.
But I plan to continue its development, and I already use it in an internal Compose/Desktop project successfully.how is the “mutable” part represented? Using Snapshot State?@Tash, it took a while to understand your question - sorry 🙂 Compose knows nothing about the mutable variant of the state model. Composables always work with immutable data, ie.
@Composable
functions always use XYImmutable
classes as parameters.
State modification happens only inside mutateState {...}
calls, so the mutable variant of the domain model is used only in the lambda of mutateState {...}
calls.
After modifying the state, Compose will recompose the UI based on the current immutable state, so Compose works only with immutable data.
To summarize, this PoC library is not a big magic, it only makes the application's state model more readable and free of boilerplate code, by generating immutable variants of the model for efficient recompositions, and mutable variants for efficient state modification.Tash
06/07/2022, 12:24 AMMutableObjectState
, I was getting confused, thinking that it used Compose’s Snapshot MutableState<T>
😅Norbi
06/07/2022, 5:52 AMZach Klippenstein (he/him) [MOD]
06/07/2022, 3:41 PMMutableObjectState
class? TodoItemMutableImpl
could just directly use MutableStates for its properties.