
Automatically generates mutable, snapshot-backed classes from immutable state definitions for Compose UIs, enabling observable, optimized primitive state, two-way conversion, bulk updates, and serialization-friendly specs.
Snapshottable is a Kotlin compiler plugin that automatically generates mutable, snapshot-backed classes from immutable data definitions. It is designed to simplify state management in Jetpack Compose (and other Compose-based UI frameworks) by allowing you to define your state as clean, immutable interfaces and data classes, while automatically generating the mutable, observable counterparts needed for the UI.
SnapshotMutable class for your state interfaces.Snapshot state, making them observable and thread-safe.update method (similar to data class copy) for bulk updates.Int, Float, Long, Double) to avoid boxing overhead.@Serializable or @Parcelize. This enables seamless integration with rememberSaveable, allowing you to persist state across configuration changes using the serializable spec, while using the mutable version for runtime updates.Define your State Interface:
Annotate an interface with @Snapshottable. Inside, define a nested data class annotated with @SnapshotSpec that implements the interface. This data class represents the immutable snapshot of your state. You can also annotate it with @Serializable or @Parcelize for persistence.
import com.tunjid.snapshottable.Snapshottableimport com.tunjid.snapshottable.SnapshotSpec import kotlinx.serialization.Serializable import kotlinx.parcelize.Parcelize import android.os.Parcelable
@Snapshottable
interface State {
@Serializable
@Parcelize
@SnapshotSpec
data class Immutable(
val count: Int = 0,
val text: String = "Hello"
) : State, Parcelable
}
```
The compiler plugin will generate the following code for you:
```kotlin
@Snapshottable
interface State {
val count: Int
val text: String
@Serializable
@Parcelize
@SnapshotSpec
data class Immutable(
override val count: Int = 0,
override val text: String = "Hello"
) : State, Parcelable
// Generated nested class
class SnapshotMutable(
count: Int,
text: String
) : State {
override var count: Int by mutableIntStateOf(count)
override var text: String by mutableStateOf(text)
fun update(
count: Int = this.count,
text: String = this.text
): SnapshotMutable {
this.count = count
this.text = text
return this
}
}
companion object {
fun State.Immutable.toSnapshotMutable(): SnapshotMutable = State.SnapshotMutable(...)
fun State.SnapshotMutable.toSnapshotSpec(): Immutable = State.Immutable(...)
}
}
```
Use in Composable:
The plugin generates a SnapshotMutable class nested within your interface (e.g., State.SnapshotMutable). You can create instances of this class, modify its properties (which updates the underlying Compose state), and convert back to the immutable spec.
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.Saver
import com.tunjid.snapshottable.Snapshottableimport com.tunjid.snapshottable.SnapshotSpec // Import generated extension functions import com.example.mypackage.State.Companion.toSnapshotMutable import com.example.mypackage.State.Companion.toSnapshotSpec
@Composable
fun Counter() {
// Use rememberSaveable to persist state across configuration changes
// The Saver persists the Serializable 'Immutable' spec, but we work with the 'SnapshotMutable' at runtime
val state = rememberSaveable(
saver = Saver(
save = { it.toSnapshotSpec() },
restore = { it.toSnapshotMutable() }
)
) {
State.Immutable().toSnapshotMutable()
}
// Read properties (Compose will track these reads)
Text("Count: ${state.count}")
Text("Message: ${state.text}")
Button(onClick = {
// Mutate properties directly
state.count++
state.text = "Clicked!"
// Or use the generated update method for bulk updates
state.update(count = 0, text = "Reset")
}) {
Text("Increment")
}
}
```
Use in ViewModel:
You can also use the generated mutable class inside a ViewModel to manage state. Expose the state as the parent interface (which is read-only from the outside perspective) while mutating the internal SnapshotMutable instance.
import androidx.lifecycle.ViewModel
import com.example.mypackage.State.Companion.toSnapshotSpec
class MyViewModel : ViewModel() {
// Internal mutable state
private val mutableState = State.Immutable().toSnapshotMutable()
// Public read-only state exposed as the interface
val state: State get() = mutableState
fun increment() {
mutableState.count++
}
fun updateText(newText: String) {
mutableState.text = newText
}
fun reset() {
// Bulk update
mutableState.update(count = 0, text = "Hello")
}
}With Kotlin 2.0+ (and enabled in 2.3.0), you can use explicit backing fields for a more concise syntax:
class MyViewModel : ViewModel() {
val state: State
field = State.Immutable().toSnapshotMutable()
fun increment() {
// Access the backing field directly to mutate
state.count++
}
}@Snapshottable interface or @SnapshotSpec class are currently not supported.@SnapshotSpec data class must be public. Private or internal properties
are not supported for snapshot generation as the parent interface cannot have non-public properties.This project has three modules:
:compiler-plugin module contains the compiler plugin itself.:plugin-annotations module contains annotations which can be used in
user code for interacting with compiler plugin.:gradle-plugin module contains a simple Gradle plugin to add the compiler plugin and
annotation dependency to a Kotlin project.The Kotlin compiler test framework is set up for this project.
To create a new test, add a new .kt file in a compiler-plugin/testData sub-directory:
testData/box for codegen tests and testData/diagnostics for diagnostics tests.
The generated JUnit 5 test classes will be updated automatically when tests are next run.
They can be manually updated with the generateTests Gradle task as well.
To aid in running tests, it is recommended to install the Kotlin Compiler DevKit IntelliJ plugin,
which is pre-configured in this repository.
Snapshottable is a Kotlin compiler plugin that automatically generates mutable, snapshot-backed classes from immutable data definitions. It is designed to simplify state management in Jetpack Compose (and other Compose-based UI frameworks) by allowing you to define your state as clean, immutable interfaces and data classes, while automatically generating the mutable, observable counterparts needed for the UI.
SnapshotMutable class for your state interfaces.Snapshot state, making them observable and thread-safe.update method (similar to data class copy) for bulk updates.Int, Float, Long, Double) to avoid boxing overhead.@Serializable or @Parcelize. This enables seamless integration with rememberSaveable, allowing you to persist state across configuration changes using the serializable spec, while using the mutable version for runtime updates.Define your State Interface:
Annotate an interface with @Snapshottable. Inside, define a nested data class annotated with @SnapshotSpec that implements the interface. This data class represents the immutable snapshot of your state. You can also annotate it with @Serializable or @Parcelize for persistence.
import com.tunjid.snapshottable.Snapshottableimport com.tunjid.snapshottable.SnapshotSpec import kotlinx.serialization.Serializable import kotlinx.parcelize.Parcelize import android.os.Parcelable
@Snapshottable
interface State {
@Serializable
@Parcelize
@SnapshotSpec
data class Immutable(
val count: Int = 0,
val text: String = "Hello"
) : State, Parcelable
}
```
The compiler plugin will generate the following code for you:
```kotlin
@Snapshottable
interface State {
val count: Int
val text: String
@Serializable
@Parcelize
@SnapshotSpec
data class Immutable(
override val count: Int = 0,
override val text: String = "Hello"
) : State, Parcelable
// Generated nested class
class SnapshotMutable(
count: Int,
text: String
) : State {
override var count: Int by mutableIntStateOf(count)
override var text: String by mutableStateOf(text)
fun update(
count: Int = this.count,
text: String = this.text
): SnapshotMutable {
this.count = count
this.text = text
return this
}
}
companion object {
fun State.Immutable.toSnapshotMutable(): SnapshotMutable = State.SnapshotMutable(...)
fun State.SnapshotMutable.toSnapshotSpec(): Immutable = State.Immutable(...)
}
}
```
Use in Composable:
The plugin generates a SnapshotMutable class nested within your interface (e.g., State.SnapshotMutable). You can create instances of this class, modify its properties (which updates the underlying Compose state), and convert back to the immutable spec.
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.Saver
import com.tunjid.snapshottable.Snapshottableimport com.tunjid.snapshottable.SnapshotSpec // Import generated extension functions import com.example.mypackage.State.Companion.toSnapshotMutable import com.example.mypackage.State.Companion.toSnapshotSpec
@Composable
fun Counter() {
// Use rememberSaveable to persist state across configuration changes
// The Saver persists the Serializable 'Immutable' spec, but we work with the 'SnapshotMutable' at runtime
val state = rememberSaveable(
saver = Saver(
save = { it.toSnapshotSpec() },
restore = { it.toSnapshotMutable() }
)
) {
State.Immutable().toSnapshotMutable()
}
// Read properties (Compose will track these reads)
Text("Count: ${state.count}")
Text("Message: ${state.text}")
Button(onClick = {
// Mutate properties directly
state.count++
state.text = "Clicked!"
// Or use the generated update method for bulk updates
state.update(count = 0, text = "Reset")
}) {
Text("Increment")
}
}
```
Use in ViewModel:
You can also use the generated mutable class inside a ViewModel to manage state. Expose the state as the parent interface (which is read-only from the outside perspective) while mutating the internal SnapshotMutable instance.
import androidx.lifecycle.ViewModel
import com.example.mypackage.State.Companion.toSnapshotSpec
class MyViewModel : ViewModel() {
// Internal mutable state
private val mutableState = State.Immutable().toSnapshotMutable()
// Public read-only state exposed as the interface
val state: State get() = mutableState
fun increment() {
mutableState.count++
}
fun updateText(newText: String) {
mutableState.text = newText
}
fun reset() {
// Bulk update
mutableState.update(count = 0, text = "Hello")
}
}With Kotlin 2.0+ (and enabled in 2.3.0), you can use explicit backing fields for a more concise syntax:
class MyViewModel : ViewModel() {
val state: State
field = State.Immutable().toSnapshotMutable()
fun increment() {
// Access the backing field directly to mutate
state.count++
}
}@Snapshottable interface or @SnapshotSpec class are currently not supported.@SnapshotSpec data class must be public. Private or internal properties
are not supported for snapshot generation as the parent interface cannot have non-public properties.This project has three modules:
:compiler-plugin module contains the compiler plugin itself.:plugin-annotations module contains annotations which can be used in
user code for interacting with compiler plugin.:gradle-plugin module contains a simple Gradle plugin to add the compiler plugin and
annotation dependency to a Kotlin project.The Kotlin compiler test framework is set up for this project.
To create a new test, add a new .kt file in a compiler-plugin/testData sub-directory:
testData/box for codegen tests and testData/diagnostics for diagnostics tests.
The generated JUnit 5 test classes will be updated automatically when tests are next run.
They can be manually updated with the generateTests Gradle task as well.
To aid in running tests, it is recommended to install the Kotlin Compiler DevKit IntelliJ plugin,
which is pre-configured in this repository.