From 264e8f839da6d4a6e201ef332fcd4649f6c3893e Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 19 Sep 2026 01:43:24 +0200 Subject: [PATCH] Persistence --- README.LLM.md | 410 ---------- README.md | 728 +++++++----------- src/App.jsx | 51 +- src/examples/automats/cartAutomat.js | 78 ++ src/examples/automats/indexAutomat.js | 27 +- src/examples/components/CartBadgeDisplay.jsx | 200 +++++ src/examples/components/CartManager.jsx | 227 ++++++ .../components/DynamicAutomatSubscriber.jsx | 2 +- src/lib/Automat.js | 185 ++++- src/lib/index.js | 14 +- 10 files changed, 1050 insertions(+), 872 deletions(-) delete mode 100644 README.LLM.md create mode 100644 src/examples/automats/cartAutomat.js create mode 100644 src/examples/components/CartBadgeDisplay.jsx create mode 100644 src/examples/components/CartManager.jsx diff --git a/README.LLM.md b/README.LLM.md deleted file mode 100644 index 73a9170..0000000 --- a/README.LLM.md +++ /dev/null @@ -1,410 +0,0 @@ -# Automat β€” LLM Agent API Reference & Integration Guide - -This document is optimized for LLMs and AI coding assistants implementing or consuming the `automat` state management library. - ---- - -## 1. System Overview & Core Invariants - -`automat` is a lightweight (~1.1 kB minified, zero-dependency) observable state container designed specifically for React `PureComponent`. - -### Key Invariants -1. **Instance Lifetime (Module Singleton or Dynamic Map Registry)**: An `Automat` instance lives outside the React render tree. While commonly instantiated as module-level singletons, instances can also be stored dynamically in a `Map` (e.g. `window.automats = new Map()` or an in-memory entity cache) keyed by ID or index. State persists in memory across component mounts, unmounts, and subscription transfers. -2. **Direct Constructor Read**: Components read `automat.state` directly in their `constructor(props)`. State is never stale upon mounting. -3. **Lifecycle Subscription**: Components register with `automat.subscribe(this)` in `componentDidMount()` and call `this.unsubscribe()` in `componentWillUnmount()`. -4. **Hybrid State by Default**: When `automat.setState()` notifies a component, it calls `component.setState(partial)`. React's class component `setState` performs a shallow merge, preserving any component-local state fields. -5. **Fine-Grained Slice Subscriptions**: By default, `automat.subscribe(this)` notifies on any state change. When a subscriber only cares about a subset of state, passing a selector (`'key'`, `['keyA', 'keyB']`, or `(state) => ({ ... })`) enables internal shallow equality checking (`shallowEqual`). Updates to unrelated state fields will NOT trigger `setState` or re-renders. -6. **No Wrappers or Hooks**: No HOCs, no context providers, no hooks, no `connect()`. - ---- - -## 2. API Reference - -### Constructor - -```ts -new Automat>( - initialState: T, - actions?: A -): Automat -``` - -- `initialState`: Object representing the initial state snapshot. Shallow-copied internally. -- `actions` *(optional)*: Object of action functions. Accessible via `automat.actions`. - ---- - -### Instance Properties & Methods - -| Member | Signature | Description | -|---|---|---| -| `state` | `get state(): T` | Direct getter returning current state snapshot. Ideal for component constructors: `this.state = myAutomat.state;`. | -| `getState()` | `getState(): T` | Method returning current state snapshot. | -| `actions` | `get actions(): A` | Getter returning the actions object passed into the constructor. | -| `setState()` | `setState(partial: Partial): T` | Shallow-merges `partial` into current state and synchronously notifies all subscribers. Returns new state. | -| `subscribe()` | `subscribe(target: PureComponent \| ((state: T) => void), selector?: string \| string[] \| ((state: T) => object \| null)): () => void` | Subscribes either a React component instance (`this`) or a callback function. When `selector` is provided, performs shallow equality checking to ensure updates to unrelated state fields never trigger `setState` or re-renders. | -| `select()` | `select(selector: string \| string[] \| ((state: T) => any)): { readonly state: any, subscribe(target): () => void }` | Slices an automat to a specific subset of state for direct constructor reads and scoped subscriptions. | -| `unsubscribe()` | `unsubscribe(target: PureComponent \| Function): void` | Manually unregisters a subscriber. | -| `subscribeTo()` | `subscribeTo(upstream: Automat, transform: (upstreamState: U, myState: T) => Partial \| null): this` | Reactive pipeline: Derives state from an upstream automat. Returns `this` for chaining. | -| `dispose()` | `dispose(): void` | Unsubscribes all upstream listeners and clears all subscribers. | - ---- - -## 3. Canonical Patterns for Code Generation - -### Pattern A: Defining an Automat Module - -```js -// src/automats/counterAutomat.js -import { Automat } from 'automat'; // or relative path to lib/index.js - -export const counterAutomat = new Automat( - { count: 0 }, - { - increment(step = 1) { - counterAutomat.setState({ count: counterAutomat.state.count + step }); - }, - decrement(step = 1) { - counterAutomat.setState({ count: counterAutomat.state.count - step }); - }, - reset() { - counterAutomat.setState({ count: 0 }); - }, - } -); -``` - ---- - -### Pattern B: React `PureComponent` Integration & Click Wiring - -```jsx -// src/components/CounterController.jsx -import { PureComponent } from 'react'; -import { counterAutomat } from '../automats/counterAutomat.js'; - -export class CounterController extends PureComponent { - constructor(props) { - super(props); - // 1. Initialize hybrid state: shared automat fields + component-local fields - this.state = { - count: counterAutomat.state.count, // shared state from automat - step: 1, // local state private to component - }; - } - - componentDidMount() { - // 2. Subscribe component to automat updates: - this.unsubscribe = counterAutomat.subscribe(this); - } - - componentWillUnmount() { - // 3. Clean up subscription on unmount: - this.unsubscribe(); - } - - // 4. Click handlers: invoke automat action directly - handleIncrement = () => { - counterAutomat.actions.increment(this.state.step); - }; - - handleDecrement = () => { - counterAutomat.actions.decrement(this.state.step); - }; - - render() { - const { count, step } = this.state; - return ( -
-

Count: {count}

- - -
- ); - } -} -``` - ---- - -### Pattern C: Partial & Slice Subscriptions (Subscribing to Part of the State) - -When an automat manages multiple state fields (e.g. `{ count, filter, theme, user }`), subscribing with `automat.subscribe(this)` causes any state change in the automat to notify the component. When a component only cares about a subset of the automat's state, subscribe using a **slice selector**. - -`Automat` performs an internal shallow equality check (`shallowEqual(lastSlice, nextSlice)`). Updates to other, unrelated fields in the automat **will NOT trigger `setState` or re-renders** for that subscriber. - -#### Supported Selector Forms: - -1. **Single key string** (most concise): -```jsx -// Subscribes ONLY to 'count'. Injects { count } into component setState. -this.unsubscribe = myAutomat.subscribe(this, 'count'); -``` - -2. **Array of keys**: -```jsx -// Subscribes ONLY to 'count' and 'step'. Injects { count, step } into component setState. -this.unsubscribe = myAutomat.subscribe(this, ['count', 'step']); -``` - -3. **Selector function**: -```jsx -// Computes a custom slice. Returning null or undefined skips updates. -this.unsubscribe = myAutomat.subscribe(this, (state) => ({ - count: state.count, - isEven: state.count % 2 === 0, -})); -``` - -4. **Scoping via `.select()`**: -```jsx -// Slicing helper for both constructor reading and subscription: -const countSlice = myAutomat.select('count'); -this.state = countSlice.state; // { count: 0 } -this.unsubscribe = countSlice.subscribe(this); -``` - -#### Example: Passive Reader Subscribing Only to a Slice - -```jsx -// src/components/CountDisplay.jsx -import { PureComponent } from 'react'; -import { appAutomat } from '../automats/appAutomat.js'; - -export class CountDisplay extends PureComponent { - constructor(props) { - super(props); - // Initialize with only the slice needed: - this.state = { count: appAutomat.state.count }; - } - - componentDidMount() { - // πŸ’‘ Partial subscription: only re-renders when `count` changes. - // Mutations to appAutomat.theme, .user, etc. will NOT trigger setState! - this.unsubscribe = appAutomat.subscribe(this, 'count'); - } - - componentWillUnmount() { - this.unsubscribe(); - } - - render() { - return

Current Count: {this.state.count}

; - } -} -``` - ---- - -### Pattern D: Reactive Cascade with `subscribeTo()` - -Use `subscribeTo()` to connect two automats into a reactive pipe. The `transform` function receives `(upstreamState, myState)`: - -```js -// src/automats/auditAutomat.js -import { Automat } from 'automat'; -import { counterAutomat } from './counterAutomat.js'; - -export const auditAutomat = new Automat({ logs: [] }); - -// Wire reactive pipeline: -auditAutomat.subscribeTo( - counterAutomat, - (upstream, my) => - // Return null to conditionally skip updates; otherwise return partial state: - upstream.count === 0 - ? null - : { - logs: [ - { id: Date.now(), text: `Counter changed to ${upstream.count}` }, - ...my.logs.slice(0, 19), // Accumulate history up to 20 items - ], - } -); -``` - -#### Rules for `subscribeTo()`: -1. **`upstream`**: Snapshot of the observed automat after its update. -2. **`my`**: Snapshot of the current (downstream) automat *before* this update. Use this as an accumulator. -3. **Filtering (`return null`)**: Return `null` or `undefined` to bypass `setState()`, producing zero subscriber notifications and zero component re-renders. - ---- - -### Pattern E: API-Backed Auto-Sync Indexed Counter (POST) - -An automat can perform optimistic state updates immediately for responsive UI, while automatically synchronizing mutations to the backend via HTTP POST in the background: - -```js -// src/automats/syncCounterAutomat.js -import { Automat } from 'automat'; - -export const syncCounterAutomat = new Automat( - { - index: 0, - count: 0, - syncStatus: 'synced', // 'syncing' | 'synced' | 'error' - lastSyncedAt: null, - error: null, - }, - { - async increment(step = 1) { - const { index, count } = syncCounterAutomat.state; - const nextCount = count + step; - - // 1. Optimistic update (UI updates immediately): - syncCounterAutomat.setState({ - count: nextCount, - syncStatus: 'syncing', - error: null, - }); - - // 2. Automatic background sync via POST /api/counter: - try { - const res = await fetch('/api/counter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ index, count: nextCount }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - - // 3. Mark in-sync once server responds: - syncCounterAutomat.setState({ - syncStatus: 'synced', - lastSyncedAt: data.savedAt, - }); - } catch (err) { - syncCounterAutomat.setState({ - syncStatus: 'error', - error: err.message, - }); - } - }, - } -); -``` - ---- - -### Pattern F: Dynamic Window Map (Count-Driven Automat Resubscription) - -In this pattern, a standard shared counter automat drives the index. A second component watches that counter and uses its value to access, dynamically instantiate, and resubscribe to a different `Automat` stored in `window.automats = new Map()`, instantly recalling that slot's state: - -```js -// 1. Shared index/counter automat (like Example 1): -export const indexAutomat = new Automat({ index: 0 }, { - increment() { indexAutomat.setState({ index: indexAutomat.state.index + 1 }); }, - decrement() { indexAutomat.setState({ index: Math.max(0, indexAutomat.state.index - 1) }); }, -}); - -// 2. Map on window holding dynamically instantiated Automats per index: -if (typeof window !== 'undefined' && !window.automats) { - window.automats = new Map(); -} - -export function getOrCreateSlotAutomat(index) { - if (!window.automats.has(index)) { - const automat = new Automat( - { index, clicks: 0, notes: `Slot #${index} initial notes` }, - { - click() { automat.setState({ clicks: automat.state.clicks + 1 }); }, - setNotes(notes) { automat.setState({ notes }); }, - } - ); - window.automats.set(index, automat); - } - return window.automats.get(index); -} -``` - -#### Dynamic Resubscription Component: - -```jsx -// 3. Component dynamically resubscribing based on shared counter value: -export class DynamicSlotObserver extends PureComponent { - constructor(props) { - super(props); - const initialIndex = indexAutomat.state.index; - this.state = { - currentIndex: initialIndex, - slotState: getOrCreateSlotAutomat(initialIndex).state, // Recall state on mount - }; - } - - componentDidMount() { - // Watch shared index counter: - this.unsubIndex = indexAutomat.subscribe((indexState) => { - this.handleIndexChange(indexState.index); - }); - // Subscribe to initial slot automat: - this.subscribeToSlot(this.state.currentIndex); - } - - componentWillUnmount() { - this.unsubIndex?.(); - this.unsubSlot?.(); - } - - handleIndexChange(newIndex) { - if (newIndex === this.state.currentIndex) return; - - // πŸ’‘ DYNAMIC RESUBSCRIPTION WORKFLOW: - // 1. Unhook old subscription: - this.unsubSlot?.(); - - // 2. Lookup or dynamically instantiate in window.automats: - const slotAutomat = getOrCreateSlotAutomat(newIndex); - - // 3. Recall preserved state immediately: - this.setState({ - currentIndex: newIndex, - slotState: slotAutomat.state, - }); - - // 4. Resubscribe to the newly selected instance: - this.unsubSlot = slotAutomat.subscribe((slotState) => { - this.setState({ slotState }); - }); - } - - subscribeToSlot(index) { - this.unsubSlot = getOrCreateSlotAutomat(index).subscribe((slotState) => { - this.setState({ slotState }); - }); - } - - render() { - const { currentIndex, slotState } = this.state; - return ( -
-

Slot #{currentIndex}

-

Recalled Clicks: {slotState.clicks}

- -
- ); - } -} -``` - ---- - -## 4. Strict Agent Guidelines (DOs and DON'Ts) - -### βœ… DOs -- **DO** create `Automat` instances in module scope or outside React components. -- **DO** initialize component state synchronously in `constructor(props)` using `this.state = myAutomat.state;` or `{ ...myAutomat.state, localField: 'val' }`. -- **DO** register subscriptions in `componentDidMount()` via `this.unsubscribe = myAutomat.subscribe(this);`. -- **DO** use slice selectors (`myAutomat.subscribe(this, 'fieldName')` or `['fieldA', 'fieldB']`) when a component only needs part of the state, preventing unnecessary `setState` triggers and re-renders when unrelated fields change. -- **DO** clean up subscriptions in `componentWillUnmount()` via `this.unsubscribe();`. -- **DO** invoke actions directly from event handlers (e.g. `onClick={() => myAutomat.actions.doSomething()}`). -- **DO** return `null` in `subscribeTo` transforms when an update should be filtered out. - -### ❌ DON'Ts -- **DON'T** instantiate `new Automat()` inside a React component's `render()`, `constructor()`, or lifecycle method. -- **DON'T** subscribe with bare `myAutomat.subscribe(this)` if the component only depends on a specific subset of fields in a multi-field automat; use a slice selector instead. -- **DON'T** mutate state directly (e.g. `myAutomat.state.count = 5` is forbidden). Always call `myAutomat.setState({ count: 5 })` or an action. -- **DON'T** wrap components in React Context providers, HOCs, or `connect()`. -- **DON'T** use React Hooks (`useState`, `useEffect`) when targeting the `automat` class component architecture. Use `PureComponent`. -- **DON'T** forget to store the return value of `subscribe(this)` and call it in `componentWillUnmount()`, as this will cause memory leaks. diff --git a/README.md b/README.md index e67d7d4..674db90 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,16 @@ -# βš™ automat +# Automat -> Observable state management for React `PureComponent`. -> State lives **independently of component lifecycle** β€” mounts and unmounts freely without losing state. -> **No wrappers, no HOCs, no `connect()` β€” purely direct access and lifecycle subscriber handling.** - -```bash -npm run dev -``` +Observable state management for React `PureComponent` β€” state independent of component lifecycle. Lightweight (~1.1 kB minified) and zero-dependency. --- -## Motivation +## Quick Start -Redux separates state from UI, but brings boilerplate and pushes toward hooks. Higher-order wrappers and `connect()` introduce indirection, wrapper nesting, and tricky state hydration. - -`automat` provides a clean, direct approach centered on standard `React.PureComponent`: - -1. **Instantiate first**: The Automat instance is created outside React's render tree. -2. **Direct constructor access**: Components initialize directly from `automat.state` (or `getState()`) β€” never stale, even after transitions prior to mounting. -3. **Lifecycle subscriber handling**: In `componentDidMount`, register the component with `automat.subscribe(this)`. In `componentWillUnmount`, call `this.unsubscribe()` or `automat.unsubscribe(this)`. -4. **Direct event wiring**: Call `automat.actions.actionName()` or `automat.setState(...)` directly in `onClick` handlers. No dispatchers, actions creators, or prop drilling. -5. **Render minimization**: Standard `PureComponent` shallow state comparison prevents unnecessary re-renders automatically without extra layers. - ---- - -## Direct PureComponent Pattern (Wired Click Example) - -Here is a complete, two-component example showing how clicks trigger actions and synchronize independent components: - -```jsx -import { PureComponent } from 'react'; +```js +// counterAutomat.js import { Automat } from 'automat'; -// 1. Instantiate the automat outside React: -const counterAutomat = new Automat( +export const counterAutomat = new Automat( { count: 0 }, { increment(step = 1) { @@ -42,152 +19,39 @@ const counterAutomat = new Automat( decrement(step = 1) { counterAutomat.setState({ count: counterAutomat.state.count - step }); }, - reset() { - counterAutomat.setState({ count: 0 }); - }, } ); +``` -// 2. Controller component: buttons trigger actions, hybrid state tracks local clicks -class CounterButton extends PureComponent { +```jsx +// Counter.jsx +import { PureComponent } from 'react'; +import { counterAutomat } from './counterAutomat.js'; + +export class Counter extends PureComponent { constructor(props) { super(props); - // πŸ’‘ HYBRID STATE: - // Shared count comes from the automat; step & localClicks are local - this.state = { - count: counterAutomat.state.count, // ← from automat - step: 1, // ← component-local state - localClicks: 0, // ← component-local state - }; - } - - componentDidMount() { - // Subscribe component to automat updates - this.unsubscribe = counterAutomat.subscribe(this); - } - - componentWillUnmount() { - this.unsubscribe(); - } - - // πŸ’‘ CLICK HANDLERS: update local state AND trigger automat actions - handleIncrement = () => { - const { step, localClicks } = this.state; - this.setState({ localClicks: localClicks + 1 }); - counterAutomat.actions.increment(step); // ← Triggers automat! - }; - - handleDecrement = () => { - const { step, localClicks } = this.state; - this.setState({ localClicks: localClicks + 1 }); - counterAutomat.actions.decrement(step); // ← Triggers automat! - }; - - handleReset = () => { - this.setState({ localClicks: 0 }); - counterAutomat.actions.reset(); // ← Triggers automat! - }; - - render() { - const { count, step, localClicks } = this.state; - return ( -
-

Count: {count} Β· Local Clicks: {localClicks}

- - {/* πŸ’‘ WIRED ONCLICK: calls handlers directly */} - - - -
- ); - } -} - -// 3. Independent Display component: reads same automat with ZERO props passed -class CounterDisplay extends PureComponent { - constructor(props) { - super(props); - // Reads directly from automat in constructor: + // 1. Read state directly in constructor (never stale) this.state = counterAutomat.state; } componentDidMount() { - // Automatically re-renders when CounterButton triggers an increment/decrement + // 2. Subscribe component to updates this.unsubscribe = counterAutomat.subscribe(this); } componentWillUnmount() { + // 3. Clean up on unmount this.unsubscribe(); } render() { - return

Display: {this.state.count}

; - } -} -``` - ---- - -### Why Hybrid State works seamlessly with React PureComponent - -When `counterAutomat.setState({ count: 42 })` notifies the subscriber: -1. It calls `this.setState({ count: 42 })` on the component instance. -2. React's class component `setState` performs a **shallow merge** into `this.state`. -3. Local fields (`step`, `localClicks`, `inputValue`) remain untouched. -4. `PureComponent`'s shallow comparison ensures renders happen only when values change. - -```jsx -class SearchBox extends PureComponent { - constructor(props) { - super(props); - this.state = { - ...searchAutomat.state, // results, loading, etc. - inputValue: '', // component-local input - }; - } - - componentDidMount() { - this.unsubscribe = searchAutomat.subscribe(this); - } - - componentWillUnmount() { - this.unsubscribe(); - } - - handleInput = (e) => { - this.setState({ inputValue: e.target.value }); - }; - - // πŸ’‘ Wired form submission / click: - handleSubmit = (e) => { - e.preventDefault(); - const query = this.state.inputValue.trim(); - if (query) { - searchAutomat.actions.search(query); // ← Triggers async search action - } - }; - - render() { - const { loading, results, inputValue } = this.state; return ( -
- - {/* πŸ’‘ Click triggers handleSubmit β†’ searchAutomat.actions.search() */} - - -
    - {results?.map((item) => ( -
  • {item.title}
  • - ))} -
-
+
+ {this.state.count} + + +
); } } @@ -195,296 +59,304 @@ class SearchBox extends PureComponent { --- -## Core API +## Core Invariants -### `new Automat(initialState, actions?)` - -```js -import { Automat } from './src/lib/index.js'; - -const counterAutomat = new Automat( - { count: 0 }, - { - increment(step = 1) { - counterAutomat.setState({ count: counterAutomat.state.count + step }); - }, - decrement(step = 1) { - counterAutomat.setState({ count: counterAutomat.state.count - step }); - }, - reset() { - counterAutomat.setState({ count: 0 }); - }, - } -); -``` - -| Member | Description | -|---|---| -| `automat.state` | Direct getter for current state snapshot (ideal for `constructor`) | -| `automat.getState()` | Returns current state snapshot | -| `automat.setState(partial)` | Merges partial into state and notifies all subscribers | -| `automat.subscribe(target, selector?)` | Subscribes a component (`this`) or callback. Supports key string, key array, or selector function. Avoids unnecessary re-renders via shallow equality check. | -| `automat.select(selector)` | Returns a sliced view `{ readonly state, subscribe(target) }` for direct constructor reads and scoped subscriptions. | -| `automat.unsubscribe(target)` | Unsubscribes a component instance or callback function | -| `automat.subscribeTo(upstream, transform)` | Notification cascade: derives state from an upstream automat | -| `automat.actions` | Named action callbacks passed to constructor β€” call directly from `onClick` | -| `automat.dispose()` | Tears down all upstream subscriptions and clears all subscribers | +1. **Lifecycle-Independent**: `Automat` instances live outside the React tree (typically in module scope), persisting state across component mounts and unmounts. +2. **Synchronous Constructor Reads**: Components initialize with `this.state = myAutomat.state;` directly in `constructor(props)`. +3. **Automatic Shallow Merges**: When subscribed with `subscribe(this)`, `automat.setState(partial)` calls `component.setState(partial)`, preserving any component-local state. +4. **Fine-Grained Selectors**: Passing a selector to `subscribe(this, selector)` runs shallow equality checks; updates to unrelated fields skip `setState` and avoid re-renders. +5. **Zero Wrappers**: No hooks, HOCs, Context Providers, or `connect()`. +6. **Optional Persistence**: Automats can specify a `name` and persist in the `window` object (`persist: false`) for session memory across unmounts/HMR, or in `IndexedDB` (`persist: true`) to survive full page reloads. --- -## Subscribing to Part of the State (Slice Subscriptions) +## API Reference -When an automat has multiple fields (e.g. `{ count, filter, theme, user }`), subscribing without a selector will cause any state change to trigger `this.setState()` on the subscriber. +### `new Automat(initialState, actions?, options?)` -When a component only cares about a subset of the automat's state, subscribe with a **slice selector**. `Automat` performs an internal shallow equality check (`shallowEqual(lastSlice, nextSlice)`), ensuring updates to other unrelated fields **never trigger `setState` or re-renders**: +Creates an observable state container with optional persistence. -### 1. Single Key String -```jsx -// Subscribes only to changes in 'count'. Unrelated fields will NOT trigger setState: -this.unsubscribe = myAutomat.subscribe(this, 'count'); +- `initialState` *(object)*: Initial state snapshot (shallow copied). +- `actions` *(object, optional)*: Action methods stored on `automat.actions`. +- `options` *(object, optional)*: + - `name` *(string)*: Unique identifier used for persistence and `Automat.get(name)` registry lookup. + - `persist` *(boolean)*: Persistence strategy when `name` is provided: + - `false` (default): Persists in the `window` object (session memory, survives component unmounts and HMR). + - `true`: Persists in `IndexedDB` (survives page reloads and browser restarts). + +```js +// Window-persisted (session memory) +const sessionStore = new Automat( + { filter: 'all' }, + { setFilter(filter) { sessionStore.setState({ filter }); } }, + { name: 'filter', persist: false } +); + +// IndexedDB-persisted (survives browser reload) +const cartStore = new Automat( + { items: [] }, + { addItem(item) { cartStore.setState({ items: [...cartStore.state.items, item] }); } }, + { name: 'cart', persist: true } +); ``` -### 2. Array of Keys -```jsx -// Subscribes only to 'count' and 'step': -this.unsubscribe = myAutomat.subscribe(this, ['count', 'step']); +--- + +### `automat.state` / `automat.getState()` + +Returns the current state snapshot. + +```js +const current = automat.state; +// or +const current = automat.getState(); ``` -### 3. Custom Selector Function -```jsx -// Computes a derived slice; returning null/undefined skips updates: -this.unsubscribe = myAutomat.subscribe(this, (state) => ({ - count: state.count, - isEven: state.count % 2 === 0, -})); +--- + +### `automat.setState(partial)` + +Shallow-merges `partial` into current state and synchronously notifies subscribers. Returns the updated state. + +```js +automat.setState({ count: 5 }); ``` -### 4. Automat Slicing with `.select()` -```jsx +--- + +### `automat.actions` + +Provides direct access to the actions object supplied in the constructor. + +```js +automat.actions.reset(); +``` + +--- + +### `automat.subscribe(target, selector?)` + +Subscribes a React `PureComponent` instance (`this`) or a callback function. Returns an unsubscribe function. + +```ts +subscribe( + target: PureComponent | ((state: T) => void), + selector?: string | string[] | ((state: T) => object | null) +): () => void +``` + +#### Selector forms: + +- **Single Key (string)**: + ```js + // Injects { count } into component setState only when count changes + this.unsubscribe = myAutomat.subscribe(this, 'count'); + ``` +- **Multiple Keys (array)**: + ```js + // Injects { count, text } only when either property changes + this.unsubscribe = myAutomat.subscribe(this, ['count', 'text']); + ``` +- **Selector Function**: + ```js + // Custom slice with shallow equality check; return null/undefined to skip update + this.unsubscribe = myAutomat.subscribe(this, (state) => ({ + badgeCount: state.items.length, + })); + ``` +- **Callback Function (non-React)**: + ```js + const unsub = myAutomat.subscribe((state) => console.log('State changed:', state)); + ``` + +--- + +### `automat.select(selector)` + +Creates a scoped slice containing a `.state` getter and a pre-scoped `.subscribe()` helper. + +```js const countSlice = myAutomat.select('count'); -this.state = countSlice.state; // { count: 0 } + +// In constructor: +this.state = countSlice.state; // { count: 0 } + +// In componentDidMount: this.unsubscribe = countSlice.subscribe(this); ``` --- -## Notification Cascade (Wired Example) +### `automat.unsubscribe(target)` -### How `subscribeTo()` Works (Reactive Pipeline) - -`subscribeTo()` establishes a **reactive pipeline between two automats** without React components in the middle. Think of it like a database trigger or spreadsheet formula: when the upstream changes, the downstream automatically derives new state. - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” setState() β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ counterAutomat β”‚ ───────────────────> β”‚ notificationAutomat β”‚ -β”‚ (Upstream) β”‚ β”‚ (Downstream) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ notifies - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ NotificationBar β”‚ - β”‚ (PureComponent UI) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -#### Code Anatomy: +Unregisters a subscriber. Prefer calling the function returned by `subscribe()`. ```js -// 1. Upstream automat (e.g. holds raw counter) -const counterAutomat = new Automat({ count: 0 }, { - increment(step = 1) { - counterAutomat.setState({ count: counterAutomat.state.count + step }); - }, -}); - -// 2. Downstream automat (e.g. maintains an event/audit log) -const notificationAutomat = new Automat( - { messages: [] }, - { - clear() { notificationAutomat.setState({ messages: [] }); }, - } -); - -// 3. Connect downstream to upstream (returns null to filter, or state object): -notificationAutomat.subscribeTo( - counterAutomat, - (upstream, my) => - upstream.count === 0 - ? null - : { - messages: [ - { - id: Date.now(), - text: `Counter changed to ${upstream.count}`, - time: new Date().toLocaleTimeString(), - count: upstream.count, - }, - ...my.messages.slice(0, 9), // Caps list at 10 items - ], - } -); +automat.unsubscribe(this); ``` -#### Parameter Breakdown: - -| Parameter | What it receives | Purpose | -|---|---|---| -| `upstreamAutomat` | `counterAutomat` | The automat to watch. Any time it calls `setState()`, the transform runs. | -| `upstreamState` | `{ count: 42 }` | The **new state snapshot** of the upstream automat. | -| `myState` | `{ messages: [...] }` | The **current state snapshot** of *this* downstream automat right before updating. Essential for accumulating history, comparing previous values, or merging. | -| **Return value** | `{ messages: [...] }` | A **partial state object** passed to `this.setState(partial)`. Returning `null` skips the update. | - -#### Filtering Updates (Conditional Derivation): - -You can selectively ignore upstream events by returning `null`: - -```js -// Only log notifications when count exceeds 10: -notificationAutomat.subscribeTo(counterAutomat, (upstreamState, myState) => { - if (upstreamState.count < 10) { - return null; // πŸ’‘ Returning null skips setState β€” no re-renders! - } - return { - messages: [{ id: Date.now(), text: `High value reached: ${upstreamState.count}` }, ...myState.messages], - }; -}); -``` - -#### Multiple Upstream Sources & Chaining: - -`subscribeTo()` returns `this`, so an automat can aggregate from multiple independent sources: - -```js -dashboardAutomat - .subscribeTo(userAutomat, (user) => ({ username: user.name })) - .subscribeTo(cartAutomat, (cart) => ({ cartItemCount: cart.items.length })); -``` - -#### Teardown: - -Calling `notificationAutomat.dispose()` unsubscribes all upstream listeners automatically to prevent memory leaks when an automat is torn down. - - -### Wiring the Cascade in UI: - -```jsx -// 4. Controller component: buttons trigger the UPSTREAM automat -class CascadeControls extends PureComponent { - handleTrigger = (step) => { - // πŸ’‘ CLICK WIRED HERE: - // Calling counterAutomat triggers notificationAutomat downstream! - counterAutomat.actions.increment(step); - }; - - handleClear = () => { - notificationAutomat.actions.clear(); - }; - - render() { - return ( -
- - - -
- ); - } -} - -// 5. Downstream component: automatically receives derived cascade messages -class NotificationBar extends PureComponent { - constructor(props) { - super(props); - this.state = { - messages: notificationAutomat.state.messages, // ← from cascade - filter: 'all', // ← component-local - }; - } - - componentDidMount() { - this.unsub = notificationAutomat.subscribe(this); - } - - componentWillUnmount() { - this.unsub(); - } - - render() { - const { messages } = this.state; - return ( -
    - {messages.map((m) => ( -
  • {m.text} ({m.time})
  • - ))} -
- ); - } -} -``` - -When `counterAutomat.setState()` fires β†’ `transform` runs β†’ `notificationAutomat.setState()` fires β†’ `NotificationBar` automatically re-renders. - --- -## API-Backed Auto-Sync Counter (POST) +### `automat.subscribeTo(upstreamAutomat, transform)` -An automat can perform optimistic state updates immediately for instant UI feedback, while automatically synchronizing mutations to the backend via HTTP POST in the background: +Derives state reactively from an upstream automat. Whenever `upstreamAutomat` updates, `transform(upstreamState, myState)` runs. Return partial state to update, or `null` / `undefined` to skip. Returns `this` for chaining. ```js -// syncCounterAutomat.js -const syncCounterAutomat = new Automat( - { - index: 0, - count: 0, - syncStatus: 'synced', // 'syncing' | 'synced' | 'error' - lastSyncedAt: null, - }, - { - async increment(step = 1) { - const { index, count } = syncCounterAutomat.state; - const nextCount = count + step; +const auditAutomat = new Automat({ logs: [] }); - // 1. Optimistic update (UI updates immediately): - syncCounterAutomat.setState({ count: nextCount, syncStatus: 'syncing' }); - - // 2. Automatic background sync via POST /api/counter: - try { - const res = await fetch('/api/counter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ index, count: nextCount }), - }); - const data = await res.json(); - syncCounterAutomat.setState({ syncStatus: 'synced', lastSyncedAt: data.savedAt }); - } catch (err) { - syncCounterAutomat.setState({ syncStatus: 'error', error: err.message }); - } - }, - } -); +auditAutomat.subscribeTo(counterAutomat, (upstream, my) => { + if (upstream.count === 0) return null; // skip update + return { + logs: [`Count changed to ${upstream.count}`, ...my.logs.slice(0, 19)], + }; +}); ``` -### Component Wiring: +--- + +### `automat.ready` + +A promise resolving with current state when initial rehydration finishes (resolves immediately for non-persisted and window-persisted instances; resolves once IndexedDB data loads). + +```js +await cartStore.ready; +console.log('Cart rehydrated:', cartStore.state); +``` + +--- + +### `automat.clearPersistence()` + +Deletes the persisted state entry from `window` or `IndexedDB`. + +```js +await cartStore.clearPersistence(); +``` + +--- + +### `Automat.get(name)` + +Static registry method to retrieve any named `Automat` instance stored in the `window` object. + +```js +const filterStore = Automat.get('filter'); +``` + +--- + +### `automat.dispose()` + +Tears down all upstream subscriptions set up via `subscribeTo()`, clears all subscribers, and removes named instance registrations from the window registry. + +```js +automat.dispose(); +``` + +--- + +## Examples: Named Automats & Persistence + +### 1. Named Automat in Window Object (`persist: false`) + +Useful for shared app settings, tab management, or devtools inspection. State persists in memory across component unmounts and Hot Module Replacement (HMR) reloads: + +```js +// settingsAutomat.js +import { Automat } from 'automat'; + +export const settingsAutomat = new Automat( + { theme: 'dark', soundEnabled: true }, + { + setTheme(theme) { + settingsAutomat.setState({ theme }); + }, + toggleSound() { + settingsAutomat.setState({ soundEnabled: !settingsAutomat.state.soundEnabled }); + }, + }, + { name: 'settings', persist: false } // Saved in window object +); + +// Any other file or devtools console can lookup the instance by name: +const settings = Automat.get('settings'); +settings?.actions.setTheme('light'); +``` + +--- + +### 2. Reload-Resilient Persistence with IndexedDB (`persist: true`) + +Useful for shopping carts, drafts, and user form progress that must survive full page refreshes and browser restarts: + +```js +// cartAutomat.js +import { Automat } from 'automat'; + +export const cartAutomat = new Automat( + { items: [], lastUpdated: null }, + { + addItem(item) { + cartAutomat.setState({ + items: [...cartAutomat.state.items, item], + lastUpdated: Date.now(), + }); + }, + clearCart() { + cartAutomat.setState({ items: [], lastUpdated: null }); + // Optional: wipe stored record from IndexedDB + cartAutomat.clearPersistence(); + }, + }, + { name: 'cart', persist: true } // Automatically syncs with IndexedDB +); + +// Optional: wait for saved data to finish hydrating before proceeding +await cartAutomat.ready; +console.log('Hydrated cart items from IndexedDB:', cartAutomat.state.items); +``` + +--- + +### 3. PureComponent Consuming an IndexedDB-Persisted Automat + +Components mount immediately with initial state. When IndexedDB finishes loading persisted data in the background, subscribers are notified automatically: ```jsx -class SyncCounterControls extends PureComponent { - state = syncCounterAutomat.state; +// CartView.jsx +import { PureComponent } from 'react'; +import { cartAutomat } from './cartAutomat.js'; + +export class CartView extends PureComponent { + constructor(props) { + super(props); + // 1. Mount immediately with current/initial state + this.state = cartAutomat.state; + } componentDidMount() { - this.unsubscribe = syncCounterAutomat.subscribe(this); + // 2. Subscribe β€” receives automatic update once IndexedDB hydrates + this.unsubscribe = cartAutomat.subscribe(this); } componentWillUnmount() { + // 3. Clean up subscription this.unsubscribe(); } render() { - const { index, count, syncStatus, lastSyncedAt } = this.state; + const { items } = this.state; return (
-

Counter #{index}: {count}

- - Status: {syncStatus === 'syncing' ? 'POST in flight…' : `Synced (${lastSyncedAt})`} +

Shopping Cart ({items.length} items)

+
    + {items.map((item) => ( +
  • {item.name} - ${item.price}
  • + ))} +
+
); } @@ -493,45 +365,17 @@ class SyncCounterControls extends PureComponent { --- -## Building the Standalone Library +## Best Practices -```bash -npm run build:lib -``` +### βœ… DO +- Define `Automat` instances in module scope. +- Read `automat.state` directly in `constructor(props)`. +- Subscribe in `componentDidMount()` and unsubscribe in `componentWillUnmount()`. +- Use selectors (`'key'`, `['keys']`, or function) on multi-field automats to avoid unnecessary re-renders. +- Return `null` in `subscribeTo` transforms when an update should be skipped. -Produces minified, zero-dependency bundles in `dist/`: -- `dist/automat.es.js` (~1.14 kB raw / **545 B** gzipped) -- `dist/automat.umd.js` (~1.09 kB raw / **552 B** gzipped) - ---- - -## Project Structure - -``` -src/ -β”œβ”€β”€ lib/ -β”‚ β”œβ”€β”€ Automat.js ← core observable state class -β”‚ └── index.js ← public re-exports -β”‚ -└── examples/ - β”œβ”€β”€ automats/ - β”‚ β”œβ”€β”€ counterAutomat.js ← local memory automat - β”‚ β”œβ”€β”€ notificationAutomat.js ← subscribes to counter (cascade) - β”‚ β”œβ”€β”€ syncCounterAutomat.js ← API-backed auto-sync indexed counter (POST) - β”‚ └── indexAutomat.js ← shared index + dynamic window.automats Map - └── components/ - β”œβ”€β”€ CounterButton.jsx ← PureComponent with direct constructor & subscribe - β”œβ”€β”€ CounterDisplay.jsx ← independent PureComponent synced via counterAutomat - β”œβ”€β”€ CascadeControls.jsx ← PureComponent driving and demonstrating upstream cascade - β”œβ”€β”€ NotificationBar.jsx ← independent PureComponent displaying cascade stream - β”œβ”€β”€ SyncCounterControls.jsx ← PureComponent driving auto-sync indexed counter - β”œβ”€β”€ SyncBackendMonitor.jsx ← PureComponent inspecting backend DB and POST payload - β”œβ”€β”€ IndexSelector.jsx ← PureComponent driving shared index counter - └── DynamicAutomatSubscriber.jsx ← dynamically resubscribes to window.automats by index -``` - ---- - -## License - -MIT +### ❌ DON'T +- Do not instantiate `Automat` inside React component lifecycle or render methods. +- Do not mutate state directly (`automat.state.count = 1`); use `setState()` or actions. +- Do not wrap components in React Context providers or HOCs. +- Do not forget to unsubscribe in `componentWillUnmount()`. diff --git a/src/App.jsx b/src/App.jsx index ae1bee2..98ec4d3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,6 +7,8 @@ import SyncCounterControls from './examples/components/SyncCounterControls.jsx'; import SyncBackendMonitor from './examples/components/SyncBackendMonitor.jsx'; import IndexSelector from './examples/components/IndexSelector.jsx'; import DynamicAutomatSubscriber from './examples/components/DynamicAutomatSubscriber.jsx'; +import CartManager from './examples/components/CartManager.jsx'; +import CartBadgeDisplay from './examples/components/CartBadgeDisplay.jsx'; class App extends PureComponent { render() { @@ -23,7 +25,7 @@ class App extends PureComponent { Observable state management for React{' '} PureComponent
- State lives independently of mounting β€” zero prop drilling. + State lives independently of mounting β€” zero prop drilling Β· Optional Window and IndexedDB persistence.

@@ -169,6 +171,20 @@ class App extends PureComponent { β†’ Recall State for Slot #N + +
+
+ πŸ’Ύ Built-in Persistence: persist: false (Window) & persist: true (IndexedDB) +
+
    +
  • + Window Object (persist: false): When an automat is configured with name, it registers in the window object. Its state persists across component unmounts and HMR reloads. Global lookup is available via Automat.get(name). +
  • +
  • + IndexedDB (persist: true): Setting persist: true automatically synchronizes state snapshots to IndexedDB under the automat's name, surviving full page reloads and browser restarts. +
  • +
+
@@ -176,6 +192,39 @@ class App extends PureComponent {
+ +
+ + {/* ── Example 5: Partial State Subscription ────────────── */} +
+
+
+ 05 +

Partial State Subscription (Cart Length vs Content)

+
+

+ A subscriber can subscribe to only part of an automat’s state. The CartBadgeDisplay on + the right subscribes only to list length (items.length) using a slice selector. + Modifying item quantities, changing names, or typing coupon codes mutates cartAutomat state, + but because the list length is unchanged, Automat’s internal shallow equality check skips setState() β€” producing zero badge re-renders. + With {`{ name: 'cart', persist: true }`}, this cart state also automatically survives full page reloads via IndexedDB. +

+
+ cartAutomat.setState() + β†’ + selector(state) + β†’ + shallowEqual(last, next) + β†’ + Render ONLY on Length Change +
+
+ +
+ + +
+