State hoisting: componibles sin estado
Izar el estado es el patrón que convierte una función componible en una pieza reutilizable, probable y previsualizable: se le quita la memoria propia y se le entrega el valor desde arriba junto con una devolución de llamada para pedir el cambio. Esta lección desarrolla la mecánica del par valor y evento, la asimetría deliberada entre leer hacia abajo y notificar hacia arriba, el criterio para decidir hasta qué altura izar cada dato —el propietario común más bajo que lo necesita—, la diferencia entre componibles con estado y sin estado como frontera de responsabilidades, y el problema del salto de valor en campos de texto cuando el circuito de ida y vuelta cruza un límite asíncrono. El resultado es una jerarquía donde el estado desciende y los eventos ascienden, que es literalmente la forma de MVI dibujada sobre el árbol de la interfaz.
Un componible que guarda su propio estado es cómodo hasta el día en que alguien de fuera necesita saber qué contiene, o fijarlo, o restaurarlo, o probarlo. Ese día descubres que la comodidad tenía precio: el dato estaba encerrado donde nadie más lo alcanza, y la única salida es abrir un agujero en la encapsulación o duplicar la verdad. Izar el estado es la maniobra que evita llegar a ese día. Consiste en sacar el dato del componible y subirlo a quien lo necesita, dejando abajo una función que no recuerda nada: recibe lo que debe mostrar y avisa de lo que el usuario hizo. Lo llamativo es que, aplicada sistemáticamente, esta operación puramente local acaba dibujando sobre el árbol de la interfaz la misma silueta que MVI dibuja sobre la app entera.
- Aplicar el patrón de izado como el par formado por un valor que baja y un evento que sube.
- Distinguir componibles con estado y sin estado, y saber qué responsabilidad tiene cada uno.
- Elegir la altura correcta del izado con el criterio del propietario común más bajo.
- Reconocer el salto de valor en un campo de texto cuando el circuito de ida y vuelta cruza un límite asíncrono.
El par valor y evento
La transformación es mecánica y siempre igual. Un componible con memoria propia guarda el dato y lo modifica por su cuenta; el mismo componible izado recibe el dato como parámetro y recibe también una función a la que llamar cuando el usuario pide un cambio. No cambia el valor: pide que lo cambien. Quien decide si el cambio ocurre, y cómo, es quien está arriba.
// Con estado: recuerda por dentro, nadie de fuera puede leerlo ni fijarlo
@Composable
fun ContadorConEstado() {
var valor by remember { mutableStateOf(0) }
Button(onClick = { valor++ }) { Text("Van $valor") }
}
// Izado: sin memoria propia, el valor baja y el evento sube
@Composable
fun Contador(valor: Int, onIncrementar: () -> Unit) {
Button(onClick = onIncrementar) { Text("Van $valor") }
}
La segunda firma dice mucho más de lo que parece. Al no tener memoria, la función es determinística: el mismo valor produce siempre la misma pantalla, así que se puede previsualizar con datos inventados y probar sin montar nada. Al no decidir el incremento, no impone una política: quien la use puede sumar de uno en uno, de dos en dos, negarse a pasar de cien o registrar cada pulsación antes de aplicarla. Y al no guardar nada, no puede desincronizarse de la verdad de arriba, porque no tiene una verdad propia con la que discrepar.
La asimetría del par es deliberada y conviene nombrarla. El valor viaja hacia abajo como dato ya decidido; el evento viaja hacia arriba como petición sin decidir. Nunca al revés: el componible de abajo no recibe permiso para escribir en el estado de arriba, solo para notificar. Esa direccionalidad es lo que hace que el flujo sea trazable, porque para saber quién cambió un dato basta con mirar hacia arriba desde donde se leyó, no buscar en todo el árbol quién tenía una referencia mutable.
Con estado y sin estado: dos oficios distintos
De aquí sale una división de trabajo que estructura pantallas enteras. Los componibles sin estado son la mayoría y forman las hojas y las ramas: no recuerdan nada, solo describen. Los componibles con estado son pocos, viven arriba y su único oficio es sostener el dato y conectar cables. Un componible con estado bien escrito casi no dibuja: obtiene el estado, lo pasa hacia abajo y traduce las devoluciones de llamada en intenciones.
// Con estado: solo sostiene y conecta. Es el unico que conoce el ViewModel
@Composable
fun PerfilRoute(viewModel: PerfilViewModel) {
val estado by viewModel.container.stateFlow.collectAsStateWithLifecycle()
PerfilScreen(
estado = estado,
onGuardar = viewModel::guardar,
onNombreCambiado = viewModel::cambiarNombre,
)
}
// Sin estado: no sabe que existe un ViewModel. Se prueba y se previsualiza sola
@Composable
fun PerfilScreen(
estado: PerfilState,
onGuardar: () -> Unit,
onNombreCambiado: (String) -> Unit,
) { /* solo describe */ }
Esta pareja resuelve un problema real de acoplamiento. Si el ViewModel entrara hasta las hojas, cada componible quedaría atado a una arquitectura concreta, a un mecanismo de inyección y a un ciclo de vida, y no se podría reutilizar en otra pantalla ni previsualizar sin montar la mitad de la app. Concentrando ese conocimiento en un único punto de entrada, todo lo que hay debajo se vuelve portátil: la misma PerfilScreen sirve para una pantalla, para un panel dentro de otra o para una prueba de captura visual.
Una heurística que detecta problemas de diseño en diez segundos: cuenta cuántas funciones de una pantalla reciben el ViewModel como parámetro. Si la respuesta es una, el izado está bien hecho. Si son cinco, tienes cinco puntos de acoplamiento a la arquitectura, cinco funciones que no se pueden previsualizar y cinco lugares donde el estado puede leerse por caminos distintos.
Hasta dónde izar: el propietario común más bajo
Izar no es una virtud que se maximice. Subir todo el estado hasta la raíz produce firmas gigantes, recomposiciones innecesarias y componentes que no se pueden usar sueltos. La regla es izar hasta el nodo más bajo del árbol que sea antepasado de todos los que necesitan ese dato, y ni un nivel más: si dos hermanos deben coordinarse, el estado sube al padre; si solo uno lo usa y nadie más lo observa, se queda donde está.
flowchart TD VM[ViewModel con el estado de negocio] --> RT[Route con estado que conecta cables] RT --> SC[Screen sin estado] SC --> A[Cabecera sin estado] SC --> B[Formulario sin estado] SC --> C[Lista sin estado] A -.evento sube.-> RT B -.evento sube.-> RT C -.evento sube.-> RT style VM fill:#a6e3a1,color:#11111b style RT fill:#89b4fa,color:#11111b
La consecuencia inmediata de aplicar bien el criterio es que la mayoría del estado no llega al ViewModel: se queda en el nodo intermedio que lo necesita, y solo sube lo que el dominio reclama. Un componente autónomo puede incluso ofrecer las dos caras, una versión con memoria propia para el uso cómodo y otra izada para cuando alguien de fuera necesita gobernar el valor, que es exactamente el reparto que hacen los componentes de la propia biblioteca.
// Version comoda: sostiene el estado por defecto y delega en la izada
@Composable
fun Buscador(alBuscar: (String) -> Unit) {
var texto by rememberSaveable { mutableStateOf("") }
Buscador(texto = texto, onTextoCambiado = { texto = it }, alBuscar = alBuscar)
}
// Version izada: sin memoria, gobernable desde fuera
@Composable
fun Buscador(texto: String, onTextoCambiado: (String) -> Unit, alBuscar: (String) -> Unit)
Hay un caso donde el izado hasta el ViewModel sale caro y hay que saber verlo. Cuando el dato cambia a cada pulsación de tecla y el circuito de ida y vuelta pasa por un límite asíncrono, el texto que el usuario escribe y el texto que la pantalla muestra pueden desfasarse: la letra se teclea, el evento sube, el estado tarda, y mientras tanto el campo se recompone con el valor viejo. El síntoma es el cursor que salta o las letras que se comen. La causa no es el izado en sí, sino haber puesto una cola asíncrona en un bucle que el usuario percibe como instantáneo.
Un TextField cuyo valor viaja al ViewModel y vuelve por un StateFlow es correcto siempre que el reductor sea síncrono y no haya operadores que retrasen, filtren o descarten emisiones en medio. En cuanto metes un debounce, un cambio de dispatcher o una validación asíncrona en ese camino, el desfase aparece. La solución no es abandonar el izado: es izar el texto crudo a un estado local de la composición y enviar hacia arriba solo el valor ya estabilizado, dejando que el ViewModel gobierne la validación y no el cursor.
Muchas devoluciones de llamada o una sola intención
Hay una decisión de diseño que aparece en cuanto una pantalla crece y que divide opiniones con razón. Un componible izado puede recibir una función por cada gesto —guardar, cancelar, cambiar el nombre, alternar el aviso— o una sola función que acepta un tipo cerrado de intenciones. Las dos son válidas y no son intercambiables: cambian qué sabe la pantalla y cuánto cuesta añadir un gesto nuevo.
// A) Una devolución de llamada por gesto: firma explícita, crece con la pantalla
@Composable
fun AjustesScreen(
estado: AjustesState,
onGuardar: () -> Unit,
onNombreCambiado: (String) -> Unit,
onAvisosAlternados: (Boolean) -> Unit,
)
// B) Una sola puerta de entrada tipada: firma estable, acopla al vocabulario del feature
@Composable
fun AjustesScreen(estado: AjustesState, onIntent: (AjustesIntent) -> Unit)
La forma con funciones separadas es la más neutral y la que conviene a los componentes reutilizables: un botón, un campo, una tarjeta no deben conocer el vocabulario de intenciones de ningún feature, porque entonces dejan de servir para otro. La forma con una sola puerta encaja mejor en el componible de más arriba de un feature, donde ya estás dentro de ese vocabulario y donde añadir un gesto no debería obligar a tocar cinco firmas intermedias. La regla práctica que se deduce es de altura: intenciones tipadas arriba, funciones anónimas abajo, y el punto de traducción entre ambas justo en el componible que ya conocía el feature.
Pasar una función escrita en el sitio como parámetro puede hacer inestable al componible receptor si no se estabiliza, porque cada recomposición crea una instancia nueva y la comparación falla. Con referencias a métodos del ViewModel esto no ocurre, y con una sola función de intención tampoco si se recuerda. El detalle importa poco en una pantalla pequeña y mucho en una lista con cientos de filas, donde una función inestable pasada a cada fila impide saltarse ninguna.
Detente en la coincidencia, porque no es una analogía bonita sino una identidad estructural. Cuando izas el estado de un componible obtienes exactamente dos cosas: un valor que desciende y un evento que asciende. Cuando dibujas MVI obtienes exactamente dos cosas: un estado que la vista lee y una intención que la vista emite. Son la misma figura a dos escalas distintas, y esa autosemejanza tiene una consecuencia práctica enorme. Significa que no hay dos arquitecturas que aprender —una para organizar tu app y otra para organizar tus componibles— sino una sola regla que se aplica recursivamente desde el ViewModel hasta el botón más profundo: el estado desciende, los eventos ascienden, nadie escribe hacia abajo y nadie lee hacia arriba. Un sistema con esa propiedad se razona en cualquier punto sin tener el resto en la cabeza, porque cada nivel es un ejemplar del mismo patrón. Y aquí está lo que casi nadie dice: la razón por la que esta simetría es posible es que el estado no es de quien lo dibuja, es de quien lo decide. La intuición del programador imperativo es la contraria —el widget que muestra el texto es dueño del texto— y de ahí nacen todos los problemas de sincronización: dos dueños del mismo dato con dos copias que hay que mantener iguales. Izar el estado no es una técnica de refactorización; es corregir una idea equivocada sobre la propiedad. Una vez asumes que un componible es una descripción y no un poseedor, izar deja de ser un patrón que recuerdas y pasa a ser la única forma en que sabes escribir la interfaz. Y entonces MVI ya no te parece una arquitectura que alguien te impone desde arriba: te parece la consecuencia inevitable de haber ordenado bien quién posee qué.
- Toma un componible tuyo con memoria propia e ízalo, escribiendo la firma resultante con el par valor y evento.
- Separa una pantalla en una función con estado que solo conecta cables y una sin estado que solo describe; comprueba que la segunda se previsualiza sin dependencias.
- Dado un dato que necesitan dos componibles hermanos, identifica su propietario común más bajo y justifica por qué no debe subir más.
- Explica qué se pierde exactamente al izar todo el estado hasta la raíz de la pantalla, en firmas y en recomposiciones.
- Reproduce el salto de cursor en un campo de texto metiendo un
debounceen el camino de vuelta y describe la corrección sin renunciar al izado.