State Holders in Jetpack Compose
In a Compose based Android app it is a common scenario to find yourself managing state coming from both an app state container like a viewmodel and ui specific state from compose, which can quickly become an unreadable mess. What if we introduced a pattern to clarify and streamline the state so that we can treat it as a single stream of data?
An Average Compose Screen #
Here is a pretty common screen structure you will see in a Compose based UI on Android.
@Composable
fun ExampleScreen(vm: ExampleViewModel = viewModel()) {
val state: List<String> by vm.state.collectAsState()
val scope = rememberCoroutineScope()
val lazyListState = rememberLazyListState()
val scrollToBottom = remember<() -> Unit> {
{
scope.launch {
lazyListState.scrollToItem(state.lastIndex)
}
}
}
ExampleScreen(state, lazyListState, scrollToBottom)
}
In the screen composable we collect the state from the viewmodel, remember a coroutine scope and lazy list state, and finally combine those together to create a scrollToBottom
function before passing all of it to another composable to be displayed.
This component is doing a lot! It's taking state from multiple sources, bringing them together, mapping them to secondary state (like the scrollToBottom
function), and then sending those pieces off separately to be rendered. What if we could abstract away the state coordination logic so our screen could focus solely on UI?
Introducing a State Holder #
Let's introduce a new data class to represent our screen state and a composable function to aggregate all that data.
data class ScreenState(
val content: List<String>,
val lazyListState: LazyListState,
val scrollToBottom: () -> Unit
)
@Composable
fun rememberScreenState(
state: List<String>,
lazyListState: LazyListState = rememberLazyListState(),
scope: CoroutineScope = rememberCoroutineScope(),
): ScreenState {
val scrollToBottom = remember<() -> Unit> {
{
scope.launch {
lazyListState.scrollToItem(state.lastIndex)
}
}
}
return ScreenState(
content = state,
lazyListState = lazyListState,
scrollToBottom = scrollToBottom,
)
}
@Composable
fun ExampleScreen(vm: ExampleViewModel = viewModel()) {
val state by vm.state.collectAsState()
val screenState = rememberScreenState(state = state)
ExampleScreen(screenState)
}
We now have a single class, ScreenState
, that represents the state of our entire screen. In our rememberScreenState
function, we handle all the compose screen state and viewmodel state and combine them into that class. Now all our downstream ExampleScreen
composable has to handle is converting the ScreenState
data class into UI. No more combining our display and state coordination logic!
Want to see a more complex example? Check out this project on my GitHub page!
Additional Tips #
- Don't preemptively introduce a state holder if you don't need it - Our contrived example got entirely more complicated by introducing a state holder! Only introduce a state holder when it will help clarify and simplify your composable. My rough rule of thumb is when there are more than 3 effects or remember blocks, I'll think about introducing a state holder.
- Prevent unnecessary rerenders using
remember
andrememberSavable
- Due to aggregating all our state into a single class, it becomes more important that we aren't creating new instances of that class unnecessarily. We can do that usingremember
blocks in our state holder. - State holders built in a composable will follow the composable lifecycle - Unlike a viewmodel, our state holder is NOT lifecycle aware. If you need any parts of the state to persist across configuration changes, consider using
rememberSavable
or moving that state into your viewmodel. - Don't pass complex classes into your state holder - Keep your state holder simple and testable by passing parts of a complex class like a viewmodel instead of passing in the entire class.
- State holders introduce new testing opportunities - By grouping our state into a single class that is the result of a function, we can test the output in a functional matter. You could even test the screen "headless" if you wanted to.
Conclusion #
State holders can be a new tool in you tool box when building Andriod apps using Jetpack Compose. I hope you've seen how they can clarify and streamline your state management to create a more readable and testable UI. Until next time, thanks!
Did you find this content helpful?
Please share this post and be sure to subscribe to the RSS feed to be notified of all future articles!
Want to go above and beyond? Help me out by sending me $1 on Ko-fi. It goes a long way in helping run this site and keeping it advertisement free. Thank you in advance!
- Previous: Turbine and the combine operator
- Next: Kotlin Exhaustive when