Blog Infos
Author
Published
Topics
, , , ,
Published

Image generated by AI showing Parent Android bot giving “State” to Child Android bot

Not again!

State hoisting caught my attention immediately when Jetpack Compose was introduced a few years ago! And why not.. from all those DevRel’s at Google to my favourite YouTubers, everyone was talking about that. Over time, as I continued developing in Compose, or rather thinking in Compose, I realised the significance of this term.

That began my journey towards truly understanding declarative UIs and State as an entity. It wasn’t just separating concerns; it was about building truly independent, testable, and most importantly, preview-enabled rich Compose screens that I was focusing on.

When I started scaling my apps and dealing with multiple screens daily, I realised the true power of having rich previews for every screen and why state hoisting was originally there to guide us. So here’s a short dive into my learnings so far and my current favourite way of building quick and easy Composable screens.

Types of State

I believe that the State entity which drives a particular UI has 2 different types, i.e., Internal (or Local) state and External (or Global) state.

Consider a small UI element written in Compose, demonstrating the 2 types:

@Composable
fun StateDemoComponent(
externalState: String // External state that this component depends on
) {
var internalState by remember {
mutableStateOf("Internal State") // Internal state that this component owns
}
Text(text = "External State: $externalState")
Text(text = "Internal State: $internalState")
}

Here, as you can see, our component StateDemoComponent creates a piece of state internalState on its own internally, whereas another piece of state externalState is passed down to the component as an argument. Our component truly owns the internal state, while it has to be dependent on the external state. Now we can say that this component is tightly coupled with the state that it creates internally, i.e., internalState whereas it is loosely coupled with the state that is getting received externally, i.e., externalState. Understanding this is crucial, as this is the very base of what State Hoisting is.

The Problem of UI-State Tight Coupling

As seen in the above explanation of types of State, the component becomes tightly coupled with the given state if the component owns it, rather than being dependent on it. This makes the component less flexible, less testable, and more isolated. Full & rich preview generation of such components is also not possible due to missing branches.

@Preview
@Composable
private fun StateDemoComponentPreview() {
StateDemoComponent(
externalState = "Some State"
// We can only change this external state and NOT the inaccessible internal state
)
}

Apart from the issue regarding the types of State, there can be another issue. This occurs when we try to pass on such a parameter in the arguments, the instance of which cannot be easily created at the time of preview generation. A classic example of this is passing down an instance of a ViewModel.

@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel()
) {
val state: HomeScreenState by viewModel.state.collectAsStateWithLifecycle()
Text(text = state.greeting)
}
@Preview
@Composable
private fun HomeScreenPreview() {
HomeScreen(
viewModel = ??? // Cannot create a definite instance of HomeViewModel in preview
)
}
view raw HomeScreen.kt hosted with ❤ by GitHub

HomeScreen in this example directly takes in an instance of a ViewModel as its argument. This is fine in runtime, as a DI framework, such as HILT, takes care of the creation of this instance. But since now this screen is tightly coupled with a ViewModel (yes, tightly, as we are directly depending the UI on an entireViewModel), which is cumbersome to create in previews (or at compile time), generating a preview for HomeScreen is no longer possible (or is at least a very cumbersome task).

What is State Hoisting?

State hoisting is a solution to the above problems. It aims to simplify a Composable component by allowing it to be loosely coupled with its state.

Here’s a clear definition given by ChatGPT:

At its core, state hoisting is the practice of lifting the state out of a composable function and passing it in as parameters, along with the event handlers that modify it.

Instead of managing the state of a component inside it, we allow some higher-level entity (in most cases, the parent of the composable) to manage/handle it for the component. This makes the component free and more open to new modifications if needed. This turns our component into a passive UI creator which just reacts to inputs- perfect for the projects where reusability & preview-ability matter!

Why bother so much?

I feel the need to have previews for every Composable component that I write, to see that it’s getting rendered correctly. Previews can help in identifying any issues on multiple screens of different dimensions even before running it on actual device. It feels like unit testing your UI for stability & responsiveness and state hoisting makes that possible.

Also, as someone who is a big fan of reusable components, state hoisting allows me to create highly reusable & modular components, even full-fledged screens, that I can plug & play across my app. This increases development speed along with lesser redundant code.

State Hoisting and my favourite MVI template

The popular MVI architecture is widely used with Compose as it provides firm control over the state management & updates while enforcing a strict Unidirectional Data Flow (UDF) policy. Over time, I found myself gravitating towards a particular pattern- A simple & lightweight Composable screen structure which works elegantly with State Hoisting, while following each & every principle that we have discussed so far. It separates UI & State so neatly that previewing each & every different state of the screen is possible.

Here’s how I structure a screen

Given any screen, it is divided into 4 parts/blocks:

  1. Screen– Composable screen accepting a state & reacting to events
  2. ViewModel– A state holder & business logic processor
  3. Event– A sealed class enumerating all the different events a given screen should react to
  4. State– A data class (or a sealed class) depicting various aspects of the screen’s state

Let’s say that you have to develop a screen for your “Profile” destination. So, the structure would be as follows:

sealed class ProfileEvent {
data object OnPullToRefresh : ProfileEvent()
data class OnUsernameChange(
val newUsername: String
) : ProfileEvent()
}
view raw ProfileEvent.kt hosted with ❤ by GitHub
import androidx.compose.runtime.Composable
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun ProfileScreen(
state: ProfileScreenState,
onEvent: (ProfileEvent) -> Unit
) {
/**
* Here you can process things which are irrelevant to the screen's UI, such as
* handling Context related things, or setting up SideEffects to achieve something etc.
*/
// Return the private version of the screen which is only bounded by state & events
ProfileScreenContent(state, onEvent)
}
@Composable
private fun ProfileScreenContent(
state: ProfileScreenState,
onEvent: (ProfileEvent) -> Unit
) {
// Actual consumer of the given state
// Actual reactor of the events
}
@Preview
@Composable
private fun ProfileScreenPreview() = ProfileScreenContent(
state = ProfileScreenState(),
onEvent = {}
)
data class ProfileScreenState(
val isLoading: Boolean = false,
val isError: Boolean = false,
val userData: User? = null
)
data class User(
val name: String
)
import androidx.lifecycle.ViewModel
import app.package.data.ProfileRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class ProfileViewModel(
private val profileRepository: ProfileRepository
) : ViewModel() {
private val _state = MutableStateFlow(ProfileScreenState())
val state: StateFlow<ProfileScreenState> get() = _state
fun onEvent(event: ProfileEvent) {
when (event) {
ProfileEvent.OnPullToRefresh -> {
// logic
}
is ProfileEvent.OnUsernameChange -> {
// logic
}
}
}
// further logic
}

Since we are developing a feature “Profile” we have added a prefix “Profile” to each entity.

ProfileEvent

This is a sealed class to manage all the events that are generated regarding this screen. These events can either be user-generated or system-generated (auto-triggered). Screen’s ViewModel is the processor of these events. So, to not forget about a certain event, we have added it under a sealed class, so it becomes an all-or-nothing type of processing. Here we can elegantly use Kotlin’s data classes or objects to achieve a clean event structure as seen in the above code. Let’s see further how we use them in our screen & ViewModel for processing.

ProfileScreenState

I usually like to name it as <Feature>ScreenState rather than just State, as it may lead to confusion. Stating that it’s a ScreenState means only one thing- to drive our UI efficiently. Now, there are 2 approaches here that one can follow- Either to use a data class or to use a sealed class/interface. Both approaches have their pros & cons, and are currently out of scope for this article. I prefer to wrap my state under a common data class, so updates to the state can be dispatched easily with a copy function. So, we have 3 different pieces of state for our ProfileScreen here that are well described in our state. Notice that the state has a default value (i.e., all the fields have a default value). This is done to quickly build our first Preview. We just have to create new instances of this state class to see different previews. Let’s see further how.

ProfileViewModel

The main logic processor of our screen. Our ViewModel contains a private mutable state, which is exposed to our Composable screen as an immutable State. The screen can only observe this state and cannot directly edit it. ViewModel also contains a function onEvent(Event) that is the main processor of all the events received from the screen. Our screen gets a reference to this function and can invoke any event by calling it with the relevant event instance. So, you can see a UDF pattern here, as the ViewModel only emits state out and doesn’t emit events as such on its own. Whereas the Screen gets the State object, cannot manipulate it on its own, and can only emit events for our ViewModel to process. All of this while maintaining a strict loose coupling & high preview-ability.

ProfileScreen

Finally! Our screen class gets everything together to build a beautiful UI. Notice that the Screen is divided into 3 parts. The entry point itself is the Screen, which then invokes actual content that the screen draws. The third part is an independent Preview of that screen. The main screen Composable does all the heavy-lifting for its UI, such as doing things according to the current Context, reacting to SideEffects, applying themes, etc. Typically, all the logic that is not previewable on its own can go in this part, which is different than the original screen’s content. We have used the State Hoisting here to loose-couple these parts. This way, we can still achieve a full & rich preview of the screen while also incorporating side logic. This entry point receives state object and a lambda onEvent(Event) to drive itself. The screen reacts to the state changes & emits all the events that are generated. These are passed down into this screen from the ViewModel; state is created in the ViewModel itself & onEvent lambda can be passed as a reference from the ViewModel directly as shown below-

sealed class Routes {
@Serializable
data object Home : Routes()
@Serializable
data object Profile: Routes()
}
@Composable
fun AppNavHost(
navHostController: NavHostController
) = NavHost(
modifier = Modifier.fillMaxSize(),
navController = navHostController,
startDestination = Routes.Home
) {
composable<Routes.Home> {
// Home Screen architecture
}
composable<Routes.Profile> {
val viewModel: ProfileViewModel = koinViewModel() // OR hiltViewModel()
val state: ProfileScreenState by viewModel.state.collectAsStateWithLifecycle()
ProfileScreen(state, viewModel::onEvent)
}
}
view raw AppNavHost.kt hosted with ❤ by GitHub

The biggest advantage of this architecture, with proper State Hoisting, is that now we can generate previews for ProfileScreen easily. We can even preview different states that this screen can be into, just by passing a correctly formatted state object into the screen’s Content Composable-

@Preview
@Composable
private fun ProfileScreenDefaultPreview() = ProfileScreenContent(
state = ProfileScreenState(),
onEvent = {}
)
@Preview
@Composable
private fun ProfileScreenLoadingPreview() = ProfileScreenContent(
state = ProfileScreenState(
isLoading = true
),
onEvent = {}
)
@Preview
@Composable
private fun ProfileScreenErrorPreview() = ProfileScreenContent(
state = ProfileScreenState(
isError = true
),
onEvent = {}
)
@Preview
@Composable
private fun ProfileScreenDataPreview() = ProfileScreenContent(
state = ProfileScreenState(
userData = User("Dhanesh")
),
onEvent = {}
)

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

As you can see, we can now preview each & every aspect of the Screen just by tweaking the passed State object. We have also made our screen highly independent, such that now it does not directly depend on the ViewModel itself, as seen earlier.

⚠️ Please note, it’s not always a good idea to keep every piece of data in the Global state or External state (as discussed earlier). Sometimes it’s better to use the internal state for brevity. It then becomes a good trade-off between the state management & previews generation that will help you in your apps. You can divide your Composables into smaller pieces in this case, so that they are individually preview-able.

Wrapping up

We’ve seen how state hoisting can help us create independent and fully previewable Composables. The lightweight MVI template we explored allows you to quickly scaffold clean, scalable screens in no time.

Feel free to check out the entire app constructed with this MVI template — https://github.com/dkexception/aqi-app

I hope you found this walkthrough helpful, and that it inspires you to build more modular, highly previewable & independent UIs in your projects.

This article was previously published on proandroiddev.com.

Menu