What is property drilling?
So to start with, look at that small piece of code:
fun Screen(viewModel: ViewModel) { Text(text = viewModel.title) Text(text = viewModel.description) viewModel.entries.forEach { entry -> Row(onClick = { viewModel.onEntryClick(entry) }) { Text(text = entry.title) Text(text = entry.description) Button(onClick = { viewModel.onDeleteEntryClick(entry) }) } } }
This is indeed very simple. It has only 11 lines in total, while there is a high share of 8 lines that is necessary for the code to function (70%). But we all know it usually doesn’t stay like that. No example in the wild is really that simple. So over time what we usually do is to break this up into smaller pieces to keep the code comprehensible and maintainable. And whooooops, we end up with a lot of code. In a real-world example it could already be near to a monster.
fun Screen( // ⚠️ a lot of parameters title: String, description: String, entries: List<Entry>, onEntryClick: (Entry) -> Unit, onDeleteEntry: (Entry) -> Unit ) { Text(title) Text(description) EntryList(entries, onEntryClick, onDeleteEntry) } fun EntryList( entries: List<Entry>, onEntryClick: (Entry) -> Unit, onDeleteEntry: (Entry) -> Unit ) { entries.forEach { entry -> EntryView( title = entry.title, description = entry.description, onClick = { onEntryClick(entry) }, onDeleteClick = { onDeleteEntry(entry) }) } } fun EntryView( // ⚠️ a lot of parameters title: String, description: String, onClick: () -> Unit, onDeleteClick: () -> Unit ) { Row(onClick = onClick) { Text(title) Text(description) Button(onClick = onDeleteClick) } }
Property drilling is said to improve reusability and understandability as well as to mitigate performance issues in Compose. Let’s get into that later, but in the meantime we can see this code has already 54 lines in total: 22 lines of argument passing and parameter declaration, and still only 8 lines of code that are essential to get the same job done. The share of the “essential” lines plummets to 14%. Of course you can’t just reduce an architectural discussion to this number but we still have to consider: the more and deeper we want to nest our UI code, the more this trend continues. So, property drilling has the capacity to penalize understandable and clean code in some way. Thus, it’s better to know where to use it and where not – which is exactly the matter of this article.
So let’s jump into an alternative to that! Afterwards I’ll discuss the pros and cons of those competing approaches and where I think the alternative approach should be picked. My intention for you is that working on your declarative UI code with this approach will be way more fun and effective in future!
Alternative approach
class ViewModel { val state = State( title = "We have 10 entries here", description = "Click one of those 10, please!", entries = List(10) { Entry("Title $it", "Description $it") } ) fun dispatch(action: Action) { when (action) { is Action.EntryClick -> Unit // some logic goes here is Action.EntryDeleteClick -> Unit // some logic goes here } } data class State( val title: String, val description: String, val entries: List<Entry>, ) sealed interface Action { data class EntryClick(val entry: Entry) : Action data class EntryDeleteClick(val entry: Entry) : Action } } fun Screen(viewModel: ViewModel) { Screen(viewModel.state, viewModel::dispatch) } fun Screen(state: State, onAction: (Action) -> Unit) { Text(state.title) Text(state.description) state.entries.forEach { entry -> Row(onClick = { onAction(Action.EntryClick(entry)) }) { Text(entry.title) Text(entry.description) Button(onClick = { onAction(Action.EntryDeleteClick(entry)) }) } } }
What you see here is an MVI (model-view-intent) approach where the view model communicates state data structures to the view and the view communicates intents (or actions for the sake of the example) back to the view model.
Working with the alternative approach
There are indeed arguments against this alternative approach. But I don’t want to judge each approach individually but rather comparatively.
To show how easy it is to work with this approach let’s start with the original minimal code example and refactor it into the new approach, and for that we start with extracting the forEach part into a function EntryList:
fun Screen(state: State, onAction: (Action) -> Unit) { Text(state.title) Text(state.description) EntryList(state, onAction) } fun EntryList(state: State, onAction: (Action) -> Unit) { state.entries.forEach { entry -> Row(onClick = { onAction(Action.EntryClick(entry)) }) { Text(entry.title) Text(entry.description) Button(onClick = { onAction(Action.EntryDeleteClick(entry)) }) } } }
This took me 10 seconds by using IntelliJ’s function extraction refactoring feature (select the code, hit the shortcut, enter the name of the new function, in this case EntryList).
Now let’s do the same to extract the EntryView out of the list:
fun EntryList(state: State, onAction: (Action) -> Unit) { state.entries.forEach { EntryView(it, onAction) } } fun EntryView(entry: Entry, onAction: (Action) -> Unit) { Row(onClick = { onAction(Action.EntryClick(entry)) }) { Text(entry.title) Text(entry.description) Button(onClick = { onAction(Action.EntryDeleteClick(entry)) }) } }
This took me another 10 seconds.
What about if we want to move the “description” part of the UI from the Screen to the EntryList? Let’s just move that line
Text(state.description)
Into EntryList:
fun EntryList(state: State, onAction: (Action) -> Unit) { Text(state.description) state.entries.forEach { EntryView(it, onAction) } }
As you might have noticed, the line is identical: you have the same state in the EntryList as in Screen so you can just access it as before. So this took me only a few seconds as well. Also the communication in the direction of the view model is equally flexible: you can call onAction from basically everywhere.
Let’s look at the final version:
fun Screen(viewModel: ViewModel) { Screen(viewModel.state, viewModel::dispatch) } fun Screen(state: State, onAction: (Action) -> Unit) { Text(state.title) EntryList(state, onAction) } fun EntryList(state: State, onAction: (Action) -> Unit) { Text(state.description) state.entries.forEach { EntryView(it, onAction) } } fun EntryView(entry: Entry, onAction: (Action) -> Unit) { Row(onClick = { onAction(Action.EntryClick(entry)) }) { Text(entry.title) Text(entry.description) Button(onClick = { onAction(Action.EntryDeleteClick(entry)) }) } }
It has the same number of functions as its property drilling competitor, but only 20 instead of 54 lines of code, while the number of essential lines to get the job done remains almost unchanged at 10, which makes for a 50% share of the total lines of code. Additionally, the readability is significantly better and as you have seen by example, that code is extremely malleable as there’s no need to change the function signatures when you change responsibilities of a function. By making the function signatures very rigid, the code inside can change much easier.
Arguments
Now it’s time to review the pros and cons. Let’s jump into the arguments one-by-one. In short, those are the thoughts that bubble up frequently when discussing this approach:
- “This increases coupling”
- “You can’t reuse that”
- “It’s harder to understand”
- “It’s less performant”
“This increases coupling”
This conception arises a lot when using that approach. But it’s based on a wrong assumption that coupling is “that you need to know something”. Indeed, most parts of the view need to know State, Entry and Action. There are some cases where you don’t want that and we’ll discuss it later.
We don’t have time here to go down the rabbit hole about the term “coupling” but, in simplified terms, if you want to measure coupling you should instead ask “how many places do I need to change if I add/modify/remove a certain feature?”.
For example, let’s remove the “description” from the screen. In the property drilling example you’d need to remove those lines of code:
val description: String description = viewModel.description description: String Text(description)
And the more the design evolves and the deeper the hierarchy goes, the more you have to drill into it, forcing you to read, understand and finally touch more and more layers of the UI. In contrast, in the alternative approach you’d only need to remove those lines instead:
val description: String Text(state.description)
It is simplified but still right to say: the coupling of the latter example is considerably less. You’ll feel the difference the more the UI evolves over time.
This of course, comes at a cost: the signatures and calls of the view functions are now indeed very strongly coupled to the data structures:
fun Screen(state: State, onAction: (Action) -> Unit) fun EntryList(state: State, onAction: (Action) -> Unit) fun EntryView(entry: Entry, onAction: (Action) -> Unit)
So this introduces a comparatively hard-to-change contract between the view model and the views between the views. But this is the kind of coupling we even want to have for this example; we could even call it “good coupling”: if you delete something the implementation breaks only at the place where it’s really used instead of all over the UI code. This of course also applies to other changes like additions and modifications.
“You can’t reuse that”
This is undoubtedly true. Consider, we have two modules A and B, both having an EntryA and EntryB, the proposed approach doesn’t work anymore as soon as you want to reuse the view.
As
fun EntryView(entry: Entry, onAction: (Action) -> Unit)
is not compatible with EntryA and EntryB you would be better off with a signature like
fun EntryView( title: String, description: String, onClick: () -> Unit, onDeleteClick: () -> Unit )
because it can instantly be used with EntryA as well as EntryB:
EntryView(entryA.title, entryA.description, ...)
But in how many cases are you really going to need that? The answers differ. In many, if not most cases, you don’t know beforehand. Overdoing reusability can be a sign of susceptibility to the premature optimization or YAGNI fallacy. So if you design for reusability, think twice whether you’re really going to reuse it. Reusability comes with a cost, that is usually lower understandability, changeability and maintainability due to unnecessary complexity, indirection and artificial decoupling.
“It’s harder to understand”
I think it’s very important that the solutions are not more complex than needed and comparatively easy to reason about.
One can separate the thoughts about understandability into three parts:
Learning curve
You need to get accustomed to new approaches and patterns (in this case the MVI architecture). So initially, it takes some time to get into this way of views and view models communicating with each other. Thus, first: yes, it has a learning curve. But so does any new concept.
Coupling to data that is not needed
Secondly, when you just pass the same data structures all the time, not all of that data contained is used in all places. For example:
fun EntryList(state: State, onAction: (Action) -> Unit) {
state.entries.forEach { EntryView(it, onAction) }
}
This function only uses entries and forgets about the rest. This is indeed an anti-pattern in some cases (and we’ll get to that below). But what you get in return is high flexibility. Remember? You can just move a line from another function
fun EntryList(state: State, onAction: (Action) -> Unit) { Text(state.description) state.entries.forEach { EntryView(it, onAction) } }
and you’re done: it still works. This approach costs you little and gives you a lot of flexibility in return.
So why do we learn this is an anti-pattern? Because it is an anti-pattern for core, reusable API. Imagine a REST API you have to give arguments that it doesn’t even need: it’s an impossible thought! But in a self-sufficient module or package, handing over bigger data structures in that fashion is not a sin and should be decided upon individually.
Looking up where the data comes from
Passing data structures down the view hierarchy seems counterintuitive to the ones not yet accustomed to the approach. This is understandable. But in my experience this is a matter of getting used to.
For example, let’s draw the graph how the data flows from function to function:
ViewModel → Screen → EntryList → EntryView
In order to understand how the data in EntryView is wired up with the ViewModel you need to navigate through the whole graph backwards. You can never know how data fed into one function is mapped to the next, also naming is not always consistent, so you often have to read through and understand all levels of the hierarchy wherever the data is passed further.
So in contrast, in that function from above
fun EntryList(state: State, onAction: (Action) -> Unit) { state.entries.forEach { EntryView(it, onAction) } }
you can either just command/control-click entries in your IDE and you will end up in the ViewModel where the data originates from or can look up the places where entries is changed in the view model. By jumping that far of course you lose what happens in between. This can be obscuring in some cases, but in most cases we know what the State‘s and ViewModel‘s jobs are and mostly the data is simply mapped 1-on-1 onto the view anyway. So, if it’s not obvious where the data comes from, it might most probably be because of a faulty implementation of the MVVM or MVI architecture and not the architecture itself.
When judging the comprehensiveness of a technical solution you always need to be fair. Is a function with 10 parameters still comprehensible? Is passing arguments from function to function 4 levels deep comprehensible? I think this can and should be decided on a per-case basis.
“It’s less performant”
This can indeed be a problem. When speaking of Compose, passing data structures can become a problem in this regard. For each instance where data is passed to another composable function, the runtime needs to compare the whole data structure. If the data is equal then we’re fine: no recomposition needed. And if the data structure can not be compared, the runtime always triggers a recomposition which can be CPU-intensive and potentially lead to less responsive UI.
But immutable data classes can be compared. And if the data didn’t change, there’ll be no recomposition.
So why are we then concerned with performance issues in Compose? Because data structures are more expensive to allocate, compare and garbage-collect. So you should definitely not use the MVI approach for data that is either very, very big or updates very often. Let’s consider three scenarios here:
- Scenario 1: the state updates when data is loaded, the user performs a keystroke or clicks an UI element. In this scenario, an update can happen up to a few times per second at max for some short period of time. In this case we don’t need to be concerned with performance, battery life or similar.
- Scenario 2: the state updates very often, more than a few times per second, so for example while the UI is scrolling. Animations like those might need a refresh rate of 30 or more per second and we definitely need to be concerned with performance and memory management overhead; even battery life might be an issue in case of an ongoing animation.
- Scenario 3: the data structure is very big. In this case the UI state data structure is not the right place to contain that data and you should most probably use paging or a similar approach instead.
My suggestion is to not optimize for performance if your app runs smoothly on the expected range of target devices and for the specific use case (see premature optimization fallacy). Instead, in most usual cases you should be free to focus on optimizing for maintainability, simplicity and changeability.
Conclusion
Which approach to use heavily relies on your use case. So here are some key takeaways:
- Keep the premature optimization and YAGNI fallacies in your mind and try to find simple solutions to optimize for understandability, maintainability and changeability first
- If you need to optimize for performance or you have a heavy-duty UI that can easily drain the battery, separate simple data that changes fast from more complex data that changes slowly
- Only optimize for reusability what you know will become reused (because even then it might not always pay as the future solution might demand things you couldn’t anticipate)
- Give the MVI approach a shot for a real-world, frequently changed, complex and nested UI to get accustomed to it and to see where it fits for your use case
- Don’t trust guidelines blindly, even from Google 😉
I hope this will help you to pick the right tools for the challenges ahead. Have a great time coding!
