For the complete documentation index, see llms.txt. This page is also available as Markdown.

Compose Part 1

Q1. Explain the difference between remember and rememberSaveable and when each is appropriate.

remember stores a value in the Composition and survives recomposition, but it is destroyed when the Activity or process is killed (e.g., during a configuration change or process death). Use it for ephemeral UI state like scroll position, animation progress, or temporary form input that does not need to persist across the app's lifecycle. rememberSaveable, on the other hand, uses the SavedStateRegistry to automatically serialize the value into the Bundle provided by the system. This means it survives both configuration changes (like screen rotation) and process death/restoration. However, rememberSaveable only works with types that can be stored in a Bundle (primitives, Parcelable, Serializable, List, Map, etc.), or types for which you provide a custom Saver. For complex objects like a ViewModel or a network response DTO that is not Parcelable, you must either write a custom Saver or simply use remember and accept that the state will be lost.

@Composable
fun LoginForm() {
    // Lost on rotation / process death
    var username by remember { mutableStateOf("") }

    // Survives rotation and process death
    var password by rememberSaveable { mutableStateOf("") }

    // Custom Saver for a non-Bundle type
    data class User(val name: String, val age: Int)
    val userSaver = Saver<User, List<<Any>>(
        save = { listOf(it.name, it.age) },
        restore = { User(it[0] as String, it[1] as Int) }
    )
    var user by rememberSaveable(stateSaver = userSaver) { mutableStateOf(User("Alex", 25)) }

    OutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") })
    OutlinedTextField(value = password, onValueChange = { password = it }, label = { Text("Password") })
}

Q2. What triggers recomposition in Compose, and how does the compiler optimize it via stability inference?

Recomposition is triggered whenever a @Composable function reads a State object (e.g., mutableStateOf) whose value has changed. Compose tracks these reads during the composition phase using an observation system linked to the snapshot state. When the state value mutates, Compose invalidates all composable scopes that read that state and schedules them for recomposition. The Compose Compiler plugin optimizes this by analyzing parameter stability at compile time. If all parameters of a composable are deemed stable (immutable primitives, @Immutable/@Stable types), the compiler marks the function as skippable, meaning it can skip recomposition if the arguments are equal (==). The compiler infers stability automatically for Kotlin data classes with only stable properties, but it treats interfaces, var properties, and standard collection types (ArrayList, HashMap, etc.) as unstable by default, which disables skipping.


Q3. How do @Stable, @Immutable, and @NonRestartableComposable annotations affect recomposition behavior?

@Immutable tells the Compose compiler that the annotated class and all its fields are deeply immutable. Once constructed, nothing inside it can change. This allows Compose to treat any instance as stable and skip recompositions if the instance reference (===) is the same. @Stable is a weaker contract: it promises that if any property changes, Compose will be notified (typically because those properties are backed by mutableStateOf). This is used for classes like ViewModel or controller classes where the object identity stays the same but internal state changes. @NonRestartableComposable prevents the compiler from generating "restart" logic for that function. Normally, the compiler wraps composables so that if a state read inside them changes, only that specific scope recomposes (restartable). With this annotation, the function becomes a transparent call site: if it reads state, the caller recomposes instead. This is useful for tiny inline helper composables or Layout blocks where you want to avoid unnecessary composition overhead.


Q4. Why is it problematic to pass unstable types (e.g., ArrayList, ViewModel) directly into Composable parameters?

The Compose compiler treats types as unstable if it cannot prove they are immutable. Standard Java collections (ArrayList, HashMap, Calendar, etc.) and open classes/interfaces are unstable by default. When an unstable type is passed as a parameter, the compiler marks the composable as non-skippable, meaning it will recompose every time the parent recomposes—even if the data inside the collection hasn't actually changed. This destroys the primary performance benefit of Compose: fine-grained skipping. A ViewModel is technically marked @Stable in many architectures, but passing it as a parameter still forces recomposition if the parent recomposes, because the compiler cannot guarantee that the ViewModel's internal state changes are properly observed at the parameter level. The solution is to extract the exact stable data you need (e.g., List<String> instead of ArrayList<String>, or individual State values) and pass those down, or wrap the data in an @Immutable data class.


Q5. What is the role of SnapshotState and how does it integrate with Compose's observation system?

SnapshotState is the underlying mutable state system that powers Compose (and potentially other UI frameworks). When you create a state using mutableStateOf(), you are creating a SnapshotState object. The "Snapshot" part refers to the fact that all state changes in Compose happen within an atomic snapshot. When a value is changed (e.g., state.value = 5), the change is not applied globally immediately; it is recorded in the current snapshot. When the snapshot is applied (typically at the end of the event loop), all observers that read that state during composition are notified. Compose hooks into this notification system: during composition, it records which State objects each composable reads. When a snapshot is applied and those states change, Compose invalidates the associated composable scopes and schedules them for recomposition. This is why state must be read inside the composable body (or in lambdas that execute during composition) to be observed. Reading it in a side effect or a callback does not establish an observation link.


Q6. Explain the difference between derivedStateOf and remember(key) — when would you choose one over the other?

remember(key) caches a value and recomputes it whenever the key changes (using structural equality ==). It is ideal when you have a direct input that changes infrequently and you want to avoid recalculating an expensive transformation. derivedStateOf, however, creates a State object that observes other State objects. It only notifies its observers when the result of the calculation changes, not when the inputs change. This is crucial for preventing unnecessary recompositions when the calculation filters or reduces data. For example, if you have a list of 1000 items and you want to know if any item is selected, remember(selectedIds) would recompute every time the set reference changes (which might be every time an item is clicked). derivedStateOf { selectedIds.isNotEmpty() } will only trigger a recomposition of readers when the boolean result flips from false to true or vice versa, even if the set reference changes multiple times in between. Use remember(key) for external non-state inputs or one-off expensive calculations; use derivedStateOf when deriving a new observable state from existing observable state to minimize downstream recompositions.


Q7. What happens if you write var text by remember { mutableStateOf("") } inside a Composable versus hoisting it to a ViewModel?

When you declare var text by remember { mutableStateOf("") } directly inside a @Composable function, the state is local to that composable's slot in the Composition tree. If that composable leaves the Composition (e.g., due to a conditional if block or navigation), the state is destroyed. It also does not survive configuration changes like screen rotation. Furthermore, sibling or child composables cannot easily access this state without it being passed as parameters through multiple layers (prop drilling). Hoisting the same state to a ViewModel (or any class scoped to the ViewModelStoreOwner) solves these problems. The ViewModel survives configuration changes and process death (when combined with SavedStateHandle). It centralizes business logic, makes the state accessible to multiple composables via collectAsStateWithLifecycle(), and enforces a unidirectional data flow where the UI is a pure function of the ViewModel's state. The downside is slightly more boilerplate and the risk of retaining state longer than necessary if the ViewModel outlives the specific screen.


Q8. How do you implement unidirectional data flow in a Compose-heavy application?

Unidirectional Data Flow (UDF) means that state flows in one direction—from a state holder (like a ViewModel) down to the UI—and events flow in the opposite direction—from the UI up to the state holder. The UI should never mutate state directly; instead, it should invoke callbacks or fire events (often called "intents" or "actions") that the state holder processes. This makes the UI a pure function of state, which makes it predictable, easy to test, and robust against bugs. In Compose, the UI layer consists of stateless composables that receive state and lambda callbacks as parameters. The state holder (e.g., a ViewModel exposing a StateFlow<<UiState>) is the single source of truth. When a user interacts with the UI (e.g., clicks a button or types in a field), the composable calls the provided lambda, which typically delegates to the ViewModel. The ViewModel updates its internal state, which emits a new UiState value, triggering a recomposition of the UI to reflect the change.


Q9. What are the trade-offs between ViewModel state holders versus plain class state holders in Compose?

A ViewModel is lifecycle-aware and survives configuration changes because it is retained by the ViewModelStore scoped to a NavBackStackEntry, Activity, or Fragment. This makes it ideal for business logic, network calls, and state that must persist across rotation. However, it introduces a dependency on the Android Architecture Components and is often overkill for purely UI logic (like animation state or scroll position). A plain class state holder (sometimes called a UiStateHolder or simply hoisted into remember) is lighter, easier to instantiate in previews, and easier to unit test because it does not require a ViewModel testing harness. The downside is that it is destroyed when the composable leaves the Composition or when the host Activity is recreated. For complex screens, a hybrid approach works best: use a ViewModel for domain state and a plain class (or remembered instance) for UI-specific state. Jetpack Compose documentation recommends this pattern as "state holders."


Q10. How do you handle configuration changes (e.g., rotation) with Compose state without relying on ViewModel?

If you want to avoid ViewModel (for example, in a lightweight feature module or a reusable component library), you can use rememberSaveable to persist primitive and simple state across configuration changes. For more complex objects, you provide a custom Saver that tells the system how to serialize and deserialize the object into a Bundle. Another approach is to use rememberRetained from libraries like Circuit or Decompose, which mimics ViewModel's retention semantics without the AAC dependency. However, rememberSaveable does not survive process death for complex objects unless a Saver is provided, and it is not suitable for large data sets or objects holding references to Contexts, CoroutineScopes, or Repositories. For UI state that is cheap to recompute (like a toggle or text input), rememberSaveable is sufficient. For business state that requires async initialization, a ViewModel or a retained plain class is still the better choice.

Last updated