Pureza total: update no tiene efectos secundarios
La pieza update de TEA es una función matemática: de (Model, Msg) sale un Model y nada más; mismos argumentos, mismo resultado, sin tocar el mundo. Esta lección defiende esa pureza en su forma fuerte y luego resuelve la paradoja obvia: si update no puede hacer HTTP, tiempo ni azar, ¿cómo hace una app real algo útil? La respuesta de Elm es tratar los efectos como datos: update devuelve (Model, Cmd Msg), donde Cmd es una DESCRIPCIÓN de un efecto que el runtime ejecuta y cuyo resultado vuelve como otro Msg. Con analogías al IO de Haskell, la lección muestra por qué los efectos como valores mantienen update pura y por qué esa pureza es lo que compra el testing trivial, la reproducibilidad y el viaje en el tiempo.
De las cuatro piezas, update es la que carga con el compromiso más radical de todo el patrón: es una función matemática. Le das un Msg y un Model, y te devuelve un Model; con los mismos argumentos, siempre el mismo resultado, y por el camino no toca absolutamente nada del mundo exterior. No lee el reloj, no lanza un dado, no llama a una API, no escribe en disco, no imprime un log con consecuencias. Dicho así, suena bonito y también imposible: una aplicación que no puede pedir datos ni saber la hora no sirve para nada. La genialidad de Elm no está en negar los efectos, sino en un truco conceptual que los reconcilia con la pureza —tratarlos como datos— y ese truco es el que hay que entender hoy, porque es exactamente lo que Redux, TCA y MVI copiarían después.
- Enunciar la pureza de
updateen su forma fuerte: determinismo y ausencia de efectos. - Reconocer la paradoja: una función pura no puede, por sí sola, hacer HTTP, tiempo ni azar.
- Entender la solución de Elm: los efectos como datos,
updatedevuelve(Model, Cmd Msg). - Conectar la pureza con el testing trivial, la reproducibilidad y el viaje en el tiempo.
La forma fuerte de la pureza
Que update sea pura significa dos cosas a la vez, y basta romper una para perder las garantías. Determinismo: para el mismo par de entradas produce siempre la misma salida; nada de Date.now, nada de aleatoriedad, nada de leer variables externas que puedan cambiar. Ausencia de efectos: no provoca nada observable fuera de su valor de retorno; no manda peticiones, no escribe en almacenamiento, no dispara otras funciones con consecuencias. Todo lo que necesita entra por sus dos parámetros; todo lo que produce sale por su return.
-- Una funcion matematica: (Msg, Model) determina el Model, y nada mas
update : Msg -> Model -> Model
update msg model =
case msg of
Incrementar ->
{ model | cuenta = model.cuenta + 1 }
Reiniciar ->
{ model | cuenta = 0 }
En Haskell, de donde Elm hereda su linaje, esta idea se lee directamente en el tipo: una función cuya firma no menciona IO no puede, por construcción del lenguaje, tener efectos. El tipo es la promesa, y el compilador es quien la hace cumplir.
-- Sin IO en la firma: el tipo GARANTIZA que no hay efectos ocultos
update :: Msg -> Model -> Model
-- Con IO en la firma: el tipo CONFIESA que aqui puede pasar cualquier cosa
cargarDatos :: IO Model
La paradoja y su resolución: efectos como datos
Aquí surge la objeción evidente. Si update no puede hacer HTTP, ni leer el reloj, ni pedir un número al azar, ¿cómo carga una app real sus datos del servidor? La respuesta de Elm es una de las ideas más elegantes de la programación de interfaces: update no ejecuta el efecto, sino que devuelve una descripción del efecto que quiere que ocurra, y deja que el runtime —la parte impura— lo ejecute por ella.
Para eso, la firma de update crece un poco. En una app con efectos usamos Browser.element, y update pasa a devolver una tupla: el Model nuevo y un Cmd Msg, donde Cmd es un dato inerte que describe “quiero que hagas esta petición y, cuando termine, me avises con este Msg”.
type Estado
= Inicial
| Cargando
| Listo String
| Fallo
type Msg
= Pedir
| Recibido (Result Http.Error String)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Pedir ->
-- update NO hace la peticion: devuelve un DATO que la describe
( { model | estado = Cargando }
, Http.get
{ url = "/api/frase"
, expect = Http.expectString Recibido
}
)
Recibido (Ok texto) ->
( { model | estado = Listo texto }, Cmd.none )
Recibido (Err _) ->
( { model | estado = Fallo }, Cmd.none )
Sigue todo el hilo, porque es el corazón del asunto. En la rama Pedir, update no toca la red: construye un valor Cmd que la describe y lo devuelve junto al modelo Cargando. update termina y sigue siendo pura: le diste Pedir y un modelo, te devolvió un modelo nuevo y una descripción, sin haber tocado nada. Entonces el runtime toma ese Cmd, ejecuta la petición de verdad en el mundo impuro, y cuando la respuesta llega, la envuelve en un Msg Recibido y la mete por el principio del bucle. update se ejecuta otra vez, ahora con datos ya presentes en el mensaje, y vuelve a ser una función pura de sus entradas.
flowchart LR U[update puro] -->|devuelve Cmd como dato| R[runtime impuro] R -->|ejecuta el efecto en el mundo| W[HTTP tiempo azar] W -->|resultado| R R -->|lo envuelve en un Msg| U style U fill:#a6e3a1,color:#11111b style R fill:#cba6f7,color:#11111b style W fill:#f38ba8,color:#11111b
La misma idea gobierna Sub para los efectos entrantes continuos —el tic de un reloj, un websocket, el teclado—: no los escuchas tú, declaras una suscripción como dato y el runtime te manda un Msg cada vez que ocurre algo. El patrón es idéntico: tú describes, el runtime ejecuta.
Igual que Msg convierte “algo pasó” en un dato que puedes inspeccionar, Cmd convierte “quiero que algo pase” en un dato que puedes inspeccionar, y Sub hace lo mismo con “avísame cuando pase esto”. Los tres mueven el mundo real al borde del sistema y lo representan como valores en el centro. Esta es la esencia de los efectos gestionados: el efecto se declara en la zona pura y se ejecuta en la zona impura, y la frontera entre ambas es nítida y visible en los tipos.
Lo que la pureza compra
La pureza de update no es una exigencia estética; es una inversión que paga dividendos concretos. Testing trivial: para probar update le pasas un Msg y un Model y compruebas el Model que sale; no hay red que simular, ni reloj que congelar, ni mocks que montar, porque no hay nada externo de lo que update dependa. Reproducibilidad: como update es determinista y los Msg son datos, grabar la lista de mensajes de una sesión y reaplicarla reconstruye exactamente el mismo estado, siempre. Viaje en el tiempo: de esa reproducibilidad nace el depurador que rebobina y avanza por los estados —Elm lo tuvo antes que nadie, y de él salió, reconocido por su autor, el time-travel de las Redux DevTools—.
-- Un test de update es aritmetica: sin mocks, sin setup, sin mundo
prueba : Bool
prueba =
update Incrementar { cuenta = 0 } == { cuenta = 1 }
Todas estas capacidades cuelgan del mismo clavo. Si update llamara a una API o leyera la hora, reproducir el historial daría resultados distintos cada vez —la API responde otra cosa, el reloj avanza—, el test necesitaría simular medio mundo y el viaje en el tiempo sería ficción. Se compran a la vez, con una sola moneda: mantener update puro y empujar los efectos al borde.
Vale la pena subrayar que ninguna de estas tres ventajas es una funcionalidad que Elm programó aparte. Nadie escribió un módulo de “testing trivial” ni un motor de “viaje en el tiempo” como características añadidas; las tres son consecuencias que caen solas en cuanto update es una función matemática. Esta es la diferencia entre una propiedad emergente y una prestación: una prestación se construye y se mantiene, una propiedad emergente simplemente ocurre porque las piezas cumplen ciertas leyes. Cuando alguien te ofrezca time-travel como un extra vistoso de su librería, la pregunta correcta no es cómo lo implementaron, sino si su transición es realmente pura y sus mensajes realmente datos; si no lo son, el extra será frágil, y si lo son, casi no habrá hecho falta implementarlo.
El error de principiante al oír “update no puede tener efectos” es concluir que Elm es un lenguaje para juguetes, incapaz de hablar con el mundo. La verdad es la opuesta y mucho más honda: Elm no elimina los efectos, los reubica. En un programa imperativo típico, un efecto puede ocurrir en cualquier línea de cualquier función, así que el poder de tocar el mundo está repartido por todo el código y, por tanto, escondido en todas partes; para saber qué hace una función tienes que leerla entera y aun así puede sorprenderte. TEA parte el sistema en dos regiones con leyes distintas: un centro puro, determinista y comprobable —Model, update, view—, donde vive toda la lógica de cómo cambia el estado, y un borde impuro —el runtime— donde y solo donde ocurren la red, el tiempo, el azar y el disco. La frontera entre ambas regiones no está difusa ni depende de tu disciplina: está escrita en los tipos, porque un efecto solo puede cruzar como un Cmd o un Sub, es decir, como un dato. Esto invierte la relación habitual con los efectos: dejan de ser acciones ocultas que ejecutas y se vuelven valores explícitos que describes, y describir es infinitamente más manejable que ejecutar, porque un valor se puede inspeccionar, comparar, registrar, testear y reproducir, mientras que una acción ya ejecutada se ha perdido para siempre. La misma estructura reaparece con otros nombres en cada heredero: los Cmd de Elm son el tipo Effect de TCA, son el sitio adonde Redux destierra los efectos vía middleware porque su reducer, como update, debe permanecer puro. Quien entiende esto deja de ver la prohibición de efectos como una jaula y empieza a verla como lo que es: la frontera que mantiene sano el centro, la razón por la que puedes confiar en que update no te miente sobre el pasado —no puede, no tiene manera de tocarlo—, y ese es exactamente el poder que compras cada vez que aceptas que una función devuelva la descripción de un efecto en lugar de ejecutarlo.
- Escribe un
updatepuro que solo transforme un contador y demuéstralo con un test que sea una simple igualdad, sin ningún mock ni preparación. - Añade una carga de datos con
Http.get. Verifica que la rama que la lanza no ejecuta la petición, sino que devuelve unCmdcomo dato, y que la respuesta vuelve como unMsgnuevo. - Mete a propósito un
Date.nowconceptual dentro deupdatey razona qué le pasaría a la reproducibilidad si reaplicaras el historial de mensajes dos veces. Luego sácalo al borde como un efecto. - Escribe la firma de
updatecon y sin efectos (Msg -> Model -> Modelfrente aMsg -> Model -> (Model, Cmd Msg)) y explica qué gana el sistema al hacer visible en el tipo la posibilidad de un efecto. - Dibuja las dos regiones —centro puro y borde impuro— y coloca en cada una:
Model,update,view, la petición HTTP real, el reloj y el runtime. Marca por dónde cruzan losCmdy losSub.