Redux: lo que Dan Abramov tomó de Elm y lo que decidió cambiar
Redux es la vía por la que la mayoría de los programadores de interfaz conocieron la Arquitectura Elm sin enterarse de que la estaban conociendo. En julio de 2015 Dan Abramov presentó una biblioteca minúscula que resolvía un problema muy concreto de la comunidad de React, la recarga en caliente y la depuración con viaje en el tiempo, y para resolverlo importó dos restricciones que venían directamente del mundo de Elm: un único árbol de estado y una transición pura que va de estado y acción a estado. Esta lección reconstruye ese préstamo con precisión de inventario. Distingue qué se copió literalmente, qué se tradujo con pérdidas por no existir un sistema de tipos que lo sostuviera y, sobre todo, qué se quedó fuera del equipaje. La pieza ausente es la mitad de la firma de Elm que devuelve efectos, y su ausencia explica casi toda la historia posterior del ecosistema, desde la proliferación de middleware hasta la aparición de Redux Toolkit. La conclusión no es que Redux copiara mal, sino que un patrón siempre viaja más ligero que la garantía que lo sostenía en su lugar de origen, y que el coste de volver a pagarla acaba recayendo sobre cada equipo.
Hay una asimetría curiosa en la historia reciente del frontend: la idea más influyente de la última década llegó a la mayoría de sus usuarios sin remitente. Millones de programadores escribieron reducers, despacharon acciones y razonaron sobre un árbol de estado único sin haber visto nunca una línea de Elm, y muchos de ellos habrían descrito ese modo de trabajar como una invención de la comunidad de React. No lo era. Redux nació en 2015 como respuesta a un problema de herramientas, no de arquitectura: Dan Abramov quería una demostración de recarga en caliente con viaje en el tiempo para una charla, y descubrió que ese truco solo es posible si se aceptan dos condiciones muy duras que Flux no imponía. El estado tiene que estar en un sitio y solo uno, y la función que lo transforma tiene que ser pura y determinista. Esas dos condiciones no las inventó Redux: las había establecido Elm años antes, y Evan Czaplicki ya había enseñado el depurador con viaje en el tiempo en 2014. Lo que hizo Abramov fue trasplantarlas a un lenguaje sin tipos algebraicos, sin inmutabilidad garantizada y sin un runtime que ejecutara efectos. Entender qué sobrevivió a ese trasplante y qué murió por el camino es la mejor manera de entender por qué el ecosistema de JavaScript pasó los ocho años siguientes reconstruyendo, pieza a pieza, lo que en el original venía de serie.
- Identificar con precisión las dos restricciones que Redux importó de Elm y explicar por qué son exactamente las que hacen posible el viaje en el tiempo.
- Contrastar la firma de la transición en ambos sistemas y localizar el canal de efectos que desaparece en la traducción.
- Explicar el ecosistema de middleware como consecuencia estructural de esa ausencia, y no como accidente histórico.
- Formular el criterio general que se deduce del caso: al importar un patrón, preguntar siempre qué mecanismo lo hacía cierto en su origen.
Un préstamo con inventario
Conviene ser literal sobre lo que se copió. La primera pieza es el estado único: un solo valor, en la raíz, del que desciende todo lo que la interfaz muestra. Flux ya proponía flujo unidireccional, pero con múltiples stores que se coordinaban entre sí mediante un dispatcher y llamadas a waitFor, de modo que el estado de la aplicación era una suma difusa de fragmentos con dependencias implícitas. Redux colapsa eso en un único árbol, y con ello convierte la pregunta cuál es el estado de mi aplicación en una pregunta con respuesta. La segunda pieza es la transición pura: una función que recibe el estado anterior y una acción y devuelve el estado siguiente, sin tocar nada más y sin depender de nada que no le hayan pasado. La tercera, corolario de las anteriores, es que la acción sea un dato plano y serializable, un registro con una etiqueta y una carga útil, en lugar de una llamada a un método. Las tres juntas son la Arquitectura Elm menos el lenguaje.
-- Elm: la transicion completa, con su canal de salida hacia el mundo
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Incrementar ->
( { model | cuenta = model.cuenta + 1 }, Cmd.none )
PedirDato ->
( { model | cargando = True }, pedirHecho model.cuenta )
DatoRecibido (Ok texto) ->
( { model | cargando = False, hecho = Just texto }, Cmd.none )
DatoRecibido (Err _) ->
( { model | cargando = False }, Cmd.none )
// Redux: la misma forma, con la segunda mitad amputada
type Accion =
| { type: 'incrementar' }
| { type: 'pedirDato' }
| { type: 'datoRecibido'; texto: string }
function reducer(estado: Estado = inicial, accion: Accion): Estado {
switch (accion.type) {
case 'incrementar':
return { ...estado, cuenta: estado.cuenta + 1 }
case 'pedirDato':
return { ...estado, cargando: true }
case 'datoRecibido':
return { ...estado, cargando: false, hecho: accion.texto }
default:
return estado
}
}
La correspondencia es tan estrecha que casi resulta cómica: Msg se convierte en una unión de objetos con campo discriminante, case of se convierte en switch, la sintaxis de actualización de registros se convierte en la propagación con puntos suspensivos. Pero hay dos diferencias visibles en el propio código que no son cosméticas. La primera es esa rama default que devuelve el estado sin tocarlo: existe porque en JavaScript toda acción llega a todos los reducers y ninguno puede saber si le corresponde. En Elm, un mensaje sin rama es un error de compilación; en Redux, un mensaje sin rama es silencio. La segunda es que la función de Elm devuelve un par y la de Redux devuelve un solo valor. Ahí está enterrada la historia entera de la década siguiente.
La mitad que no cabía en el tipo
Un reducer no puede pedir datos al servidor sin dejar de ser puro, y sin pureza no hay reproducción determinista, y sin reproducción determinista no hay viaje en el tiempo, que era el motivo original de todo el diseño. Elm resuelve el dilema devolviendo una descripción del efecto junto al modelo nuevo: el Cmd es un dato inerte que el runtime recoge y ejecuta, de modo que la función sigue siendo pura y aun así el programa habla con el mundo. Redux no podía hacer eso, porque no tenía runtime propio y porque el tipo de retorno del reducer se había fijado en el estado a secas. La consecuencia inmediata fue que el efecto tuvo que buscarse la vida fuera del contrato, y el sitio donde lo encontró fue el middleware, esa cadena de funciones que envuelven al despachador y pueden interceptar, transformar o retrasar lo que pasa por ella.
// El hueco se rellena interceptando el despachador
const thunk = (store) => (next) => (accion) =>
typeof accion === 'function' ? accion(store.dispatch, store.getState) : next(accion)
// A partir de aqui, una accion puede no ser un dato
const pedirDato = () => async (dispatch, getState) => {
dispatch({ type: 'pedirDato' })
const texto = await fetch('/api/hecho').then((r) => r.text())
dispatch({ type: 'datoRecibido', texto })
}
Merece la pena leer despacio lo que ocurre en esas cinco líneas, porque es el punto exacto donde el préstamo se rompe. La acción deja de ser un dato para poder ser una función, y en el instante en que eso sucede el registro de acciones deja de ser serializable, la reproducción deja de ser fiel y el depurador que motivó el diseño pierde parte de su poder. El propio ecosistema percibió la pérdida y produjo alternativas que, sin decirlo, volvían a Elm por el camino largo: redux-saga hizo que sus generadores emitieran descripciones de efecto como objetos planos, ejecutadas por un intérprete externo, que es literalmente el mecanismo del Cmd; redux-observable externalizó el efecto a un flujo; Redux Toolkit acabó normalizando createAsyncThunk y luego RTK Query, es decir, una capa entera dedicada a devolver al sistema la gestión de peticiones que la firma original había expulsado.
// La variante que vuelve a Elm por el camino largo:
// lo que se emite no es la llamada, sino su descripcion
function* pedirDatoSaga() {
yield put({ type: 'pedirDato' })
const texto = yield call(api.hecho) // un objeto plano, no una ejecucion
yield put({ type: 'datoRecibido', texto })
}
// call(api.hecho) evalua a algo asi, y un interprete externo lo ejecuta
const descripcion = {
CALL: { context: null, fn: api.hecho, args: [] },
}
El detalle que conviene subrayar es que call no llama a nada: construye un objeto que dice cómo llamar. El generador cede ese objeto al intérprete de la biblioteca, que lo ejecuta y devuelve el resultado por la misma vía. Es, línea por línea, el mecanismo del Cmd, con la diferencia de que aquí hay que instalar una biblioteca adicional y aprender un modelo de concurrencia entero para recuperar algo que en el original venía escrito en el tipo de retorno.
La pureza del reducer en Elm es una propiedad del lenguaje: no existe manera de escribir un update impuro, igual que no existe manera de escribir un número que no sea un número. En Redux es una recomendación de la documentación, vigilada por la revisión de código y, con suerte, por una regla de linter. La forma del patrón se copia gratis; la garantía que lo sostenía hay que volver a pagarla entera, y se paga en disciplina humana, que es la moneda más cara y la que peor se conserva con el tiempo.
flowchart TB A[Elm update devuelve modelo y comando] --> B[Redux reducer devuelve solo estado] B --> C[El efecto no cabe en el tipo] C --> D[Thunk: la accion pasa a ser funcion] C --> E[Saga: efectos como datos otra vez] C --> F[Toolkit y RTK Query] D --> G[Se pierde la reproduccion fiel] E --> H[Se recupera pagando un interprete] style A fill:#a6e3a1,color:#11111b style C fill:#f38ba8,color:#11111b style E fill:#89b4fa,color:#11111b
Lo que cambió por decisión y no por carencia
Sería injusto leerlo todo como pérdida, porque hubo cambios deliberados y algunos resolvían problemas reales de un entorno distinto. La composición es el caso más claro. Elm compone módulos con estado a mano, delegando explícitamente y remapeando mensajes con Cmd.map, de manera que la ruta de cada mensaje es visible en el código y verificada por el compilador. Redux eligió lo contrario: combineReducers reparte cada acción a todos los reducers, y cualquiera puede reaccionar a una acción emitida por otro sin que exista cableado entre ellos. Se pierde la localidad, se gana la capacidad de que varias partes del sistema respondan a un mismo hecho sin acoplarse, que en aplicaciones grandes con eventos transversales resulta genuinamente útil. Es una decisión con signo, no un descuido.
Copiado tal cual
Estado único en la raíz, transición pura, acciones como datos planos, flujo estrictamente unidireccional y la vista como función del estado.
Amputado
El canal de efectos del tipo de retorno, las suscripciones como valor y el runtime que ejecuta las descripciones. Todo eso hubo que reinventarlo fuera del contrato.
Cambiado a propósito
La difusión de acciones a todos los reducers en lugar de la delegación explícita, y la integración con la vista mediante selectores en vez de una vista total del modelo.
Devuelto años después
Immer restituyó la ergonomía de la actualización inmutable, y las uniones discriminadas de TypeScript devolvieron la exhaustividad que el default había disuelto.
Redux Toolkit adoptó Immer para poder escribir mutaciones aparentes sobre un borrador que por debajo produce un valor nuevo. Es una solución elegante, pero conviene ver qué problema resuelve: la actualización inmutable de estructuras anidadas es incómoda en JavaScript porque el lenguaje no tiene sintaxis para ella. Elm sí la tiene, integrada en el lenguaje desde el primer día. La biblioteca más popular del ecosistema es, leída así, una prótesis para una carencia sintáctica del anfitrión.
La lección más útil de este episodio no es histórica sino metodológica, y se aplica cada vez que alguien propone traer a un proyecto una arquitectura que funcionaba en otro sitio. Cuando observamos un patrón que triunfa, vemos su forma: las cajas, las flechas, los nombres de las funciones. Lo que no vemos, porque es invisible por naturaleza, es el mecanismo que en su entorno original hacía que esa forma fuera necesariamente cierta. En Elm, la pureza del update no se sostiene porque los programadores de Elm sean disciplinados, sino porque el lenguaje no ofrece ninguna otra opción; la exhaustividad del case of no depende de que nadie olvide un caso, sino de que olvidarlo impide compilar; la serializabilidad de los mensajes no es una convención sobre cómo escribirlos, sino una consecuencia de que un valor de un tipo unión no puede contener una función que se ejecute a escondidas. Al copiar la forma sin el mecanismo, lo que se obtiene no es la arquitectura sino su fotografía, y la diferencia entre ambas se paga en tiempo de mantenimiento durante años. La historia de Redux lo demuestra con una limpieza casi experimental: cada garantía perdida en el trasplante generó, tarde o temprano, una biblioteca dedicada a recuperarla, y esas bibliotecas son hoy más grandes y más numerosas que la biblioteca original. No hay aquí ningún reproche a Abramov, que hizo exactamente lo correcto para su ecosistema y fue explícito sobre sus fuentes, sino una advertencia para el lector que mañana escriba un documento de arquitectura. La pregunta que hay que hacerse ante cualquier patrón importado no es si me gusta ni si funcionará, sino esta otra, mucho más incómoda: en su casa, quién impedía que esto se rompiera, y en la mía, quién va a impedirlo. Si la respuesta es la buena voluntad del equipo, ya sabemos cuánto durará.
- Toma un reducer real de tu proyecto y determina si es puro de verdad; busca lecturas de reloj, de aleatoriedad, de almacenamiento local o de variables externas, y anota cuántas encuentras.
- Reescribe su firma para que devuelva un par de estado y descripción de efecto, sin ejecutar nada, y comprueba qué partes del código dejan de ser posibles.
- Localiza el punto exacto de tu aplicación donde una acción deja de ser serializable y razona qué capacidad de depuración pierdes a partir de ahí.
- Sustituye el
defaultque devuelve el estado por una comprobación de exhaustividad con el tiponeverdeTypeScripty describe qué errores aparecen. - Compara la trazabilidad de un mensaje en un módulo de Elm con la de una acción difundida a todos tus reducers, y decide con argumentos cuál prefieres para tu caso.
- Argumenta en contra de esta lección: defiende que la amputación del canal de efectos fue una buena decisión de diseño para el ecosistema de JavaScript de 2015 y enumera qué se ganó con ella.