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:
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
.valuemanually - Smart-casts don’t work on
_uiState - Need two properties for one state
Then I saw the new pattern in Kotlin 2.3:
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:
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:
user@macbook:~$ kotlinc -cp kotlin-stdlib.jar TestComparison.kterror: unresolved reference: _uiStateerror: unresolved reference: _uiStateerror: unresolved reference: _uiStateRight! The private _uiState field is not accessible from outside the ViewModel. But the real issue is inside the ViewModel:
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:
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:
// The traditional pattern needs two properties because:// 1. private mutable backing field// 2. public immutable property with getterclass 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:
// Kotlin 2.3 backing fields eliminate the need for:// - private backing field// - getter-based propertyclass 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:
// Traditional - requires .value access everywhereval 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:
-
Traditional Pattern: The
get()syntax creates a property that delegates to a backing field. The compiler treats_uiStateas the backing field anduiStateas the property. Smart-casts work on properties, but when you access_uiState.value, you’re calling a method on the backing field. -
New Pattern: The
fieldsyntax tells the compiler to create an automatic backing field for the property. When you accessuiState, you’re accessing the property directly, and the compiler knows it can smart-cast because the backing field is automatically handled. -
StateFlow Specific: StateFlow’s
.valueproperty is what makes this tricky. Traditional access requires_uiState.value(method call), while new access allows directuiState(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