Quatro maneiras de evitar prop drilling no Jetpack Compose
Prop drilling não é um problema do Compose — é um problema de design. Aqui estão os quatro padrões que uso dependendo do que a interface está tentando fazer.
Cada projeto Compose parece chegar ao mesmo ponto.
Para ilustrar o problema, vamos começar com uma hierarquia de composáveis simples.
Ao longo deste artigo, ParentScreen serve como o composável de nível superior. Ele chama Root, que por sua vez chama Content.
ParentScreen -> Root -> ContentCada composável está apenas passando os mesmos valores para o próximo.
@Composable
fun ParentScreen() {
Root(
navigator = navigator,
user = user,
onRefresh = viewModel::refresh,
onDelete = viewModel::delete,
onRetry = viewModel::retry,
)
}
@Composable
fun Root(
navigator: Navigator,
user: User,
onRefresh: () -> Unit,
onDelete: () -> Unit,
onRetry: () -> Unit
) {
Content(
navigator = navigator,
user = user,
onRefresh = onRefresh,
onDelete = onDelete,
onRetry = onRetry
)
}
@Composable
fun Content(
navigator: Navigator,
user: User,
onRefresh: () -> Unit,
onDelete: () -> Unit,
onRetry: () -> Unit
) {
// finalmente usado aqui
}Eventualmente, você percebe algo.
Root não usa nenhum desses parâmetros por si mesmo.
Ele apenas os encaminha para Content.
Isso é prop drilling.
Com o tempo, parei de pensar no prop drilling como o problema em si. Mais frequentemente do que não, é um sintoma de uma decisão de design em algum lugar da interface.
Depois de construir várias aplicações Compose, notei que a causa quase sempre cai em uma das quatro categorias — e cada uma exige uma solução diferente.
1. O composable existe apenas para layout
Esta é a situação que mais encontro.
Suponha que um composable apenas organize seus filhos.
@Composable
fun Card(
userName: String
) {
Surface {
UserName(userName)
}
}Agora imagine que há mais três composables de layout acima do Card.
Nenhum deles se importa com userName.
Eles apenas movem para baixo na árvore.
Quando vejo isso, geralmente paro de passar dados completamente.
Em vez disso, passo a UI.
Antes
@Composable
fun ParentScreen() {
val userName = "Alice"
Root(userName)
}
@Composable
fun Root(userName: String) {
Content(userName)
}
@Composable
fun Content(userName: String) {
Card(userName)
}
@Composable
fun Card(userName: String) {
UserName(userName)
}Depois
@Composable
fun ParentScreen() {
val userName = "Alice"
CardLayout {
UserName(userName)
}
}
@Composable
fun CardLayout(
content: @Composable () -> Unit
) {
Card {
content()
}
}Agora o componente de layout não sabe nada sobre userName.
Ele simplesmente define onde o conteúdo deve aparecer.
Depois que comecei a pensar dessa forma, percebi por que tantas APIs do Compose seguem o mesmo design.
Scaffold, LazyColumn, AlertDialog e até mesmo Button todos dependem de slots em vez de passar dados através de intermediários composables.
Se a responsabilidade do composable é layout, Slot APIs geralmente são minha primeira escolha.
2. O valor realmente pertence à árvore inteira
Às vezes o contrário é verdade.
O valor não se relaciona a um único composable em absoluto.
Coisas como
- navegador
- análises
- tema atual
- sessão do usuário atual
são frequentemente necessárias de muitos lugares diferentes.
Passe-las por cinco camadas apenas para que a sexta possa usá-las parece desnecessário.
Antes
@Composable
fun ParentScreen() {
Root(navigator)
}
@Composable
fun Root(
navigator: Navigator
) {
Content(navigator)
}
@Composable
fun Content(
navigator: Navigator
) {
Detail(navigator)
}
@Composable
fun Detail(
navigator: Navigator
) {
Button(
onClick = {
navigator.pop()
}
) {
Text("Voltar")
}
}
Depois
val LocalNavigator =
compositionLocalOf<Navigator> {
error("Navegador não fornecido")
}
@Composable
fun ParentScreen() {
CompositionLocalProvider(
LocalNavigator provides navigator
) {
Root()
}
}
@Composable
fun Detail() {
val navigator = LocalNavigator.current
Button(
onClick = {
navigator.pop()
}
) {
Text("Voltar")
}
}
O composável intermediário desaparece completamente da cadeia de dependência.
3. O problema real são callbacks
Às vezes, o estado não é o problema.
Callbacks são.
Já trabalhei em telas que pareciam com essa.
Child(
state = state,
onRefresh = viewModel::refresh,
onRetry = viewModel::retry,
onDelete = viewModel::delete,
onRename = viewModel::rename,
onLogout = viewModel::logout
)Todos os composáveis intermediários passaram exatamente as mesmas callbacks.
Nada usou essas callbacks até o final da árvore.
Sempre que noto isso acontecendo, geralmente paro de adicionar callbacks e mudo para um único dispatchador de eventos.
sealed interface ScreenEvent {
data object Refresh : ScreenEvent
data object Retry : ScreenEvent
data object Delete : ScreenEvent
data object Logout : ScreenEvent
data class Rename(
val name: String
) : ScreenEvent
}Agora a API fica muito mais simples.
Child(
state = state,
onEvent = viewModel::onEvent
)Dentro da UI, toda interação envia um evento.
Button(
onClick = {
onEvent(ScreenEvent.Refresh)
}
) {
Text("Refresh")
}Adicionar outra ação do usuário não significa mais modificar todos os composáveis entre a tela e o botão.
4. Talvez o ViewModel seja muito grande
Às vezes, tento tudo isso e a prop drilling ainda não some.
Isso geralmente é um sinal de que o ViewModel possui muito conteúdo.
Considere essa hierarquia.
Screen
├── Toolbar
├── Content
│ ├── Tab
│ │ ├── BottomSheet
│ │ └── DialogSe apenas o BottomSheet precisa de uma determinada parte do estado, por que ela vive no ViewModel da tela?
Em vez disso, costumo dar a essa funcionalidade seu próprio ViewModel.
@Composable
fun BottomSheet() {
val viewModel: BottomSheetViewModel = viewModel()
val state by viewModel.state.collectAsState()
...
}Isso naturalmente encurta o fluxo de dados porque agora o estado vive mais próximo do local onde é realmente usado.
Com a versão 3 da Navigation, escopar ViewModels para partes menores da UI ficou muito mais fácil, tornando essa abordagem ainda mais prática.
Não há uma ‘melhor’ solução
Quando comecei a usar o Compose, estava procurando por uma resposta universal para o problema do prop drilling.
Eventualmente percebi que a própria pergunta estava errada.
O prop drilling pode acontecer por razões completamente diferentes.
Às vezes, o componente apenas fornece layout.
Às vezes, os dados realmente pertencem à composição inteira.
Às vezes, as callbacks tomam conta da API.
E às vezes o ViewModel simplesmente ficou muito grande.
Uma vez que comecei a identificar por que os parâmetros estavam fluindo pela árvore, a solução geralmente se tornava óbvia.
Interessantemente, a maioria das telas reais acaba usando vários desses padrões juntos.
Uma tela pode usar APIs de Slot para layout, um CompositionLocal para navegação, uma única callback onEvent para interações do usuário e um ViewModel dedicado para um BottomSheet.
Nenhuma dessas técnicas substitui as outras.
Elas se complementam.
E uma vez que você para de procurar por uma única solução, o prop drilling se torna muito mais fácil de gerenciar.

