El criterio: cuándo extraer un submódulo con estado y cuándo basta una función
Las cuatro lecciones anteriores han dejado dos herramientas sobre la mesa y una pregunta sin responder. La función de vista es barata, compone sin condiciones y cubre la inmensa mayoría de los casos; el submódulo con estado es caro en trámite pero indispensable cuando un fragmento tiene vida interior, efectos propios y varias instancias simultáneas. Falta el criterio que decide entre ambas, y ese criterio no puede ser una preferencia estética porque tiene consecuencias medibles sobre el tamaño del modelo, la profundidad del árbol de mensajes y el coste de cada cambio futuro. Esta lección propone una batería de preguntas ordenadas por poder discriminante, examina los tres casos en los que la respuesta es casi siempre la misma, expone las señales de que una decisión tomada hace meses ha envejecido mal, y describe cómo se revierte en cada dirección, porque un criterio que no contempla la marcha atrás no es un criterio sino una apuesta.
Los criterios de diseño se transmiten casi siempre en forma de máximas que suenan bien y no deciden nada: hazlo simple, no te repitas, separa responsabilidades. El problema de esas máximas es que son verdaderas en ambas direcciones, de modo que quien ya ha decidido encuentra en ellas la justificación de lo que iba a hacer de todos modos. Un criterio útil tiene otra forma: es una secuencia de preguntas con respuestas objetivas que puede contestar alguien que no participó en la decisión original, y cuyo resultado es a veces incómodo. Lo que sigue intenta ser un criterio de esa clase para la única decisión estructural que Elm te obliga a tomar de verdad en una aplicación grande. No hay muchas más, y esa escasez es una de las virtudes del lenguaje: no tendrás que elegir entre cinco maneras de gestionar el estado ni entre tres bibliotecas de efectos, pero sí tendrás que decidir, un número finito de veces, si esto es una función o si esto merece su propio bucle. Hacerlo con método y no por costumbre es lo que separa una base de código que crece bien de una que se convierte en una jerarquía de tuberías.
- Aplicar en orden las preguntas que discriminan entre una función de vista y un submódulo con estado propio.
- Distinguir el estado auténtico del estado derivado y del estado del dominio colocado en el sitio equivocado.
- Reconocer los tres casos canónicos en los que la respuesta está prácticamente determinada de antemano.
- Detectar una decisión envejecida y ejecutar la marcha atrás en cualquiera de las dos direcciones.
Las preguntas, en orden de poder
La primera pregunta descarta más casos que todas las demás juntas y es la más incómoda de contestar con honestidad: ese estado que crees local, ¿es realmente estado o es una función de algo que ya tienes? Un desplegable que se ve abierto cuando su identificador coincide con el seleccionado no tiene estado; una fila resaltada porque el puntero está encima tiene estado, pero es de la pantalla y no de la fila; un total que se recalcula al cambiar las cantidades no es estado en absoluto. Solo sobrevive a esta pregunta la información que no se puede derivar de nada, y suele ser mucha menos de la esperada.
Conviene ser explícito sobre por qué esta pregunta va primero y no en cualquier otro sitio. El estado derivado que se guarda como si fuera estado auténtico produce el peor defecto de todos, que es la posibilidad de desincronización: dos lugares que deberían decir lo mismo y que pueden dejar de decirlo si alguien olvida actualizar uno de ellos. Ese defecto no lo detecta el compilador, porque ambos campos son perfectamente válidos por separado, y se manifiesta como un error intermitente que nadie reproduce. Derivar en vez de almacenar hace la incoherencia inexpresable, y esa garantía vale más que cualquier ahorro de cómputo, entre otras cosas porque el ahorro de cómputo suele ser imaginario a la escala de una interfaz.
-- Estado duplicado: total puede quedar desincronizado de lineas
type alias Model =
{ lineas : List Linea, total : Centimos }
-- Derivado: la incoherencia deja de poder escribirse
type alias Model =
{ lineas : List Linea }
total : Model -> Centimos
total model =
List.foldl (\linea acumulado -> acumulado + Linea.importe linea) 0 model.lineas
La segunda pregunta se aplica a lo que sobrevivió: ¿el padre necesita leer o escribir ese estado? Si lo necesita con frecuencia, encapsularlo obliga a abrir ventanas en el módulo hijo hasta que la encapsulación deja de existir, y sale más barato que el estado viva en el modelo del padre desde el principio. La tercera pregunta indaga en la autonomía: ¿ese fragmento dispara efectos por su cuenta o mantiene suscripciones propias? Un comando pendiente y una suscripción activa son justamente lo que un submódulo sabe gestionar y lo que un campo suelto en el modelo raíz gestiona mal, porque obliga al update central a conocer detalles de un proceso que no le concierne. La cuarta pregunta es la de la multiplicidad: ¿habrá dos o más instancias simultáneas con estados independientes? Si la respuesta es sí, el submódulo se paga solo, porque la alternativa es un diccionario de estados en el modelo raíz y una identificación manual en cada mensaje.
1. ¿Es estado o es derivado?
Si se puede calcular a partir de lo que ya tienes, no es estado. Una función basta y sobra.
2. ¿El padre lo consulta?
Si el padre lee o escribe ese estado a menudo, encapsularlo solo añade ventanas. Súbelo al modelo.
3. ¿Tiene efectos propios?
Comandos y suscripciones que nacen y mueren dentro del fragmento son el argumento más fuerte a favor del submódulo.
4. ¿Habrá varias instancias?
Dos copias vivas con estados independientes convierten el trámite del embebido en la opción barata.
Los tres casos canónicos
Los casos canónicos existen porque el mismo problema reaparece con formas distintas, y tener el veredicto memorizado ahorra deliberaciones que ya se hicieron mil veces en la comunidad. Conviene tratarlos como jurisprudencia y no como dogma: si tu caso se parece a uno de los tres en las cuatro respuestas, hereda su veredicto; si difiere en alguna, vuelve a las preguntas.
Hay tres situaciones que aparecen en casi toda aplicación grande y cuyo veredicto conviene memorizar para no volver a deliberar sobre ellas. La primera es la página de un enrutador, y es el caso donde el submódulo con estado no solo se justifica sino que resulta natural: cada página tiene su propio ciclo de carga, sus peticiones, su formulario a medio rellenar y su vocabulario de mensajes, solo hay una viva a la vez y la frontera coincide exactamente con una frontera del dominio que ya existía, la de las direcciones del sitio. La segunda es el control complejo y reutilizable, del tipo selector de fecha con navegación de calendario o campo con sugerencias que consulta al servidor: tiene efectos propios, tiene estado que al padre no le interesa y suele instanciarse más de una vez, así que cumple tres de las cuatro preguntas y merece su módulo.
El caso de las páginas merece un desarrollo algo mayor porque es el único en el que la anidación se recomienda sin reservas y conviene entender exactamente por qué. La clave está en que la frontera no la inventa el programador: viene dada por las direcciones del sitio, que ya son una partición del dominio, y esa partición cumple una propiedad que ninguna otra suele cumplir, la exclusividad. Solo hay una página viva a la vez, de modo que la unión etiquetada que representa la página actual hace imposible que dos estados de pantalla coexistan, y el compilador impide siquiera escribir código que consulte el estado de una página que no está activa. Además, el estado de una página muere de forma natural al navegar, con lo cual no hay que decidir cuándo limpiarlo. Es difícil encontrar una frontera mejor justificada, y por eso el enrutador con una variante por página es prácticamente la única estructura anidada que aparece en aplicaciones grandes bien escritas.
La tercera es la más frecuente y su veredicto es el contrario. El fragmento visual que se repite, por elaborado que sea, casi nunca necesita estado: una tabla ordenable, una tarjeta, un panel plegable, un formulario entero. Todos ellos son funciones de vista con parámetros y constructores de mensaje prestados, y darles un bucle propio produce el peor resultado posible, que es un submódulo cuyo update no hace más que guardar en su modelo lo que el padre necesitará leer inmediatamente después.
-- Veredicto: funcion. El orden lo decide y lo guarda el padre.
verTabla :
{ columnas : List (Columna a)
, filas : List a
, orden : Orden
, alOrdenar : Columna a -> msg
}
-> Html msg
-- Veredicto: submodulo. Estado propio, peticiones propias, dos instancias.
module Autocompletar exposing (Model, Msg, Evento, init, update, view, subscriptions)
-- Veredicto: submodulo por pagina, con la union como enrutador
type Pagina
= Inicio Inicio.Model
| Detalle Detalle.Model
| NoEncontrada
Existe una forma concreta de fracaso que conviene saber nombrar. Un submódulo degenerado es aquel cuyo update solo contiene variantes que asignan un campo, cuya vista se llama siempre desde un único sitio y cuyo estado el padre consulta en el mismo ciclo en que se produjo. Se reconoce porque el módulo exporta funciones de consulta para casi todos sus campos, señal inequívoca de que la encapsulación es nominal. Ese módulo no protege ninguna invariante, no aporta reutilización y cobra el peaje completo del embebido en cada mensaje. Disolverlo en el padre suele eliminar entre un tercio y la mitad de sus líneas sin perder nada.
Las preguntas están ordenadas y se aplican en cascada, no se suman. Un fragmento que falla la primera no llega a la segunda por muchos efectos que parezca tener, porque si su estado es derivado no hay nada que encapsular. Y un fragmento que llega hasta la cuarta y responde que no habrá varias instancias todavía puede quedarse como un campo del modelo del padre con sus funciones auxiliares, que es la opción intermedia. Solo el que cruza las cuatro obtiene su propio bucle. Aplicado con esta severidad, el criterio deja pasar muy pocos candidatos, y esa es exactamente la proporción que se observa en las aplicaciones grandes que envejecen bien.
Revertir en las dos direcciones
Un criterio serio incluye el procedimiento de vuelta, porque toda decisión de este tipo se toma con información incompleta y algunas envejecen mal. La conversión de submódulo a función es la más sencilla y casi siempre reduce código: se suben los campos del modelo del hijo al del padre, se aplanan sus variantes de mensaje en el vocabulario del padre eliminando el constructor envolvente, se retiran las llamadas de traducción y la vista pasa a recibir por parámetro lo que antes leía de su propio modelo. Las señales de que ha llegado el momento son las del recuadro anterior, más una adicional bastante fiable: que el tipo de evento que el hijo devuelve al padre haya crecido hasta tener casi tantas variantes como su tipo de mensajes, lo que significa que ya no encapsula nada porque cuenta hacia arriba todo lo que le ocurre.
Hay además una tercera opción intermedia que el criterio no debe olvidar y que resuelve muchos casos dudosos sin comprometerse con ninguno de los dos extremos: agrupar el estado del fragmento en un registro dentro del modelo del padre, con sus funciones auxiliares en un módulo aparte, pero sin darle mensajes ni actualización propios. El padre conserva el control del bucle, la vista sigue siendo una función y, sin embargo, el estado queda nombrado y agrupado en lugar de disperso entre quince campos sueltos. Es la forma más barata de ordenar un modelo que ha crecido, y es reversible en las dos direcciones casi sin coste, lo que la convierte en el primer movimiento recomendable cuando la duda persiste.
La conversión inversa, de función a submódulo, cuesta más y conviene hacerla en dos pasos para no romperlo todo a la vez. Primero se agrupan en un registro los campos del modelo del padre que pertenecen a ese fragmento y se agrupan sus mensajes bajo un único constructor, sin mover todavía nada de archivo; el programa sigue funcionando y el modelo empieza a tener la forma del futuro módulo. Solo cuando esa agrupación se ha estabilizado se mueve a su propio archivo, se cierra el tipo y se añade el trámite de traducción. Hacerlo en un solo salto suele producir un módulo con la lista de exportación demasiado abierta, porque se escribe bajo la presión de que el padre siga compilando.
-- Paso intermedio: agrupado y nombrado, sin bucle propio
type alias EstadoFiltro =
{ texto : String, soloActivos : Bool, orden : Orden }
type alias Model =
{ pedidos : List Pedido, filtro : EstadoFiltro }
-- Funciones auxiliares en su modulo, sin Msg ni update
aplicar : EstadoFiltro -> List Pedido -> List Pedido
flowchart TD
A[Fragmento candidato] --> B{Es estado real}
B -->|No, es derivado| F[Funcion de vista]
B -->|Si| C{El padre lo consulta}
C -->|A menudo| G[Campo en el modelo del padre]
C -->|Casi nunca| D{Tiene efectos propios}
D -->|No| G
D -->|Si| E{Varias instancias}
E -->|No| G
E -->|Si| S[Submodulo con estado]
style F fill:#a6e3a1,color:#11111b
style G fill:#89b4fa,color:#11111b
style S fill:#cba6f7,color:#11111bHay una manera de entender todo este nivel que reordena el resto y conviene enunciarla al final, cuando ya hay material para sostenerla. La pregunta que llevamos cuatro lecciones respondiendo no es cuál es la arquitectura correcta de una aplicación, porque esa pregunta no tiene respuesta estable: la arquitectura correcta depende de un dominio que todavía estás aprendiendo y que cambiará bajo tus pies. La pregunta real es cuál de las estructuras posibles es más barata de abandonar cuando descubras que te equivocaste, y ahí la asimetría es brutal y casi siempre se ignora. Convertir una función en un módulo con estado es una operación local y mecánica que un compilador te guía paso a paso, porque el compilador conoce todos los sitios donde se llamaba y te los enseña uno a uno. Convertir un módulo con estado en una función implica deshacer una jerarquía de mensajes, redistribuir estado entre niveles y renegociar los contratos de todo lo que colgaba de él, y aunque el compilador también te acompañe, el número de decisiones humanas es de otro orden. Cuando dos opciones parecen igual de razonables, esa asimetría desempata sin necesidad de adivinar el futuro: elige la que se deshace más barato, que es siempre la menos estructurada. Este razonamiento tiene un nombre en el diseño de sistemas, que es preferir las decisiones reversibles a las óptimas, y una consecuencia que va mucho más allá de Elm: la calidad de una arquitectura no se mide por lo bien que encaja hoy con el problema que conoces, sino por lo poco que te penaliza el día en que el problema resulte ser otro. Todo el rechazo de la comunidad a los componentes, la insistencia en dejar crecer los archivos, la preferencia por funciones y la prohibición de ciclos son manifestaciones distintas de la misma idea, y quien la interioriza deja de necesitar las reglas porque puede derivarlas.
- Elige cinco fragmentos de tu aplicación y pásales las cuatro preguntas por orden, anotando en cuál se detiene cada uno.
- Demuestra, para al menos dos de ellos, que lo que parecía estado local es derivable de datos que ya están en el modelo.
- Busca un submódulo degenerado según los tres síntomas descritos y estima cuántas líneas desaparecerían al disolverlo.
- Ejecuta esa disolución sobre el candidato más claro y compara el resultado en líneas, en profundidad de mensajes y en número de archivos.
- Toma una función de vista que sí merezca convertirse en submódulo y haz solo el primer paso: agrupar campos y mensajes sin mover archivos.
- Aplica el paso intermedio a un modelo que tenga más de diez campos: agrupa los que van juntos y ponles nombre.
- Escribe para tu equipo la versión de una página de este criterio, con los tres casos canónicos y las señales de marcha atrás, y contrástala con una decisión reciente.