wandres.dev
ESTADO EN COMPOSE · recomposición

Efectos en Compose: LaunchedEffect y DisposableEffect

El cuerpo de un componible es territorio prohibido para todo lo que actúa sobre el mundo, y sin embargo la interfaz tiene que actuar: navegar, mostrar un snackbar, registrar un oyente, arrancar una animación. Esta lección estudia las API de efectos como la puerta controlada por la que ese trabajo entra en la composición sin romper su pureza. Se analiza LaunchedEffect como corrutina cuyo ciclo de vida sigue al de la composición y cuyas claves gobiernan la cancelación y el relanzamiento, DisposableEffect como el par simétrico de registrar y liberar recursos no suspendibles, el problema de las claves inestables que relanzan el efecto en cada recomposición, el uso de rememberUpdatedState para capturar una función sin reiniciar el efecto y, como culminación del nivel, cómo se consumen los side effects de entrega única que emite el ViewModel sin volver a dispararlos jamás.

⏱ 19 min

Todo el nivel ha defendido que un componible es una descripción y no una acción, y esa disciplina es lo que hace que la recomposición sea segura. Pero una interfaz que solo describe no sirve para nada: en algún momento hay que navegar a otra pantalla, mostrar un mensaje efímero, suscribirse al sensor de orientación o pedirle al teclado que aparezca. Compose no niega esa necesidad; la canaliza. Las API de efectos son la puerta por la que el trabajo con consecuencias entra en la composición bajo un contrato explícito de cuándo empieza, cuándo se cancela y qué se limpia. Y son también la pieza que faltaba para cerrar el bucle de MVI sobre Compose, porque el canal de side effects del ViewModel desemboca exactamente aquí.

🎯 Al terminar esta lección sabrás
  • Usar LaunchedEffect como corrutina atada a la composición y gobernar su relanzamiento con claves.
  • Usar DisposableEffect para el par simétrico de registrar y liberar recursos no suspendibles.
  • Diagnosticar el efecto que se relanza sin parar por culpa de una clave inestable.
  • Consumir los side effects de entrega única del ViewModel sin re-disparo al recomponer o rotar.

LaunchedEffect: una corrutina con el ciclo de vida de la composición

LaunchedEffect lanza una corrutina cuando el componible entra en la composición y la cancela cuando sale. Ese es todo el contrato, y basta para resolver la mayoría de los casos: cargar algo al abrir una pantalla, mostrar un mensaje cuando aparece un error, desplazar una lista hasta una posición, arrancar una animación de entrada. Lo importante es que la corrutina no vive en un ámbito ajeno que haya que recordar cancelar: vive en el ámbito de ese punto del árbol, y desaparece con él.

Las claves gobiernan el resto del comportamiento. LaunchedEffect recibe uno o más argumentos que actúan como identidad del efecto: mientras sean iguales entre recomposiciones, la corrutina en curso sigue intacta; en cuanto una cambia, la corrutina actual se cancela y se lanza una nueva. Esa semántica es la que hace que un efecto parametrizado por un identificador se comporte correctamente sin que escribas una línea de gestión.

@Composable
fun DetalleScreen(id: String, estado: DetalleState, snackbar: SnackbarHostState) {
    // Cambia el id: cancela la carga anterior y arranca la nueva
    LaunchedEffect(id) { viewModel.cargar(id) }

    // Se relanza solo cuando aparece un error distinto
    LaunchedEffect(estado.error) {
        estado.error?.let { snackbar.showSnackbar(it) }
    }
}

La elección de la clave es toda la decisión. Pasar un valor constante significa una sola ejecución mientras el componible viva; pasar un dato que cambia significa cancelar y reiniciar en cada cambio. La avería típica está en el extremo opuesto: pasar como clave algo que se recrea en cada recomposición —una lambda escrita en el sitio, una lista construida al vuelo, un objeto sin igualdad estructural— hace que la clave nunca sea igual a la anterior, y entonces el efecto se cancela y se relanza sin parar. Si ves una petición de red disparándose en bucle, mira la clave antes que ninguna otra cosa.

💡
rememberUpdatedState para lo que cambia sin deber reiniciar

A veces el efecto es largo —un temporizador, una animación, una espera— y usa una función que sí puede cambiar entre recomposiciones. Ponerla como clave lo reiniciaría todo; no ponerla dejaría capturada la versión vieja. rememberUpdatedState resuelve la tensión: guarda una referencia que se actualiza sola sin invalidar el efecto, de modo que la corrutina lanzada hace mucho llama siempre a la última versión. Es la herramienta exacta para separar lo que debe reiniciar un efecto de lo que solo debe mantenerse fresco dentro de él.

DisposableEffect: lo que se registra hay que liberarlo

No todo el trabajo con consecuencias es suspendible. Registrar un oyente en el sistema, suscribirse a un difusor, adquirir un recurso nativo o observar el ciclo de vida son operaciones que no se cancelan cancelando una corrutina: exigen una llamada explícita de liberación. DisposableEffect es el par simétrico de LaunchedEffect para ese caso, y su firma obliga a escribir el bloque de limpieza: no compila si no lo pones.

@Composable
fun ObservadorDePantalla(owner: LifecycleOwner, onReanudar: () -> Unit) {
    val callbackFresco by rememberUpdatedState(onReanudar)
    DisposableEffect(owner) {
        val observador = LifecycleEventObserver { _, evento ->
            if (evento == Lifecycle.Event.ON_RESUME) callbackFresco()
        }
        owner.lifecycle.addObserver(observador)
        onDispose { owner.lifecycle.removeObserver(observador) }   // obligatorio
    }
}

Esa obligación sintáctica es una de las decisiones de diseño más acertadas de Compose. La fuga de memoria clásica de Android nace de una asimetría: registrar es fácil y visible, liberar es fácil de olvidar y ocurre en otro sitio del archivo, a veces en otro método, a veces en un método que ni siquiera se llama en todos los caminos. Aquí el registro y la liberación están adosados, en el mismo bloque y a la vista, y el compilador no te deja escribir uno sin el otro. La simetría deja de depender de tu memoria y pasa a depender del tipo.

flowchart LR
E[El componible entra en la composicion] --> L[LaunchedEffect lanza la corrutina]
E --> D[DisposableEffect registra el recurso]
K[Cambia una clave] --> C[Se cancela y se vuelve a lanzar]
S[El componible sale del arbol] --> X[Corrutina cancelada]
S --> O[onDispose libera el recurso]
style O fill:#a6e3a1,color:#11111b
style X fill:#a6e3a1,color:#11111b

El destino final de los side effects de una sola vez

Aquí se cierra el bucle que llevas construyendo desde el primer nivel. El ViewModel emite por su canal eventos de entrega única —navega, muestra este error, vibra— y ese canal necesita un consumidor en la interfaz que cumpla dos condiciones incompatibles a primera vista: reaccionar mientras la pantalla se ve, y no volver a reaccionar cuando la pantalla se recompone o se recrea. Un efecto con clave constante es justo eso: se lanza una vez al entrar en la composición, colecciona el flujo mientras vive y no se relanza por recomposiciones.

@Composable
fun LoginRoute(viewModel: LoginViewModel, navegar: (String) -> Unit) {
    val estado by viewModel.container.stateFlow.collectAsStateWithLifecycle()
    val snackbar = remember { SnackbarHostState() }

    // Orbit ya envuelve el patron correcto: coleccion unica y consciente del ciclo de vida
    viewModel.collectSideEffect { efecto ->
        when (efecto) {
            LoginSideEffect.NavegarAHome -> navegar("home")
            is LoginSideEffect.MostrarError -> snackbar.showSnackbar(efecto.mensaje)
        }
    }

    LoginScreen(estado = estado, onEntrar = viewModel::entrar)
}
// Sin el ayudante de Orbit, el patrón se escribe a mano con las piezas del nivel
val owner = LocalLifecycleOwner.current
LaunchedEffect(viewModel, owner) {                    // clave estable: no se relanza al recomponer
    viewModel.container.sideEffectFlow
        .flowWithLifecycle(owner.lifecycle, Lifecycle.State.STARTED)
        .collect { efecto -> manejar(efecto) }
}

Merece la pena ver qué hay debajo de esa comodidad, porque es exactamente lo que has estudiado. collectSideEffect lanza un efecto con clave estable que colecciona el flujo del canal restringido al umbral de visibilidad del ciclo de vida. Clave estable significa que una recomposición no relanza nada; canal en lugar de estado conflado significa que la resuscripción tras una rotación no reentrega el evento anterior; umbral de visibilidad significa que un evento de navegación no se procesa con la pantalla en segundo plano, cuando el gráfico de navegación ya no está donde el evento suponía.

⚠️
Navegar desde un efecto exige idempotencia

Aunque el canal entregue el evento una sola vez, la acción que dispara puede ejecutarse dos veces si el usuario toca dos veces antes de que la transición termine, o si dos eventos llegan casi a la vez. El resultado es la misma pantalla apilada dos veces. La defensa no está en el canal sino en la acción: haz que navegar sea idempotente comprobando el destino actual antes de empujar, o deshabilita el control mientras el estado indique que hay una operación en curso. Entrega única y ejecución única no son lo mismo.

El resto del catálogo y cuándo toca cada uno

Las dos API centrales no agotan el repertorio, y conocer las demás evita usar la herramienta equivocada por no saber que existía otra. Todas comparten la misma idea —dar un contrato explícito a algo que la recomposición volvería impredecible— y se diferencian en qué dirección cruzan la frontera.

// Publicar un valor de Compose hacia un objeto que no es de Compose, tras cada composicion exitosa
SideEffect { analitica.pantallaActual = ruta }

// Convertir una fuente ajena a Compose en un estado observable, con limpieza incluida
val ubicacion by produceState<Ubicacion?>(initialValue = null, proveedor) {
    val oyente = proveedor.escuchar { value = it }
    awaitDispose { proveedor.dejarDeEscuchar(oyente) }
}

// Convertir un estado de Compose en un Flow para aplicarle operadores
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .distinctUntilChanged()
        .collect { indice -> viewModel.recordarPosicion(indice) }
}

El criterio para elegir es direccional y se resume en tres preguntas. Si necesitas ejecutar trabajo suspendible atado a la vida del componible, es la corrutina lanzada. Si necesitas adquirir algo que exige liberación explícita, es el efecto desechable. Si lo que quieres es empujar un valor hacia un sistema externo que no entiende de estado observable, es el efecto de publicación, que corre después de cada composición confirmada y por eso no debe contener trabajo pesado. Y cuando la frontera se cruza en sentido contrario —un estado de Compose que quieres tratar con operadores de flujo— la conversión a flujo instantáneo es la puerta.

ℹ️
Producir estado es izar al reves

La función que produce estado merece una lectura arquitectónica. Convierte una fuente externa en un estado observable dentro de la composición, y por eso resulta tentadora para traer datos directamente a la pantalla saltándose el ViewModel. Úsala para fuentes genuinamente de presentación —la ubicación mientras un mapa está en pantalla, la orientación del dispositivo, un temporizador visual— y no para datos de dominio: si el valor que produce debería influir en el reductor o sobrevivir a la navegación, se ha colado negocio en la capa equivocada por una puerta cómoda.

Un efecto es una promesa de simetria en un mundo que se recompone

Vale la pena entender por qué las API de efectos existen, porque la respuesta ilumina todo lo demás. En un modelo imperativo el código se ejecuta una vez y en un orden que tú controlas: registras algo al crear la pantalla y lo liberas al destruirla, y la simetría entre ambos actos la sostiene tu disciplina. En un modelo declarativo esa garantía desaparece: tu función se ejecuta un número de veces que no decides, en un orden que no controlas, y puede ser abandonada a medias. Bajo esas condiciones, cualquier acción escrita ingenuamente en el cuerpo no es que sea de mal gusto, es que carece de significado: no se puede decir cuántas veces ocurrirá ni si su contrapartida llegará a ejecutarse. Los efectos restauran ese significado imponiendo un contrato al que la recomposición no puede llegar. Dentro del contrato vuelves a poder afirmar cosas: esto ocurre exactamente una vez al entrar, esto se cancela exactamente al salir, esto se reinicia exactamente cuando esta clave cambia, esto se libera siempre porque el tipo no me deja no liberarlo. Fíjate en la forma común de las tres API: todas emparejan un comienzo con un final y hacen que el sistema, no el programador, garantice el emparejamiento. Ese es el patrón profundo y trasciende a Compose: allí donde un sistema se vuelve impredecible en su número de ejecuciones —reintentos, recomposiciones, reconciliaciones, reactivaciones— la única forma de recuperar el razonamiento es dejar de escribir acciones sueltas y empezar a escribir pares simétricos cuya clausura garantice la máquina. Cuando entiendes esto, un LaunchedEffect deja de parecer una excepción a la pureza del componible y se revela como lo contrario: es precisamente el mecanismo que permite que el componible siga siendo puro mientras la app hace cosas de verdad. La pureza no se defiende prohibiendo los efectos; se defiende dándoles una puerta con contrato.

⚔️ Pon los efectos donde deben estar
  1. Escribe un efecto parametrizado por un identificador y explica qué ocurre exactamente con la corrutina en curso cuando ese identificador cambia.
  2. Provoca el bucle de relanzamiento pasando como clave un objeto que se recrea en cada recomposición, y arréglalo dejando la clave estable.
  3. Registra un oyente del ciclo de vida con el efecto desechable y argumenta por qué la obligación de escribir la liberación elimina una clase entera de fugas.
  4. Usa la referencia actualizada para que un efecto largo llame siempre a la última versión de una función sin reiniciarse, y di qué pasaría con cada una de las dos alternativas ingenuas.
  5. Consume el canal de side effects del ViewModel en una pantalla y demuestra, girando el dispositivo, que el evento no se vuelve a entregar; después haz idempotente la navegación y explica por qué hacía falta.