Yet another pattern to handle UI State in Jetpack Compose
Yet another pattern to handle UI State in Jetpack Compose

Yet another pattern to handle UI State in Jetpack Compose

Tags
Android
Kotlin
Advanced
Published
January 1, 2025
Author
Umit AYDIN

Background

I had the opportunity to work with this new UI framework during its alpha stage. What surprised me most was that the UI is no longer responsible for presenting dataβ€”instead, the data should come from elsewhere. This approach results in cleaner UI elements and establishes a single source of truth for the presented data.
During my journey with Compose, I discovered that this separation of concerns not only makes the code more maintainable but also enables better testing practices. The UI layer becomes a pure function of state, making it predictable and easier to debug. This architectural approach led me to explore different patterns for managing UI states effectively.

Iteration Zero

I believe that a consistent UI experience requires all UI elements to adhere to the same state management principles. This suggests that UI components should have defined states (e.g., Loading, Loaded, Error). These states should be managed by ViewModels and reflect the current state of the underlying data. ViewModels should also be responsible for providing the necessary data to render each UI state effectively.
Sealed classes proved to be an ideal solution for this purpose, allowing me to create distinct UI states with their associated data.
Β 
sealed class ScreenState { data object Loading : ScreenState() data class Loaded(val data: Data) : ScreenState() data class Error(val error: ErrorData) : ScreenState() } class ScreenViewModel : ViewModel(){ private mutableState: MutableStateFlow<ScreenState> = MutableStateFlow(ScreenState.Loading) val state:StateFlow = mutableState.stateIn(...) } @Composable fun Screen(viewModel:ScreenViewModel){ val state by viewModel.state.collectAsState() val isLoading = state is ScreenState.Loading val loaded:ScreenState.Loaded? = state as? ScreenState.Loaded val error:ScreenState.Error? = state as? ScreenState.Error // and somewhere in the code show loading animation using if statement if(isLoading){ // show loading animaton } // and somewhere in the code base show data when loaded successfully by checking if loaded is null or not if(loaded != null){ // show data } // and somewhere is the code base show the error if(error != null){ // show error } } data class Data(val id:String) data class ErrorData(val exception:String)
Β 
If you've written Compose UI code, this pattern likely looks familiar. When I wrote this code around 2020, it seemed adequate. However, this design presented several maintenance challenges.
The screen state organization lacks clarity. UI components that are shared between states with minor or no differences can lead to unexpected behaviors as multiple developers modify the code. The UI code becomes particularly problematic with its tangled if-checks, making it difficult to visualize and understand. Furthermore, the QA testing effort increases significantly with each code modification.
When building applications, we rarely encounter all possible data scenarios during development. This often results in unexpected behaviors in production that are hard to diagnose without access to the specific data that triggered them. Moreover, when new QA team members test issues without considering how changes might affect other states, bugs can easily slip into production.
How can we create a better design that reduces complexity and QA effort? Let's explore an improved version of this approach.

Iteration One

I still love using sealed classes for my states. After reflecting on my past implementations, I realised changes were needed. This new design addressed most of the previously mentioned issues. By creating a separate composable for each state and encapsulating the UI components and logic within them, the code became more maintainable. This significant improvement led to fewer UI bugs since each state was handled in its own function.
While this approach improved the design and reduced both QA and developer effort, there was still room for enhancement.
sealed class ScreenState { data object Loading : ScreenState() data class Loaded(val data: Data) : ScreenState() data class Error(val error: ErrorData) : ScreenState() } class ScreenViewModel : ViewModel(){ private mutableState: MutableStateFlow<ScreenState> = MutableStateFlow(ScreenState.Loading) val state:StateFlow = mutableState.stateIn(...) } @Composable fun Screen(viewModel:ScreenViewModel){ val state by viewModel.state.collectAsState() when(state){ is Loading -> ScreenLoadingState() is Loaded -> ScreenLoadedState(data = (state as Loaded).data) is Error -> ScreenErrorState(error = (state as Error).error) } } @Composable fun ScreenLoadingState(){ ... // Loading State Implementation} @Composable fun ScreenLoadedState(data:Data){ ... // Loaded State Implementation} @Composable fun ScreenErrorState(error:ErrorData){ ... // Loaded State Implementation} data class Data(val id:String) data class ErrorData(val exception:String)

Iteration Two

The final touch focuses on improving readability and conciseness. I removed unnecessary type casting and streamlined the implementation.
sealed class ScreenState { data object Loading : ScreenState() data class Loaded(val data: Data) : ScreenState() data class Error(val error: ErrorData) : ScreenState() } inline operator fun ScreenState.invoke(body:ScreenState.()->Unit){ body() } inline fun ScreenState.loading(body: ScreenState.Loading.()->Unit):ScreenState{ if (this is ScreenState.Loading) body() return this } inline fun ScreenState.loaded(body: ScreenState.Loaded.()->Unit):ScreenState{ if (this is ScreenState.Loaded) body() return this } inline fun ScreenState.error(body: ScreenState.Error.()->Unit):ScreenState{ if (this is ScreenState.Error) body() return this } class ScreenViewModel : ViewModel(){ private mutableState: MutableStateFlow<ScreenState> = MutableStateFlow(ScreenState.Loading) val state:StateFlow = mutableState.stateIn(...) } @Composable fun Screen(viewModel:ScreenViewModel){ val state by viewModel.state.collectAsState() state { loading { ScreenLoadingState() } loaded { ScreenLoadedState(data = data) } error { ScreenErrorState(error = error) } } } @Composable fun ScreenLoadingState(){ ... // Loading State Implementation} @Composable fun ScreenLoadedState(data:Data){ ... // Loaded State Implementation} @Composable fun ScreenErrorState(error:ErrorData){ ... // Loaded State Implementation} data class Data(val id:String) data class ErrorData(val exception:String)

Benefits of having extension functions for states

  • Improved Readability and Conciseness: They make your code more readable by clearly separating the logic for handling different states. Instead of using lengthy if-else or when statements, you can directly call functions like error { ... }, loaded { ... }, which are more expressive and easier to understand.
  • Type Safety: By using specific receiver types for the lambda functions (e.g., ScreenState.Error.() -> Unit), you get compile-time type safety. The compiler ensures that you only access members relevant to the current state within the lambda.
  • Reduced Boilerplate: These functions eliminate the need for repetitive state checks and casting. The conditional execution within the functions handles this automatically, reducing the amount of boilerplate code you need to write.
  • Better State Management: They encourage a more structured approach to handling UI state. By defining specific functions for each state, you can encapsulate the logic related to that state, making your code more organized and maintainable.
  • Extensibility: You can easily add new states and corresponding functions as your UI requirements evolve. This makes your code more flexible and adaptable to changes.
In summary, these state-specific functions provide a more elegant and efficient way to manage UI state in your Android applications, leading to improved code quality and developer productivity.
Β 
page icon
You can even go even further and start using this library to generate those cumbersome boiler plate code for state extensions for sealed classes / interfaces.
Β 

Iteration Three πŸ‘‹πŸ»

Let me know if you take this design further and make it better.

Conclusion

Developing this design involved learning from multiple projects over nearly four years. I hope this approach inspires you to explore and contribute to improving the elegance and efficiency of Jetpack Compose code. This pattern demonstrates a significant advancement in UI state management, resulting in more maintainable, testable, and readable applications.
If you enjoy reading this, please follow me on LinkedIn
Β