remember y rememberSaveable: el estado local legítimo
No todo el estado de una pantalla pertenece al ViewModel. Esta lección delimita con precisión el territorio del estado local de la interfaz y sus dos herramientas: remember, que sobrevive a la recomposición pero muere con la composición, y rememberSaveable, que además sobrevive a los cambios de configuración y a la muerte del proceso por medio del estado guardado de la instancia. Se estudian las claves de recordado y por qué cambiarlas descarta la memoria, la restricción de serializabilidad y los guardadores personalizados, el coste real de guardar y por qué el paquete de la instancia no es un almacén general, y sobre todo el criterio que separa el estado efímero de presentación del estado de negocio que debe vivir en el modelo y que ninguna de estas dos funciones debería custodiar jamás.
Si el estado de la app vive en el ViewModel y la interfaz es una función pura de ese estado, ¿qué hace Compose ofreciendo funciones para recordar cosas dentro de un componible? La pregunta parece señalar una contradicción y no la señala: existe una franja de datos que son genuinamente de la interfaz y de nadie más —si un menú está desplegado, cuánto se ha desplazado una lista, si una tarjeta está expandida—, y meterlos en el modelo de negocio sería tan erróneo como sacar de él el saldo de una cuenta. Esta lección va de dónde está exactamente esa frontera, porque es la línea que separa una arquitectura limpia de dos patologías simétricas: el ViewModel que sabe si un acordeón está abierto, y el componible que se guarda por su cuenta un dato que la app necesita para funcionar.
- Entender
remembercomo memoria de la composición y sus claves de invalidación. - Comprender qué añade
rememberSaveablefrente a la muerte del proceso y qué exige a cambio. - Aplicar un criterio operativo para clasificar un dato como estado de interfaz o estado de negocio.
- Reconocer las dos patologías simétricas: negocio en la composición y presentación en el modelo.
remember: memoria que dura lo que dura la composición
Un componible es una función que se reejecuta muchas veces, así que cualquier variable declarada en su cuerpo se recrea en cada recomposición. remember rompe esa amnesia: guarda el resultado del bloque en la ranura que ese punto del árbol ocupa en la composición, y lo devuelve tal cual en las siguientes ejecuciones. Combinado con mutableStateOf, que crea un contenedor observable, produce un valor que persiste entre recomposiciones y que además provoca la recomposición de quien lo lea cuando cambie.
@Composable
fun Acordeon(titulo: String, contenido: String) {
var expandido by remember { mutableStateOf(false) } // vive en la composicion
Column(Modifier.clickable { expandido = !expandido }) {
Text(titulo)
if (expandido) Text(contenido)
}
}
Conviene ser preciso sobre cuánto dura esa memoria, porque el nombre invita a confiarse. remember sobrevive a la recomposición y a nada más. Si el componible sale del árbol —porque se navegó a otra pantalla, porque una condición dejó de cumplirse, porque un elemento de una lista perezosa salió de la ventana visible— la ranura se descarta y el valor se pierde. Y sobre todo: no sobrevive a un cambio de configuración, porque una rotación destruye la Activity y con ella la composición entera. Un acordeón con remember se cierra al girar el teléfono.
Las claves son la segunda mitad del contrato y se pasan por alto con demasiada frecuencia. remember acepta argumentos que actúan como claves de identidad: mientras sean iguales, la memoria se conserva; en cuanto una cambia, el bloque se vuelve a ejecutar y el valor anterior se descarta. Olvidar la clave produce el bug de la memoria contaminada, donde el estado del elemento anterior se queda pegado al nuevo.
El caso canónico es una lista de tarjetas expandibles reutilizadas al desplazarse: si el estado de expansión se recuerda sin declarar el identificador del elemento como clave, la ranura se reutiliza y la tarjeta nueva aparece expandida porque la anterior lo estaba. La regla es directa: si el valor recordado depende conceptualmente de una entrada, esa entrada debe ir como clave. Y en listas perezosas, declara además una clave de elemento estable para que la identidad de las ranuras siga a los datos y no a la posición.
rememberSaveable: sobrevivir a la muerte del proceso
rememberSaveable hace lo mismo que remember y añade una capa: además de la ranura en la composición, guarda el valor en el mecanismo de estado guardado de la instancia, el mismo que Android usa para restaurar una Activity. Eso le permite sobrevivir a dos eventos que remember no aguanta: los cambios de configuración —rotación, tema oscuro, cambio de idioma, redimensionado en multiventana— y la muerte del proceso cuando el sistema recupera memoria con la app en segundo plano.
var expandido by rememberSaveable { mutableStateOf(false) } // aguanta rotacion y muerte de proceso
// Para tipos propios hace falta ensenarle a guardar y restaurar
val estadoFiltro by rememberSaveable(stateSaver = FiltroSaver) {
mutableStateOf(Filtro(texto = "", soloFavoritos = false))
}
El precio de esa supervivencia es una restricción de tipos: solo se guarda lo que sabe viajar en el paquete de estado de la instancia, es decir primitivos, cadenas y tipos que implementen los mecanismos de serialización de Android. Para lo demás hay que aportar un guardador que traduzca el objeto a una forma almacenable y de vuelta. Esa fricción es informativa: si te cuesta mucho hacer serializable un dato, probablemente ese dato no era estado de interfaz.
El segundo precio es de tamaño y no admite discusión. El paquete de estado guardado viaja por una transacción del sistema con un límite duro de unos cientos de kilobytes compartidos por toda la app; excederlo no degrada el rendimiento, hace caer el proceso. Nunca es el sitio para una lista descargada, un mapa de resultados o un objeto de dominio grande: eso se vuelve a obtener del repositorio, que para eso tiene caché.
remember
Memoria de la composición. Sobrevive a la recomposición, muere al salir del árbol y con cualquier cambio de configuración. Sin restricción de tipos y sin coste de serialización.
rememberSaveable
Memoria de la instancia. Aguanta rotación y muerte de proceso. Exige tipos almacenables o un guardador propio, y tiene un límite de tamaño estricto.
ViewModel
Memoria del ámbito de pantalla. Aguanta cambios de configuración pero no la muerte del proceso salvo que use el manejador de estado guardado. Es el sitio del estado de negocio.
Repositorio
Memoria del dominio. Fuente de verdad real y única capa que debe volver a producir los datos grandes tras una restauración.
La frontera: presentación contra negocio
Con las tres capas sobre la mesa, la pregunta operativa es en cuál poner cada dato. La prueba que resuelve casi todos los casos se formula así: si este valor desapareciera y la pantalla se reconstruyera desde el estado del modelo, ¿el usuario perdería algo que la app le prometió? Si la respuesta es no —se cierra un menú, se colapsa una tarjeta, se pierde una animación a medias— es estado de interfaz y su sitio es la composición. Si la respuesta es sí —se pierde un borrador escrito, un artículo del carrito, un paso de un formulario largo— es estado de negocio y su sitio es el modelo, por muy visual que parezca.
flowchart TD
D[Un dato de la pantalla] --> Q{Se pierde algo que el usuario espera si desaparece}
Q -->|No| UI[Estado de interfaz]
UI --> R1[remember si basta con la composicion]
UI --> R2[rememberSaveable si debe aguantar rotacion]
Q -->|Si| BIZ[Estado de negocio]
BIZ --> VM[Vive en el ViewModel y en el reductor]
style UI fill:#89b4fa,color:#11111b
style BIZ fill:#a6e3a1,color:#11111bHay casos fronterizos que la prueba resuelve mejor de lo que parece si se aplica con honestidad. El texto a medio escribir en un buscador es de interfaz mientras el usuario está en la pantalla y deja de serlo en cuanto el producto promete recordar la última búsqueda. La pestaña seleccionada es de interfaz salvo que forme parte de la ruta de navegación, en cuyo caso pertenece al estado de navegación y debe poder restaurarse. El desplazamiento de una lista es de interfaz siempre, aunque duela perderlo, y por eso las listas perezosas ofrecen su propio estado recordado y guardable en lugar de invitarte a subirlo al modelo.
// Interfaz con estructura pero sin negocio: un sosten de estado recordado
class FiltroUiState(inicial: Filtro) {
var abierto by mutableStateOf(false)
private set
var seleccion by mutableStateOf(inicial)
private set
fun alternar() { abierto = !abierto }
fun elegir(nuevo: Filtro) { seleccion = nuevo }
}
@Composable
fun rememberFiltroUiState(inicial: Filtro) = remember(inicial) { FiltroUiState(inicial) }
Las dos patologías que este criterio previene son simétricas y ambas duelen. La primera es el negocio escondido en la composición: un borrador de mensaje guardado con remember se evapora al navegar, no se puede probar sin renderizar la pantalla y no participa de las reglas del reductor. La segunda es la presentación colonizando el modelo: un menuAbierto en el estado del ViewModel obliga a emitir una intención por cada gesto trivial, ensucia el reductor con lógica que no es del dominio y arrastra a los tests de negocio detalles de la interfaz que cambiarán con el próximo rediseño.
Cuando un dato de interfaz es complejo pero sigue siendo de interfaz —el estado de desplazamiento de varias listas coordinadas, la posición de un carrusel con lógica propia— existe un punto intermedio: una clase de sostén de estado recordada en la composición, que agrupa varios remember y expone funciones con nombre. No es un ViewModel y no debe llevar reglas de negocio; es simplemente estado de interfaz con estructura, extraído del cuerpo del componible para que este siga leyéndose como una descripción.
El manejador de estado guardado: negocio que sobrevive al proceso
Falta cerrar un flanco. Si un dato es de negocio, su sitio es el modelo; pero el ViewModel sobrevive a los cambios de configuración y no a la muerte del proceso, así que un borrador confiado solo a él se pierde cuando el sistema recupera memoria con la app en segundo plano. La pieza que resuelve exactamente eso es el manejador de estado guardado que el ViewModel puede recibir: el mismo mecanismo de la instancia que usa rememberSaveable, pero accesible desde la capa que sí sabe de reglas de negocio.
class BorradorViewModel(
private val guardado: SavedStateHandle,
) : ContainerHost<BorradorState, BorradorSideEffect>, ViewModel() {
override val container = container<BorradorState, BorradorSideEffect>(
BorradorState(texto = guardado["texto"] ?: "")
)
fun escribir(nuevo: String) = intent {
guardado["texto"] = nuevo // sobrevive a la muerte del proceso
reduce { state.copy(texto = nuevo) }
}
}
Con esta pieza sobre la mesa la escalera queda completa y sin huecos: la composición para lo efímero, la instancia guardada para lo efímero que debe aguantar una rotación, el modelo para el negocio de la sesión, el modelo con estado guardado para el negocio que debe aguantar la muerte del proceso, y el repositorio para lo que debe aguantar el cierre de la app. Cada peldaño cuesta más que el anterior en complejidad y en límites, y por eso la respuesta correcta es siempre el peldaño más bajo que cumpla el requisito, nunca el más alto por si acaso.
Cuando el proceso muere y se restaura, la app vuelve con un estado reconstruido a partir de fragmentos, no con la sesión intacta. Eso significa que el estado guardado debe contener lo mínimo para reconstruir —identificadores, texto escrito, paso del formulario— y que todo lo demás se vuelve a pedir. Guardar el resultado completo de una consulta en lugar de su clave es el error típico: infla el paquete, se queda obsoleto y duplica una responsabilidad que ya tenía la caché del repositorio.
Casi todo el mundo elige entre remember, rememberSaveable y el ViewModel por criterios técnicos: cuánto dura cada uno, qué sobrevive a qué, qué se puede serializar. Son criterios correctos y son secundarios. El criterio primario es de responsabilidad, y se formula preguntando quién tendría que dar explicaciones si ese dato se perdiera. Si el usuario perdiera media hora de escritura, quien responde es el producto: ese dato era una promesa, y una promesa se custodia en la capa que sabe de promesas, que es el dominio. Si lo que se pierde es que un menú se cerró, no responde nadie, porque nadie prometió que un menú permaneciera abierto entre sesiones. Mirado así, la duración no es la causa de la decisión sino su consecuencia: el dato del que alguien responde vive donde vive la lógica, y la lógica ya tiene un sitio; el dato del que nadie responde puede vivir donde sea más barato, y lo más barato es la composición. Esto explica por qué las dos patologías son igual de graves aunque una parezca inofensiva. Guardar negocio en la composición es asumir una responsabilidad sin tener las herramientas para cumplirla: la composición no tiene reductor, ni tests, ni persistencia, ni forma de razonar sobre transiciones válidas. Subir presentación al modelo es lo contrario: obligar a la capa que responde de las promesas a cargar con datos de los que nadie responde, diluyendo qué es importante hasta que el estado de negocio deja de leerse como un contrato y pasa a leerse como un vertedero. La higiene arquitectónica no consiste en poner cada cosa donde quepa; consiste en que cada capa cargue exactamente con las responsabilidades que puede cumplir y ni una más. Y la buena noticia es que la pregunta se responde en cinco segundos, siempre, sin conocer una sola API: si esto se pierde, ¿a quién le reclama el usuario?
- Enumera todos los datos de una pantalla de composición de mensaje y clasifícalos con la prueba de la pérdida: interfaz o negocio.
- Escribe un componible con estado recordado dentro de una lista perezosa y provoca la contaminación de memoria al desplazarte; corrígela con la clave adecuada.
- Explica por qué un borrador guardado con
rememberes un error y qué exactamente se rompe al navegar fuera y volver. - Justifica por qué un dato grande no debe ir en
rememberSaveabley qué capa debe reproducirlo tras una restauración. - Escribe un guardador propio para un tipo compuesto y argumenta qué te dice sobre el dato el hecho de que serializarlo resulte incómodo.