Introduction
Jetpack Compose is by far the most exciting thing that has happened in my developer career. It has changed how I work and how I think about problems. It has introduced many exciting tools that are both easy to use and flexible, allowing you to do almost anything with ease.
I adopted Jetpack Compose early on for a project that was already in production, and I was immediately hooked. Even though I already had experience creating UI with Compose at that point, organizing and architecting my new and shiny Jetpack Compose-powered features introduced a lot of back and forth.
The goal of this article is to share the results of those findings, present an architecture that is scalable, easy to use and operate in, and, of course, collect feedback to make it even better.
Disclaimer ⚠️: This article will only deal with the UI side. The way the rest of the application is built will be left out, but you can assume it follows the classic Clean Architecture approach. I also assume that you are familiar with Jetpack Compose, as I will not dive deep into the specifics of UI implementation.
Example
To provide a concrete example, let me introduce the guinea pig for this article going forward. The application, we are going to build allows the user to cycle through different Landmarks and navigate to them. Here is a basic description of the flow:
- Users can swipe through Place Cards and view different information about the Place, such as the Place's picture, name, and rating.
- Users can mark/unmark the Place as a favorite.
- Users can navigate and plan a route to the selected Place from their location. To do that, we will need the user's permission to collect their location.
- If there is an error, we would like to show a toast.
- The permission is only asked if the user chooses to plan the route. If the user declines the permission, we navigate to a different screen (Location Rationale Screen).
- We also want to track user interactions with the Analytics service.
Basics
The very first thing I remember learning about Jetpack Compose is this equation: UI = f(state)
. This means that the UI is a result of a function applied to a certain state. Let's do a quick catch-up on the important aspects of Compose and Reactive UI in general, specifically regarding state handling: State Hoisting and Uni-Directional Data Flow.
State Hoisting
State hoisting is a technique used in software development, particularly in UI programming, where the responsibility for managing and manipulating the state of a component is moved to a higher-level component or a more centralized location. The purpose of state hoisting is to improve code organization, reusability, and maintainability. You can learn more about state hoisting here.
Uni-directional Data Flow
A uni-directional data flow (UDF) is a design pattern where the state flows down, and events flow up. By following uni-directional data flow, you can decouple composables that display the state in the UI from the parts of your app that store and change the state.
The gist of it is that we want our UI components to consume the state and emit events. Having our components handle events that originated from outside breaks this rule, introducing multiple sources of truth. The important part is that any "event" we introduce should be based on the state.
Getting Started
First, let us introduce core components, the bricks that will be the groundwork of our architecture.
State
Let us start with the obvious thing first, the State
The State
can be whatever makes sense to you and your use case. It can be a data class that has all the properties your UI might need or a sealed interface that represent all the possible scenarios. In any case, the State
is a "static" representation of your component or entire Screen UI that you can easily manipulate.
Based on our requirements, we have a list of Places and an optional error so our state could look like this
data class PlacesState(
val places: List<Place> = emptyList(),
val error: String? = null
)
Screen
The Screen
is the f
function of our equation. To follow the state hoisting pattern, we need to make this component stateless and expose user interactions as callbacks. This will make our Screen testable, previewable, and reusable!
We already have the state, and based on our requirements, we have only two user interactions that we need to handle. So here is how our Screen
could look like. We are also including other Composable states we might need, so they are hoisted outside of Screen
.
@Composable
fun PlacesScreen(
state: PlacesState,
pagerState: PagerState,
onFavoritesButtonClick: (Place) -> Unit,
onNavigateToPlaceButtonClick: (Place) -> Unit
) {
Scaffold {
PlacesPager(
pagerState = pagerState,
state = state,
onFavoritesButtonClick = onFavoritesButtonClick,
onNavigateToPlaceButtonClick = onNavigateToPlaceButtonClick
)
}
}
Route
Our last missing piece of the puzzle is the component that would handle these callbacks, provide state to Screen
and emit it—introducing Route
. Route
is an entry point to our flow. Let's see how our version of Route
would look like
@Composable
fun PlacesRoute(
navController: NavController,
viewModel: PlacesViewModel = hiltViewModel(),
) {
// ... state collection
LaunchedEffect(state.error) {
state.error?.let {
context.showError()
viewModel.dismissError()
}
}
PlacesScreen(
state = uiState,
onFavoritesButtonClick = //..
onNavigateToPlaceClick = {
when {
permissionState.isGranted -> {
analyitcs.track("StartRoutePlanner")
navController.navigate("RoutePlanner")
}
permissionState.shouldShowRationale -> {
analytics.track("RationaleShown")
navController.navigate("LocationRationale")
}
else -> {
permissionState.launchPermissionRequest()
}
}
}
)
}
This is a very stripped version of the PlacesRoute
function, and it is already quite big for what it is. With every new user interaction and state-based effects, this function will grow in size, making it harder to understand and maintain. Another issue is the callbacks. With every new user interaction, we will have to add another callback to the PlacesScreen
declaration, and it can also become quite big.
On another note, let's think about testing. We can easily test the Screen
, and the ViewModel
, but what about the Route
? It has a lot going on, and not everything can be easily mocked. For one, it is coupled with the Screen
, so we won't be able to unit-test it properly without referencing it. Replacing other components with stubs will require us to move everything from into the Route
declaration.
Making changes
Let's try and address these problems we have identified so far
Actions
The very first thing that to came to my mind looking at those callbacks is to group them somehow. And the very first thing I did back in the day was this:
sealed interface PlacesAction {
data class NavigateToButtonClicked(val place: Place) : ParcelAction
data class FavoritesButtonClicked(val place: Place) : ParcelAction
}
While this allows us to group our actions into a well-defined structure, it brings different issues.
On the Screen
level, we will have to instantiate the classes and invoke our onAction
callback. If you are familiar with how Re-composition works when it comes down to lambdas, you might also have the urge to also surround it with remember
to avoid unnecessary UI re-rendering
@Composable
fun PlacesScreen(
state: PlacesState,
onAction: (PlacesAction) -> Unit
) {
PlacesPager(
onFavoritesButtonClicked = { onAction(PlacesAction.FavoritesButtonClicked(it))}
)
}
On the other side, the Route
also introduces another thing that I didn't like that much—possibly giant when
statements.
PlacesScreen(
state = uiState,
onAction = { when(it) {
FavoritesButtonClick = //..
NavigateToPlaceClicked = {
when {
permissionState.isGranted -> {
analyitcs.track("StartRoutePlanner")
navController.navigate("RoutePlanner")
}
permissionState.shouldShowRationale -> {
analytics.track("RationaleShown")
navController.navigate("LocationRationale")
}
else -> {
permissionState.launchPermissionRequest()
}
}
}
)
All this lead me to the solution which works way better, and it is a simple data class
data class ParcelActions(
val onFavoritesClicked: (Place) -> Unit = {},
val onNavigateToButtonClicked: (Place) -> Unit = {},
)
This allows us to introduce the same level and ease of grouping our Actions
related to our Screen
and a more simple way to pass these actions to the relevant components.
@Composable
fun PlacesScreen(
state: PlacesState,
actions: PlacesActions
) {
PlacesPager(
onFavoritesButtonClicked = actions.onFavoritesButtonClicked,
onNavigateToPlaceButtonClicked = actions.onNavigateToPlaceButtonClicked
)
}
Now, on the Route
side, we can also avoid the when
statements and introduce the following utility to not recreate the Actions
class every re-composition, leaving the Route
much more concise.
@Composable
fun PlacesRoute(
viewModel: PlacesViewModel,
navController: NavController,
) {
val uiState by viewModel.stateFlow.collectAsState()
val actions = rememberPlacesActions(navController)
LaunchedEffect(state.error) {
state.error?.let {
context.showError()
viewModel.dismissError()
}
}
PlacesScreen(
state = uiState,
actions = actions
)
}
@Composable
fun rememberPlacesActions(
navController: NavController,
analytics: Analytics = LocalAnalytics.current,
permissionState: PermissionState = rememberPermissionState(),
) : PlacesActions {
return remember(permissionState, navController, analytics) {
PlacesActsions(
onNavigateToPlaceClick = {
when {
permissionState.isGranted -> {
analyitcs.track("RoutePlannerClicked")
navController.navigate("RoutePlanner")
}
permissionState.shouldShowRationale -> {
analytics.track("RationaleShown")
navController.navigate("LocationRationale")
}
else -> {
permissionState.launchPermissionRequest()
}
}
}
)
}
}
While the PlacesRoute
is now more straightforward, all we did is moved all its Actions logic to another function, and it didn't improve either readability or scalability. Moreover, our second issue is still intact - state-based effects. Our UI logic is now also split, introducing inconsistencies, and we didn't make it any more testable. It is time we introduce one last component.
Coordinator
At its core, the Coordinator,
as you might have guessed from its name, is here to coordinate different action handlers and state providers. The Coordinator
observes and reacts to the state changes and handles user actions. You can think of it as Compose state of our flow. In our stripped example, the Coordinator would look like this.
Notice that since our Coordinator
now is not inside a composable scope, we can everything in a more straightforward way, without the need for LaunchedEffect
, just like we would normally do in our ViewModel
except here our business logic here - is UI logic.
class PlacesCoordinator(
val viewModel: PlacesViewModel,
val navController: NavController,
val context: Context,
private val permissionState: PermissionState,
private val scope: CoroutineScope
) {
val stateFlow = viewModel.stateFlow
init {
// now we can observe our state and react to it
stateFlow.errorFlow
.onEach { error ->
context.toast(error.message)
viewModel.dismissError()
}.launchIn(scope)
}
// and handle actions
fun navigateToRoutePlanner() {
when {
permissionState.isGranted -> {
viewModel.trackRoutePlannerEvent()
navController.navigate("RoutePlanner")
}
permissionState.shouldShowRationale -> {
viewModel.trackRationaleEvent()
navController.navigate("LocationRationale")
}
else -> permissionState.launchPermissionRequest()
}
}
}
This allows us to modify our Actions utility
@Composable
fun rememberPlacesActions(
coordinator: PlacesCoordinator
) : PlacesActions {
return remember(coordinator: PlacesCoordinator) {
PlacesActsions(
onFavoritesButtonClicked = coordinator.viewModel::toggleFavorites,
onNavigateToPlaceButtonClicked = coordinator::navigateToRoutePlanner
)
}
And our Route
@Composable
fun PlacesRoute(
coordinator: PlacesCoordinator = rememberPlacesCoordinator()
) {
val uiState by coordinator.stateFlow.collectAsState()
val actions = rememberPlacesActions(coordinator)
PlacesScreen(
state = uiState,
actions = actions
)
}
In our example, the PlacesCoordinator
is now responsible for UI Logic happening in our feature flow. Since it knows about different states, we can easily react to state changes and build conditional logic for every user interaction. In case the interaction is straightforward, we can easily delegate it to the relevant component, such as ViewModel
.
Another thing that we can do by having the Coordinator is to control which state is exposed to the Screen. In case we have multiple ViewModels
or ViewModel
state that is too big for the Screen
that we are dealing with, we can either combine these states or expose a partial state.
val screenStateFlow = viewModel.stateFlow.map { PartialScreenState() }
// or
val screenStateFlow = combine(vm1.stateFlow, vm2.stateFlow) { ScreenStateFlow() }
Another bonus, the entire flow UI logic is now decoupled from the Route
, meaning we can use our Coordinator
as part of another Route without duplicating the important stuff and keeping the Screen
part state-less.
@Composable
fun TwoPanePlacesRoute(
detailsCoordinator: PlacesDetailsCoordinator,
placesCoordinator: PlacesCoordinator
) {
TwoPane(
first = {
PlacesScreen(
state = placesCoordinator.state,
actions = rememberPlacesActions(placesCoordinator)
)
},
second = {
PlaceDetailsScreen(
state = detailsCoordinator. state,
actions = rememberDetailsActions(detailsCoordinator)
)
}
)
}
And finally, now we can test our UI logic by testing the component that implements it. Let us see how we can test our Coordinator by using our navigate to Rationale Screen when permission was denied as the example.
⚠️ This part assumes that you have some knowledge of how to test Composable components.
/**
* GIVEN
* - Permission was denied previously
* WHEN
* - Navigate Button Clicked
* THEN
* - Current destination is "locationRationale"
*/
fun test_NavigateToRatinoleIfPermissionWasDeniedBefore() {
composeRule.setContent {
// 1
ComposableUnderTest(
coordinator = rememberPlacesCoordinator(
navController = testNavController,
viewModel = NearbyPlacesViewModel()
)
)
}
// 2
composeRule.onNode(hasText("Navigate")).performClick()
// 3
Assert.assertEquals(
"locationRationale",
navController.currentBackStackEntry?.destination?.route
)
}
Let's quickly walk through this test.
- First, we emit the Composable UI that we use as our test subject. This UI has a simple structure and directly calls our
Coordinator
.
@Composable
private fun ComposableUnderTest(coordinator: NearbyPlacesCoordinator) {
NavHost(
navController = coordinator.navController,
startDestination = "home"
) {
composable("home") {
Button(onClick = { coordinator.navigateToPlace(Place.Mock) }) {
Text(text = "Navigate")
}
}
composable("locationRationale") {
Text(text = "No permission")
}
}
}
- Then, we programmatically click the "Navigate" button to trigger the action and let the
Coordinator
handle it. - Finally, we check that our assumption is valid and our implementation is working by checking that the current destination in our
NavHostController
is the one we expect
And that's it. Let us summarize the things we refactored and what we achieved.
- Our
Screen
remains completely state-less. It only relies on whatever is being passed as the function parameter. All the user interactions are exposed viaActions
for the other components to handle. - The
Route
now serves as a simple entry point in our Navigation Graph. It collects the state, remembers our Actions across re-compositions - The
Coordinator
is now doing most of the heavy lifting: reacting to state changes and delegating user interactions to other relevant components. It is completely decoupled from ourScreen
andRoute
and can be reused in anotherRoute
and tested separately
The following diagram demonstrates the flow of data we now have
Questions and answers
This section contains some questions I've been asked a lot when presenting this approach. So I might as well give some answers right here in case you have been wondering the same thing while reading this article.
Does every Composable Screen needs a Coordinator?
The short answer: it depends! For a very simple flow, let's say a Dialog with two Actions. It might be an overkill. And you might as well drop the Actions
data class altogether and handle these actions in your Route
. For a screen that can grow in complexity over time, I would say it is worth investing in having it from the start or starting refactoring when you see that your Route
grows.
Is LaunchedEffect
"Deprecated"?
Of course, it is not! Again, a simple screen without Coordinator
might as well use the LaunchedEffect
to react to state changes, and it is completely fine. And you still can use LaunchedEffect
in your Screen
when the UI logic lives and dies in the Screen
layer, for example, animations.
The Route
doesn't do much
Yes, in our example, the Route
is pretty lightweight in terms of responsibilities. But having it as a navigation entry point means much more. A lot of effects that are not state-based belong to the Route
to handle. For example, we can use SideEffect
to adjust the color or put a BackHandler
to intercept back button presses which would not always makes sense inside the Screen
.
Won't the Coordinator
grow over time in the same way the Route
would?
Most probably, yes. And that's probably a sign that it is doing too much, and some of the things can be extracted into another stateful component or even another coordinator. In the same way, you extract different UI components from your Screen that encapsulate some piece of UI; you can build other components or coordinators that would encapsulate UI logic.
Additional Resources
IDE Plugin
If you think that there are a lot of files in this approach, I've got you covered! Please check Jetpack Compose UI Architecture IDE Plugin
This plugin has been published a while back, and there is a chance you are using it already. If you are, I thank you, and I hope now you have more context about how each component works. If not, it will be a nice place to get started. Either way, I hope both this article and the plugin will help you in your dev routine.
Documentation and Best Practices
I was not able to cover everything in this article, so there are also GitHub Pages available where you can learn more about the presented approach. I'm planning to update these alongside the IDE plugin to accommodate any feedback that will come my way.
Afterword
Thank you for reading this article, and I hope it has sparked your interest in the proposed solution. As I shared my thought process that started a few years ago, it was an enjoyable experience to recreate and reflect upon the journey.
_ Article Photo by Pierre Châtel-Innocenti on Unsplash