---
title: 'State Holders'
source: 'https://academia.sh/en/courses/architecture-components-and-data-layer/state-holders'
course: 'Architecture Components and the Data Layer'
language: en
updated: '2026-10-21T05:02:37+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains that UI state is gathered in a state holder as the single source of truth'
    - 'Distinguishes a state holder from a view model and from a plain class'
    - 'Measures that saved fields return from disk after process death'
    - 'Shows that an unsaved field falls to the default or the network, and that the origins change with the number of saved fields'
---

# State Holders

A state holder gathers a screen's state in one place; saved state selects the fields that return even after the process dies. The lesson measures which origin each field of a screen returns from and with which saved fields.

In the previous lesson, memory was kept when the screen rotated, emptied when the process died, and the unsaved tab selection fell to the default. In this lesson we gather the screen's state in one place and choose which of its fields return after the process dies. The question is the same: **where does the answer to this read come from?**

The measuring tool is the core of the previous lessons; we repeat it unchanged.

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

## The Single Source of Truth

A screen's state consists of a few fields: the count in the cart, the text in the search box, the open tab, the sort order. If the fields are scattered across UI objects, the same information has several copies, and the copies can drift apart. The solution is for each field to have exactly one writable owner; this is called the single source of truth. The UI reads this owner and does not keep the field in its own copy.

The class that carries the state and the logic that changes it is called a state holder. The view model of the previous lesson is a state holder, but being a state holder does not require being a view model. A plain class can also be a state holder; the difference is who determines its lifetime.

```kotlin
// Platform code: CartStateHolder
data class CartState(val quantity: Int = 1, val filter: String = "")

class CartStateHolder {
    var state = CartState()
        private set

    fun setQuantity(value: Int) {
        state = state.copy(quantity = value)
    }
}
```

`CartStateHolder` keeps the state in the `state` field and is the only one that writes it. This piece is platform code, and here we make no claim about output, timing or file paths. The model builds this rule of the platform; the model is not the platform itself.

## Saved Fields

When the process dies, the state holder in memory goes away. For the fields that must return, the platform offers saved state: the state holder chooses for itself which fields it saves.

```kotlin
// Platform code: CartViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel

class CartViewModel(private val handle: SavedStateHandle) : ViewModel() {
    val filter: String = handle["filter"] ?: ""

    fun setFilter(value: String) {
        handle["filter"] = value
    }
}
```

The `filter` key is written to saved state and is handed back to the new view model after the process dies. A field that is not saved stays only in memory.

In the model, the state holder carries the fields and the set of keys to save. `memory` returns the live fields; `snapshot` filters only the saved ones. `screenReads` builds the screen's four reads.

```kotlin
// Holder.kt — state holder: screen fields in one place, those to be saved marked separately
class Holder(private val fields: Map<String, String>, private val saved: Set<String>) {
    fun memory(): Map<String, String> = fields.toMap()
    fun snapshot(): Map<String, String> = fields.filterKeys { it in saved }
}

fun screenReads(chain: List<Tier>): List<Read> = listOf(
    Read("cart-count", "cart.count", chain, "0"),
    Read("filter-text", "filter.text", chain, "none"),
    Read("screen-tab", "screen.tab", chain, "first"),
    Read("sorting", "sort.order", chain, "name"),
)
```

First, the state before the process dies. All four fields of the state holder are in memory.

```kotlin
// Main.kt — the state holder keeps the screen fields in one place
fun main() {
    val holder = Holder(
        mapOf("cart.count" to "3", "filter.text" to "headphones", "screen.tab" to "summary", "sort.order" to "price"),
        setOf("filter.text", "screen.tab"),
    )
    println("keys to save: ${holder.snapshot().keys.sorted()}")
    val reads = screenReads(listOf(Tier("memory", holder.memory())))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
keys to save: [filter.text, screen.tab]
cart-count: key=cart.count | value=3 -> memory
filter-text: key=filter.text | value=headphones -> memory
screen-tab: key=screen.tab | value=summary -> memory
sorting: key=sort.order | value=price -> memory
memory: 4
disk: 0
network: 0
default: 0
```

All four reads came from memory. Two keys are marked to be saved: the search text and the open tab. The cart count is not marked because it already sits on the network, and the sort order is not marked because it is only a UI preference.

## When the Process Dies

The process died. Memory is empty; the new state holder receives the saved state. In the model we build saved state as the disk tier: the place that remains even when the process dies. The network holds only the cart count.

```kotlin
// Main.kt — the process died, saved state returns from disk
fun main() {
    val network = mapOf("cart.count" to "3")
    val holder = Holder(
        mapOf("cart.count" to "3", "filter.text" to "headphones", "screen.tab" to "summary", "sort.order" to "price"),
        setOf("filter.text", "screen.tab"),
    )
    val chain = listOf(Tier("memory", emptyMap()), Tier("disk", holder.snapshot()), Tier("network", network))
    val reads = screenReads(chain)
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
cart-count: key=cart.count | value=3 -> network
filter-text: key=filter.text | value=headphones -> disk
screen-tab: key=screen.tab | value=summary -> disk
sorting: key=sort.order | value=name -> default
memory: 0
disk: 2
network: 1
default: 1
```

The search text and the tab returned from disk. The count was not saved, so it was read from the network. The sort order was in no tier, so it fell to the default. On the same screen at the same time there are three origins: disk 2, network 1, default 1.

**AA1.** The model builds saved state as the disk tier. The model does not say where this bundle is stored on the platform; the only thing the model distinguishes is that the bundle survives process death.

**AA2.** The model hands the set of keys to save to the state holder by hand. On the platform the saved value must be small and serializable; the model has no such constraint.

## The Bounding Measurement

A field that is not saved falls to the default or the network. So how do the origins change when the saved set changes? We try three sets with the same fields: none, two fields, all.

```kotlin
// Main.kt — the set of saved fields changes the distribution of origins
fun main() {
    val network = mapOf("cart.count" to "3")
    val fields = mapOf("cart.count" to "3", "filter.text" to "headphones", "screen.tab" to "summary", "sort.order" to "price")
    val savedSets = listOf(
        "none" to emptySet<String>(),
        "two-fields" to setOf("filter.text", "screen.tab"),
        "all" to fields.keys,
    )
    for ((name, saved) in savedSets) {
        val snapshot = Holder(fields, saved).snapshot()
        val chain = listOf(Tier("memory", emptyMap()), Tier("disk", snapshot), Tier("network", network))
        println("saved: $name")
        countOrigins(screenReads(chain))
    }
}
```

```text
saved: none
memory: 0
disk: 0
network: 1
default: 3
saved: two-fields
memory: 0
disk: 2
network: 1
default: 1
saved: all
memory: 0
disk: 4
network: 0
default: 0
```

When no field is saved, disk is zero: the count comes from the network and three fields fall to the default. When two fields are saved, the distribution of the previous measurement appears. When all are saved, four of the four reads come from disk and the network is not consulted. The system that rebuilds the screen is the same; the only thing that changes is which fields the state holder marks.

**AA3.** The model marks saving by the field's key. On the platform saving happens on every write; in the model the saved set is fixed and is not updated instantly.

## Summary

- This lesson's origin: disk. After the process died, the saved search text and tab selection returned from saved state, which the model builds as the disk tier.
- The lesson's measure is which origin a field returns from after the process dies. On the same screen four reads gave disk 2, network 1, default 1.
- The state holder gathers the fields in one place; the set of saved keys determines which ones survive the process.
- With an empty saved set the result was disk 0, network 1, default 3; with all fields saved it was disk 4.
- A field that is not saved goes away together with memory; it is rebuilt from the network or the default.

## Next Step

In this lesson the fields of the state holder returned after the process died, but the holder does not yet know whether loading has finished. The next lesson, Observable State, measures the difference between the first value of a state flow and the loaded value.
