14 KiB
Immutability Patterns
@Immutable annotation, data classes, and immutable collections for Compose performance.
Table of Contents
- Why Immutability Matters
- @Immutable Annotation
- Data Classes
- Immutable Collections
- Common Patterns
- Performance Impact
Why Immutability Matters
Compose Recomposition
Mental model: Compose tracks state changes by comparing references. If an @Immutable object reference doesn't change, Compose skips recomposition.
// Without @Immutable - Recomposes on every parent recomposition
data class User(val name: String, val age: Int)
@Composable
fun UserCard(user: User) { // Recomposes unnecessarily
Text(user.name)
}
// With @Immutable - Only recomposes when user reference changes
@Immutable
data class User(val name: String, val age: Int)
@Composable
fun UserCard(user: User) { // Smart recomposition
Text(user.name)
}
Performance difference:
- Without
@Immutable: 1000UserCardrecompositions per screen update - With
@Immutable: 10UserCardrecompositions (only changed users)
Thread Safety
Immutable objects are inherently thread-safe:
@Immutable
data class Event(
val id: String,
val content: String,
val createdAt: Long
)
// Safe to share across coroutines without synchronization
val sharedEvent: Event = fetchEvent()
launch { processEvent(sharedEvent) } // Safe
launch { saveEvent(sharedEvent) } // Safe
@Immutable Annotation
Basic Usage
Pattern from Amethyst:
// TextNoteEvent.kt:51-63
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
// All properties are val (immutable)
// No var properties
// No mutable collections
}
Requirements for @Immutable:
- All properties must be
val(novar) - All property types must be immutable or primitives
- No mutable collections (
MutableList,MutableMap) - Arrays are allowed (treated as immutable by contract)
- No public mutable state
@Immutable vs @Stable
@Immutable: Value never changes after construction
@Immutable
data class User(val name: String, val age: Int)
// Once created, user.name and user.age never change
@Stable: Value can change, but changes are tracked
@Stable
class MutableCounter {
var count by mutableStateOf(0) // Changes tracked by Compose
}
Amethyst uses @Immutable extensively:
- 173+ event classes annotated with
@Immutable - All Nostr events immutable by design
- Critical for feed performance (thousands of events)
Data Classes
Immutable Data Classes
Pattern:
@Immutable
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val error: String? = null,
val messageCount: Int = 0
) {
// Immutable properties only (val)
// Default values allowed
}
Benefits:
- Structural equality:
equals()compares values, not references - copy(): Create modified copies without mutation
- toString(): Debugging-friendly output
- hashCode(): Consistent hashing for collections
- componentN(): Destructuring support
copy() for Updates
Mental model: Instead of mutating, create modified copies.
val status = RelayStatus(
url = "wss://relay.damus.io",
connected = false,
error = null
)
// Immutable update
val updatedStatus = status.copy(connected = true)
// Original unchanged
assert(status.connected == false)
assert(updatedStatus.connected == true)
StateFlow pattern:
private val _relayStatuses = MutableStateFlow<Map<String, RelayStatus>>(emptyMap())
fun updateRelay(url: String, connected: Boolean) {
_relayStatuses.value = _relayStatuses.value.mapValues { (key, status) ->
if (key == url) {
status.copy(connected = connected) // Immutable update
} else {
status
}
}
}
All Properties in Constructor
Why important for data classes:
// BAD: Properties outside constructor not included in equals/hashCode
data class User(val name: String) {
var age: Int = 0 // NOT in equals/hashCode/copy!
}
val user1 = User("Alice")
val user2 = User("Alice")
user1.age = 25
user2.age = 30
assert(user1 == user2) // TRUE! age not compared
assert(user1.copy() == user1) // TRUE! age not copied
// GOOD: All properties in constructor
@Immutable
data class User(
val name: String,
val age: Int // Included in equals/hashCode/copy
)
Immutable Collections
kotlinx.collections.immutable
Installation:
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
}
Why use:
- Structural sharing (efficient copies)
- Explicit immutability (compiler enforced)
- Safe for Compose state
ImmutableList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
// Create immutable list
val relays: ImmutableList<String> = persistentListOf(
"wss://relay1.com",
"wss://relay2.com"
)
// Add returns NEW list
val updated = relays.add("wss://relay3.com")
assert(relays.size == 2) // Original unchanged
assert(updated.size == 3) // New list has 3 items
// Convert from regular list
val mutableList = mutableListOf("a", "b", "c")
val immutable = mutableList.toImmutableList()
ImmutableMap
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
// Create immutable map
val relayStatuses: ImmutableMap<String, RelayStatus> = persistentMapOf(
"wss://relay1.com" to RelayStatus(...),
"wss://relay2.com" to RelayStatus(...)
)
// Put returns NEW map
val updated = relayStatuses.put("wss://relay3.com", RelayStatus(...))
// Remove returns NEW map
val removed = relayStatuses.remove("wss://relay1.com")
ImmutableSet
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
val connectedRelays: ImmutableSet<String> = persistentSetOf(
"wss://relay1.com",
"wss://relay2.com"
)
val updated = connectedRelays.add("wss://relay3.com")
Structural Sharing
Mental model: Immutable collections reuse internal structure for efficiency.
val list1 = persistentListOf(1, 2, 3, 4, 5) // 5 items
val list2 = list1.add(6) // Shares structure with list1
// Internally:
// list1 and list2 share nodes for items 1-5
// list2 has one additional node for item 6
// O(1) time, O(1) space for add operation
Common Patterns
Pattern: Immutable State Updates
@Immutable
data class FeedState(
val events: ImmutableList<Event>,
val loading: Boolean,
val error: String?
)
class FeedViewModel {
private val _state = MutableStateFlow(
FeedState(
events = persistentListOf(),
loading = false,
error = null
)
)
val state: StateFlow<FeedState> = _state.asStateFlow()
fun loadEvents() {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
try {
val events = repository.getEvents()
_state.value = _state.value.copy(
events = events.toImmutableList(),
loading = false
)
} catch (e: Exception) {
_state.value = _state.value.copy(
loading = false,
error = e.message
)
}
}
}
fun addEvent(event: Event) {
_state.value = _state.value.copy(
events = _state.value.events.add(event) // Immutable add
)
}
fun removeEvent(eventId: String) {
_state.value = _state.value.copy(
events = _state.value.events.filter { it.id != eventId }.toImmutableList()
)
}
}
Pattern: Deep Immutability
// Nested immutable structures
@Immutable
data class User(
val name: String,
val profile: Profile // Also immutable
)
@Immutable
data class Profile(
val bio: String,
val avatar: String,
val relays: ImmutableList<String> // Immutable collection
)
// Safe deep copy
val user = User(
name = "Alice",
profile = Profile(
bio = "Nostr enthusiast",
avatar = "https://...",
relays = persistentListOf("wss://relay1.com")
)
)
val updatedUser = user.copy(
profile = user.profile.copy(
bio = "Bitcoin & Nostr enthusiast" // Deep update
)
)
Pattern: Collection Builder to Immutable
// Build mutable, convert to immutable
fun processEvents(input: List<Event>): ImmutableList<Event> {
val processed = mutableListOf<Event>()
for (event in input) {
if (event.isValid()) {
processed.add(event.normalize())
}
}
return processed.toImmutableList() // Convert once at end
}
Pattern: Immutable Map Updates
private val _relayStatuses = MutableStateFlow<ImmutableMap<String, RelayStatus>>(
persistentMapOf()
)
fun updateRelay(url: String, connected: Boolean) {
val currentStatuses = _relayStatuses.value
val currentStatus = currentStatuses[url] ?: RelayStatus(url, false)
_relayStatuses.value = currentStatuses.put(
url,
currentStatus.copy(connected = connected)
)
}
fun removeRelay(url: String) {
_relayStatuses.value = _relayStatuses.value.remove(url)
}
Performance Impact
Benchmarks (Approximate)
Recomposition cost:
// 1000 items in LazyColumn
// Without @Immutable: ~100ms per frame (skipped frames)
// With @Immutable: ~16ms per frame (smooth 60fps)
@Immutable
data class Item(val id: String, val name: String)
@Composable
fun ItemList(items: ImmutableList<Item>) {
LazyColumn {
items(items, key = { it.id }) { item ->
ItemRow(item) // Only recomposes when item changes
}
}
}
Structural sharing efficiency:
val list1 = persistentListOf(1..10000)
val list2 = list1.add(10001) // O(log n) time, shares structure
// Regular list (copy on modification):
val mutableList = (1..10000).toMutableList()
val copy = mutableList.toList() + 10001 // O(n) time, full copy
When to Use Immutable Collections
Use ImmutableList/Map/Set when:
- Storing in Compose state (@Immutable class)
- Sharing across coroutines
- Frequent modifications (structural sharing efficient)
- Need compile-time immutability guarantee
Use Array when:
- Fixed size, no modifications
- Nostr protocol (tags are
Array<Array<String>>) - Performance-critical (array access is fastest)
Use regular List/Map/Set when:
- Local scope only
- Build once, read many times
- Converting to immutable at boundary
Anti-Patterns
❌ Mutable Properties in @Immutable Class
@Immutable
data class BadEvent(
val id: String,
var content: String // BAD: var breaks immutability
)
✅ All val Properties
@Immutable
data class GoodEvent(
val id: String,
val content: String
)
❌ Mutable Collections in @Immutable Class
@Immutable
data class BadState(
val items: MutableList<Item> // BAD: Can mutate items
)
// Caller can mutate:
val state = BadState(mutableListOf())
state.items.add(newItem) // Breaks immutability!
✅ Immutable Collections
@Immutable
data class GoodState(
val items: ImmutableList<Item>
)
// Caller must create new state:
val updated = state.copy(items = state.items.add(newItem))
❌ Direct Mutation
val status = RelayStatus(url, connected = false)
status.connected = true // Compile error (val)
// But could happen with mutable nested objects:
@Immutable
data class Config(
val settings: Settings // If Settings is mutable...
)
class Settings {
var theme: String = "dark" // BAD
}
val config = Config(Settings())
config.settings.theme = "light" // Mutates "immutable" config!
✅ Deep Immutability
@Immutable
data class Config(
val settings: Settings
)
@Immutable
data class Settings(
val theme: String // val only
)
val config = Config(Settings("dark"))
val updated = config.copy(
settings = config.settings.copy(theme = "light")
)
❌ Exposing Mutable Internal State
@Immutable
class BadViewModel {
private val _items = mutableListOf<Item>()
val items: List<Item> = _items // BAD: Exposes mutable list
fun addItem(item: Item) {
_items.add(item)
}
}
// Caller can cast and mutate:
val vm = BadViewModel()
(vm.items as MutableList).clear() // Breaks encapsulation!
✅ Convert to Immutable at Boundary
@Immutable
class GoodViewModel {
private val _items = mutableListOf<Item>()
val items: ImmutableList<Item>
get() = _items.toImmutableList() // GOOD: Copy to immutable
fun addItem(item: Item) {
_items.add(item)
}
}
Checklist for Immutability
For @Immutable classes:
- All properties are
val, nevervar - No mutable collections (
MutableList,MutableMap,MutableSet) - Nested objects are also
@Immutableor primitives - No public mutable state
- Use
copy()for updates, never mutation - Arrays used only when truly immutable by contract
For StateFlow state:
- State class is
@Immutable - Use immutable collections (ImmutableList, ImmutableMap)
- Create new instances for updates (
copy(),.add(),.put()) - Never mutate state in-place
For Compose performance:
- All
@Composableparameters are@Immutableor@Stable - Lists use
ImmutableListandkeyparameter initems() - Heavy objects (events, profiles) cached and reused
References
- TextNoteEvent.kt:51-63 - @Immutable event example
- RelayConnectionManager.kt - Immutable map updates
- Compose Performance | Android Developers
- kotlinx.collections.immutable | GitHub
- @Stable and @Immutable | Compose Docs