Ciclo de vida e efeitos colaterais

Funções que podem ser compostas precisam ser livres de efeitos colaterais. No entanto, quando necessárias para mudar o estado do app, elas precisam ser chamadas em um ambiente controlado, que conheça o ciclo de vida dela. Nesta página, você aprenderá sobre o ciclo de vida de uma função que pode ser composta e as diferentes APIs de efeito colateral oferecidas pelo Jetpack Compose.

Ciclo de vida de uma função que pode ser composta

Como mencionado no artigo Como gerenciar a documentação sobre os estados, uma composição descreve a IU do app e é produzida pela execução de funções que podem ser compostas. Uma composição é uma estrutura em árvore de funções que podem ser compostas que descrevem a IU.

Quando o Jetpack Compose executa suas funções que podem ser compostas pela primeira vez, ele rastreia as funções chamadas durante a composição inicial para descrever a IU em uma composição. Depois, quando o estado do app mudar, o Jetpack Compose programará uma recomposição. A recomposição é quando o Jetpack Compose executa novamente as funções que podem ser compostas que tenham mudado em resposta a mudanças de estado e, em seguida, atualiza a composição para refletir essas mudanças.

Uma composição só pode ser produzida por uma composição inicial e atualizada por recomposição. A única maneira de modificar uma composição é pela recomposição.

Diagrama mostrando o ciclo de vida de uma função que pode ser composta

Figura 1. Ciclo de vida de uma função que pode ser composta na composição. A função entra na composição, é recomposta ou mais vezes e sai da composição.

A recomposição geralmente é acionada por uma mudança em um objeto State<T>. O Compose rastreia esses itens e executa todas as funções que podem ser compostas na Composição que lê esse State<T> específico e todas as funções chamadas que não podem ser ignorados.

Caso uma função que pode ser composta seja chamada várias vezes, diversas instâncias serão colocadas na Composição. Cada chamada tem um ciclo de vida próprio na composição.

@Composable
fun MyComposable() {
    Column {
        Text("Hello")
        Text("World")
    }
}

Diagrama mostrando a organização hierárquica dos elementos no snippet de código anterior

Figura 2. Representação de MyComposable na composição. Se uma função que pode ser composta for chamada várias vezes, várias instâncias serão colocadas na composição. Um elemento com uma cor diferente indica uma instância separada.

Anatomia de uma função que pode ser composta na composição

A instância de uma função que pode ser composta na composição é identificada pelo local de chamada. O compilador Compose considera que cada local de chamada é distinto. Chamar funções de vários locais de chamadas criará diversas instâncias da mesma função na composição.

Se, durante uma recomposição, uma função que pode ser composta chama funções diferentes das chamadas na composição anterior, o Compose identificará quais funções foram chamadas ou não. Para as funções que podem ser compostas chamadas em ambas as composições, o Compose evitará uma recomposição caso as entradas não tenham mudado.

Preservar a identidade é crucial para associar efeitos colaterais a funções que podem ser compostas, para que elas sejam concluídas corretamente, e não reiniciem para cada recomposição.

Veja o exemplo a seguir:

@Composable
fun LoginScreen(showError: Boolean) {
    if (showError) {
        LoginError()
    }
    LoginInput() // This call site affects where LoginInput is placed in Composition
}

@Composable
fun LoginInput() { /* ... */ }

No snippet de código acima, LoginScreen chamará condicionalmente o LoginError que pode ser composto e chamará sempre o LoginInput que pode ser composto. Cada chamada tem um local e posição de origem exclusivos, que o compilador usará para identificá-la.

Diagrama mostrando como ocorre a recomposição do código anterior se a sinalização showError mudar para &quot;true&quot;. O LoginError que pode ser composto é adicionado, mas não ocorre a recomposição das outras funções.

Figura 3. Representação de LoginScreen na composição quando o estado muda e uma recomposição ocorre. A mesma cor indica que não houve recomposição.

Embora LoginInput tenha passado de primeiro a ser chamado para segundo, a instância LoginInput será preservada após as recomposições. Além disso, como LoginInput não tem parâmetros que mudaram na recomposição, a chamada para LoginInput será ignorada pelo Compose.

Adicionar mais informações para ajudar na recomposição inteligente

Chamar uma função que pode ser composta várias vezes fará com que ela também seja adicionada várias vezes à composição. Ao chamar uma função do mesmo local de chamadas várias vezes, o Compose não recebe informações para identificar cada chamada de forma exclusiva. Assim, a ordem de execução é usada junto ao local da chamada para diferenciar as instâncias. Algumas vezes, esse comportamento é o suficiente. Mas, em alguns casos, ele pode causar um comportamento indesejado.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            // MovieOverview composables are placed in Composition given its
            // index position in the for loop
            MovieOverview(movie)
        }
    }
}

No exemplo acima, o Compose usa a ordem de execução, além do local da chamada para diferenciar as instâncias na composição. Se um novo movie for adicionado ao fim da lista, o Compose poderá reutilizar as instâncias que já estão na composição, já que as posições na lista não mudaram e, portanto, a entrada movie é a mesma para essas instâncias.

Diagrama mostrando como ocorre a recomposição do código anterior se um novo elemento for adicionado ao fim da lista. A posição dos outros itens da lista não mudou, portanto, não ocorre a recomposição desses itens.

Figura 4. Representação de MoviesScreen na composição quando um novo elemento é adicionado ao fim da lista. Os MovieOverview que podem ser compostos na composição podem ser reutilizados. A mesma cor em MovieOverview indica que não ocorreu a recomposição da função.

No entanto, se a lista movies mudar adicionando itens ao topo ou ao meio da lista, removendo ou reorganizando itens, ela causará uma recomposição em todas as chamadas MovieOverview cujo parâmetro de entrada mudou de posição na lista. Isso é extremamente importante se, por exemplo, MovieOverview buscar uma imagem de filmes usando um efeito colateral. Se a reposição ocorrer enquanto o efeito estiver em andamento, ela será cancelada e começará novamente.

@Composable
fun MovieOverview(movie: Movie) {
    Column {
        // Side effect explained later in the docs. If MovieOverview
        // recomposes, while fetching the image is in progress,
        // it is cancelled and restarted.
        val image = loadNetworkImage(movie.url)
        MovieHeader(image)

        /* ... */
    }
}

Diagrama mostrando como ocorre a recomposição do código anterior se um novo elemento for adicionado ao topo da lista. Todos os outros itens da lista mudam de posição e precisam ser recompostos.

Figura 5. Representação de MoviesScreen na composição quando um novo elemento é adicionado à lista. Os elementos MovieOverview que podem ser compostos não podem ser reutilizados e todos os efeitos colaterais são reiniciados. Uma cor diferente em MovieOverview indica que a função foi recomposta.

O ideal é que a identidade da instância MovieOverview seja considerada como vinculada à identidade do movie que é passada a ela. Se a lista de filmes for reordenada, o ideal será reorganizar as instâncias correspondentes na árvore de composição, em vez de recompor cada função MovieOverview em outra instância de filme. O Compose oferece uma maneira de informar ao ambiente de execução quais valores você quer usar para identificar uma determinada parte da árvore: a key que pode ser composta.

Ao agrupar um bloco de código a uma chamada à chave que pode ser composta com um ou mais valores transmitidos, esses valores serão combinados para identificar essa instância na composição. O valor de uma key não precisa ser globalmente exclusivo. Ele precisa ser único apenas entre as invocações de funções no local de chamada. Portanto, nesse exemplo, cada movie precisa ter uma key que seja única entre os movies. Não há problema se essa for a mesma key de outra função em outro local do app.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            key(movie.id) { // Unique ID for this movie
                MovieOverview(movie)
            }
        }
    }
}

Com o exemplo acima, mesmo que os elementos na lista mudem, o Compose reconhece chamadas individuais para MovieOverview e pode reutilizá-las.

Diagrama mostrando como ocorre a recomposição do código anterior se um novo elemento for adicionado ao topo da lista. Como os itens da lista são identificados por chaves, o Compose sabe que não é necessário realizar a recomposição, mesmo que as posições tenham sido alteradas.

Figura 6. Representação de MoviesScreen na composição quando um novo elemento é adicionado à lista. Como as funções MovieOverview têm chaves exclusivas, o Compose reconhece quais instâncias de MovieOverview não mudaram e podem reutilizá-las. Os efeitos colaterais continuarão sendo executados.

Algumas funções têm compatibilidade integrada com a key. Por exemplo, LazyColumn aceita especificar uma key personalizada na DSL items.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    LazyColumn {
        items(movies, key = { movie -> movie.id }) { movie ->
            MovieOverview(movie)
        }
    }
}

Como ignorar caso as entradas não tenham mudado

Se uma função já estiver em composição, ela poderá ignorar a recomposição caso todas as entradas sejam estáveis e não tenham mudado.

Um tipo estável precisa obedecer aos seguintes termos:

  • O resultado de equals para duas instâncias sempre será o mesmo para essas duas instâncias.
  • Se uma propriedade pública do tipo mudar, a composição será notificada.
  • Todos os tipos de propriedade pública também são estáveis.

Há alguns tipos comuns importantes que se enquadram nesses termos que serão tratados como estáveis pelo compilador do Compose, mesmo que não estejam explicitamente marcados como estáveis pela anotação @Stable:

  • Todos os tipos de valor primitivo: Boolean, Int, Long, Float, Char etc.
  • Strings
  • Todos os tipos de função (lambdas)

Todos esses tipos podem seguir os termos de estabilidade porque são imutáveis. Como os tipos imutáveis nunca mudam, eles nunca precisam notificar a composição sobre uma mudança. Por esse motivo, fica muito mais fácil seguir os termos.

Um tipo importante que é estável, mas é mutável é o tipo MutableState do Compose. Se um valor for mantido em um MutableState, o objeto de estado será considerado estável de forma geral, porque o Compose será notificado sobre qualquer mudança na propriedade .value do State.

Quando todos os tipos passados como parâmetros para uma função são estáveis, os valores são comparados para verificar se eles são iguais, com base na posição da função na árvore de IU. A recomposição será ignorada caso todos os valores tenham permanecido inalterados desde a chamada anterior.

O Compose só considera que um tipo é estável se for possível provar isso. Por exemplo, uma interface geralmente é tratada como não estável, assim como tipos com propriedades públicas mutáveis (cuja implementação pode ser imutável).

Caso o Compose não consiga concluir que um tipo é estável, mas você queira forçá-lo a tratar o tipo como estável, marque-o com a anotação @Stable.

// Marking the type as stable to favor skipping and smart recompositions.
@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?

    val hasError: Boolean
        get() = exception != null
}

No snippet de código acima, como UiState é uma interface, geralmente o Compose poderia considerar que esse tipo não é estável. Ao adicionar a anotação @Stable, você informa ao Compose que esse tipo é estável, permitindo que ele priorize recomposições inteligentes. Isso também significa que o Compose tratará todas as implementações da interface como estáveis se ela for usada como o tipo de parâmetro.

Casos de uso de estado e efeito

Conforme visto na documentação Trabalhando com o Compose, as funções que podem ser compostas precisam ser livres de efeitos colaterais. Quando você precisar fazer mudanças no estado do app, conforme descrito no documento Como gerenciar a documentação sobre os estados, use as APIs Effect para que esses efeitos colaterais sejam executados de forma previsível.

Devido às diferentes possibilidades oferecidas por efeitos no Compose, eles podem ser usados de forma excessiva. Confira se o trabalho que você realiza nesses efeitos é relacionado à IU e não quebra o fluxo de dados unidirecional, conforme explicado no artigo Como gerenciar a documentação sobre os estados.

LaunchedEffect: executar funções de suspensão no escopo de uma função que pode ser composta

Para chamar funções de suspensão de forma segura em uma função que pode ser composta, use o LaunchedEffect que pode ser composto. Quando LaunchedEffect entra na composição, ele inicia uma corrotina com o bloco de código transmitido como um parâmetro. A corrotina será cancelada se LaunchedEffect sair da composição. Se LaunchedEffect for recomposto com chaves diferentes (consulte a seção Como reiniciar efeitos abaixo), a corrotina existente será cancelada e a nova função de suspensão será iniciada em uma nova corrotina.

Por exemplo, a ação de mostrar uma Snackbar em um Scaffold é realizada com a função SnackbarHostState.showSnackbar, que é uma função de suspensão.

@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    scaffoldState: ScaffoldState = rememberScaffoldState()
) {

    // If the UI state contains an error, show snackbar
    if (state.hasError) {

        // `LaunchedEffect` will cancel and re-launch if `scaffoldState` changes
        LaunchedEffect(scaffoldState.snackbarHostState) {
            // Show snackbar using a coroutine, when the coroutine is cancelled the
            // snackbar will automatically dismiss. This coroutine will cancel whenever
            // `state.hasError` is false, and only start when `state.hasError`
            // is true (due to the above if-check), or if `scaffoldState` changes.
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(scaffoldState = scaffoldState) {
        /* ... */
    }
}

No código acima, uma corrotina será acionada caso o estado contenha um erro e será cancelada quando isso não ocorrer. Como o local de chamadas LaunchedEffect está dentro de uma instrução if, quando a instrução for falsa, se LaunchedEffect estiver na Composição, ele será removido e, portanto, a corrotina será cancelada.

recordoutCoroutineScope: ter um escopo com reconhecimento de composição para iniciar uma corrotina fora de uma função que pode ser composta

Como LaunchedEffect é uma função que pode ser composta, ela só pode ser usada dentro de outras funções desse tipo. Para iniciar uma corrotina fora de uma função que pode ser composta, mas com escopo para que ela seja automaticamente cancelada ao sair da composição, use rememberCoroutineScope. Além disso, use rememberCoroutineScope sempre que precisar controlar o ciclo de vida de uma ou mais corrotinas manualmente, por exemplo, para cancelar uma animação quando um evento de usuário ocorrer.

A rememberCoroutineScope é uma função que pode ser composta que retorna um CoroutineScope vinculado ao ponto da composição em que ele é chamado. O escopo será cancelado quando a chamada sair da Composição.

Seguindo o exemplo anterior, esse código poderia ser usado para exibir uma Snackbar quando o usuário toca em um Button:

@Composable
fun MoviesScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(scaffoldState = scaffoldState) {
        Column {
            /* ... */
            Button(
                onClick = {
                    // Create a new coroutine in the event handler
                    // to show a snackbar
                    scope.launch {
                        scaffoldState.snackbarHostState
                            .showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

RememberUpdatedState: referenciar um valor em um efeito que não pode ser reiniciado se o valor mudar

O LaunchedEffect é reiniciado quando um dos parâmetros de chave muda. No entanto, em algumas situações, você pode querer capturar um valor que, se modificado, faria com que o efeito fosse reiniciado, e é possível que você não queira que isso aconteça. Para fazer isso, é necessário usar rememberUpdatedState para criar uma referência a esse valor que possa ser capturada e atualizada. Essa abordagem é útil para efeitos que contêm operações de longa duração que podem ser caras ou proibidas de recriar e reiniciar.

Por exemplo, suponha que o app tenha uma LandingScreen que desaparece após determinado período. Mesmo que LandingScreen seja recomposta, o efeito que aguarda por um determinado período e avisa que o período passou não precisa ser reiniciado:

@Composable
fun LandingScreen(onTimeout: () -> Unit) {

    // This will always refer to the latest onTimeout function that
    // LandingScreen was recomposed with
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}

Para criar um efeito que corresponda ao ciclo de vida do local de chamada, uma constante que nunca muda (como Unit ou true) é transmitida como parâmetro. No código acima, LaunchedEffect(true) é usado. Para garantir que o lambda onTimeout sempre contenha o valor mais recente com que LandingScreen foi recomposta, onTimeout precisa ser agrupado com a função rememberUpdatedState. O State retornado, currentOnTimeout no código, será usado no efeito.

DisposableEffect: efeitos que exigem limpeza

Para efeitos colaterais que exigem limpeza depois que as chaves mudam ou se a função que pode ser composta sai da Composição, use DisposableEffect. Se as chaves DisposableEffect mudarem, a função precisa descartar o efeito atual, ou seja, fazer a limpeza, e ser redefinida chamando o efeito novamente.

Por exemplo, um OnBackPressedCallback precisa ser registrado para detectar o botão "Voltar" pressionado em um OnBackPressedDispatcher. Para detectar esses eventos no Compose, use um DisposableEffect para registrar e cancelar o registro do callback quando necessário.

@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {

    // Safely update the current `onBack` lambda when a new one is provided
    val currentOnBack by rememberUpdatedState(onBack)

    // Remember in Composition a back callback that calls the `onBack` lambda
    val backCallback = remember {
        // Always intercept back events. See the SideEffect for
        // a more complete version
        object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                currentOnBack()
            }
        }
    }

    // If `backDispatcher` changes, dispose and reset the effect
    DisposableEffect(backDispatcher) {
        // Add callback to the backDispatcher
        backDispatcher.addCallback(backCallback)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            backCallback.remove()
        }
    }
}

No código acima, o efeito adicionará o backCallback lembrado ao backDispatcher. Se backDispatcher mudar, o efeito será descartado e reiniciado.

Uma DisposableEffect precisa incluir uma cláusula onDispose como a instrução final no bloco de código. Caso contrário, o ambiente de desenvolvimento integrado exibirá um erro de tempo de compilação.

SideEffect: publicar estado do Compose em código que não é do Compose

Para compartilhar o estado do Compose com objetos não gerenciados pelo Compose, use o SideEffect que pode ser composto, que é invocado a cada recomposição concluída corretamente.

Usando o código BackHandler anterior como exemplo, para informar se o callback precisa ser ativado ou não, use SideEffect para atualizar o valor.

@Composable
fun BackHandler(
    backDispatcher: OnBackPressedDispatcher,
    enabled: Boolean = true, // Whether back events should be intercepted or not
    onBack: () -> Unit
) {
    /* ... */
    val backCallback = remember { /* ... */ }

    // On every successful composition, update the callback with the `enabled` value
    // to tell `backCallback` whether back events should be intercepted or not
    SideEffect {
        backCallback.isEnabled = enabled
    }

    /* Rest of the code */
}

produceState: converter um estado que não é do Compose em um estado do Compose

produceState lança uma corrotina com escopo no Compose, que pode enviar valores para um State retornado. Use essa função para converter um estado externo em um estado do Compose, por exemplo, para trazer um estado externo por assinatura, como Flow, LiveData ou RxJava, para o Compose.

O produtor é iniciado quando produceState entra na composição e será cancelado quando ele sair da composição. O State é mesclado. Definir o mesmo valor não acionará uma recomposição.

Mesmo que o produceState crie uma corrotina, ele também pode ser usado para observar fontes de dados não suspensas. Para remover a assinatura dessa origem, use a função awaitDispose.

O exemplo a seguir mostra como usar produceState para carregar uma imagem da rede. A função que pode ser composta loadNetworkImage retorna um State que pode ser usado em outras funções.

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository
): State<Result<Image>> {

    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new keys.
    return produceState(initialValue = Result.Loading, url, imageRepository) {

        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

derivedStateOf: converter um ou vários objetos de estado em outro estado

Use derivedStateOf quando um determinado estado é calculado ou derivado de outros objetos de estado. O uso dessa função garante que o cálculo só ocorra quando um dos estados usados no cálculo mudar.

O exemplo a seguir mostra uma lista de Tarefas básicas, em que as tarefas com palavras-chave de alta prioridade definidas pelo usuário aparecem primeiro:

@Composable
fun TodoList(
    highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")
) {
    val todoTasks = remember { mutableStateListOf<String>() }

    // Calculate high priority tasks only when the todoTasks or
    // highPriorityKeywords change, not on every recomposition
    val highPriorityTasks by remember(todoTasks, highPriorityKeywords) {
        derivedStateOf {
            todoTasks.filter { it.containsWord(highPriorityKeywords) }
        }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) { /* ... */ }
            items(todoTasks) { /* ... */ }
        }
        /* Rest of the UI where users can add elements to the list */
    }
}

No código acima, derivedStateOf garante que sempre que todoTasks ou highPriorityKeywords mudar, o cálculo de highPriorityTasks será executado e a IU será atualizada com o resultado. Como o filtro para calcular highPriorityTasks pode ser caro, ele só deve ser executado quando uma das listas mudar, e não a cada recomposição.

Além disso, uma atualização no estado gerada por derivedStateOf não faz com que a função em que ele foi declarado seja recomposta. O Compose só recompõe funções cujo estado retornado é lido, dentro de LazyColumn no exemplo.

snapshotFlow: converter o estado do Compose em fluxos

Use snapshotFlow para converter objetos State<T> em um fluxo frio. snapshotFlow executa o bloco quando coletado e emite o resultado dos objetos State lidos nele. Quando um dos objetos State lidos no bloco snapshotFlow é modificado, o fluxo emite o novo valor para o coletor se o novo valor não for igual ao emitido anteriormente. Esse comportamento é semelhante ao de Flow.distinctUntilChanged (links em inglês).

O exemplo a seguir mostra um efeito colateral que registra quando o usuário rola pelo primeiro item em uma lista para análise:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

No código acima, listState.firstVisibleItemIndex é convertido em um fluxo que pode se beneficiar dos recursos dos operadores de fluxo.

Como reiniciar efeitos

Alguns efeitos no Compose, como LaunchedEffect, produceState ou DisposableEffect, usam um número variável de argumentos e chaves, que são usados para cancelar o efeito de execução e iniciar um novo argumento com as novas chaves.

A forma típica dessas APIs é:

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

Devido às particularidades desse comportamento, podem ocorrer problemas se os parâmetros usados para reiniciar o efeito não forem os corretos:

  • Ter menos reiniciações de efeitos que o necessário pode causar bugs no app.
  • Ter mais reiniciações de efeitos que o necessário pode ser ineficiente.

Como regra geral, as variáveis mutáveis e imutáveis usadas no bloco de efeito do código precisam ser adicionadas como parâmetros à função do efeito. Além desses parâmetros, outros podem ser adicionados para que sejam forçados quando o efeito for reiniciado. Se a mudança de uma variável não fizer com que o efeito seja reiniciado, ela precisa ser agrupada em rememberUpdatedState. Se a variável nunca mudar porque está agrupada em remember sem chaves, você não precisará transmitir a variável como uma chave para o efeito.

No código DisposableEffect mostrado acima, o efeito usa backDispatcher usado no bloco como parâmetro, já que qualquer mudança faria com que o efeito fosse reiniciado.

@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {
    /* ... */
    val backCallback = remember { /* ... */ }

    DisposableEffect(backDispatcher) {
        backDispatcher.addCallback(backCallback)
        onDispose {
            backCallback.remove()
        }
    }
}

backCallback não é necessário como uma chave DisposableEffect, porque o valor nunca mudará na composição. Ele é agrupado em remember sem chaves. Se backDispatcher não for transmitido como um parâmetro e ele mudar, BackHandler será recomposto, mas o DisposableEffect não será descartado e será reiniciado. Isso causará problemas, porque o backDispatcher incorreto será usado desse ponto em diante.

Constantes como chaves

Você pode usar uma constante, como true, como uma chave de efeito para fazê-la seguir o ciclo de vida do local de chamada. Existem casos de uso válidos dessa opção, como o exemplo LaunchedEffect mostrado acima. No entanto, pense duas vezes antes de fazer isso e confira se é o que você precisa.