Skip to content

Kotlin Backing Fields vs Traditional _state Pattern

Purpose

When I was working on Android ViewModels with Kotlin 2.3, I got confused about the difference between the new backing fields syntax and the traditional _uiState pattern.

Here’s the problem: I kept seeing different approaches to StateFlow properties in ViewModels, and I didn’t understand why some code worked with smart-casts while others didn’t.

Environment

  • Kotlin 2.3
  • Android Studio
  • Android Development
  • StateFlow in ViewModels

The Problem

I started with the traditional ViewModel pattern I knew well:

TraditionalViewModel.kt
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(Loading)
val uiState: StateFlow<UiState> get() = _uiState
fun loadData() {
if (_uiState.value is Loading) {
// Must access .value manually
_uiState.value = Loading
_uiState.value = Loaded(data)
}
}
fun updateState(newState: UiState) {
// Still need .value access
_uiState.value = newState
}
}

This pattern works, but I noticed:

  • Always need to access .value manually
  • Smart-casts don’t work on _uiState
  • Need two properties for one state

Then I saw the new pattern in Kotlin 2.3:

NewViewModel.kt
class MyViewModel : ViewModel() {
val uiState: StateFlow<UiState> field = MutableStateFlow(Loading)
fun loadData() {
// Smart-cast works directly on field!
if (uiState is Loading) {
// Can assign directly to field
uiState = Loading
uiState = Loaded(data)
}
}
fun updateState(newState: UiState) {
// Direct field assignment
uiState = newState
}
}

But wait, this looked too good to be true. I thought there must be something I was missing.

What happened?

I tried to test both patterns to see the real difference:

TestComparison.kt
fun testTraditionalPattern() {
val viewModel = TraditionalViewModel()
// No smart-cast on _uiState
if (viewModel._uiState.value is Loading) {
// Must access .value manually
val currentState = viewModel._uiState.value
viewModel._uiState.value = Loaded("data")
}
}
fun testNewPattern() {
val viewModel = NewViewModel()
// Smart-cast works directly on field!
if (viewModel.uiState is Loading) {
// No .value needed!
val currentState = viewModel.uiState // This works!
viewModel.uiState = Loaded("data") // This works!
}
}

When I ran this code, I got something unexpected with the traditional pattern:

Terminal window
user@macbook:~$ kotlinc -cp kotlin-stdlib.jar TestComparison.kt
error: unresolved reference: _uiState
error: unresolved reference: _uiState
error: unresolved reference: _uiState

Right! The private _uiState field is not accessible from outside the ViewModel. But the real issue is inside the ViewModel:

InsideTraditionalViewModel.kt
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(Loading)
val uiState: StateFlow<UiState> get() = _uiState
fun checkState() {
if (_uiState.value is Loading) {
// No smart-cast on _uiState.value
val test = _uiState.value
// compiler doesn't know test is Loading
if (test is Loading) {
// Works, but redundant
}
}
}
}

With the new pattern:

InsideNewViewModel.kt
class MyViewModel : ViewModel() {
val uiState: StateFlow<UiState> field = MutableStateFlow(Loading)
fun checkState() {
if (uiState is Loading) {
// Smart-cast works directly!
val test = uiState // Compiler knows test is Loading
if (test is Loading) {
// No redundant check needed
}
}
}
}

How to solve it?

I tried to understand why the traditional pattern required all this boilerplate:

TraditionalExplained.kt
// The traditional pattern needs two properties because:
// 1. private mutable backing field
// 2. public immutable property with getter
class MyViewModel : ViewModel() {
// Private mutable field
private val _uiState = MutableStateFlow<UiState>(Loading)
// Public read-only property
val uiState: StateFlow<UiState> get() = _uiState
fun updateState() {
// Must access .value because _uiState is MutableStateFlow
_uiState.value = NewState
// No smart-cast possible
if (_uiState.value is Loading) {
// Manual .value access required
}
}
}

Then I discovered the new backing fields syntax:

NewExplained.kt
// Kotlin 2.3 backing fields eliminate the need for:
// - private backing field
// - getter-based property
class MyViewModel : ViewModel() {
// Single property with automatic backing field
val uiState: StateFlow<UiState> field = MutableStateFlow(Loading)
fun updateState() {
// Direct field assignment - no .value needed!
uiState = NewState
// Smart-cast works directly!
if (uiState is Loading) {
// No manual .value access
}
}
}

The key difference:

KeyDifference.kt
// Traditional - requires .value access everywhere
val currentState = _uiState.value
_uiState.value = newState
// New - direct field access (smart-cast works!)
val currentState = uiState // Smart-cast!
uiState = newState // No .value needed!

The reason

I think the key reason for the difference is:

  1. Traditional Pattern: The get() syntax creates a property that delegates to a backing field. The compiler treats _uiState as the backing field and uiState as the property. Smart-casts work on properties, but when you access _uiState.value, you’re calling a method on the backing field.

  2. New Pattern: The field syntax tells the compiler to create an automatic backing field for the property. When you access uiState, you’re accessing the property directly, and the compiler knows it can smart-cast because the backing field is automatically handled.

  3. StateFlow Specific: StateFlow’s .value property is what makes this tricky. Traditional access requires _uiState.value (method call), while new access allows direct uiState (field assignment).

Summary

In this post, I showed the difference between Kotlin backing fields and traditional _state pattern. The key point is smart-cast support - the new syntax allows direct field access without .value while maintaining type safety.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments