Átomos y mapas: la API mínima de nanostores
La superficie completa de nanostores cabe en una tarjeta: atom para un valor, map para un objeto con actualización por clave, deepMap para estructuras anidadas, computed para derivar y un puñado de utilidades oficiales para persistir, enrutar y coordinar tareas asíncronas. Esta lección recorre esa API con lupa, explica por qué map existe si atom ya guarda objetos, cómo la derivación hereda la pereza del núcleo y qué garantiza y qué no la persistencia en almacenamiento local con sincronía entre pestañas.
Hay librerías cuya API se aprende y librerías cuya API se lee de una sentada. Nanostores pertenece a las segundas, y esa brevedad no es pobreza sino una tesis: casi todo lo que un gestor de estado ofrece de más son atajos que se pueden escribir encima cuando hacen falta. El núcleo son cuatro constructores y tres operaciones. Alrededor, un puñado de paquetes oficiales que resuelven los problemas que ninguna aplicación real se ahorra: persistir entre sesiones, sincronizar entre pestañas, leer la ruta actual, coordinar trabajo asíncrono. Esta lección recorre esa superficie entera con el detalle suficiente para que puedas decidir, ante cada necesidad, si te sirve una pieza del núcleo, una utilidad oficial o quince líneas propias.
- Manejar
atom,mapydeepMapy saber cuándo cada uno es la estructura correcta. - Construir valores derivados con
computedy entender cómo heredan la pereza del núcleo. - Persistir estado entre sesiones y pestañas con la utilidad oficial, conociendo sus garantías reales.
- Decidir con criterio entre una utilidad del ecosistema y una implementación propia sobre la API mínima.
atom: un valor y tres operaciones
El constructor atom guarda un valor de cualquier tipo y expone exactamente lo que hace falta para gobernarlo: obtenerlo, sustituirlo y escuchar sus cambios. No hay reductor, ni acción, ni actualizador funcional. La sustitución es total y por identidad, lo que significa que escribir el mismo valor no notifica a nadie.
import { atom } from 'nanostores'
export const tema = atom<'claro' | 'oscuro'>('claro')
export function alternarTema() {
tema.set(tema.get() === 'claro' ? 'oscuro' : 'claro')
}
const cancelar = tema.subscribe((valor) => console.log('tema', valor))
Dos detalles del comportamiento conviene fijarlos pronto. El primero es que subscribe invoca al oyente de inmediato con el valor actual, además de en cada cambio posterior; si solo quieres los cambios, existe listen, que omite la llamada inicial. El segundo es que la comparación para decidir si hubo cambio es por identidad, de modo que un atom que guarda un objeto y al que le asignas un objeto equivalente pero distinto sí notifica. Esa es la razón de que exista map.
La API no impone dónde escribir, y por eso conviene imponérselo uno mismo. El patrón que envejece bien es un módulo por dominio que exporta los stores y las funciones que los modifican, de forma que ninguna vista llame nunca a set directamente. La ganancia no es ceremonia: es que el conjunto de transiciones posibles queda enumerado en un archivo, se puede probar sin montar nada y no se dispersa por veinte componentes.
map y deepMap: objetos con clave
Un map es un store cuyo valor es un objeto plano y que además permite escribir una sola clave sin reconstruir el resto. La diferencia con guardar un objeto en un atom es doble: la escritura por clave es más cómoda y, sobre todo, los suscriptores reciben información sobre qué clave cambió, lo que permite a los adaptadores evitar trabajo innecesario.
import { map, deepMap } from 'nanostores'
export const perfil = map({ nombre: 'Ada', ciudad: 'Londres', activo: true })
perfil.setKey('ciudad', 'Cambridge')
perfil.set({ nombre: 'Grace', ciudad: 'Nueva York', activo: false })
export const ajustes = deepMap({
notificaciones: { correo: true, push: false },
})
ajustes.setKey('notificaciones.push', true)
deepMap extiende la idea a estructuras anidadas mediante rutas por texto, lo que evita reconstruir a mano los niveles intermedios cuando cambia una hoja. Es la respuesta de nanostores al mismo dolor que Valtio resuelve con el proxy y que Redux resolvió con Immer, solo que aquí se paga con una ruta en forma de cadena en vez de con una capa de intercepción. La contrapartida es evidente y honesta: una ruta escrita como texto no la comprueba el sistema de tipos con la misma solidez que un acceso directo, así que el error de tecleo llega hasta el tiempo de ejecución.
`atom`
Un valor cualquiera, sustituido entero. Ideal para primitivos, banderas, identificadores y objetos pequeños que cambian de golpe.
`map`
Objeto plano con escritura por clave. Notifica que clave cambio, lo que permite a la vista reaccionar de forma mas fina.
`deepMap`
Rutas anidadas por cadena de texto. Evita reconstruir niveles intermedios a costa de perder algo de seguridad de tipos.
`computed`
Valor derivado de uno o varios stores. No se guarda, se calcula, y solo mientras alguien lo escucha.
Derivación perezosa con computed
computed construye un store nuevo a partir de otros y de una función pura. El resultado es de solo lectura y se recalcula cuando alguna fuente cambia, con la particularidad heredada del núcleo de que no calcula nada mientras nadie lo escucha.
import { atom, computed } from 'nanostores'
export const lineas = atom<Array<{ precio: number; cantidad: number }>>([])
export const iva = atom(0.21)
export const total = computed([lineas, iva], (items, tasa) => {
const base = items.reduce((suma, l) => suma + l.precio * l.cantidad, 0)
return base * (1 + tasa)
})
El principio que gobierna esta pieza es el mismo que atraviesa todo el track: lo derivado no se guarda, se calcula. Un total almacenado es una segunda fuente de verdad que puede desincronizarse; un total computado no puede estar mal por definición, porque no existe salvo como función de sus fuentes. La diferencia con Reselect o con los átomos derivados de Jotai no está en la idea sino en el tamaño del mecanismo.
flowchart LR A[atom lineas] --> C[computed total] B[atom iva] --> C C --> V1[isla cabecera] C --> V2[isla resumen] C -.->|sin oyentes no calcula| Z[inerte] style C fill:#f9e2af,color:#11111b style Z fill:#585b70,color:#cdd6f4
La pereza tiene una consecuencia que sorprende al depurar: poner un registro dentro de la función de un computed no imprime nada hasta que algo se suscribe. No está roto, está dormido. Y tiene una consecuencia de diseño que sí conviene aprovechar: encadenar derivaciones es barato, porque una cadena entera de valores computados permanece inerte mientras la vista final no exista. En una página con islas que se hidratan según visibilidad, eso significa que el trabajo de cálculo llega justo cuando alguien lo va a mirar.
Además de onMount, el núcleo expone ganchos para interceptar escrituras y notificaciones. Sirven para validar antes de aceptar un valor, para registrar cambios en un log de auditoría o para abortar una notificación redundante. Son el punto de extensión que hace innecesario un sistema de middleware: donde otras librerías construyeron una cadena de intermediarios, aquí hay tres funciones que se enganchan al ciclo de vida y ya. Úsalos con moderación, porque lógica escondida en un gancho es lógica que nadie encuentra leyendo el flujo principal.
Persistencia y el resto del ecosistema
La utilidad oficial @nanostores/persistent sustituye a atom y map por variantes que se sincronizan con el almacenamiento del navegador. Lo interesante no es que guarden, sino que escuchan el evento de almacenamiento y, por tanto, propagan los cambios entre pestañas del mismo origen sin ninguna configuración adicional.
import { persistentAtom, persistentMap } from '@nanostores/persistent'
export const tema = persistentAtom<'claro' | 'oscuro'>('tema', 'claro')
export const carrito = persistentMap<Record<string, string>>('carrito:', {}, {
encode: JSON.stringify,
decode: JSON.parse,
})
Las garantías reales conviene enunciarlas sin adornos. Se persiste texto, así que todo valor que no sea cadena necesita codificación explícita y pierde por el camino los tipos que el formato de intercambio no representa, como fechas o conjuntos. La sincronía entre pestañas es a través de un evento del navegador y por tanto asíncrona: dos pestañas pueden estar momentáneamente desalineadas y no hay resolución de conflictos si ambas escriben a la vez, gana la última. Y en renderizado del lado del servidor no hay almacenamiento, de modo que el valor inicial es el predeterminado y llega al real tras la hidratación, con el parpadeo consiguiente si no lo previenes.
El caso clásico es el tema oscuro persistido: el servidor no sabe la preferencia, envía el valor por defecto y la hidratación corrige a los pocos milisegundos, con un destello visible. Ninguna librería de estado puede evitarlo, porque el problema ocurre antes de que exista. La solución es un fragmento de script en la cabecera del documento que lea el almacenamiento y aplique el atributo correspondiente antes de pintar. Es un recordatorio útil de que ciertos problemas viven fuera del gestor de estado y ninguna cantidad de API los resolverá.
Otra garantía que conviene entender es la de la clave de almacenamiento. Persistir es publicar en un espacio de nombres compartido por todo el origen, así que dos aplicaciones servidas desde el mismo dominio, o dos versiones de la tuya, pueden pisarse. Prefijar las claves y versionarlas cuando cambie la forma del dato es una precaución barata que evita el escenario más desagradable de todos: código nuevo leyendo un valor persistido por código viejo con otra estructura, que no falla al leer sino más tarde, en un sitio sin relación aparente.
El resto del ecosistema oficial sigue la misma filosofía de piezas pequeñas: un router que expone la ruta actual como un store y permite que cualquier isla reaccione a la navegación, y utilidades para coordinar trabajo asíncrono y esperar a que las tareas pendientes terminen, útiles sobre todo en pruebas y en renderizado de servidor. Ninguna es imprescindible, y esa es la prueba de la coherencia del diseño: cuando el núcleo son tres operaciones, casi cualquier utilidad se puede escribir encima en un rato, y la decisión entre adoptar un paquete o escribir quince líneas propias vuelve a ser una decisión honesta de ingeniería.
Comparar la superficie de nanostores con la de Redux Toolkit produce vértigo: unas pocas funciones frente a un catálogo de generadores de slices, adaptadores de entidades, capas de consulta y middleware. Es tentador leer la diferencia como madurez frente a juventud, pero es más exacto leerla como dos apuestas opuestas sobre dónde debe vivir la complejidad de una aplicación. La apuesta de la API grande dice que los problemas recurrentes deben resolverse una vez, bien, dentro de la librería, y que cada equipo que los resuelva por su cuenta los resolverá peor; su precio es que todo el mundo carga con abstracciones que quizá no necesita y debe aprender un vocabulario antes de escribir la primera línea. La apuesta de la API mínima dice que los problemas recurrentes son menos universales de lo que parecen, que sus soluciones genéricas cuestan más de configurar que de escribir, y que un núcleo pequeño y bien elegido deja al equipo componer exactamente lo que su caso pide; su precio es que cada equipo reinventa un poco, con calidad desigual y sin la revisión que una librería popular recibe. Ninguna de las dos apuestas es correcta en abstracto, y quien pretenda que sí lo es está vendiendo algo. Lo que decide es un parámetro externo a la tecnología: cuánta variabilidad tiene tu problema y cuánta uniformidad necesita tu equipo. Un producto con veinte ingenieros rotando y un dominio auditable gana con la abstracción compartida, aunque le sobre la mitad. Un sitio de contenido con tres islas y dos desarrolladores gana con veinte líneas propias que caben en una pantalla y que nadie tiene que aprender. La destreza que este track quiere dejarte no es preferir una escala u otra, sino reconocer que la elección de tamaño de API es una decisión sobre tu organización tanto como sobre tu código, y que confundir ambas cosas —adoptar la ceremonia de un equipo de cien o el minimalismo de un equipo de dos sin serlo— es el error que ninguna documentación te va a señalar.
- Modela un carrito con
mappara las líneas ycomputedpara el total. Comprueba que el total nunca puede desincronizarse porque no se guarda en ningún sitio. - Sustituye el
mappor unatomcon el mismo objeto y observa qué pierdes en la escritura por clave y en la información que reciben los suscriptores. - Encadena dos
computedy coloca un registro dentro del primero. Confirma que no imprime nada hasta que alguien se suscribe al último de la cadena. - Persiste el carrito con
persistentMap, ábrelo en dos pestañas y verifica la sincronía. Provoca una escritura simultánea y describe qué pasó con el conflicto. - Persiste un valor que no sea texto plano —una fecha o un conjunto— y comprueba qué llega tras recargar. Escribe la codificación explícita que lo arregla.
- Elige una utilidad oficial del ecosistema, escribe tu propia versión mínima sobre
atomysubscribe, y decide con argumentos concretos cuál de las dos te quedas.