@mongez/atom
Advanced tools
+1
-1
@@ -31,3 +31,3 @@ { | ||
| }, | ||
| "version": "6.0.3", | ||
| "version": "6.0.4", | ||
| "main": "./cjs/index.cjs", | ||
@@ -34,0 +34,0 @@ "module": "./esm/index.mjs", |
+714
-141
@@ -0,22 +1,79 @@ | ||
| <div align="center"> | ||
| # @mongez/atom | ||
| > A framework-agnostic, action-shaped state primitive with first-class SSR isolation, derived values, persistence, and Redux DevTools. | ||
| **Framework-agnostic, action-shaped state primitive — typed atoms with verbs bound to them, computed values, per-request SSR isolation, pluggable persistence, and Redux DevTools.** | ||
| `@mongez/atom` is the core of the Mongez state-management family. The React adapter lives in [`@mongez/react-atom`](https://github.com/hassanzohdy/mongez-react-atom); the client-side server-state cache in [`@mongez/atomic-query`](https://github.com/hassanzohdy/mongez-atomic-query). | ||
| [](https://www.npmjs.com/package/@mongez/atom) | ||
| [](LICENSE) | ||
| [](https://bundlephobia.com/package/@mongez/atom) | ||
| [](https://www.npmjs.com/package/@mongez/atom) | ||
| The atom isn't just a value — it's a value with **methods bound to it**. Instead of writing setters and helper functions everywhere, you define actions on the atom itself and call them as verbs: `cart.push(item)`, `modal.open()`, `auth.login(creds)`. | ||
| </div> | ||
| ## Install | ||
| --- | ||
| ## Why @mongez/atom? | ||
| Zustand bundles state into a single store with a single setter, so every feature reaches across the same global hook. Jotai gives you atoms but no methods on them — `set(cartAtom, [...get(cartAtom), item])` everywhere. Recoil shares the atom shape but demands a `RecoilRoot` and a React tree to function. MobX gives you methods, but only through ES class decorators and observable proxies that make plain-object debugging painful. `@mongez/atom` is the smallest layer that makes each atom a typed value carrying its own bound verbs (`cart.push(item)`, `sidebar.toggle()`, `auth.login(creds)`), works in plain Node and in any framework, isolates state per SSR request without a Provider, and exposes one `persist` slot that accepts any `{ get, set, remove }` adapter. | ||
| ```ts | ||
| import { createAtom } from "@mongez/atom"; | ||
| const sidebar = createAtom({ | ||
| key: "ui.sidebar", | ||
| default: false, | ||
| actions: { | ||
| open() { this.update(true); }, | ||
| close() { this.update(false); }, | ||
| toggle() { this.update(!this.value); }, | ||
| }, | ||
| }); | ||
| sidebar.toggle(); // verb on the atom — no setSidebar(!sidebar) ceremony | ||
| sidebar.value; // true | ||
| ``` | ||
| --- | ||
| ## Features | ||
| | Feature | Description | | ||
| |---|---| | ||
| | **Action-shaped atoms** | Define verbs in the `actions` bag; `this` inside is bound to the atom — call as `atom.toggle()`, not `setAtom(!atom.value)`. | | ||
| | **Type-safe by shape** | `Atom<V>` is conditional: `change` / `merge` / `watch` are stripped when `V` is a primitive, so `Atom<boolean>.change(...)` is a compile error. | | ||
| | **Array atoms with verbs** | `atomCollection` pre-installs `push`, `pop`, `shift`, `unshift`, `replace`, `remove`, `removeItem`, `map`, `forEach`, `index`, and a `length` getter. | | ||
| | **Derived atoms** | `derive(key, get => …)` auto-tracks dependencies. Conditional reads work; the dep set is diffed each run. Chains propagate. | | ||
| | **Per-request SSR isolation** | `AtomStore` clones templates per request; the module-level registry stays untouched. Snapshot and hydrate ship in the box. | | ||
| | **Pluggable persistence** | `persist: true` uses the built-in localStorage adapter. Any `{ get, set, remove }` (sync or async) plugs in — cookies, IndexedDB, `@mongez/cache`. | | ||
| | **Redux DevTools** | `enableAtomDevtools()` ships a timeline, action log, and `JUMP_TO_STATE` time-travel. Tree-shaken when unused. | | ||
| | **Lifecycle events** | Every atom emits on `atoms.${key}.update` / `.reset` / `.delete` via `@mongez/events` — segment-aware namespaces. | | ||
| | **Framework-agnostic** | Zero React / Vue / Solid coupling. The React adapter lives in [`@mongez/react-atom`](https://github.com/hassanzohdy/mongez-react-atom). | | ||
| --- | ||
| ## Installation | ||
| ```sh | ||
| npm install @mongez/atom | ||
| ``` | ||
| ```sh | ||
| yarn add @mongez/atom | ||
| # peer deps: @mongez/events, @mongez/reinforcements | ||
| ``` | ||
| ## A 30-second tour | ||
| ```sh | ||
| pnpm add @mongez/atom | ||
| ``` | ||
| Peer dependencies (installed automatically): `@mongez/events`, `@mongez/reinforcements`. | ||
| --- | ||
| ## Quick start | ||
| ```ts | ||
| import { createAtom, atomCollection, derive } from "@mongez/atom"; | ||
| // 1. A boolean toggle with action verbs. | ||
| // 1. A primitive atom with action verbs. | ||
| const sidebar = createAtom({ | ||
@@ -32,6 +89,5 @@ key: "ui.sidebar", | ||
| sidebar.toggle(); | ||
| // 2. An array atom — push/pop/remove built in. | ||
| type Todo = { id: number; text: string; done: boolean }; | ||
| // 2. A list with built-in mutation helpers. | ||
| type Todo = { id: number; text: string; done: boolean }; | ||
| const todos = atomCollection<Todo>({ key: "todos", default: [] }); | ||
@@ -41,3 +97,3 @@ todos.push({ id: 1, text: "Buy bread", done: false }); | ||
| // 3. A derived atom — auto-tracks its dependencies. | ||
| // 3. A derived atom — recomputes when dependencies change. | ||
| const incompleteCount = derive("todos.incomplete", get => | ||
@@ -47,155 +103,408 @@ get(todos).filter(t => !t.done).length, | ||
| incompleteCount.value; // 1 | ||
| incompleteCount.value; // 1 | ||
| todos.push({ id: 2, text: "Read book", done: false }); | ||
| incompleteCount.value; // 2 (recomputes automatically) | ||
| incompleteCount.value; // 2 — auto-tracked | ||
| ``` | ||
| ## What's in the box | ||
| That's the entire happy path. Everything below is depth on the same surface. | ||
| | Export | Purpose | | ||
| |---|---| | ||
| | `createAtom` | The atom factory. | | ||
| | `atomCollection` | Array-shaped atoms with mutation verbs. | | ||
| | `derive` | Computed atoms with auto-tracked dependencies. | | ||
| | `AtomStore` / `createAtomStore` | Per-request isolation primitive for SSR. | | ||
| | `enableAtomDevtools` | Redux DevTools bridge with time-travel. | | ||
| | `persist` (option on `AtomOptions`) | Persist atom values via localStorage or any adapter. | | ||
| | `getAtom`, `atomsList`, `atomsObject` | Registry helpers. | | ||
| --- | ||
| ## Methods on every atom | ||
| ## createAtom — the core factory | ||
| `createAtom(options)` builds an atom and registers it in the module-level `atoms` map. The `key` is the unique identifier — duplicates overwrite. | ||
| ```ts | ||
| const counter = createAtom({ key: "counter", default: 0 }); | ||
| import { createAtom } from "@mongez/atom"; | ||
| // Reads | ||
| counter.value; | ||
| counter.defaultValue; | ||
| const counter = createAtom({ | ||
| key: "counter", | ||
| default: 0, | ||
| }); | ||
| // Writes | ||
| counter.update(5); | ||
| counter.value; // 0 | ||
| counter.update(5); // emits update event | ||
| counter.update(prev => prev + 1); | ||
| counter.silentUpdate(0); // no update event | ||
| counter.reset(); // back to default + emit events | ||
| counter.silentReset(); // back to default, no update event | ||
| counter.silentUpdate(0); // sets value, suppresses update event | ||
| counter.reset(); // back to default, emits update + reset | ||
| counter.silentReset(); // back to default, emits reset only | ||
| ``` | ||
| // Subscriptions | ||
| counter.onChange((next, prev) => …); // returns { unsubscribe } | ||
| counter.onReset(…); | ||
| counter.onDestroy(…); | ||
| ### AtomOptions | ||
| // Lifecycle | ||
| counter.clone(); // returns a new atom with key `${key}.clone.{n}` | ||
| counter.destroy(); | ||
| ``` | ||
| | Option | Type | Purpose | | ||
| |---|---|---| | ||
| | `key` | `string` | Unique registry key. Namespace with dots: `"ui.sidebar"`, `"auth.user"`. | | ||
| | `default` | `V` | Complete initial value. Drives type inference. Must NOT be `Partial<V>`. | | ||
| | `actions` | `Record<string, fn \| getter>` | Methods/getters bound to the atom — `this` resolves to the atom. | | ||
| | `beforeUpdate` | `(next, prev, atom) => V \| void` | Transform or veto an incoming value. Returning `undefined` keeps `next`. | | ||
| | `onUpdate` | `(cb) => EventSubscription` | Subscribe to updates at construction. | | ||
| | `get` | `(key, default?, value?) => V[K]` | Custom getter routed through `atom.get(key)`. | | ||
| | `persist` | `boolean \| PersistAdapter<V>` | Persist value externally — see [Persistence](#persistence). | | ||
| For object-valued atoms, you also get: | ||
| ### Base methods on every atom | ||
| | Method | What it does | | ||
| |---|---| | ||
| | `atom.value` / `atom.currentValue` | Read the current value (getter and plain field). | | ||
| | `atom.defaultValue` / `atom.default` | Read the construction-time default. | | ||
| | `atom.update(next \| prev => next)` | Replace the value; emit `update` event. Updater fn supported. | | ||
| | `atom.silentUpdate(next)` | Replace the value with no `update` event — used for hydration. | | ||
| | `atom.reset()` | Restore the default; emit `update` AND `reset` events. | | ||
| | `atom.silentReset()` | Restore the default; emit `reset` only. | | ||
| | `atom.onChange(cb)` | Subscribe to updates. Returns `{ unsubscribe }`. | | ||
| | `atom.onReset(cb)` / `atom.onDestroy(cb)` | Lifecycle subscriptions. | | ||
| | `atom.clone({ register? })` | Deep-clone into `${key}.clone.{n}`. `register: false` skips the global map. | | ||
| | `atom.destroy()` | Remove from registry, unsubscribe namespace events, emit `delete`. | | ||
| | `atom.type` | `"object" \| "array" \| typeof primitive` — locked at construction. | | ||
| | `atom.length` | Array/string length getter. | | ||
| ### Object-only methods (conditional) | ||
| When `V` is an object/array, the atom also carries: | ||
| | Method | What it does | | ||
| |---|---| | ||
| | `atom.merge(partial)` | Shallow-merge into the value; emit `update`. | | ||
| | `atom.change(key, value)` | Set one property; emit `update`. Typed as `(K, V[K])`. | | ||
| | `atom.silentChange(key, value)` | Same without the `update` event. | | ||
| | `atom.get(key, default?)` | Read one property (or via the custom `get` option). | | ||
| | `atom.watch(key, cb)` | Subscribe to changes of a single key. Returns `{ unsubscribe }`. | | ||
| > Calling `merge` / `change` / `watch` on a primitive atom (`Atom<boolean>`, `Atom<number>`, `Atom<string>`) is a compile error — these methods are stripped from the type because they would silently corrupt the value at runtime. | ||
| ### Object atom with watch | ||
| ```ts | ||
| type User = { name: string; email: string }; | ||
| const user = createAtom({ | ||
| key: "user", | ||
| default: { name: "Anon", age: 0 }, | ||
| key: "auth.user", | ||
| default: { name: "Anon", email: "" } satisfies User, | ||
| }); | ||
| user.merge({ age: 31 }); // shallow merge + update event | ||
| user.change("name", "Alice"); // set one key + update event | ||
| user.silentChange("age", 32); // set one key, no update event | ||
| user.get("name"); // read one key | ||
| user.watch("name", (next, prev) => …); // subscribe to one key only | ||
| user.watch("name", (next, prev) => { | ||
| // Why: only react to the field we care about, not the whole object. | ||
| console.log(`Name: ${prev} → ${next}`); | ||
| }); | ||
| user.merge({ name: "Alice" }); // "Name: Anon → Alice" | ||
| user.merge({ email: "a@b.co" }); // (watcher does not fire — different key) | ||
| ``` | ||
| Calling `change` / `merge` / `watch` on a primitive atom (`Atom<boolean>`, `Atom<number>`, `Atom<string>`) is a **compile error** — the methods are stripped from the type because they would silently corrupt the value at runtime. | ||
| ### beforeUpdate as a validator | ||
| ## Derived atoms | ||
| ```ts | ||
| const port = createAtom({ | ||
| key: "config.port", | ||
| default: 3000, | ||
| beforeUpdate(next) { | ||
| // Why: keep ports in the valid TCP range and integer-only. | ||
| if (next < 1 || next > 65535) return; // returning void keeps `next` as-is | ||
| return Math.floor(next); // transform: rewrite the value | ||
| }, | ||
| }); | ||
| port.update(8443.7); // value becomes 8443 | ||
| port.update(999999); // ignored — out of range | ||
| port.value; // 8443 | ||
| ``` | ||
| > `silentUpdate` still runs `beforeUpdate`. The "silent" only suppresses the `update` event emission, not the validation pipeline. | ||
| ### Registry helpers | ||
| ```ts | ||
| import { getAtom, atomsList, atomsObject } from "@mongez/atom"; | ||
| getAtom("auth.user"); // Atom<User> | undefined | ||
| atomsList(); // Atom<any>[] | ||
| atomsObject(); // Record<string, Atom<any>> | ||
| ``` | ||
| --- | ||
| ## Actions — verbs on the atom | ||
| The `actions` bag accepts three shapes: | ||
| | Kind | Behavior | | ||
| |---|---| | ||
| | Plain function | Bound to the atom; `this` resolves to the atom instance. | | ||
| | Property getter (`get x()`) | Forwarded as a real getter on the atom — recomputed per read. | | ||
| | Other value | Copied through as a plain property. | | ||
| ```ts | ||
| type Item = { id: string; price: number; qty: number }; | ||
| const cart = createAtom({ | ||
| key: "cart", | ||
| default: [] as Item[], | ||
| actions: { | ||
| addItem(item: Item) { | ||
| this.update([...this.value, item]); | ||
| }, | ||
| setQty(id: string, qty: number) { | ||
| this.update(this.value.map(i => i.id === id ? { ...i, qty } : i)); | ||
| }, | ||
| get total() { | ||
| // Getter: recomputed each read, no subscription bookkeeping. | ||
| return this.value.reduce((sum, i) => sum + i.price * i.qty, 0); | ||
| }, | ||
| get isEmpty() { | ||
| return this.value.length === 0; | ||
| }, | ||
| MAX_LINES: 99, // plain value — copied through | ||
| }, | ||
| }); | ||
| cart.addItem({ id: "a", price: 10, qty: 2 }); | ||
| cart.total; // 20 | ||
| cart.setQty("a", 5); | ||
| cart.total; // 50 | ||
| cart.isEmpty; // false | ||
| cart.MAX_LINES; // 99 | ||
| ``` | ||
| > The action installer detects descriptors via `Object.getOwnPropertyDescriptor` and routes getters as getters. Arrow functions in `actions` work but `this` will be `undefined` — use regular function syntax so `this` resolves to the atom. | ||
| --- | ||
| ## atomCollection — arrays with verbs built-in | ||
| `atomCollection<T>(options)` is a thin layer over `createAtom` that pre-installs mutation verbs. Use it whenever the value is an array. | ||
| ```ts | ||
| import { atomCollection, type Atom } from "@mongez/atom"; | ||
| type Todo = { id: number; text: string; done: boolean }; | ||
| const todos = atomCollection<Todo>({ | ||
| key: "todos", | ||
| default: [], | ||
| actions: { | ||
| toggle(this: Atom<Todo[]>, id: number) { | ||
| this.update(this.value.map(t => t.id === id ? { ...t, done: !t.done } : t)); | ||
| }, | ||
| }, | ||
| }); | ||
| ``` | ||
| ### Built-in actions | ||
| | Action | Effect | | ||
| |---|---| | ||
| | `push(...items)` | Append to end. | | ||
| | `unshift(...items)` | Prepend to start. | | ||
| | `pop()` / `shift()` | Drop the last / first item. | | ||
| | `replace(index, item)` | Overwrite the item at `index`. | | ||
| | `remove(indexOrPredicate)` | Drop one item by index or `(item, i, arr) => boolean`. | | ||
| | `removeItem(item)` | Remove the first occurrence by `===` equality. | | ||
| | `removeAll(item)` | Drop every `===` match. Mutates and emits update. | | ||
| | `get(indexOrPredicate)` | Single-item read. Returns `undefined` if no match. | | ||
| | `index(predicate)` | `Array.findIndex` wrapper. | | ||
| | `map(cb)` | In-place map — rewrites the value AND returns the new array. | | ||
| | `forEach(cb)` | Read-only iteration. | | ||
| | `length` | Property getter — current size. | | ||
| ```ts | ||
| todos.push({ id: 1, text: "Buy bread", done: false }); | ||
| todos.push({ id: 2, text: "Read book", done: true }); | ||
| todos.toggle(1); // custom action | ||
| todos.remove(t => t.done); // by predicate | ||
| todos.replace(0, { id: 1, text: "Buy sourdough", done: false }); | ||
| todos.index(t => t.id === 1); // 0 | ||
| todos.get(t => t.id === 1); // { id: 1, text: "Buy sourdough", done: false } | ||
| todos.length; // 1 — property, NOT a function call | ||
| ``` | ||
| > `map` mutates and emits an update event. If you only want a pure transform, read `todos.value.map(...)`. | ||
| --- | ||
| ## Derived atoms — `derive` | ||
| `derive(key, compute)` builds an atom whose value is computed from other atoms. Whichever atoms the `compute` function reads via the `get` argument become dependencies. When any of them change, the derived atom recomputes and its subscribers fire. | ||
| ```ts | ||
| import { createAtom, derive } from "@mongez/atom"; | ||
| const firstName = createAtom({ key: "first", default: "Ada" }); | ||
| const lastName = createAtom({ key: "last", default: "Lovelace" }); | ||
| const firstName = createAtom({ key: "auth.firstName", default: "Ada" }); | ||
| const lastName = createAtom({ key: "auth.lastName", default: "Lovelace" }); | ||
| const fullName = derive("fullName", get => `${get(firstName)} ${get(lastName)}`); | ||
| const fullName = derive("auth.fullName", get => | ||
| `${get(firstName)} ${get(lastName)}`, | ||
| ); | ||
| fullName.value; // "Ada Lovelace" | ||
| fullName.value; // "Ada Lovelace" | ||
| firstName.update("Grace"); | ||
| fullName.value; // "Grace Lovelace" | ||
| fullName.value; // "Grace Lovelace" — recomputed | ||
| ``` | ||
| Conditional reads work — branches re-track dependencies on each run: | ||
| ### Behavior | ||
| | Trait | Effect | | ||
| |---|---| | ||
| | Eager initial compute | The compute fn runs once on creation to seed the value and discover deps. | | ||
| | Eager recompute | Any tracked dep change reruns compute and pushes through the atom's normal `update`. | | ||
| | Dynamic dep graph | Conditional reads add/drop deps each run — diffed and reconciled automatically. | | ||
| | Chaining | A derive that reads another derive recomputes when either changes. | | ||
| | Error isolation | Throws inside `compute` keep the previous value and re-throw asynchronously via `queueMicrotask` — they don't break the source atom's update cycle. | | ||
| | Auto cleanup | `derivedAtom.destroy()` unsubscribes from every tracked dep. | | ||
| ### Conditional reads (dynamic dep graph) | ||
| ```ts | ||
| const branch = createAtom({ key: "branch", default: "first" as "first" | "last" }); | ||
| const selected = derive("selected", get => | ||
| const selected = derive("auth.selected", get => | ||
| // The dep set changes when `branch` flips — old deps drop, new ones subscribe. | ||
| get(branch) === "first" ? get(firstName) : get(lastName), | ||
| ); | ||
| branch.update("last"); | ||
| selected.value; // reads lastName now; firstName dep was dropped | ||
| ``` | ||
| Chained derivations propagate: a derive that reads another derive recomputes when either changes. | ||
| ### Cross-atom gates | ||
| ```ts | ||
| const canCheckout = derive("checkout.allowed", get => | ||
| get(cart).length > 0 && | ||
| get(user).loggedIn && | ||
| !get(checkoutLoading).isLoading, | ||
| ); | ||
| if (canCheckout.value) { /* show button */ } | ||
| ``` | ||
| > Don't call `update` / `merge` / `change` on a derived atom — it works, but the next dependency change overwrites your write. Use a regular `createAtom` if you need writable state. | ||
| --- | ||
| ## Persistence | ||
| Atoms can persist their value to any store-shaped object: localStorage, sessionStorage, cookies, IndexedDB, `@mongez/cache`, a remote backend — anything matching the `PersistAdapter` shape. | ||
| ### PersistAdapter contract | ||
| ```ts | ||
| import { createAtom, type PersistAdapter } from "@mongez/atom"; | ||
| type PersistAdapter<V = unknown> = { | ||
| get(key: string): V | undefined | Promise<V | undefined>; | ||
| set(key: string, value: V): void | Promise<void>; | ||
| remove(key: string): void | Promise<void>; | ||
| }; | ||
| ``` | ||
| // Built-in localStorage adapter (client-only; no-ops on the server) | ||
| Methods may be sync OR async — the engine awaits Promises internally. | ||
| ### Built-in localStorage adapter | ||
| ```ts | ||
| import { createAtom } from "@mongez/atom"; | ||
| const themeAtom = createAtom({ | ||
| key: "theme", | ||
| default: "light", | ||
| persist: true, | ||
| key: "ui.theme", | ||
| default: "light" as "light" | "dark", | ||
| persist: true, // shorthand for the built-in localStorageAdapter | ||
| }); | ||
| // Or plug in any adapter (sync or async) | ||
| const myAdapter: PersistAdapter = { | ||
| get: (key) => myCache.get(key), | ||
| set: (key, value) => myCache.set(key, value), | ||
| remove: (key) => myCache.delete(key), | ||
| }; | ||
| themeAtom.update("dark"); | ||
| // On next page load, themeAtom.value === "dark" — restored silently from localStorage["ui.theme"] | ||
| ``` | ||
| const userAtom = createAtom({ | ||
| key: "user", | ||
| default: { name: "Anon" }, | ||
| persist: myAdapter, | ||
| The built-in adapter checks `typeof window === "undefined"` and no-ops on the server. | ||
| ### Lifecycle | ||
| 1. **Bootstrap.** On creation the adapter is read. A stored value lands via `silentUpdate` (no `update` event fires for the hydration). Async adapters resolve after the constructor returns. | ||
| 2. **Write-through.** Every `update` / `change` / `merge` writes to the adapter. `silentUpdate` does NOT — silent means silent, including to storage. | ||
| 3. **Reset removes.** `reset()` deletes the entry from the adapter; the next session starts fresh. | ||
| 4. **Error tolerance.** Sync throws and async rejections are swallowed so a transient storage error (quota exceeded, private-mode block) never crashes the atom. | ||
| ### Custom adapter — cookies (SSR-friendly) | ||
| ```ts | ||
| import { createAtom, type PersistAdapter } from "@mongez/atom"; | ||
| function cookieAdapter(): PersistAdapter { | ||
| return { | ||
| get(key) { | ||
| if (typeof document === "undefined") return undefined; | ||
| const m = document.cookie.match(new RegExp(`(?:^|; )${key}=([^;]*)`)); | ||
| if (!m) return undefined; | ||
| try { return JSON.parse(decodeURIComponent(m[1])); } catch { return undefined; } | ||
| }, | ||
| set(key, value) { | ||
| if (typeof document === "undefined") return; | ||
| document.cookie = `${key}=${encodeURIComponent(JSON.stringify(value))};path=/;max-age=31536000`; | ||
| }, | ||
| remove(key) { | ||
| if (typeof document === "undefined") return; | ||
| document.cookie = `${key}=;path=/;max-age=0`; | ||
| }, | ||
| }; | ||
| } | ||
| const localeAtom = createAtom({ | ||
| key: "ui.locale", | ||
| default: "en", | ||
| persist: cookieAdapter(), | ||
| }); | ||
| ``` | ||
| Behavior: | ||
| > The atom's `key` IS the storage key. Namespace your keys (`auth.user`, `ui.theme`) to avoid collisions across atoms. | ||
| 1. On atom creation the adapter is read. A stored value replaces the default via `silentUpdate` (no `update` event fires for the hydration). | ||
| 2. Every subsequent `update` writes through to the adapter. | ||
| 3. `reset()` removes the entry. | ||
| --- | ||
| For SSR, pair with a cookie-aware adapter so the server can read the persisted value during render — localStorage doesn't exist on the server and the built-in adapter no-ops there. | ||
| ## SSR isolation — `AtomStore` | ||
| The module-level `atoms` registry is shared per Node process. Two concurrent SSR requests would write to the same atoms. `AtomStore` solves it: | ||
| The module-level `atoms` registry is shared per Node process. In SSR, two concurrent requests would write to the same atoms. `AtomStore` gives each request its own scoped clones — the template atoms are never mutated. | ||
| ```ts | ||
| import { createAtomStore } from "@mongez/atom"; | ||
| import { userAtom } from "./state"; | ||
| import { userAtom, localeAtom } from "./state"; | ||
| const store = createAtomStore(); | ||
| store.use(userAtom).update({ name: "Alice" }); | ||
| async function handleRequest(req, res) { | ||
| const store = createAtomStore(); | ||
| userAtom.value; // { name: "Anon" } (template untouched) | ||
| store.get("user")?.value; // { name: "Alice" } (scoped to this store) | ||
| ``` | ||
| try { | ||
| // Lazily clone — first call creates a scoped copy of the template. | ||
| const user = store.use(userAtom); | ||
| user.update({ name: req.user.name, loggedIn: true }); | ||
| API: | ||
| userAtom.value; // { name: "Anon", loggedIn: false } — template untouched | ||
| store.get("user")?.value; // { name: req.user.name, ... } — scoped | ||
| ```ts | ||
| class AtomStore { | ||
| use<V, A>(template: Atom<V, A>): Atom<V, A>; // lazy clone | ||
| get<V>(key: string): Atom<V> | undefined; | ||
| has(key: string): boolean; | ||
| list(): Atom<any>[]; | ||
| hydrate(snapshot: Record<string, unknown>): void; | ||
| snapshot(): Record<string, unknown>; | ||
| destroy(): void; | ||
| const html = renderApp(store); | ||
| const snapshot = store.snapshot(); // serialize for client hydration | ||
| res.send(buildHtml(html, snapshot)); | ||
| } finally { | ||
| // CRITICAL: scoped clones subscribe to the event bus; destroy to release them. | ||
| store.destroy(); | ||
| } | ||
| } | ||
| ``` | ||
| For the React side (`<AtomStoreProvider>`, `useAtom`, `useAtomStore`, hydration helpers), see [`@mongez/react-atom`](https://github.com/hassanzohdy/mongez-react-atom). | ||
| ### API | ||
| ## DevTools | ||
| | Member | Behavior | | ||
| |---|---| | ||
| | `store.use(template)` | Lazy clone — first call creates the scoped clone, later calls return it. | | ||
| | `store.get(key)` | Look up an existing scoped atom by the template's original key. `undefined` if not yet `use()`d. | | ||
| | `store.has(key)` | True when `use(template)` has been called for the matching key. | | ||
| | `store.list()` | All scoped atoms currently in this store, in insertion order. | | ||
| | `store.hydrate(snapshot)` | Apply a snapshot. Atoms not yet used have their values queued and applied on the first `use(template)` call. | | ||
| | `store.snapshot()` | Serialize every scoped atom's value to a plain object — pair with `hydrate` on the client. | | ||
| | `store.destroy()` | Destroy every scoped clone and clear the store. Always call after each request. | | ||
| > `store.use(template)` is the only path that sees scoped state on the server. Reading `userAtom.value` directly on the server returns the template default, not the request-scoped value. Route every server-side read through `store.use()`. | ||
| > The React-side wiring (`<AtomStoreProvider>`, `useAtom`, `useAtomStore`) lives in [`@mongez/react-atom`](https://github.com/hassanzohdy/mongez-react-atom). | ||
| --- | ||
| ## Redux DevTools — `enableAtomDevtools` | ||
| ```ts | ||
@@ -207,3 +516,4 @@ import { enableAtomDevtools } from "@mongez/atom"; | ||
| name: "MyApp", | ||
| ignore: [/^mouse\./, /^scroll\./], // skip high-frequency atoms | ||
| ignore: [/^mouse\./, /^scroll\./, "perf.heartbeat"], // skip noisy atoms | ||
| scanInterval: 1000, // ms; default 1000 | ||
| }); | ||
@@ -213,82 +523,345 @@ } | ||
| Connects to `window.__REDUX_DEVTOOLS_EXTENSION__`. You get a live atom list, an update timeline, and time-travel via `JUMP_TO_STATE`. No-op when the extension isn't installed. Tree-shaken when you don't import it. | ||
| | Option | Default | Effect | | ||
| |---|---|---| | ||
| | `name` | `"@mongez/atom"` | Label shown in the extension UI. | | ||
| | `ignore` | `[]` | Patterns (string exact-match or RegExp) — atoms whose key matches are skipped. | | ||
| | `scanInterval` | `1000` | ms between polls for newly-registered atoms (lazy-loaded routes). | | ||
| What you get: | ||
| - Initial snapshot of every registered atom. | ||
| - Per-update timeline entries typed as `${atomKey}/update` with the new value as payload. | ||
| - Lifecycle entries: `${atomKey}/reset` and `${atomKey}/destroy`. | ||
| - Time-travel: jumping in the timeline restores every atom via `silentUpdate` + a synthetic update event so React subscribers re-render. | ||
| `enableAtomDevtools` returns a teardown function. Call it on hot-reload to release subscriptions cleanly: | ||
| ```ts | ||
| const teardown = enableAtomDevtools({ name: "MyApp" }); | ||
| if ((import.meta as any).hot) { | ||
| (import.meta as any).hot.dispose(() => teardown()); | ||
| } | ||
| ``` | ||
| > Connects to `window.__REDUX_DEVTOOLS_EXTENSION__`. No-op (returns an empty teardown) when the extension isn't installed. Tree-shaken from your bundle when never imported. | ||
| --- | ||
| ## Lifecycle events | ||
| Each atom emits on the `@mongez/events` bus under the namespace `atoms.${key}`: | ||
| Every atom emits on the `@mongez/events` bus under the namespace `atoms.${key}`: | ||
| - `atoms.${key}.update` — fired by `update` / `change` / `merge` | ||
| - `atoms.${key}.reset` — fired by `reset` / `silentReset` | ||
| - `atoms.${key}.delete` — fired by `destroy` | ||
| | Event | Fired by | | ||
| |---|---| | ||
| | `atoms.${key}.update` | `update()`, `change()`, `merge()` | | ||
| | `atoms.${key}.reset` | `reset()`, `silentReset()` | | ||
| | `atoms.${key}.delete` | `destroy()` | | ||
| Namespace matching in `@mongez/events` is segment-aware, so destroying `users.1` does **not** also destroy `users.10`. | ||
| Namespace matching is segment-aware, so destroying `users.1` does NOT also wipe `users.10`. | ||
| ## Examples | ||
| ```ts | ||
| import events from "@mongez/events"; | ||
| ### A cart with computed totals via getters | ||
| events.subscribe("atoms.auth.user.update", (next, prev, atom) => { | ||
| console.log("user changed:", prev, "→", next); | ||
| }); | ||
| ``` | ||
| --- | ||
| ## Recipes | ||
| ### Persist user preferences across reloads | ||
| Reach for this when small, JSON-serializable settings (theme, language, sidebar collapsed) need to survive refresh. | ||
| ```ts | ||
| type Item = { id: string; price: number; qty: number }; | ||
| import { createAtom } from "@mongez/atom"; | ||
| const cart = atomCollection<Item>({ | ||
| key: "cart", | ||
| type Prefs = { | ||
| theme: "light" | "dark"; | ||
| locale: "en" | "fr" | "ar"; | ||
| sidebarCollapsed: boolean; | ||
| }; | ||
| const prefsAtom = createAtom({ | ||
| key: "ui.prefs", | ||
| default: { theme: "light", locale: "en", sidebarCollapsed: false } satisfies Prefs, | ||
| persist: true, // localStorage["ui.prefs"] | ||
| }); | ||
| prefsAtom.change("theme", "dark"); // typed: ("theme", "light" | "dark") | ||
| prefsAtom.merge({ locale: "fr" }); | ||
| // On next page load, prefsAtom.value reflects whatever the user last set. | ||
| ``` | ||
| ### Build a shopping cart with computed totals | ||
| Reach for this when you need an array atom that tracks line items plus derived properties (subtotal, tax, count) that should never go stale. | ||
| ```ts | ||
| import { atomCollection, type Atom } from "@mongez/atom"; | ||
| type LineItem = { sku: string; name: string; price: number; qty: number }; | ||
| const cart = atomCollection<LineItem>({ | ||
| key: "cart.items", | ||
| default: [], | ||
| actions: { | ||
| get total() { | ||
| return this.value.reduce((sum, i) => sum + i.price * i.qty, 0); | ||
| addItem(item: LineItem) { | ||
| const existing = this.value.find(i => i.sku === item.sku); | ||
| if (existing) { | ||
| // Why: merge quantities instead of duplicating SKUs. | ||
| this.update(this.value.map(i => | ||
| i.sku === item.sku ? { ...i, qty: i.qty + item.qty } : i, | ||
| )); | ||
| } else { | ||
| this.push(item); | ||
| } | ||
| }, | ||
| setQty(this: Atom<Item[]>, id: string, qty: number) { | ||
| this.update(this.value.map(i => (i.id === id ? { ...i, qty } : i))); | ||
| setQty(this: Atom<LineItem[]>, sku: string, qty: number) { | ||
| if (qty <= 0) { | ||
| this.remove(i => i.sku === sku); | ||
| return; | ||
| } | ||
| this.update(this.value.map(i => i.sku === sku ? { ...i, qty } : i)); | ||
| }, | ||
| get subtotal() { | ||
| return this.value.reduce((s, i) => s + i.price * i.qty, 0); | ||
| }, | ||
| get itemCount() { | ||
| return this.value.reduce((s, i) => s + i.qty, 0); | ||
| }, | ||
| }, | ||
| }); | ||
| cart.push({ id: "a", price: 10, qty: 2 }); | ||
| cart.total; // 20 | ||
| cart.setQty("a", 5); | ||
| cart.total; // 50 | ||
| cart.addItem({ sku: "BREAD-1", name: "Sourdough", price: 6.5, qty: 1 }); | ||
| cart.addItem({ sku: "BREAD-1", name: "Sourdough", price: 6.5, qty: 2 }); | ||
| cart.subtotal; // 19.5 | ||
| cart.itemCount; // 3 | ||
| cart.setQty("BREAD-1", 0); // removes line | ||
| cart.value; // [] | ||
| ``` | ||
| ### Derived "can checkout" across atoms | ||
| ### Compose state across atoms with `derive` | ||
| Reach for this when one value depends on several others and must stay in sync — checkout eligibility, badge counts, filtered views. | ||
| ```ts | ||
| const canCheckout = derive("canCheckout", get => | ||
| get(cart).length > 0 && | ||
| get(userAtom).loggedIn && | ||
| !get(checkoutLoading).isLoading, | ||
| import { createAtom, atomCollection, derive } from "@mongez/atom"; | ||
| type Order = { id: string; status: "draft" | "placed" | "paid" }; | ||
| const orders = atomCollection<Order>({ key: "orders", default: [] }); | ||
| const filter = createAtom({ key: "orders.filter", default: "all" as "all" | "draft" | "paid" }); | ||
| const user = createAtom({ key: "auth.user", default: { loggedIn: false } }); | ||
| const visibleOrders = derive("orders.visible", get => { | ||
| const list = get(orders); | ||
| const f = get(filter); | ||
| if (f === "all") return list; | ||
| return list.filter(o => o.status === f); | ||
| }); | ||
| const canPlaceOrder = derive("orders.canPlace", get => | ||
| get(user).loggedIn && get(orders).some(o => o.status === "draft"), | ||
| ); | ||
| if (canCheckout.value) { /* show button */ } | ||
| orders.push({ id: "o1", status: "draft" }); | ||
| user.merge({ loggedIn: true }); | ||
| visibleOrders.value; // [{ id: "o1", status: "draft" }] | ||
| canPlaceOrder.value; // true | ||
| filter.update("paid"); | ||
| visibleOrders.value; // [] — recomputed: filter changed | ||
| ``` | ||
| ### Persisted preferences | ||
| ### Isolate atom state per SSR request (Next.js / Express) | ||
| Reach for this when multiple concurrent server requests must never see each other's atom values — auth context, request locale, draft state. | ||
| ```ts | ||
| const themeAtom = createAtom({ | ||
| key: "ui.theme", | ||
| default: "light", | ||
| persist: true, | ||
| // state.ts — module-level template atoms (never mutated on the server) | ||
| import { createAtom } from "@mongez/atom"; | ||
| export const userAtom = createAtom({ key: "user", default: { name: "Anon", loggedIn: false } }); | ||
| export const localeAtom = createAtom({ key: "locale", default: "en" }); | ||
| ``` | ||
| ```ts | ||
| // server.ts — one store per request | ||
| import { createAtomStore } from "@mongez/atom"; | ||
| import { userAtom, localeAtom } from "./state"; | ||
| app.get("/dashboard", async (req, res) => { | ||
| const store = createAtomStore(); | ||
| try { | ||
| store.use(userAtom).update({ name: req.user.name, loggedIn: true }); | ||
| store.use(localeAtom).update(req.headers["accept-language"]?.slice(0, 2) ?? "en"); | ||
| const html = renderApp(store); | ||
| const snapshot = store.snapshot(); | ||
| // Embed snapshot in HTML so <AtomStoreProvider initialValues={...}> on | ||
| // the client can pick up where the server left off. | ||
| res.send(buildHtml(html, snapshot)); | ||
| } finally { | ||
| // Releases scoped clones' event-bus subscriptions; failing to call this | ||
| // creates a per-request listener leak that grows with traffic. | ||
| store.destroy(); | ||
| } | ||
| }); | ||
| ``` | ||
| // On next page load, themeAtom.value is whatever the user last set. | ||
| ### Wrap an async backend (IndexedDB, `@mongez/cache`) | ||
| Reach for this when localStorage is too small (5 MB cap) or too synchronous — large drafts, image blobs, queryable indexes. | ||
| ```ts | ||
| import { createAtom, type PersistAdapter } from "@mongez/atom"; | ||
| // Any async store works — the engine awaits the Promise and applies the | ||
| // value via silentUpdate when it resolves. | ||
| const idbAdapter: PersistAdapter = { | ||
| async get(key) { return await idb.get(key); }, | ||
| async set(k, v) { await idb.set(k, v); }, | ||
| async remove(k) { await idb.delete(k); }, | ||
| }; | ||
| const draftAtom = createAtom({ | ||
| key: "post.draft", | ||
| default: { title: "", body: "", attachments: [] as string[] }, | ||
| persist: idbAdapter, | ||
| }); | ||
| // First render shows the default; once the IDB read resolves, the value | ||
| // flips in via silentUpdate. In React (via @mongez/react-atom), the | ||
| // component re-renders once the snapshot changes. | ||
| draftAtom.merge({ title: "Draft post" }); | ||
| ``` | ||
| ### Mirror a derived value into another atom via `onChange` | ||
| Reach for this when you need a side-effect tied to another atom (analytics, URL sync, devtools logging) — `derive` is for pure values, `onChange` is for effects. | ||
| ```ts | ||
| import { createAtom } from "@mongez/atom"; | ||
| const inputAtom = createAtom({ key: "search.input", default: "" }); | ||
| const slugAtom = createAtom({ key: "search.slug", default: "" }); | ||
| const sub = inputAtom.onChange(next => { | ||
| // Why: keep the URL slug in sync with the user's typing. | ||
| slugAtom.update(next.toLowerCase().trim().replace(/\s+/g, "-")); | ||
| }); | ||
| inputAtom.update("Hello World"); | ||
| slugAtom.value; // "hello-world" | ||
| sub.unsubscribe(); // stop mirroring | ||
| ``` | ||
| ### A throwaway scratch atom | ||
| Reach for this when you need ephemeral state but don't want to invent a long-lived key. | ||
| ```ts | ||
| import { createAtom } from "@mongez/atom"; | ||
| const scratch = createAtom({ | ||
| key: `scratch.${crypto.randomUUID()}`, | ||
| default: { x: 0, y: 0 }, | ||
| }); | ||
| scratch.merge({ x: 10 }); | ||
| // Clean up so the registry doesn't grow without bound. | ||
| scratch.destroy(); | ||
| ``` | ||
| > The registry is process-wide. For long-lived ad-hoc atoms, give them a deterministic, namespaced key instead so two callers can reach the same atom. | ||
| ### Wire DevTools with HMR-safe teardown | ||
| Reach for this when you want a Redux DevTools timeline in dev without leaking subscriptions across hot reloads. | ||
| ```ts | ||
| import { enableAtomDevtools } from "@mongez/atom"; | ||
| let teardownDevtools: (() => void) | undefined; | ||
| if (process.env.NODE_ENV !== "production") { | ||
| teardownDevtools = enableAtomDevtools({ | ||
| name: "MyApp", | ||
| ignore: [/^mouse\./, /^scroll\./, /^perf\./], | ||
| }); | ||
| } | ||
| // Vite HMR | ||
| if ((import.meta as any).hot) { | ||
| (import.meta as any).hot.dispose(() => teardownDevtools?.()); | ||
| } | ||
| ``` | ||
| The extension is detected at call time — `enableAtomDevtools` returns a no-op teardown when the extension isn't installed, so the same code is safe in CI / preview / production builds. | ||
| --- | ||
| ## TypeScript | ||
| - `Atom<V>` is a conditional type. Object-only methods (`merge`, `change`, `silentChange`, `get(key)`, `watch(key, cb)`) only exist when `V` is an object/array. `Atom<boolean>.change(...)` is a compile error. | ||
| - `AtomActions<V>` no longer collapses to `any`. Per-action type safety is preserved. | ||
| - `AtomOptions.default: V` must be a complete value (no `Partial<V>` default). | ||
| ```ts | ||
| import type { | ||
| Atom, | ||
| AtomOptions, | ||
| AtomActions, | ||
| AtomChangeCallback, | ||
| AtomCollectionActions, | ||
| AtomPartialChangeCallback, | ||
| AtomValue, | ||
| BaseAtom, | ||
| CollectionOptions, | ||
| CreateAtomOptions, | ||
| DeriveGetter, | ||
| DeriveOptions, | ||
| EnableDevtoolsOptions, | ||
| IsObjectValue, | ||
| ObjectAtom, | ||
| PersistAdapter, | ||
| PersistOption, | ||
| } from "@mongez/atom"; | ||
| ``` | ||
| See [`MIGRATION.md`](./MIGRATION.md) for the v1 → v2 transition. | ||
| Notes: | ||
| - `Atom<V, A>` is a conditional type — `BaseAtom<V, A> & (IsObjectValue<V> extends true ? ObjectAtom<V> : {}) & A`. Object-only methods only exist when `V` is an object/array. | ||
| - `AtomActions<V>` no longer collapses to `any` (a v1-era index-signature bug). Per-action types are preserved. | ||
| - `AtomOptions.default: V` must be a complete value. `Partial<V>` is rejected at the type level. | ||
| - See [`MIGRATION.md`](./MIGRATION.md) for the full 1.x → 2.0 transition. | ||
| --- | ||
| ## Related packages | ||
| | Package | Purpose | | ||
| | Package | Use when you need | | ||
| |---|---| | ||
| | [`@mongez/react-atom`](https://github.com/hassanzohdy/mongez-react-atom) | React hooks, `<AtomStoreProvider>`, SSR helpers, preset atoms. | | ||
| | [`@mongez/atomic-query`](https://github.com/hassanzohdy/mongez-atomic-query) | Client-side query cache: `useQuery`, `useMutation`, `useInfiniteQuery`, `useSuspenseQuery`. | | ||
| | [`@mongez/events`](https://github.com/hassanzohdy/events) | Tiny event bus. Used internally. | | ||
| | [`@mongez/reinforcements`](https://github.com/hassanzohdy/reinforcements) | TypeScript utility belt. `clone`, `get`, … used internally. | | ||
| | [`@mongez/react-atom`](https://github.com/hassanzohdy/mongez-react-atom) | React hooks (`useValue`, `useState`, `use(key)`, `useWatch`), `<AtomStoreProvider>`, SSR hydration helpers. | | ||
| | [`@mongez/atomic-query`](https://github.com/hassanzohdy/mongez-atomic-query) | Server-state cache built on atoms: `useQuery`, `useMutation`, `useInfiniteQuery`, `useSuspenseQuery`. | | ||
| | [`@mongez/cache`](https://github.com/hassanzohdy/mongez-cache) | Pluggable cache (localStorage / sessionStorage / encrypted / in-memory) — its driver shape matches `PersistAdapter`. | | ||
| | [`@mongez/events`](https://github.com/hassanzohdy/events) | Tiny event bus. Used internally for the `atoms.${key}.*` namespace. | | ||
| --- | ||
| ## Further reading | ||
| - [`MIGRATION.md`](./MIGRATION.md) — 1.x → 2.0 breaking changes and the migration steps. | ||
| - [`CHANGELOG.md`](./CHANGELOG.md) — release notes and documented quirks. | ||
| - [`llms-full.txt`](./llms-full.txt) — exhaustive single-file API surface for tool-assisted development. | ||
| - [`skills/`](./skills) — per-topic deep-dives (atoms, collections, derived, persist, stores, devtools, actions, recipes). | ||
| --- | ||
| ## License | ||
| MIT | ||
| MIT — see [LICENSE](./LICENSE). |
268094
8.98%863
197.59%