
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.Snapshottable
import 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:
@Snapshottable
interface State {
// Generated: properties hoisted onto the interface
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: convert the immutable spec into the mutable, snapshot-backed class
fun toSnapshotMutable(): SnapshotMutable
}
// Generated nested class, backed by Compose snapshot state
@Stable
class SnapshotMutable(
count: Int,
text: String
) : State {
override var count: Int by mutableIntStateOf(count)
override var text: String by mutableStateOf(text)
// Generated: convert back to the immutable spec
fun toSnapshotSpec(): Immutable
// Generated: bulk update; omitted arguments keep their current value
fun update(
count: Int = this.count,
text: String = this.text
): SnapshotMutable {
this.count = count
this.text = text
return this
}
}
}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.Snapshottable
import com.tunjid.snapshottable.SnapshotSpec
@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
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 and @SnapshotSpec data class must declare the same type
parameters in the same order. The spec must extend the interface with those type parameters as-is (e.g.
data class Immutable<T : Comparable<T>>(...) : State<T>); upper bounds, including F-bounds, are
propagated to the generated SnapshotMutable.@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.Snapshottable
import 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:
@Snapshottable
interface State {
// Generated: properties hoisted onto the interface
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: convert the immutable spec into the mutable, snapshot-backed class
fun toSnapshotMutable(): SnapshotMutable
}
// Generated nested class, backed by Compose snapshot state
@Stable
class SnapshotMutable(
count: Int,
text: String
) : State {
override var count: Int by mutableIntStateOf(count)
override var text: String by mutableStateOf(text)
// Generated: convert back to the immutable spec
fun toSnapshotSpec(): Immutable
// Generated: bulk update; omitted arguments keep their current value
fun update(
count: Int = this.count,
text: String = this.text
): SnapshotMutable {
this.count = count
this.text = text
return this
}
}
}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.Snapshottable
import com.tunjid.snapshottable.SnapshotSpec
@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
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 and @SnapshotSpec data class must declare the same type
parameters in the same order. The spec must extend the interface with those type parameters as-is (e.g.
data class Immutable<T : Comparable<T>>(...) : State<T>); upper bounds, including F-bounds, are
propagated to the generated SnapshotMutable.@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.