atomFamily y atomWithStorage: familias y persistencia
Dos utilidades que amplían el grafo sin inflar el núcleo. atomFamily fabrica un átomo por identidad y memoiza la fábrica, convirtiendo colecciones dinámicas en suscripciones independientes; su contrapartida es un ciclo de vida que hay que gestionar a mano para no acumular átomos muertos. atomWithStorage integra la persistencia como un nodo más del grafo, con sincronización entre pestañas incluida, y expone el problema clásico de la hidratación desajustada cuando el servidor no puede leer el almacenamiento del navegador.
El núcleo de Jotai es deliberadamente diminuto: un constructor de átomos y tres hooks. Todo lo demás vive en utilidades opcionales que se pagan solo si se usan, y dos de ellas resuelven problemas que aparecen en casi cualquier aplicación seria. atomFamily responde a la pregunta de qué hacer cuando el número de átomos no se conoce al escribir el código, porque depende de datos: mil filas, doscientos campos generados, una celda por coordenada. atomWithStorage responde a la de cómo sobrevivir a una recarga sin escribir la coreografía de lectura, escritura y sincronización entre pestañas. Ambas son sencillas de usar y traicioneras si se ignoran sus contrapartidas, que son respectivamente el ciclo de vida y la hidratación.
- Fabricar átomos parametrizados por identidad con
atomFamilyy entender su memoización por clave. - Gestionar el ciclo de vida de una familia para evitar la acumulación indefinida de átomos huérfanos.
- Persistir estado con
atomWithStoragey comprender su sincronización entre pestañas. - Diagnosticar el desajuste de hidratación en SSR y elegir entre carga diferida y valor inicial explícito.
Un átomo por identidad
atomFamily recibe una fábrica y devuelve una función que, dada una clave, produce el átomo correspondiente. La pieza esencial es la memoización: invocarla dos veces con la misma clave devuelve exactamente el mismo objeto átomo, lo que preserva la identidad referencial de la que depende todo el modelo. Sin esa memoización cada renderizado fabricaría átomos nuevos y el estado se perdería en cada ciclo.
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'
const celdaAtom = atomFamily((id: string) => atom(''))
// la fabrica puede producir derivados: cada celda calculada lee otras por su id
const celdaCalculadaAtom = atomFamily((id: string) =>
atom((get) => evaluarFormula(get(celdaAtom(id)))),
)
// claves compuestas: hace falta un comparador porque la identidad de objeto no sirve
const puntoAtom = atomFamily(
({ fila, col }: { fila: number; col: number }) => atom(0),
(a, b) => a.fila === b.fila && a.col === b.col,
)
Nótese que la fábrica se invoca una sola vez por clave y que su resultado queda fijado. Cualquier valor capturado en el cuerpo de la fábrica —una configuración leída al vuelo, una fecha, un parámetro externo— quedará congelado en el átomo resultante y no se actualizará nunca, por mucho que la fuente original cambie después. Las dependencias que deban seguir vivas tienen que entrar por donde entran siempre en este modelo: leyéndolas desde dentro de una función de derivación, no capturándolas por cierre.
La clave compuesta merece una nota. Como la memoización se hace por identidad, un objeto literal usado como clave produciría un átomo nuevo en cada llamada. El segundo argumento de atomFamily es un comparador que sustituye la identidad por igualdad estructural, y sin él una familia parametrizada por objetos es una fuga garantizada. Con claves primitivas —cadenas o números— el comparador no hace falta.
Conviene también distinguir la familia de una utilidad vecina con la que se confunde a menudo. Descomponer un átomo de lista en un átomo por elemento parte de una colección existente y la reparte; la familia parte de una clave y fabrica bajo demanda, sin que exista ninguna lista previa. La primera sirve cuando los datos llegan juntos y el objetivo es aislar el renderizado; la segunda, cuando el conjunto de identidades es abierto y los elementos aparecen y desaparecen por su cuenta.
El efecto arquitectónico de la familia es que el aislamiento deja de ser algo que se construye y pasa a ser la topología por defecto. En un store central, mil filas viven en un mismo array y evitar que editar una re-renderice a las novecientas noventa y nueve restantes exige selectores memoizados por índice y comparadores cuidadosos. Con una familia, mil filas son mil átomos independientes con mil suscripciones independientes, y el aislamiento no cuesta trabajo porque es consecuencia de la estructura.
flowchart TD F[atomFamily celdas] --> A1[atomo celda A1] F --> A2[atomo celda A2] F --> B1[atomo celda B1] A1 --> C[celda calculada C1] A2 --> C C --> V[vista de C1] B1 --> W[vista de B1] style F fill:#89b4fa,color:#11111b style C fill:#a6e3a1,color:#11111b
El ciclo de vida que nadie recoge
La memoización que hace correcta a la familia es también su punto débil: el mapa interno crece y nada lo vacía por su cuenta. Una familia parametrizada por identificadores de una lista infinita, o por coordenadas visitadas durante una sesión larga, acumula átomos que ya nadie observa y que sin embargo siguen ocupando memoria junto a sus valores en el store. La API expone operaciones explícitas para recortar ese mapa, y usarlas es responsabilidad del código, no del sistema.
const tareaAtom = atomFamily((id: string) => atom({ hecha: false }))
// eliminar una entrada concreta cuando su elemento desaparece del dominio
tareaAtom.remove('t-42')
// recortar por criterio: por ejemplo, todo lo creado antes de un instante
tareaAtom.setShouldRemove((creadoEn, _id) => Date.now() - creadoEn > 300_000)
Es tentador usar atomFamily como si fuese una caché de datos remotos y esperar que se limpie sola. No lo hace: retiene todas las claves vistas hasta que alguien las retira. Cuando la clave proviene de datos externos y su cardinalidad no está acotada —identificadores de una lista paginada, resultados de búsqueda, rutas visitadas—, hay que decidir explícitamente una política de retirada y conectarla al momento en que el elemento deja de ser relevante. Para estado de servidor con revalidación, reintentos y desalojo real, la herramienta adecuada sigue siendo una librería de datos dedicada, y la familia debe reservarse para el estado de cliente asociado a cada entidad.
La persistencia como nodo del grafo
atomWithStorage produce un átomo cuyo valor se lee del almacenamiento al inicializarse y se escribe en él con cada cambio. La virtud del diseño es que el resultado es un átomo normal: participa en el grafo como cualquier otro, otros derivados pueden leerlo y sus dependientes se recomputan igual. La persistencia deja de ser una capa aparte con su propia coreografía de efectos y se convierte en una propiedad del nodo.
import { atomWithStorage, createJSONStorage } from 'jotai/utils'
export const preferenciasAtom = atomWithStorage('prefs', {
tema: 'oscuro',
idioma: 'es',
})
// el almacen es intercambiable: sesion en lugar de local, o cualquier otro backend
export const borradorAtom = atomWithStorage(
'borrador',
'',
createJSONStorage(() => sessionStorage),
)
La firma esconde una decisión importante: el valor por defecto no es un simple respaldo para la primera visita, sino el valor que el átomo tendrá siempre que la lectura del almacén falle o devuelva algo inservible. Un dato guardado por una versión anterior de la aplicación, con una forma que el código actual ya no entiende, entrará en el grafo sin que nada lo valide. Persistir es abrir una frontera de confianza con el pasado, y en aplicaciones que evolucionan conviene versionar la clave o validar lo leído antes de aceptarlo, exactamente igual que se validaría una respuesta de red.
Dos comportamientos suelen sorprender. El primero es que la sincronización entre pestañas viene incluida: el almacén por defecto se suscribe a los eventos de almacenamiento del navegador, de modo que cambiar el tema en una pestaña lo cambia en las demás sin una línea de coordinación. El segundo es que el almacén es un punto de extensión, no una imposición: sustituirlo permite persistir en almacenamiento asíncrono, en una base de datos local o en un backend remoto conservando exactamente la misma interfaz de átomo.
Persistir con criterio exige además elegir qué merece sobrevivir. Las preferencias del usuario, los borradores no enviados y el estado de navegación lo merecen; las cachés de datos remotos y los estados transitorios de la interfaz casi nunca, porque resucitar un dato caducado es peor que no tenerlo. La pregunta útil no es qué se puede persistir sino qué resultaría inquietante encontrar intacto tras una semana sin abrir la aplicación.
Combinar ambas utilidades es donde el modelo se luce. Una familia cuya fábrica devuelve átomos persistidos produce, en una línea, una colección de valores independientes que sobreviven a la recarga y se mantienen coherentes entre pestañas.
const notaAtom = atomFamily((id: string) =>
atomWithStorage(`nota:${id}`, ''),
)
Esa línea, sin embargo, arrastra una obligación que el ejemplo no muestra: retirar el átomo de la familia no borra su entrada del almacenamiento. Eliminar una nota debe hacer ambas cosas, y olvidarlo produce un almacén que crece indefinidamente con claves de entidades que ya no existen.
Hidratación: el desajuste inevitable
El servidor no tiene acceso al almacenamiento del navegador. Esto significa que el primer renderizado en servidor usará siempre el valor inicial por defecto, mientras que el primer renderizado en cliente podría usar el valor persistido, y React detectará una divergencia entre ambos árboles. El síntoma es un aviso de hidratación desajustada y, con frecuencia, un parpadeo visible: la interfaz aparece en tema claro durante un instante antes de saltar al oscuro que el usuario había elegido.
La utilidad permite diferir la lectura del almacenamiento hasta después de la hidratación, de modo que servidor y cliente coincidan en el primer pintado y el valor real se aplique en un segundo paso. Eso elimina el aviso pero conserva el parpadeo. La alternativa es evitar que el servidor tenga que adivinar: enviar la preferencia en una cookie legible en servidor y usarla como valor inicial explícito del átomo. La primera vía es más simple, la segunda elimina el parpadeo de raíz. Elegir entre ambas no es una cuestión técnica sino de cuánto cuesta ese parpadeo en la experiencia concreta.
Familia por identidad
Una fábrica memoizada por clave. Misma clave, mismo átomo; identidad preservada y suscripciones independientes.
Retirada explícita
El mapa crece hasta que alguien lo recorta. Retirar por clave o por criterio es parte del diseño, no una optimización.
Persistencia integrada
Un átomo persistido sigue siendo un átomo: deriva, se compone y sincroniza entre pestañas sin código extra.
Almacén intercambiable
Cambiar el backend de persistencia no cambia la interfaz. Sesión, base local o remoto detrás del mismo nodo.
Hay una regla de diseño que atraviesa esta lección y que se generaliza mucho más allá de Jotai: la estructura del estado debe reflejar la estructura de identidad del dominio, no la comodidad de quien la escribe. Cuando un dominio tiene entidades con identidad propia —una celda con coordenada, una nota con identificador, un campo de formulario con nombre— aplanarlas en una colección única obliga a reconstruir esa identidad en cada lectura mediante índices, búsquedas y comparadores, y cada una de esas reconstrucciones es una oportunidad de error y una fuente de trabajo proporcional al tamaño de la colección. Una familia de átomos hace lo contrario: eleva la identidad del dominio a identidad del sistema, de modo que la entidad y su nodo de estado son la misma cosa y no hay traducción que mantener. Esa correspondencia produce el aislamiento sin esfuerzo, pero produce también algo más valioso y menos visible: hace que el ciclo de vida del estado sea discutible en términos del dominio. La pregunta deja de ser cuándo limpiar la caché y pasa a ser cuándo deja de existir esta entidad, que es una pregunta que el negocio sabe responder. Ahí está el criterio de madurez de esta lección: la retirada de átomos no es una tarea de higiene técnica que se resuelve con un temporizador, sino la expresión en código de una regla de dominio sobre la vida de sus objetos. Un sistema donde nadie puede responder cuándo muere una entidad acumulará memoria haga lo que haga la librería, porque el problema no era técnico. Y a la inversa, cuando la respuesta existe, conectarla a la retirada convierte una fuga potencial en una consecuencia bien definida del modelo.
- Convierte una lista editable de tu aplicación en una
atomFamilypor identificador y mide con instrumentación cuántos componentes se renderizan al editar un solo elemento, antes y después del cambio. - Crea una familia con clave compuesta sin comparador y observa cómo pierde el estado en cada llamada. Añade después el comparador y explica exactamente qué cambió.
- Instrumenta el tamaño del mapa interno de una familia mientras navegas por una lista paginada. Documenta el crecimiento y decide una política de retirada basada en una regla de dominio, no en un temporizador arbitrario.
- Persiste una preferencia con
atomWithStoragey comprueba la sincronización abriendo dos pestañas. Después cambia el almacén a sesión y describe qué comportamiento se pierde. - Combina familia y persistencia para que cada elemento de una colección sobreviva a la recarga de forma independiente. Verifica qué claves quedan en el almacenamiento cuando un elemento se elimina y añade la limpieza que falte.
- Reproduce deliberadamente un desajuste de hidratación con un átomo persistido en una página renderizada en servidor. Resuélvelo primero difiriendo la lectura y luego con un valor inicial explícito, y argumenta cuál es preferible en tu caso.