Android & Mobile

Kotlin val vs var in Android: Write State That Is Easier to Reason About

A practical guide to choosing val or var, designing function boundaries, and keeping Android state ownership explicit instead of spreading mutation across the UI.

Table of Contents12 sections
A laptop displaying code during a Kotlin state and function review.
Text-free hero visual supporting Kotlin val vs var in Android: Write State That Is Easier to Reason About.

Predictable Android state starts with explicit ownership of change.

val versus var looks like one of the easiest Kotlin lessons. In a real Android codebase, it becomes an architecture decision. For a related Kotlin boundary, see structuring and naming Android libraries.

A property that can be reassigned from too many places makes state transitions harder to explain, tests harder to isolate, and UI bugs harder to reproduce. A val does not magically make an object immutable, but using read-only references by default forces an important question: where is mutation actually allowed to happen?

That question matters more than memorizing syntax.

This guide uses val, var, and function design as a practical way to reason about state ownership in ViewModels, repositories, and Jetpack Compose.

Kotlin val vs var: the distinction that actually matters

At the language level, the rule is simple:

val userId = "42"
var retryCount = 0

retryCount += 1

val cannot be reassigned after initialization. var can.

But this does not mean a value referenced by val is deeply immutable:

val users = mutableListOf<String>()
users += "Ray"

The reference still points to the same list, so the list itself can change. This distinction prevents a common false sense of safety: val protects the reference from reassignment; the type and API determine whether the underlying object can mutate.

A useful Android default is therefore:

Start with val. Introduce mutation only at the boundary that owns the state transition.

That produces code where mutation is deliberate instead of ambient.

Why var becomes expensive when ownership is unclear

Imagine a checkout screen with this state:

var totalPrice = 0L
var isLoading = false
var errorMessage: String? = null

The syntax is valid. The Structuring Intents Modular Architecture In Kotlin question is: who can write those properties?

If a Fragment, callback, repository response, and retry handler can all mutate them independently, debugging becomes a timeline reconstruction exercise. You see the wrong value on screen, but the declaration tells you nothing about which transition produced it.

Instead, make the mutable representation private and expose a read-only contract:

private val _uiState = MutableStateFlow(OrderUiState())
val uiState: StateFlow<OrderUiState> = _uiState

Now callers can observe state but cannot arbitrarily replace it. Mutation stays behind the ViewModel boundary.

The benefit is not fewer characters. It is a smaller write surface.

Model state transitions instead of exposing writable fields

Once mutation has an owner, functions should describe valid transitions.

data class OrderUiState(
    val quantity: Int = 1,
    val pricePerItem: Long = 20_000,
    val isSubmitting: Boolean = false,
)

fun OrderUiState.incrementQuantity(): OrderUiState =
    copy(quantity = quantity + 1)

This approach gives you a before-state, an operation, and an after-state. That is much easier to test than a function that reaches into several mutable properties and changes them as side effects.

For ViewModel behavior, keep the mutation point explicit:

fun incrementQuantity() {
    _uiState.update { current ->
        current.copy(quantity = current.quantity + 1)
    }
}

A reader can now answer three questions quickly:

  1. Where does state live?
  2. Who may change it?
  3. Which operation caused the change?

Those questions are more useful during debugging than whether a particular line happens to use val or var.

Design functions around inputs and outputs

Kotlin functions are especially useful when business rules can be separated from Android framework state.

Consider price calculation:

fun calculateTotal(quantity: Int, pricePerItem: Long): Long =
    quantity * pricePerItem

The function has explicit inputs and one output. It does not read a Fragment field, mutate a global variable, or depend on lifecycle timing.

That makes a test boring,in a good way:

@Test
fun `three items use the expected total`() {
    assertEquals(60_000, calculateTotal(3, 20_000))
}

When a function needs five hidden properties, two callbacks, and a mutable singleton to work, the problem is usually not Kotlin syntax. The function boundary is hiding dependencies.

Prefer domain transformations over generic mutation

Collection operators such as map, filter, fold, and sumOf can reduce temporary mutation, but concise code is not automatically better code.

For example:

val paidTotal = orders
    .filter { it.isPaid }
    .sumOf { it.totalPrice }

is clearer than maintaining an accumulator manually because the transformation says what the result means.

The same principle applies to serialization. If an API needs a different shape from the domain model, make that boundary visible:

fun Order.toRequest(): OrderRequest = OrderRequest(
    id = id,
    total = totalPrice,
)

Do not let network formatting rules gradually leak into UI state simply because every property is writable.

Compose makes state ownership visible very quickly

Jetpack Compose reacts to state reads. That makes poorly scoped mutation particularly noticeable: when state changes, the UI that reads it can be scheduled for recomposition.

Local UI-only state can legitimately be mutable:

var expanded by remember { mutableStateOf(false) }

That does not contradict the “prefer val” rule. The composable owns this small state, and the mutation boundary is obvious.

The problem starts when the same business state is independently represented as mutable state in multiple places,for example, a ViewModel owns selectedOrder, while a composable creates another mutable copy and a callback maintains a third version.

A better test is not “can I remove every var?” It is:

Can I identify one authoritative owner for each meaningful piece of state?

If the answer is yes, necessary mutation is usually manageable.

val and immutable data classes work well together

For UI models, immutable data classes create explicit snapshots:

data class ProfileUiState(
    val name: String,
    val isSaving: Boolean,
    val error: String?,
)

Changing the state means creating a new value:

val saving = current.copy(isSaving = true, error = null)

This has an important debugging advantage. Instead of asking which field was mutated first, you can reason about transitions between snapshots.

It also reduces accidental partial updates. A state transition can express related changes together,for example, entering a loading state while clearing an old error.

When var is the right choice

Avoiding var as a purity contest creates awkward code. Mutation is appropriate when the lifecycle and owner are clear.

Good examples include local counters inside an algorithm, UI interaction state owned by one composable, mutable builders, and internal implementation details that are not exposed as shared writable state.

fun countValid(items: List<Item>): Int {
    var count = 0

    for (item in items) {
        if (item.isValid) count++
    }

    return count
}

You could rewrite this function with collection operators, but the local var is contained, understandable, and cannot corrupt application state elsewhere.

The cost of mutation grows with its scope, lifetime, and number of writers.

A practical decision table

Situation Prefer Why
Value never needs reassignment val Makes the reference stable
Public ViewModel state Read-only val Prevents external writers
Internal state holder Private mutable property Centralizes transitions
Immutable UI snapshot data class with val fields Makes changes explicit via copy()
Short-lived local algorithm state Local var Mutation stays contained
UI toggle owned by one composable Local mutable state Ownership and lifetime are clear
Shared writable property across layers Usually redesign Too many possible writers

Debugging state: trace writers before adding fixes

When a value unexpectedly resets, developers often patch the visible symptom first. A better sequence is:

  1. Identify the authoritative state owner.
  2. Search for every write path to that state.
  3. Record the expected transitions.
  4. Reproduce the bug and determine which transition is missing or invalid.
  5. Only then change implementation code.

Suppose a list does not refresh after a database operation. The first question should not be “should this variable become var?” Check whether the database stream emitted, whether the repository preserved one source of truth, whether the ViewModel transformed that emission, and whether the UI is observing the expected state.

Changing mutability without identifying the broken transition often hides the bug rather than solving it.

The RayLabs rule: minimize the write surface

A useful way to review Kotlin state is to treat every writable property as part of the system’s write surface.

For each var or mutable holder, ask:

The goal is not zero mutation. The goal is mutation you can locate, explain, and test.

Final takeaway

Use val by default because stable references make intent clearer, not because var is inherently bad. Keep mutable state close to its owner, expose read-only contracts across architectural boundaries, and use functions to represent meaningful transitions instead of letting unrelated layers modify fields directly.

Once those boundaries are explicit, Kotlin’s concise syntax becomes more than convenience. It becomes a way to make Android state behavior easier to reason about when the app, team, and feature set grow.

Continue Exploring

You Might Also Like

View all articles