DerivedState: Hash-Based Invalidation Without Tracking Dependencies

skydovesJaewoong Eum (skydoves)||13 min read

DerivedState: Hash-Based Invalidation Without Tracking Dependencies

Compose's derivedStateOf provides a way to create computed state that only triggers recomposition when the computed result actually changes. When you write val fullName by remember { derivedStateOf { "${firstName.value} ${lastName.value}" } }, Compose tracks which state objects were read during calculation and intelligently determines when recalculation is necessary. While most developers know that derivedStateOf helps avoid unnecessary recompositions from intermediate state changes, the deeper question remains: how does Compose know when to recalculate without explicitly tracking dependencies, and what makes this different from a simple remember { computed value }?

In this article, you'll dive deep into the internal mechanisms of derivedStateOf, exploring how the Snapshot.observe() mechanism captures dependencies during calculation, how the nesting level system distinguishes direct from indirect reads, how hash based validation determines invalidation without value comparison, how the ResultRecord structure caches results across snapshots, and how equivalence policies enable allocation free updates when values haven't changed. This isn't a guide on using derivedStateOf. It's an exploration of the runtime machinery that makes intelligent state derivation possible.

The fundamental problem: Computed values that recompose too often

Just imagine a search screen with a filter:

@Composable
fun SearchScreen() {
    var searchQuery by remember { mutableStateOf("") }
    var selectedCategory by remember { mutableStateOf<Category?>(null) }
    var items by remember { mutableStateOf(listOf<Item>()) }

    val filteredItems = items.filter { item ->
        item.name.contains(searchQuery, ignoreCase = true) &&
            (selectedCategory == null || item.category == selectedCategory)
    }

    LazyColumn {
        items(filteredItems) { item ->
            ItemCard(item)
        }
    }
}

Every time any state changes, filteredItems is recalculated. Worse, even if the filter produces the same result, the recomposition still happens because Compose sees a new list object. With 10,000 items, this becomes a performance problem.

The naive solution is memoization with remember:

val filteredItems = remember(searchQuery, selectedCategory, items) {
    items.filter { ... }
}

This helps, but you must manually specify all dependencies. Miss one, and you get stale results. Add an unnecessary one, and you get extra recalculations.

derivedStateOf solves both problems:

val filteredItems by remember {
    derivedStateOf {
        items.filter { item ->
            item.name.contains(searchQuery, ignoreCase = true) &&
                (selectedCategory == null || item.category == selectedCategory)
        }
    }
}

Dependencies are tracked automatically. Recalculation happens only when dependencies change. Recomposition happens only when the result changes. But how does this work without explicit dependency declarations?

The DerivedState architecture: StateObject with cached results

The derivedStateOf function creates a DerivedSnapshotState, which integrates with Compose's snapshot system while maintaining cached calculation results:

private class DerivedSnapshotState<T>(
    private val calculation: () -> T,
    override val policy: SnapshotMutationPolicy<T>?,
) : StateObjectImpl(), DerivedState<T> {
    private var first: ResultRecord<T> = ResultRecord(currentSnapshot().snapshotId)
}

Like other state objects, DerivedSnapshotState maintains a linked list of records for different snapshots. Unlike mutableStateOf, it stores not just a value but also the metadata needed to determine when recalculation is required.

The ResultRecord structure

Each ResultRecord captures a complete calculation snapshot:

class ResultRecord<T>(snapshotId: SnapshotId) :
    StateRecord(snapshotId), DerivedState.Record<T> {
    companion object {
        val Unset = Any()
    }

    var validSnapshotId: SnapshotId = SnapshotIdZero
    var validSnapshotWriteCount: Int = 0

    override var dependencies: ObjectIntMap<StateObject> = emptyObjectIntMap()
    var result: Any? = Unset
    var resultHash: Int = 0
}

Four fields work together to enable intelligent caching:

dependencies: An ObjectIntMap<StateObject> mapping each state object read during calculation to its nesting level. This is the automatically tracked dependency list.

result: The cached calculation result, or the Unset sentinel if not yet computed.

resultHash: A hash computed from the state records of dependencies. This enables fast invalidation checking without value comparison.

validSnapshotId and validSnapshotWriteCount: Cache validity markers that track which snapshot this result was computed in and how many writes had occurred.

Dependency tracking: Capturing reads during calculation

When you access .value on a derived state, the runtime must determine whether the cached result is still valid. If not, it runs the calculation while observing all state reads.

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor