Decompose and Essenty: Under the Hood of State Saving without ViewModel
In this article, we dive deep inside Decompose and Essenty: how they save state without using ViewModel and onSaveInstanceState, what happens at the StateKeeper and InstanceKeeper level, how all this relates to Android SavedStateRegistry and serialization through kotlinx.serialization. A detailed, step-by-step analysis of the entire chain — from components to low-level details.
Introduction
This is a continuation of the four previous articles.
- In the first one, we figured out where
ViewModelStore
is ultimately stored in the case ofActivity
. - In the second — how this is organized in
Fragment
. - In the third — where
ViewModel
s are stored when we use Compose (or even justView
). - In the fourth — how
onSaveInstanceState
/onRestoreInstanceState
methods work, Saved State API and where theBundle
is stored.
In this article, we’ll figure out how the widely used KMP library Decompose manages without ViewModel
and onSaveInstanceState
methods, since it is a cross-platform (KMP) library.
The article is not about how to use these APIs, but about how they work internally. Therefore, I’ll assume that you’re already familiar with them or at least have a general understanding.
As always, let’s start with the basics. Let’s first define Decompose:
Basics
Decompose is a multiplatform library for separating business logic and UI, developed by Arkadiy Ivanov. It works on top of ComponentContext
, which manages the lifecycle, state, and navigation between components.
Supports: Android, iOS, JS, JVM, macOS, watchOS, tvOS.
Why use it:
- logic is separated from UI and easily testable
- works with Compose, SwiftUI, React, etc.
- navigation and state are cross-platform
- components survive configuration changes (like
ViewModel
) - you can extend and customize
ComponentContext
for your tasks
Decompose is not a framework, but a powerful tool on which you can build your own API. In short, it’s a Swiss Army knife.
In Android, it’s hard to imagine an app without the standard ViewModel
, and it’s surprising that Decompose doesn’t have one, but it can save data both during configuration changes and when the process is destroyed.
Let’s quickly understand the entities on which Decompose is based:
Everything in Decompose revolves around ComponentContext
— a component associated with a specific screen or set of child components. Each component has its own ComponentContext
, which implements the following interfaces:
- LifecycleOwner — provided by the Essenty library, gives each component its own lifecycle.
- StateKeeperOwner — allows saving any state during configuration changes and/or process death.
- InstanceKeeperOwner — provides the ability to save any objects inside the component (analog of
ViewModel
in AndroidX). - BackHandlerOwner — allows each component to handle back button presses.
We’ll focus mainly on StateKeeperOwner
(StateKeeper
) and InstanceKeeperOwner
(InstanceKeeper
). As you can see, they actually come from the Essenty library, which was also created by Arkadiy Ivanov. However, this library gained particular popularity precisely thanks to Decompose.
Let’s start diving into the work of StateKeeperOwner
(StateKeeper
). I’ll assume that you’ve already read the previous articles. Let’s begin.
StateKeeperOwner
To understand how it works, let’s implement a simple Counter
screen. The goal is to see how the counter can survive configuration changes and even process death.
Let’s start by creating a component for the counter:
class DefaultCounterComponent(
componentContext: ComponentContext
) : ComponentContext by componentContext {
val model: StateFlow<Int> field = MutableStateFlow(stateKeeper.consume(KEY, Int.serializer()) ?: 0)
init {
stateKeeper.register(KEY, Int.serializer()) { model.value }
}
fun increase() {
model.value++
}
fun decrease() {
model.value--
}
companion object {
private const val KEY = "counter_state"
}
}
Pretty simple logic: we have a model
that stores the current counter value, and two methods to change it. When initializing the variable, we get the value from stateKeeper
through consume
, if it’s absent — we use 0
by default.
And in the init
block, we register a lambda that will be called when saving state. Just remember this point for now — we’ll figure out later how and when it triggers.
Now the counter screen that works with DefaultCounterComponent
:
@Composable
fun CounterScreen(component: DefaultCounterComponent) {
val count by component.model.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = count.toString(), style = MaterialTheme.typography.headlineLarge)
Row(horizontalArrangement = Arrangement.spacedBy(40.dp)) {
FloatingActionButton(onClick = { component.decrease() }) { Text("-", fontSize = 56.sp) }
FloatingActionButton(onClick = { component.increase() }) { Text("+", fontSize = 56.sp) }
}
}
}
And finally, the Activity
where ComponentContext
is initialized and the CounterScreen
is called:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val counterComponent = DefaultCounterComponent(defaultComponentContext())
setContent { CounterScreen(component = counterComponent) }
}
}
Now let’s check the behavior visually:
- How the counter will behave during configuration changes (specifically screen rotation).
- How the counter will behave when the process is destroyed while the app is in the background.

As we can see, everything works exactly as expected. The counter value is preserved both during screen rotation and after complete process killing. At the same time, we don’t see any onSaveInstanceState
methods or ViewModel
here. Let’s look at the counter component again:
class DefaultCounterComponent(
componentContext: ComponentContext
) : ComponentContext by componentContext {
val model: StateFlow<Int> field = MutableStateFlow(stateKeeper.consume(KEY, Int.serializer()) ?: 0)
init {
stateKeeper.register(KEY, Int.serializer()) { model.value }
}
...
companion object {
private const val KEY = "counter_state"
}
}
When the activity is recreated — both due to configuration changes and after process death — DefaultCounterComponent
will be created anew, and the model
field is created along with it. In this case, we call stateKeeper
and, by calling its consume
method, get the saved value by key. If there’s no saved value, we use the default value — 0
.
In the init
block, we register a callback through the stateKeeper.register
method, passing it the key, serialization strategy from kotlinx.serialization
, and a lambda that returns the current value of model
.
Let’s look at the source code to understand where the stateKeeper
field comes from. Our DefaultCounterComponent
implements the ComponentContext
interface, and the stateKeeper
field comes from StateKeeperOwner
. The full inheritance chain is as follows:
interface StateKeeperOwner {
val stateKeeper: StateKeeper
}
interface GenericComponentContext<out T : Any> :
LifecycleOwner,
StateKeeperOwner,
InstanceKeeperOwner,
BackHandlerOwner,
ComponentContextFactoryOwner<T>
interface ComponentContext : GenericComponentContext<ComponentContext>
Thus, the inheritance chain looks like this: StateKeeperOwner
← GenericComponentContext
← ComponentContext
← DefaultCounterComponent
.
We implement ComponentContext
, delegating it to the componentContext
parameter passed to the constructor.
class DefaultCounterComponent(
componentContext: ComponentContext
) : ComponentContext by componentContext {
...
}
And in MainActivity
we create ComponentContext
using the ready-made extension function defaultComponentContext
, which already creates ComponentContext
with all the necessary components for us, like StateKeeper
:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
val counterComponent = DefaultCounterComponent(defaultComponentContext())
...
}
}
Continuing the analysis: chain to the actual storage
So, we’ve already seen how stateKeeper.consume()
and stateKeeper.register()
are called in the component, and we know that the component itself gets stateKeeper
through its ComponentContext
. But what exactly happens between the call in Activity
/Fragment
and the final storage? Let’s go through the chain we just derived from the source code.
How StateKeeper
is created
In Activity
(or Fragment
), DefaultComponentContext
is created, and the result of calling defaultComponentContext()
is passed to it. Let’s look inside:
fun <T> T.defaultComponentContext(
discardSavedState: Boolean = false,
isStateSavingAllowed: () -> Boolean = { true },
): DefaultComponentContext where
T : SavedStateRegistryOwner, T : OnBackPressedDispatcherOwner, T : ViewModelStoreOwner, T : LifecycleOwner =
defaultComponentContext(
backHandler = BackHandler(onBackPressedDispatcher),
discardSavedState = discardSavedState,
isStateSavingAllowed = isStateSavingAllowed,
)
Note that the function is an extension for T
, where T
must be an object implementing the interfaces SavedStateRegistryOwner
, OnBackPressedDispatcherOwner
, ViewModelStoreOwner
, LifecycleOwner
. Classes like ComponentActivity
, FragmentActivity
, AppCompatActivity
perfectly fit these requirements.
Inside, essentially all the necessary dependencies are gathered and passed further — to another wrapper function, where everything needed for state storage is already initialized:
private fun <T> T.defaultComponentContext(
backHandler: BackHandler?,
discardSavedState: Boolean,
isStateSavingAllowed: () -> Boolean,
): DefaultComponentContext where
T : SavedStateRegistryOwner, T : ViewModelStoreOwner, T : LifecycleOwner {
val stateKeeper = stateKeeper(discardSavedState = discardSavedState, isSavingAllowed = isStateSavingAllowed)
...
return DefaultComponentContext(
lifecycle = lifecycle.asEssentyLifecycle(),
stateKeeper = stateKeeper,
instanceKeeper = instanceKeeper(discardRetainedInstances = marker == null),
backHandler = backHandler,
)
}
This is where the most interesting part begins — the StateKeeper
object is created by calling the stateKeeper
function and passed further.
How the StateKeeper
itself is created
Now let’s see where this object came from. Everything comes down to the extension function stateKeeper
, which is an extension for SavedStateRegistryOwner
:
private const val KEY_STATE = "STATE_KEEPER_STATE"
fun SavedStateRegistryOwner.stateKeeper(
discardSavedState: Boolean = false,
isSavingAllowed: () -> Boolean = { true },
): StateKeeper =
stateKeeper(
key = KEY_STATE,
discardSavedState = discardSavedState,
isSavingAllowed = isSavingAllowed,
)
Here the key is simply passed through (by default "STATE_KEEPER_STATE"
), and another stateKeeper
method is called:
fun SavedStateRegistryOwner.stateKeeper(
key: String,
discardSavedState: Boolean = false,
isSavingAllowed: () -> Boolean = { true },
): StateKeeper =
StateKeeper(
savedStateRegistry = savedStateRegistry,
key = key,
discardSavedState = discardSavedState,
isSavingAllowed = isSavingAllowed
)
Here we explicitly call the StateKeeper
constructor (actually it’s a function, not a class). The main object is passed here — savedStateRegistry
. Yes, the same one from AndroidX, which is inside Activity
and Fragment
and is used by the system for all onSaveInstanceState
calls.
What actually happens inside StateKeeper
Now we’ve approached the essence. StateKeeper
is a function that creates a real object of the StateKeeper
interface:
fun StateKeeper(
savedStateRegistry: SavedStateRegistry,
key: String,
discardSavedState: Boolean = false,
isSavingAllowed: () -> Boolean = { true },
): StateKeeper {
val dispatcher =
StateKeeperDispatcher(
savedState = savedStateRegistry
.consumeRestoredStateForKey(key = key)
?.getSerializableContainer(key = KEY_STATE)
?.takeUnless { discardSavedState },
)
savedStateRegistry.registerSavedStateProvider(key = key) {
Bundle().apply {
if (isSavingAllowed()) {
putSerializableContainer(key = KEY_STATE, value = dispatcher.save())
}
}
}
return dispatcher
}
Here it is — our main gateway between the Android world and the state saving system in Essenty/Decompose. Let’s go line by line:
- Previously saved state is extracted from
SavedStateRegistry
by key — essentially from the standardBundle
where Android saves data during onPause/onStop - A
StateKeeperDispatcher
object is created — this is the concrete implementation of theStateKeeper
interface, which can store manually registered serialized values and return them back throughconsume
when needed. - A new
SavedStateProvider
is registered — this is a lambda that Android will call when it needs to save state. It’s in this lambda thatdispatcher.save()
collects the registered values and prepares them for saving.
The call to SavedStateRegistry.registerSavedStateProvider
here is the connection point to the Android restoration system. It allows saving the state of StateKeeperDispatcher
in a Bundle
so that it can be restored on the next launch. This entire mechanism is an adapter between KMP state saving mechanics and Android API.
And this is where SerializableContainer
comes into play.
When dispatcher.save()
is called, all values registered through stateKeeper.register(...)
are serialized and wrapped in a SerializableContainer
.
This is a universal wrapper that stores data as ByteArray
and then converts it to a string using Base64
. Thanks to this, the result can be safely saved in a Bundle
as a regular string — without Parcelable
, putSerializable()
, and without Java Serializable
. When restoring, this path is traversed in reverse: string → bytes → object through kotlinx.serialization
.
Thus, when calling dispatcher.save()
, we get a serializable container that can be safely put in a Bundle
. And here what’s important is not just serialization, but how exactly it’s organized. This is not Parcelable
, and not Serializable
— this is SerializableContainer
.
SerializableContainer
is a separate entity that wraps an object and can work with kotlinx.serialization
directly. It’s serializable itself since it implements KSerializer
, and can be saved in a Bundle
without additional effort. Below is its internal implementation:
@Serializable(with = SerializableContainer.Serializer::class)
class SerializableContainer private constructor(
private var data: ByteArray?,
) {
constructor() : this(data = null)
private var holder: Holder<*>? = null
fun <T : Any> consume(strategy: DeserializationStrategy<T>): T? {
val consumedValue: Any? = holder?.value ?: data?.deserialize(strategy)
holder = null
data = null
@Suppress("UNCHECKED_CAST") return consumedValue as T?
}
fun <T : Any> set(value: T?, strategy: SerializationStrategy<T>) {
holder = Holder(value = value, strategy = strategy)
data = null
}
private class Holder<T : Any>(
val value: T?,
val strategy: SerializationStrategy<T>,
)
internal object Serializer : KSerializer<SerializableContainer> {
private const val NULL_MARKER = "."
override val descriptor = PrimitiveSerialDescriptor("SerializableContainer", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: SerializableContainer) {
val bytes = value.holder?.serialize() ?: value.data
encoder.encodeString(bytes?.toBase64() ?: NULL_MARKER)
}
override fun deserialize(decoder: Decoder): SerializableContainer =
SerializableContainer(data = decoder.decodeString().takeUnless { it == NULL_MARKER }?.base64ToByteArray())
}
}
What’s important here:
- In the
set(...)
method, the object and corresponding serialization strategy are saved, but immediate serialization doesn’t occur. - Only when the serializer (
Serializer
) is called does the object turn intoByteArray
, and then into a string. - After restoration —
decodeString()
→ByteArray
→ deserialization using the pre-known strategy.
This gives control over the serialization moment and the possibility of deferred processing.
Now about how all this ends up inside a Bundle
. Below are helper functions used inside the Essenty/Decompose library for serialization and deserialization of SerializableContainer
and arbitrary objects, calls to which we’ve already encountered in the StateKeeper functions:
fun <T : Any> Bundle.putSerializable(key: String?, value: T?, strategy: SerializationStrategy<T>) {
putParcelable(key, ValueHolder(value = value, bytes = lazy { value?.serialize(strategy) }))
}
fun <T : Any> Bundle.getSerializable(key: String?, strategy: DeserializationStrategy<T>): T? =
getParcelableCompat<ValueHolder<T>>(key)?.let { holder ->
holder.value ?: holder.bytes.value?.deserialize(strategy)
}
@Suppress("DEPRECATION")
private inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String?): T? =
classLoader.let { savedClassLoader ->
try {
classLoader = T::class.java.classLoader
getParcelable(key) as T?
} finally {
classLoader = savedClassLoader
}
}
fun Bundle.putSerializableContainer(key: String?, value: SerializableContainer?) {
putSerializable(key = key, value = value, strategy = SerializableContainer.serializer())
}
fun Bundle.getSerializableContainer(key: String?): SerializableContainer? =
getSerializable(key = key, strategy = SerializableContainer.serializer())
It’s worth mentioning the ValueHolder
entity separately:
private class ValueHolder<out T : Any>(
val value: T?,
val bytes: Lazy<ByteArray?>,
) : Parcelable {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeByteArray(bytes.value)
}
override fun describeContents(): Int = 0
companion object CREATOR : Parcelable.Creator<ValueHolder<Any>> {
override fun createFromParcel(parcel: Parcel): ValueHolder<Any> =
ValueHolder(value = null, bytes = lazyOf(parcel.createByteArray()))
override fun newArray(size: Int): Array<ValueHolder<Any>?> =
arrayOfNulls(size)
}
}
ValueHolder
is needed here for safe packaging of serialized bytes into a Bundle
through Parcelable
. It doesn’t serialize the object directly — it saves only the ByteArray
, which can later be unfolded back into an object through kotlinx.serialization
. The true reason this object is needed is that Bundle can store Parcelable and Java Serializable, but it can’t work directly with kotlinx.serialization
, so it serves as a wrapper.
Thus, SerializableContainer
+ ValueHolder
is low-level serialization infrastructure that allows saving arbitrary Kotlin Multiplatform values without dependencies on Android-specific interfaces, maintaining cross-platform compatibility and control over serialization.
What this all leads to
So, in fact, StateKeeper
is just an adapter between the internal state storage system in Essenty/Decompose and the system SavedStateRegistry
(and therefore the same onSaveInstanceState
in Activity
/Fragment
, only more convenient and declarative, and with serialization support through kotlinx.serialization
).
Briefly through the chain:
- In the
DefaultCounterComponent
component, we callconsume
/register
through theStateKeeper
interface. StateKeeper
is implemented asStateKeeperDispatcher
.StateKeeperDispatcher
internally stores values, serializes them, and registers a function for saving to the systemBundle
throughSavedStateRegistry
. It’s important to understand that the values we register inStateKeeper
don’t directly callsavedStateRegistry.registerSavedStateProvider
and don’t create separateSavedStateProvider
s. Everything is saved centrally — in oneStateKeeperDispatcher
object, and only it is registered inSavedStateRegistry
.- Everything is serialized and deserialized through
kotlinx.serialization
, withoutParcelable
,Bundle.putX
, and other boilerplate.
Let’s look at the StateKeeper
interface and its direct descendant StateKeeperDispatcher
:
com.arkivanov.essenty.statekeeper.StateKeeper.kt:
interface StateKeeper {
fun <T : Any> consume(key: String, strategy: DeserializationStrategy<T>): T?
fun <T : Any> register(key: String, strategy: SerializationStrategy<T>, supplier: () -> T?)
fun unregister(key: String)
fun isRegistered(key: String): Boolean
}
consume
— extracts and removes a previously saved value by the given key, using the deserialization strategy.register
— registers a value supplier that will be serialized and saved at the next state save.unregister
— removes a previously registered supplier so its value is no longer saved.isRegistered
— returnstrue
if a value supplier is already registered for the specified key.
com.arkivanov.essenty.statekeeper.StateKeeperDispatcher.kt:
interface StateKeeperDispatcher : StateKeeper {
fun save(): SerializableContainer
}
@JsName("stateKeeperDispatcher")
fun StateKeeperDispatcher(savedState: SerializableContainer? = null): StateKeeperDispatcher =
DefaultStateKeeperDispatcher(savedState)
The save()
method in StateKeeperDispatcher
is that same method we’ve already encountered before: dispatcher.save()
. It’s called at the moment when Android is about to save the activity or fragment state, and all registered values are serialized through it. Here we see the StateKeeperDispatcher
function again, which we’ve encountered before. Let me remind you — this is not a class, but a factory function that creates an instance of DefaultStateKeeperDispatcher
— the only implementation of the StateKeeperDispatcher
interface:
internal class DefaultStateKeeperDispatcher(
savedState: SerializableContainer?,
) : StateKeeperDispatcher {
private val savedState: MutableMap<String, SerializableContainer>? = savedState?.consume(strategy = SavedState.serializer())?.map
private val suppliers = HashMap<String, Supplier<*>>()
override fun save(): SerializableContainer {
val map = savedState?.toMutableMap() ?: HashMap()
suppliers.forEach { (key, supplier) ->
supplier.toSerializableContainer()?.also { container ->
map[key] = container
}
}
return SerializableContainer(value = SavedState(map), strategy = SavedState.serializer())
}
private fun <T : Any> Supplier<T>.toSerializableContainer(): SerializableContainer? =
supplier()?.let { value ->
SerializableContainer(value = value, strategy = strategy)
}
override fun <T : Any> consume(key: String, strategy: DeserializationStrategy<T>): T? =
savedState
?.remove(key)
?.consume(strategy = strategy)
override fun <T : Any> register(key: String, strategy: SerializationStrategy<T>, supplier: () -> T?) {
check(!isRegistered(key)) { "Another supplier is already registered with the key: $key" }
suppliers[key] = Supplier(strategy = strategy, supplier = supplier)
}
override fun unregister(key: String) {
check(isRegistered(key)) { "No supplier is registered with the key: $key" }
suppliers -= key
}
override fun isRegistered(key: String): Boolean = key in suppliers
private class Supplier<T : Any>(
val strategy: SerializationStrategy<T>,
val supplier: () -> T?,
)
@Serializable
private class SavedState(
val map: MutableMap<String, SerializableContainer>
)
}
This implementation manages two main structures:
savedState
— a map of already restored values fromSavedStateRegistry
, if they were saved previously;suppliers
— all registered value suppliers that should be serialized at the next state save.
When the save()
method is called, it collects all current values from suppliers
, serializes them, and packages them in a SerializableContainer
, which is then saved by the system. Restoration happens through the consume()
method, where the value is extracted from savedState
by key and deserialized using the passed strategy.
Conclusion
We’ve gone through the entire path — from a component using stateKeeper.consume()
and register()
to the final object serialized in a Bundle
. We’ve analyzed how StateKeeper
connects to SavedStateRegistry
, how values are stored inside StateKeeperDispatcher
, and how exactly they are saved and restored through serialization.
StateKeeper
— in Android this is a wrapper over Android Saved State API, which came to replace onSaveInstanceState
, but implemented declaratively and cross-platform. It allows saving arbitrary values through kotlinx.serialization
, without using Parcelable
, Bundle.putX
, reflection, and other low-level details.
Let’s visually look at the call chain to understand how StateKeeper works:
StateKeeper.register(...)
:
DefaultCounterComponent
└── stateKeeper.register(...)
└── StateKeeper (interface)
└── StateKeeperDispatcher (interface)
└── DefaultStateKeeperDispatcher.register(...)
└── suppliers[key] = Supplier(...)
StateKeeper(...) // creation during initialization
└── SavedStateRegistry.registerSavedStateProvider("state_keeper_key")
└── dispatcher.save()
└── serialization of values through kotlinx.serialization
└── wrapping in SerializableContainer
└── Bundle.putSerializable("state", ...)
StateKeeper.consume(...)
:
defaultComponentContext()
└── stateKeeper(...)
└── StateKeeper(...)
└── StateKeeperDispatcher(savedState = ...)
└── DefaultStateKeeperDispatcher.consume(key, strategy)
└── savedState.remove(key)?.consume(strategy)
└── SerializableContainer.consume(strategy)
└── kotlinx.serialization.decodeFromByteArray(...)
Now let’s analyze another state saving mechanism in Decompose — or rather, in the Essenty library on which everything is built.
InstanceKeeper
InstanceKeeper is one of the “horsemen” of ComponentContext
. Its task is to save arbitrary objects that shouldn’t be destroyed during configuration changes (for example, during screen rotation). This is an analog of ViewModel
from Android Jetpack, but in the context of cross-platform development (KMP).
Let’s remake our DefaultCounterComponent
component to use InstanceKeeper
instead of StateKeeper
:
class DefaultCounterComponent(
componentContext: ComponentContext
) : ComponentContext by componentContext {
val model: StateFlow<Int> field = instanceKeeper.getOrCreate(
key = KEY,
factory = {
object : InstanceKeeper.Instance {
val state = MutableStateFlow(0)
}
}
).state
fun increase() {
model.value++
}
fun decrease() {
model.value--
}
companion object {
private const val KEY = "counter_state"
}
}
Note: the init
block was removed, and only the model
variable was changed. Everything else remained unchanged.
Now let’s check the behavior visually:
- How the counter will behave during configuration changes (specifically screen rotation).
- How the counter will behave when the process is destroyed while the app is in the background.

What do we see? The counter survives screen rotation, but resets to zero when the process dies. This is exactly the behavior of ViewModel
, and this is exactly what we expect from InstanceKeeper
.
Now let’s see how this construction works under the hood.
First, let’s define who is responsible for storing InstanceKeeper
. In Essenty (and, accordingly, in Decompose) this is the interface:
/**
* Represents a holder of [InstanceKeeper].
*/
interface InstanceKeeperOwner {
val instanceKeeper: InstanceKeeper
}
It’s implemented in GenericComponentContext
, and therefore in ComponentContext
, which is used in each component:
interface GenericComponentContext<out T : Any> :
LifecycleOwner,
StateKeeperOwner,
InstanceKeeperOwner,
BackHandlerOwner,
ComponentContextFactoryOwner<T>
interface ComponentContext : GenericComponentContext<ComponentContext>
Thus, the inheritance chain looks like this: InstanceKeeperOwner
← GenericComponentContext
← ComponentContext
← DefaultCounterComponent
.
Now let’s figure out where the implementation comes from.
In MainActivity
we create a top-level component through the defaultComponentContext()
function. It’s this function that forms the ComponentContext
, injecting all the necessary dependencies inside: Lifecycle
, StateKeeper
, InstanceKeeper
, BackHandler
.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
val counterComponent = DefaultCounterComponent(defaultComponentContext())
...
}
}
Let’s look at the defaultComponentContext()
source code again:
fun <T> T.defaultComponentContext(
discardSavedState: Boolean = false,
isStateSavingAllowed: () -> Boolean = { true },
): DefaultComponentContext where
T : SavedStateRegistryOwner, T : OnBackPressedDispatcherOwner, T : ViewModelStoreOwner, T : LifecycleOwner =
defaultComponentContext(
backHandler = BackHandler(onBackPressedDispatcher),
discardSavedState = discardSavedState,
isStateSavingAllowed = isStateSavingAllowed,
)
At this level, only call proxying occurs — all dependencies are gathered and passed further to the private function:
private fun <T> T.defaultComponentContext(
backHandler: BackHandler?,
discardSavedState: Boolean,
isStateSavingAllowed: () -> Boolean,
): DefaultComponentContext where
T : SavedStateRegistryOwner, T : ViewModelStoreOwner, T : LifecycleOwner {
...
return DefaultComponentContext(
lifecycle = lifecycle.asEssentyLifecycle(),
stateKeeper = stateKeeper,
instanceKeeper = instanceKeeper(discardRetainedInstances = marker == null),
backHandler = backHandler,
)
}
The key line here is instanceKeeper = instanceKeeper(...)
.
This is the very point where the InstanceKeeper
is created (or restored). Now our task is to understand what this instanceKeeper(...)
function is, how it’s structured, and how the internal storage logic is implemented.
Let’s start with the fact that instanceKeeper
is an extension function for ViewModelStoreOwner
. It becomes available inside defaultComponentContext
because its generic explicitly requires that the calling object implements the ViewModelStoreOwner
interface. This condition ensures access to the ViewModelStore
, which is passed inside InstanceKeeper(...)
. Here’s the signature of this function:
/**
* Creates a new instance of [InstanceKeeper] and attaches it to the AndroidX [ViewModelStore].
*
* @param discardRetainedInstances a flag indicating whether any previously retained instances should be
* discarded and destroyed or not, default value is `false`.
*/
fun ViewModelStoreOwner.instanceKeeper(discardRetainedInstances: Boolean = false): InstanceKeeper =
InstanceKeeper(viewModelStore = viewModelStore, discardRetainedInstances = discardRetainedInstances)
At first glance, it seems that InstanceKeeper
is a class, but in this case it’s not a constructor at all, but a function returning an implementation of the InstanceKeeper
interface. Here’s how it’s structured:
/**
* Creates a new instance of [InstanceKeeper] and attaches it to the provided AndroidX [ViewModelStore].
*
* @param discardRetainedInstances a flag indicating whether any previously retained instances should be
* discarded and destroyed or not, default value is `false`.
*/
fun InstanceKeeper(
viewModelStore: ViewModelStore,
discardRetainedInstances: Boolean = false,
): InstanceKeeper =
ViewModelProvider(
viewModelStore,
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = InstanceKeeperViewModel() as T
}
)
.get<InstanceKeeperViewModel>()
.apply {
if (discardRetainedInstances) {
recreate()
}
}
.instanceKeeperDispatcher
Now it becomes clear: the InstanceKeeper
implementation on Android is directly tied to ViewModelStore
. The concept of long-lived objects is implemented here through a wrapper around a regular ViewModel
.
An InstanceKeeperViewModel
is created, and then instanceKeeperDispatcher
is extracted from it, which is returned as InstanceKeeper
.
The API itself seems abstract and independent of Android at first glance, but under the hood it’s pure ViewModel
. Moreover, inside all this logic there’s not even a hint that Android ViewModel is used — everything is hidden behind the InstanceKeeper
interface.
Here’s how InstanceKeeperViewModel is structured:
internal class InstanceKeeperViewModel : ViewModel() {
var instanceKeeperDispatcher: InstanceKeeperDispatcher = InstanceKeeperDispatcher()
private set
override fun onCleared() {
instanceKeeperDispatcher.destroy()
}
fun recreate() {
instanceKeeperDispatcher.destroy()
instanceKeeperDispatcher = InstanceKeeperDispatcher()
}
}
What’s important here:
instanceKeeperDispatcher
is the storage for all registered instances (InstanceKeeper.Instance
).- The
onCleared()
method is called when the ViewModel is removed fromViewModelStore
. It callsdestroy()
on thedispatcher
, destroying all registered instances. - The
recreate()
method allows manually resetting all previously saved instances — useful if you need to clear state when recreating a component.
After understanding that InstanceKeeperViewModel
returns instanceKeeperDispatcher
, a logical question arises — what does it represent.
/**
* Represents a destroyable [InstanceKeeper].
*/
interface InstanceKeeperDispatcher : InstanceKeeper {
/**
* Destroys all existing instances. Instances are not cleared, so that they can be
* accessed later. Any new instances will be immediately destroyed.
*/
fun destroy()
}
InstanceKeeperDispatcher
is an interface that extends InstanceKeeper
and adds the vital destroy()
function to it. It destroys all current Instance
instances, but doesn’t clear them from the internal storage — they can still be accessed when needed. However, any new instances created after calling destroy()
are destroyed immediately.
The destroy()
method is called by the system when the component’s lifecycle comes to an end — for example, when completely removed from the back stack. This allows timely resource cleanup and termination of background tasks.
The implementation is created through a factory function:
/**
* Creates a default implementation of [InstanceKeeperDispatcher].
*/
@JsName("instanceKeeperDispatcher")
fun InstanceKeeperDispatcher(): InstanceKeeperDispatcher = DefaultInstanceKeeperDispatcher()
Now let’s examine what InstanceKeeper
itself represents.
/**
* A generic keyed store of [Instance] objects. Instances are destroyed at the end of the
* [InstanceKeeper]'s scope, which is typically tied to the scope of a back stack entry.
* E.g. instances are retained over Android configuration changes, and destroyed when the
* corresponding back stack entry is popped.
*/
interface InstanceKeeper {
fun get(key: Any): Instance?
fun put(key: Any, instance: Instance)
fun remove(key: Any): Instance?
interface Instance {
fun onDestroy() {}
}
class SimpleInstance<out T>(val instance: T) : Instance
}
InstanceKeeper
is a key storage for long-lived objects that survive configuration changes, but are destroyed when the component’s lifecycle finally ends. A typical example is removing an element from the back stack.
The storage works on the key -> Instance
principle and provides methods for getting, storing, and removing objects.
The Instance
interface itself is minimal: for an object to become managed, you need to implement a single onDestroy()
method. It will be called by the system when the component is destroyed — this is analogous to onCleared()
in ViewModel
, but with more flexible control.
For cases where no cleanup is required, you can use the SimpleInstance
wrapper. It implements Instance
but does nothing in onDestroy()
— it simply turns any object into one compatible with InstanceKeeper
.
Now let’s look at how the storage implementation itself works:
internal class DefaultInstanceKeeperDispatcher : InstanceKeeperDispatcher {
private val map = HashMap<Any, Instance>()
private var isDestroyed = false
override fun get(key: Any): Instance? =
map[key]
override fun put(key: Any, instance: Instance) {
check(key !in map) { "Another instance is already associated with the key: $key" }
map[key] = instance
if (isDestroyed) {
instance.onDestroy()
}
}
override fun remove(key: Any): Instance? =
map.remove(key)
override fun destroy() {
if (!isDestroyed) {
isDestroyed = true
map.values.toList().forEach(Instance::onDestroy)
}
}
}
DefaultInstanceKeeperDispatcher
is a concrete implementation of InstanceKeeperDispatcher
. Inside it has a regular HashMap
where all current Instance
objects are stored by key. The put()
method adds an object, first checking that the key is not occupied. The isDestroyed
flag allows tracking whether the storage has finished working — if true
, then even a just-added object is immediately destroyed through onDestroy()
.
The destroy()
method goes through all registered objects and calls onDestroy()
on each one. The objects themselves remain in the map
so that they can be accessed later if needed — although new ones won’t live anymore.
Now — about what we use in our DefaultCounterComponent
component. There getOrCreate
is called instead of put
, and here’s how it works:
inline fun <T : InstanceKeeper.Instance> InstanceKeeper.getOrCreate(key: Any, factory: () -> T): T {
@Suppress("UNCHECKED_CAST")
var instance: T? = get(key) as T?
if (instance == null) {
instance = factory()
put(key, instance)
}
return instance
}
The getOrCreate()
method is a convenient helper: first it tries to get an object by key, and if there isn’t one yet, it creates one through factory()
and saves it. It’s used in 90% of cases because it eliminates manual existence checking and duplicate code.
DefaultComponentContext
Throughout the article, we’ve touched on the defaultComponentContext()
function many times — it acts as the entry point where all component dependencies are gathered:
private fun <T> T.defaultComponentContext(
backHandler: BackHandler?,
discardSavedState: Boolean,
isStateSavingAllowed: () -> Boolean,
): DefaultComponentContext where
T : SavedStateRegistryOwner, T : ViewModelStoreOwner, T : LifecycleOwner {
val stateKeeper = stateKeeper(discardSavedState = discardSavedState, isSavingAllowed = isStateSavingAllowed)
val marker = stateKeeper.consume(key = KEY_STATE_MARKER, strategy = String.serializer())
stateKeeper.register(key = KEY_STATE_MARKER, strategy = String.serializer()) { "marker" }
return DefaultComponentContext(
lifecycle = lifecycle.asEssentyLifecycle(),
stateKeeper = stateKeeper,
instanceKeeper = instanceKeeper(discardRetainedInstances = marker == null),
backHandler = backHandler,
)
}
private const val KEY_STATE_MARKER = "DefaultComponentContext_state_marker"
We’ve already examined in detail where StateKeeper
comes from, how InstanceKeeper
is created, and what role the marker
plays. But we still haven’t looked inside DefaultComponentContext
itself — let’s fix that:
class DefaultComponentContext(
override val lifecycle: Lifecycle,
stateKeeper: StateKeeper? = null,
instanceKeeper: InstanceKeeper? = null,
backHandler: BackHandler? = null,
) : ComponentContext {
override val stateKeeper: StateKeeper = stateKeeper ?: StateKeeperDispatcher()
override val instanceKeeper: InstanceKeeper = instanceKeeper ?: InstanceKeeperDispatcher().attachTo(lifecycle)
override val backHandler: BackHandler = backHandler ?: BackDispatcher()
override val componentContextFactory: ComponentContextFactory<ComponentContext> =
ComponentContextFactory(::DefaultComponentContext)
constructor(lifecycle: Lifecycle) : this(
lifecycle = lifecycle,
stateKeeper = null,
instanceKeeper = null,
backHandler = null,
)
}
As you can see, DefaultComponentContext
is just a convenient bundle that combines Lifecycle
, StateKeeper
, InstanceKeeper
, and BackHandler
. If some dependencies weren’t passed from outside — it creates default implementations itself. All this is wrapped in a single ComponentContext
object, which is then passed to components and navigation structures.
Thus, DefaultComponentContext
can be considered the connecting link between Android infrastructure and the cross-platform architecture of Decompose — it turns low-level entities into a universal interface.
Finale
If you’ve made it to this point — it means you’ve gone through the entire journey with me through state storage in Android at a deep, under-the-hood level: from where ViewModelStore
actually lives in Activity
and Fragment
, to how ViewModel
is stored in Compose
and View
, how the Saved State API
works, how it differs from onSaveInstanceState
, and where the Bundle
ultimately ends up.
In the last part, we examined how state saving logic works in Decompose
and Essenty
, to remove the illusion of “magic” and show that under the hood — it’s all the same standard Android mechanisms, just wrapped in a more universal API. All of this was considered strictly through the lens of data storage and restoration.
This article completes the series. Everything written here is not documentation or a guide. It’s just an attempt to look inside, understand, and build a complete picture.
If you think this might be useful to someone else — feel free to share it. If you want to discuss or suggest corrections — I’m open.
Discussion
No comments yet. Be the first to share your thoughts!