Átomos derivados: el grafo que se construye solo
Un átomo derivado no guarda nada: se define por una función que lee otros átomos y se recomputa cuando ellos cambian. Esta lección recorre las tres formas del derivado —solo lectura, solo escritura y ambas—, explica por qué las dependencias descubiertas en tiempo de ejecución son estructuralmente superiores a los arrays de dependencias de useMemo, y desmonta la propagación en orden topológico que garantiza la ausencia de glitches incluso ante diamantes de dependencias.
Si el átomo primitivo fuese todo lo que ofrece Jotai, sería un useState global con más ceremonia. La potencia está en el segundo tipo de átomo: el derivado, que no almacena nada y se define exclusivamente por una función que lee otros átomos. Ese acto de lectura tiene una consecuencia que conviene subrayar antes de nada: al leer, el átomo declara una dependencia. No hay lista que mantener, ni array que sincronizar con el cuerpo de la función, ni riesgo de olvidar una entrada. El grafo se construye solo, a partir de lo que la ejecución real leyó, y se reconstruye en cada evaluación. De ahí nace tanto la ergonomía del modelo como su garantía de consistencia.
- Componer átomos derivados de solo lectura y entender el descubrimiento dinámico de dependencias.
- Escribir átomos de acción con lectura nula y átomos de lectura y escritura con doble representación.
- Explicar la propagación en orden topológico y la ausencia de glitches ante diamantes de dependencias.
- Contrastar el grafo declarativo con la memoización manual de
useMemoy sus arrays de dependencias.
El derivado de solo lectura y la dependencia implícita
Un derivado de solo lectura se declara pasando a atom una función que recibe un lector y devuelve un valor. Cada átomo que esa función lee queda registrado como dependencia, y el resultado se cachea hasta que alguna de esas dependencias cambie. La derivación encadena sin límite: un derivado puede leer a otros derivados, y el sistema resuelve la cadena entera.
import { atom } from 'jotai'
const precioAtom = atom(100)
const cantidadAtom = atom(2)
const descuentoAtom = atom(0)
const subtotalAtom = atom((get) => get(precioAtom) * get(cantidadAtom))
const baseAtom = atom((get) => get(subtotalAtom) * (1 - get(descuentoAtom)))
const ivaAtom = atom((get) => get(baseAtom) * 0.21)
const totalAtom = atom((get) => get(baseAtom) + get(ivaAtom))
Un derivado no se evalúa hasta que alguien lo observa, y esa pereza tiene consecuencias que conviene anticipar. Declarar cien derivados no cuesta nada mientras nadie los lea; el trabajo aparece con la primera suscripción y desaparece cuando el último consumidor se va. Por eso un cuerpo de derivación debe ser una función pura: si contiene efectos, su ejecución quedará atada a un calendario que depende de quién mira y cuándo, es decir, a algo que el código no controla.
La comparación con la memoización manual de React es instructiva y no favorece a esta última. Un useMemo exige declarar sus dependencias en un array separado del cuerpo de la función, y esa duplicación es una promesa que el programador hace y que nada verifica en tiempo de ejecución: si el cuerpo cambia y el array no, el valor memoizado se queda obsoleto sin que nadie avise. El derivado no puede desincronizarse porque no hay dos sitios que sincronizar; la dependencia es un efecto del acto de leer, no una declaración paralela.
Merece la pena detenerse en lo que ocurre bajo la superficie durante esa evaluación. El lector que recibe la función no es un simple acceso al valor: es un instrumento de registro que, además de devolver el dato, anota la arista en el grafo y suscribe al derivado a futuros cambios de esa fuente. Como el registro se rehace en cada evaluación, las aristas que dejaron de recorrerse se descartan y las nuevas se añaden, de modo que el grafo nunca conserva dependencias fósiles de una ejecución anterior. Esa reconstrucción continua es lo que permite afirmar que el grafo describe el programa real y no una intención declarada.
Esa propiedad se vuelve más notable con dependencias condicionales. Si un derivado lee un átomo solo dentro de una rama, la dependencia existe únicamente cuando esa rama se ejecuta, y el grafo se reconfigura en cada evaluación según el camino tomado. Un componente que solo depende del modo simple no se recomputa cuando cambian los átomos que únicamente el modo avanzado consulta.
const modoAvanzadoAtom = atom(false)
const recargoAtom = atom(12)
// la dependencia sobre recargoAtom solo existe cuando el modo avanzado esta activo
const totalFinalAtom = atom((get) =>
get(modoAvanzadoAtom) ? get(totalAtom) + get(recargoAtom) : get(totalAtom),
)
Cada átomo derivado es una afirmación que el sistema se compromete a mantener cierta para siempre: el total es el resultado de esta fórmula sobre estas entradas. Un valor derivado no puede quedar desactualizado respecto de su fuente porque no es un dato aparte que alguien deba recalcular, sino una función que se reevalúa sola. La clase entera de bugs consistente en cambiar una entrada y olvidar propagar el cambio desaparece por construcción, no por diligencia.
Escribir a través del grafo
La segunda función que acepta atom convierte a un derivado en escribible. Recibe un lector, un escritor y el valor entrante, y su cuerpo traduce esa intención en escrituras sobre átomos primitivos. Cuando el primer argumento es nulo, el átomo no tiene valor de lectura y funciona como pura acción: el equivalente atómico de un action creator que despacha varias mutaciones coordinadas.
const carritoAtom = atom<string[]>([])
const cuponAtom = atom<string | null>(null)
// atomo de accion: sin valor de lectura, solo coordina escrituras
const vaciarCarritoAtom = atom(null, (_get, set) => {
set(carritoAtom, [])
set(cuponAtom, null)
})
// derivado de lectura y escritura: dos representaciones de una sola fuente
const celsiusAtom = atom(20)
const fahrenheitAtom = atom(
(get) => get(celsiusAtom) * 9 / 5 + 32,
(_get, set, nuevoF: number) => set(celsiusAtom, (nuevoF - 32) * 5 / 9),
)
Nótese que el escritor no está obligado a escribir el mismo átomo que el lector consulta, ni siquiera a escribir uno solo. Esa libertad convierte al derivado escribible en un punto de traducción entre el vocabulario de la interfaz y el del dominio: la vista habla de grados Fahrenheit, de porcentajes o de etiquetas, y el modelo sigue hablando de la única representación canónica que decidió guardar.
El átomo de temperatura merece atención porque ilustra una técnica que trasciende el ejemplo. Existen dos representaciones visibles del mismo hecho físico, pero solo una es estado: la otra es derivación bidireccional. Ambas vistas quedan siempre coherentes porque no hay dos datos que sincronizar. Ese patrón —una fuente de verdad y varias proyecciones escribibles sobre ella— resuelve limpiamente problemas que en un store plano degeneran en efectos que copian un campo en otro y en carreras entre esas copias.
El átomo de acción, por su parte, restituye algo que el modelo atómico parecía haber perdido frente a Redux: un lugar con nombre donde vive una transición completa. Vaciar el carrito no es escribir en dos átomos desde el componente, sino invocar una operación que encapsula esa coordinación y puede probarse en aislamiento, sin montar un solo componente de React.
Hay además una razón de consistencia que suele descubrirse tarde. Cuando un componente escribe dos átomos por su cuenta, entre ambas escrituras existe un instante en que el estado global es incoherente: el carrito ya está vacío pero el cupón sigue aplicado. Cualquier suscriptor que reaccione en ese hueco observa una combinación que el dominio considera imposible. Al mover ambas escrituras al cuerpo de un átomo de acción, la coordinación queda expresada en un solo lugar y se vuelve revisable como una unidad, que es la condición previa para razonar sobre atomicidad en el sentido transaccional del término, no en el de la partícula.
La función escritora recibe también un lector, y usarlo es correcto para consultar valores durante la transición. Lo que no ocurre es lo que muchos esperan: leer un átomo dentro del escritor no crea ninguna dependencia. Un átomo de acción no se recomputa nunca porque no tiene valor que recomputar; solo se ejecuta cuando alguien lo invoca. Confundir ambas funciones lleva al error de esperar que una acción se relance sola al cambiar algo que leyó, y la ausencia de esa reacción resulta desconcertante hasta que se entiende que la reactividad vive en el lado de la lectura, nunca en el de la escritura.
Queda un límite que conviene tener presente para no forzar la herramienta. Un derivado escribible sigue siendo un nodo del grafo, no un lugar donde alojar reglas de negocio extensas ni orquestaciones largas. Cuando la traducción entre la vista y el dominio empieza a requerir validaciones, ramas y llamadas externas, la señal es que esa lógica pertenece a una función de dominio ordinaria que el átomo se limita a invocar. El grafo gana claridad cuando sus nodos son pequeños y su significado cabe en una frase.
Orden topológico y ausencia de glitches
Cuando una escritura ocurre, el sistema no reevalúa el grafo entero: recorre las aristas salientes del átomo escrito y recomputa únicamente la subred alcanzable. Ese recorrido se hace en orden topológico, lo que garantiza dos propiedades no negociables. La primera es que ningún derivado se recomputa más de una vez por transacción aunque varias de sus dependencias hayan cambiado a la vez. La segunda, más sutil, es que ningún derivado se evalúa con una mezcla de valores nuevos y viejos.
flowchart TD P[precio] --> S[subtotal] C[cantidad] --> S S --> B[base] DC[descuento] --> B B --> IVA[iva] B --> T[total] IVA --> T style B fill:#89b4fa,color:#11111b style T fill:#cba6f7,color:#11111b
La poda del recorrido es tan importante como su orden. Un cambio en el descuento no toca el subtotal, porque el subtotal no lo lee, y por tanto ninguna vista suscrita únicamente al subtotal vuelve a renderizarse. Ese recorte no depende de que nadie haya escrito un comparador ni memoizado un selector: se deduce de la topología, que a su vez se dedujo de las lecturas. Cuando el grafo crece hasta cientos de nodos, esta propiedad es la diferencia entre un sistema cuyo coste por escritura es proporcional al tamaño del subgrafo afectado y otro cuyo coste es proporcional al número total de suscriptores.
El diamante del diagrama es el caso de prueba clásico. El total depende de la base y del iva, y el iva depende también de la base. Si un sistema propagase sin orden, un cambio en la base podría alcanzar al total antes que al iva, produciendo por un instante un total calculado con la base nueva y el iva viejo. Ese estado intermedio incoherente es lo que la literatura de programación reactiva llama glitch, y su peligro no es visual sino semántico: un efecto que se dispare en ese instante observa un mundo que jamás debió existir. La propagación topológica lo hace imposible porque un nodo espera a que todos sus padres estén resueltos antes de calcularse.
La recomputación se detiene donde el valor deja de cambiar según identidad. Si un derivado recalcula y produce el mismo resultado que ya tenía, sus dependientes no se recomputan y sus consumidores no se re-renderizan. Esto tiene una implicación práctica sobre los objetos y arrays: devolver un literal nuevo en cada evaluación rompe la comparación y propaga siempre. Cuando un derivado produce estructuras, conviene devolver primitivos siempre que sea posible o estabilizar el resultado, del mismo modo que un selector memoizado evita fabricar objetos frescos en cada llamada.
Solo lectura
Una función y un lector. Cachea su resultado y se recomputa cuando cambian las dependencias que realmente leyó.
Solo escritura
Lectura nula y una función escritora. Encapsula transiciones que tocan varios átomos y se prueba sin React.
Lectura y escritura
Dos representaciones de una única fuente de verdad, coherentes por construcción y no por sincronización.
Dependencia condicional
Leer dentro de una rama crea la arista solo si esa rama corre. El grafo refleja la ejecución real, no una promesa.
La pregunta que separa a quien acumula herramientas de quien diseña sistemas no es qué librería guarda mejor el total de un carrito, sino si el total debe guardarse en absoluto. Casi siempre la respuesta es que no, y esa respuesta reorganiza la arquitectura entera. Existe una frontera nítida entre lo que es fuente —aquello que solo puede cambiar porque alguien lo decide desde fuera del sistema— y lo que es consecuencia —aquello que se deduce de las fuentes mediante una función—. Todo lo que cae del lado de la consecuencia y sin embargo se almacena como si fuera fuente introduce una obligación de sincronización que ningún equipo mantiene indefinidamente: tarde o temprano alguien escribe una entrada por un camino que no recalcula la salida, y aparece el bug característico del descuento aplicado sobre un total viejo. Los átomos derivados no son una comodidad sintáctica para escribir menos; son el mecanismo por el que esa frontera se declara una vez y el sistema la hace cumplir. Minimizar el estado real hasta dejar únicamente las fuentes irreductibles es una decisión de diseño que reduce simultáneamente la superficie de bugs, el tamaño de lo que hay que persistir, la complejidad de lo que hay que probar y el número de invariantes que un revisor debe verificar a mano. Y hay un corolario que suele pasar inadvertido: cuando las derivaciones son declarativas, el grafo se convierte en documentación ejecutable del modelo de dominio, porque leer las aristas es leer las reglas del negocio. Un sistema donde el total es una función es un sistema donde nadie puede introducir un total incoherente, no porque el equipo sea cuidadoso, sino porque el lenguaje del sistema no ofrece la manera de expresarlo.
- Modela un cálculo real de tu dominio con al menos tres niveles de profundidad y escríbelo íntegramente como átomos primitivos y derivados. Dibuja el grafo en papel antes de ejecutarlo.
- Reescribe ese mismo cálculo con
useMemoy arrays de dependencias. Añade después una entrada nueva al cuerpo de la función y olvida a propósito registrarla en el array. Observa el bug y explica por qué el derivado es inmune a él. - Introduce una dependencia condicional y verifica con instrumentación que la arista solo aparece cuando la rama se ejecuta.
- Construye un diamante deliberado: dos derivados que dependan de un mismo padre y un tercero que dependa de ambos. Registra cada evaluación y comprueba que el nodo final se calcula una sola vez y nunca con valores mezclados.
- Convierte una transición que toca tres átomos en un átomo de acción con lectura nula y escribe una prueba unitaria que la ejercite sin montar componentes.
- Toma un par de valores que hoy mantienes sincronizados a mano y reescríbelos como una fuente y un derivado de lectura y escritura. Cuenta cuántas líneas de sincronización desaparecen.
- Recorre el estado de un módulo y marca cada pieza como fuente o consecuencia. Elimina del almacenamiento todo lo marcado como consecuencia y comprueba que nada se pierde.
- Haz que un derivado devuelva un objeto nuevo en cada evaluación y observa la propagación innecesaria. Estabiliza el resultado y vuelve a medir los renderizados.