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}
-
β{step}
-
+{step}
-
- );
- }
-}
-```
-
----
-
-### 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}
-
getOrCreateSlotAutomat(currentIndex).actions.click()}>
- Click Slot #{currentIndex}
-
-
- );
- }
-}
-```
-
----
-
-## 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 */}
-
β{step}
-
+{step}
-
Reset
-
- );
- }
-}
-
-// 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 (
-
+
+ {this.state.count}
+ counterAutomat.actions.increment()}>+1
+ counterAutomat.actions.decrement()}>-1
+
);
}
}
@@ -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 (
-
- this.handleTrigger(1)}>Trigger (+1)
- this.handleTrigger(5)}>Trigger (+5)
- Clear Stream
-
- );
- }
-}
-
-// 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}
-
syncCounterAutomat.actions.increment(1)}>+1
-
Status: {syncStatus === 'syncing' ? 'POST in flightβ¦' : `Synced (${lastSyncedAt})`}
+
Shopping Cart ({items.length} items)
+
+ {items.map((item) => (
+ {item.name} - ${item.price}
+ ))}
+
+
cartAutomat.actions.addItem({ id: Date.now(), name: 'New Item', price: 20 })}>
+ Add Item (Persists on Reload)
+
);
}
@@ -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
+
+
+
+
+
+
+
+
diff --git a/src/examples/automats/cartAutomat.js b/src/examples/automats/cartAutomat.js
new file mode 100644
index 0000000..0a1e743
--- /dev/null
+++ b/src/examples/automats/cartAutomat.js
@@ -0,0 +1,78 @@
+import { Automat } from '../../lib/index.js';
+
+let nextId = 4;
+
+/**
+ * Cart Automat managing a list of items and checkout metadata.
+ *
+ * Demonstrates:
+ * 1. Partial state subscription: Subscribers who only care about cart badge quantity
+ * (list length) can subscribe to `state.items.length` without re-rendering when
+ * item quantities, prices, names, or coupon codes mutate.
+ * 2. IndexedDB persistence (`persist: true`): Cart state persists across full browser reloads.
+ */
+export const cartAutomat = new Automat(
+ {
+ items: [
+ { id: 1, name: 'Mechanical Keyboard', price: 129, quantity: 1 },
+ { id: 2, name: 'Wireless Mouse', price: 79, quantity: 2 },
+ { id: 3, name: 'Desk Mat (Gruvbox)', price: 29, quantity: 1 },
+ ],
+ couponCode: '',
+ },
+ {
+ addItem(name = 'USB Hub', price = 35) {
+ const { items } = cartAutomat.state;
+ cartAutomat.setState({
+ items: [
+ ...items,
+ { id: nextId++, name, price, quantity: 1 },
+ ],
+ });
+ },
+
+ removeItem(id) {
+ const { items } = cartAutomat.state;
+ cartAutomat.setState({
+ items: items.filter((item) => item.id !== id),
+ });
+ },
+
+ updateQuantity(id, delta) {
+ const { items } = cartAutomat.state;
+ cartAutomat.setState({
+ items: items.map((item) =>
+ item.id === id
+ ? { ...item, quantity: Math.max(1, item.quantity + delta) }
+ : item
+ ),
+ });
+ },
+
+ renameItem(id, newName) {
+ const { items } = cartAutomat.state;
+ cartAutomat.setState({
+ items: items.map((item) =>
+ item.id === id ? { ...item, name: newName } : item
+ ),
+ });
+ },
+
+ setCouponCode(couponCode) {
+ cartAutomat.setState({ couponCode });
+ },
+
+ resetCart() {
+ nextId = 4;
+ cartAutomat.setState({
+ items: [
+ { id: 1, name: 'Mechanical Keyboard', price: 129, quantity: 1 },
+ { id: 2, name: 'Wireless Mouse', price: 79, quantity: 2 },
+ { id: 3, name: 'Desk Mat (Gruvbox)', price: 29, quantity: 1 },
+ ],
+ couponCode: '',
+ });
+ },
+ },
+ { name: 'cart', persist: true }
+);
diff --git a/src/examples/automats/indexAutomat.js b/src/examples/automats/indexAutomat.js
index eb7ab91..00f83c9 100644
--- a/src/examples/automats/indexAutomat.js
+++ b/src/examples/automats/indexAutomat.js
@@ -23,25 +23,25 @@ export const indexAutomat = new Automat(
/**
* Global Map on window holding dynamically instantiated Automats per index.
+ * Linked to window.__AUTOMATS__ for registry discovery.
*/
-if (typeof window !== 'undefined' && !window.automats) {
- window.automats = new Map();
+if (typeof window !== 'undefined') {
+ window.automats = window.__AUTOMATS__ || (window.__AUTOMATS__ = new Map());
}
/**
- * Accesses or dynamically instantiates an Automat in window.automats for the given index.
+ * Accesses or dynamically instantiates a named Automat in the window object for the given index.
+ * Demonstrates named automats and window-level persistence: `persist: false`.
*
* @param {number} index
* @returns {Automat}
*/
export function getOrCreateSlotAutomat(index) {
- const map = typeof window !== 'undefined' ? window.automats : null;
- if (!map) {
- return new Automat({ index, clicks: 0, notes: `Slot #${index}` });
- }
+ const name = `slot_${index}`;
+ let slotAutomat = Automat.get(name);
- if (!map.has(index)) {
- const slotAutomat = new Automat(
+ if (!slotAutomat) {
+ slotAutomat = new Automat(
{
index,
clicks: 0,
@@ -67,10 +67,13 @@ export function getOrCreateSlotAutomat(index) {
lastModified: new Date().toLocaleTimeString(),
});
},
- }
+ },
+ { name, persist: false } // Named automat persisted to window object
);
- map.set(index, slotAutomat);
+ if (typeof window !== 'undefined' && window.automats) {
+ window.automats.set(index, slotAutomat);
+ }
}
- return map.get(index);
+ return slotAutomat;
}
diff --git a/src/examples/components/CartBadgeDisplay.jsx b/src/examples/components/CartBadgeDisplay.jsx
new file mode 100644
index 0000000..908d0b9
--- /dev/null
+++ b/src/examples/components/CartBadgeDisplay.jsx
@@ -0,0 +1,200 @@
+import { PureComponent } from 'react';
+import { cartAutomat } from '../automats/cartAutomat.js';
+
+/**
+ * CartBadgeDisplay β right column of the Partial Subscription example.
+ *
+ * Demonstrates partial state subscription:
+ * Subscribes ONLY to `state.items.length`, regardless of list content!
+ *
+ * When item quantities change or coupon codes are typed, `items.length` remains
+ * unchanged. The internal shallow equality check skips `setState()`, resulting in
+ * ZERO re-renders for this component.
+ */
+class CartBadgeDisplay extends PureComponent {
+ _renderCount = 0;
+ _badgeRef = null;
+
+ constructor(props) {
+ super(props);
+ // 1. Initial read: extract only what this component needs
+ this.state = {
+ badgeCount: cartAutomat.state.items.length,
+ };
+ }
+
+ componentDidMount() {
+ // 2. π‘ PARTIAL SUBSCRIPTION:
+ // Selector maps to { badgeCount: state.items.length }.
+ // When items are added/removed, badgeCount changes -> setState() is called.
+ // When item quantities/names change or coupon is typed, badgeCount is unchanged -> setState() is SKIPPED!
+ this.unsubscribe = cartAutomat.subscribe(this, (state) => ({
+ badgeCount: state.items.length,
+ }));
+ }
+
+ componentWillUnmount() {
+ if (this.unsubscribe) this.unsubscribe();
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevState.badgeCount !== this.state.badgeCount && this._badgeRef) {
+ this._badgeRef.classList.remove('count-animate');
+ void this._badgeRef.offsetWidth;
+ this._badgeRef.classList.add('count-animate');
+ }
+ }
+
+ render() {
+ this._renderCount++;
+ const { badgeCount } = this.state;
+
+ return (
+
+
+ π·οΈ
+
Cart Badge (Partial Subscriber)
+ Selector: items.length
+
+
+
+ {/* Render count & efficiency indicator */}
+
+
+ Badge Renders: #{this._renderCount}
+
+
+ shallowEqual guarded
+
+
+
+ {/* Visual Navbar / Cart Badge Mockup */}
+
+
+ Simulated App Navigation Bar
+
+
+
+
+ Store Header
+
+
+
+
+ {/* Shopping Cart Icon with Badge */}
+
+
+ π
+
+
+ {/* Animated Badge Count */}
+ { this._badgeRef = el; }}
+ style={{
+ position: 'absolute',
+ top: -6,
+ right: -10,
+ background: 'var(--orange)',
+ color: 'var(--surface-0)',
+ fontWeight: 800,
+ fontSize: 12,
+ minWidth: 20,
+ height: 20,
+ borderRadius: 10,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: '0 5px',
+ boxShadow: '0 2px 5px rgba(0,0,0,0.5)',
+ }}
+ >
+ {badgeCount}
+
+
+
+
+
+ Badge displays {badgeCount} unique items in cart
+
+
+
+ {/* Explanation Callout */}
+
+
+ π Why Partial State Subscription Matters:
+
+
+
+ Quantity changes (+ / β) modify item content, but items.length remains {badgeCount}. Automat detects that lastSlice is shallow-equal to nextSlice and skips this.setState() completely .
+
+
+ Typing coupon codes updates unrelated state, also triggering 0 renders here.
+
+
+ Adding or removing an item alters items.length, immediately updating the badge number!
+
+
+
+
+ {/* Canonical Code Snippet */}
+
+
{`// Partial subscription to ONLY the list length:
+class CartBadgeDisplay extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { badgeCount: cartAutomat.state.items.length };
+ }
+
+ componentDidMount() {
+ // π‘ Regardless of item content/quantity/price changes,
+ // only re-renders when items.length changes:
+ this.unsubscribe = cartAutomat.subscribe(this, (state) => ({
+ badgeCount: state.items.length,
+ }));
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ render() {
+ return Cart: {this.state.badgeCount} ;
+ }
+}`}
+
+
+
+ );
+ }
+}
+
+export default CartBadgeDisplay;
diff --git a/src/examples/components/CartManager.jsx b/src/examples/components/CartManager.jsx
new file mode 100644
index 0000000..7296483
--- /dev/null
+++ b/src/examples/components/CartManager.jsx
@@ -0,0 +1,227 @@
+import { PureComponent } from 'react';
+import { cartAutomat } from '../automats/cartAutomat.js';
+
+/**
+ * CartManager β left column of the Partial Subscription example.
+ *
+ * Full subscriber to cartAutomat.
+ * Allows adding/removing items (changing list length) AND modifying
+ * existing item content (quantity, name, coupon) without changing list length.
+ */
+class CartManager extends PureComponent {
+ _renderCount = 0;
+
+ constructor(props) {
+ super(props);
+ this.state = cartAutomat.state;
+ }
+
+ componentDidMount() {
+ this.unsubscribe = cartAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ if (this.unsubscribe) this.unsubscribe();
+ }
+
+ handleAddItem = (name, price) => {
+ cartAutomat.actions.addItem(name, price);
+ };
+
+ handleRemoveItem = (id) => {
+ cartAutomat.actions.removeItem(id);
+ };
+
+ handleQuantityChange = (id, delta) => {
+ cartAutomat.actions.updateQuantity(id, delta);
+ };
+
+ handleCouponChange = (e) => {
+ cartAutomat.actions.setCouponCode(e.target.value);
+ };
+
+ handleReset = () => {
+ cartAutomat.actions.resetCart();
+ };
+
+ render() {
+ this._renderCount++;
+ const { items, couponCode } = this.state;
+
+ return (
+
+
+
π
+
Shopping Cart Manager
+
+ πΎ IndexedDB
+ Full Subscriber
+
+
+
+
+
+
+ Component Renders: #{this._renderCount}
+
+
+
+ Reset List
+
+ {
+ await cartAutomat.clearPersistence();
+ this.handleReset();
+ }}
+ title="Clear persisted state from IndexedDB"
+ >
+ Clear IndexedDB
+
+
+
+
+ {/* Quick Add Buttons (Changes length) */}
+
+
+ Add Item (Changes List Length):
+
+
+ this.handleAddItem('Keycaps Set', 45)}
+ >
+ + Keycaps ($45)
+
+ this.handleAddItem('Wrist Rest', 25)}
+ >
+ + Wrist Rest ($25)
+
+ this.handleAddItem('Coiled Cable', 32)}
+ >
+ + Cable ($32)
+
+
+
+
+ {/* Items List */}
+
+
+ Cart Items ({items.length}):
+
+
+ {items.length === 0 ? (
+
+ Cart is empty. Click an add button above.
+
+ ) : (
+
+ {items.map((item) => (
+
+
+
+ {item.name}
+
+
+ ${item.price} each
+
+
+
+ {/* Quantity controls (Changes content, NOT length!) */}
+
+ Qty:
+ this.handleQuantityChange(item.id, -1)}
+ title="Decrease quantity (content changed, length unchanged)"
+ >
+ β
+
+
+ {item.quantity}
+
+ this.handleQuantityChange(item.id, 1)}
+ title="Increase quantity (content changed, length unchanged)"
+ >
+ +
+
+
+ {/* Remove item (Changes length!) */}
+ this.handleRemoveItem(item.id)}
+ title="Remove item (changes length)"
+ >
+ β
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Unrelated field: Coupon Code */}
+
+
+ Unrelated State Field (Coupon Code):
+
+
+
+ Typing here calls cartAutomat.setState({`{ couponCode }`}). Notice how the badge on the right ignores this completely!
+
+
+
+
+ );
+ }
+}
+
+export default CartManager;
diff --git a/src/examples/components/DynamicAutomatSubscriber.jsx b/src/examples/components/DynamicAutomatSubscriber.jsx
index 82a7672..81ad518 100644
--- a/src/examples/components/DynamicAutomatSubscriber.jsx
+++ b/src/examples/components/DynamicAutomatSubscriber.jsx
@@ -94,7 +94,7 @@ class DynamicAutomatSubscriber extends PureComponent {
β‘
Dynamic Slot Automat (Resubscribed by Index)
- window.automats.get({currentIndex})
+ Automat.get('slot_{currentIndex}') (window)
diff --git a/src/lib/Automat.js b/src/lib/Automat.js
index 71ce433..b77e94c 100644
--- a/src/lib/Automat.js
+++ b/src/lib/Automat.js
@@ -5,6 +5,11 @@
* State persists across mounts and unmounts; components always see the latest value
* when they mount because they read `automat.state` or `automat.getState()` in their constructor.
*
+ * Optional Persistence:
+ * - `persist: false` (default when `name` is provided): Persists state in `window` object
+ * (session memory; survives unmounts, dynamic imports, and HMR).
+ * - `persist: true`: Persists state in `IndexedDB` (survives page reloads and browser restarts).
+ *
* Direct PureComponent usage:
* ```jsx
* class CounterDisplay extends PureComponent {
@@ -50,6 +55,80 @@ function shallowEqual(a, b) {
return true;
}
+const IDB_NAME = 'automat_db';
+const IDB_STORE = 'states';
+let idbPromise = null;
+
+function getIdb() {
+ if (typeof indexedDB === 'undefined') return Promise.resolve(null);
+ if (!idbPromise) {
+ idbPromise = new Promise((resolve) => {
+ try {
+ const req = indexedDB.open(IDB_NAME, 1);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(IDB_STORE)) {
+ db.createObjectStore(IDB_STORE);
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => resolve(null);
+ } catch {
+ resolve(null);
+ }
+ });
+ }
+ return idbPromise;
+}
+
+function idbGet(key) {
+ return getIdb().then((db) => {
+ if (!db) return undefined;
+ return new Promise((resolve) => {
+ try {
+ const tx = db.transaction(IDB_STORE, 'readonly');
+ const req = tx.objectStore(IDB_STORE).get(key);
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => resolve(undefined);
+ } catch {
+ resolve(undefined);
+ }
+ });
+ });
+}
+
+function idbSet(key, value) {
+ return getIdb().then((db) => {
+ if (!db) return;
+ return new Promise((resolve) => {
+ try {
+ const tx = db.transaction(IDB_STORE, 'readwrite');
+ tx.objectStore(IDB_STORE).put(value, key);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => resolve();
+ } catch {
+ resolve();
+ }
+ });
+ });
+}
+
+function idbDelete(key) {
+ return getIdb().then((db) => {
+ if (!db) return;
+ return new Promise((resolve) => {
+ try {
+ const tx = db.transaction(IDB_STORE, 'readwrite');
+ tx.objectStore(IDB_STORE).delete(key);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => resolve();
+ } catch {
+ resolve();
+ }
+ });
+ });
+}
+
export class Automat {
/** @type {object} */
#state;
@@ -59,14 +138,106 @@ export class Automat {
#callbacks;
/** @type {Array} */
#upstreamUnsubscribers = [];
+ /** @type {string|null} */
+ #name = null;
+ /** @type {boolean} */
+ #persist = false;
+ /** @type {Promise} */
+ #ready = Promise.resolve();
/**
* @param {object} initialState Initial state snapshot.
* @param {object} [callbacks] Named action callbacks. Exposed via `.actions`.
+ * @param {object} [options] Configuration options.
+ * @param {string} [options.name] Identifier for persistence and registry lookup.
+ * @param {boolean} [options.persist] true = IndexedDB (survives reload), false = window object (default).
*/
- constructor(initialState = {}, callbacks = {}) {
- this.#state = { ...initialState };
+ constructor(initialState = {}, callbacks = {}, options = {}) {
this.#callbacks = callbacks;
+ this.#name = options.name ?? null;
+ this.#persist = Boolean(options.persist);
+
+ if (this.#name && typeof window !== 'undefined') {
+ window.__AUTOMATS__ = window.__AUTOMATS__ || new Map();
+ window.__AUTOMATS__.set(this.#name, this);
+ }
+
+ if (this.#name && !this.#persist) {
+ // Window object persistence (session memory, persists across unmounts & HMR)
+ if (typeof window !== 'undefined') {
+ window.__AUTOMAT_STATE__ = window.__AUTOMAT_STATE__ || new Map();
+ if (window.__AUTOMAT_STATE__.has(this.#name)) {
+ this.#state = { ...initialState, ...window.__AUTOMAT_STATE__.get(this.#name) };
+ } else {
+ this.#state = { ...initialState };
+ window.__AUTOMAT_STATE__.set(this.#name, this.#state);
+ }
+ } else {
+ this.#state = { ...initialState };
+ }
+ this.#ready = Promise.resolve(this.#state);
+ } else if (this.#name && this.#persist) {
+ // IndexedDB persistence (survives page reloads)
+ this.#state = { ...initialState };
+ this.#ready = idbGet(this.#name).then((saved) => {
+ if (saved && typeof saved === 'object') {
+ this.setState(saved);
+ }
+ return this.#state;
+ });
+ } else {
+ this.#state = { ...initialState };
+ this.#ready = Promise.resolve(this.#state);
+ }
+ }
+
+ /**
+ * Retrieves an Automat instance registered by name.
+ * @param {string} name
+ * @returns {Automat|undefined}
+ */
+ static get(name) {
+ if (typeof window !== 'undefined' && window.__AUTOMATS__) {
+ return window.__AUTOMATS__.get(name);
+ }
+ return undefined;
+ }
+
+ /**
+ * The registered name of the automat, or null if unnamed.
+ * @returns {string|null}
+ */
+ get name() {
+ return this.#name;
+ }
+
+ /**
+ * Whether this automat is persisted to IndexedDB (true) or window object (false).
+ * @returns {boolean}
+ */
+ get persist() {
+ return this.#persist;
+ }
+
+ /**
+ * Promise resolving when initial state rehydration is complete.
+ * @returns {Promise}
+ */
+ get ready() {
+ return this.#ready;
+ }
+
+ /**
+ * Clears persisted state from window or IndexedDB.
+ * @returns {Promise}
+ */
+ async clearPersistence() {
+ if (!this.#name) return;
+ if (this.#persist) {
+ await idbDelete(this.#name);
+ } else if (typeof window !== 'undefined') {
+ window.__AUTOMAT_STATE__?.delete(this.#name);
+ }
}
/**
@@ -95,6 +266,13 @@ export class Automat {
*/
setState(partial) {
this.#state = { ...this.#state, ...partial };
+ if (this.#name) {
+ if (this.#persist) {
+ idbSet(this.#name, this.#state);
+ } else if (typeof window !== 'undefined') {
+ window.__AUTOMAT_STATE__?.set(this.#name, this.#state);
+ }
+ }
this.#notify(partial);
return this.#state;
}
@@ -268,6 +446,9 @@ export class Automat {
this.#upstreamUnsubscribers.forEach((fn) => fn());
this.#upstreamUnsubscribers = [];
this.#subscribers.clear();
+ if (this.#name && typeof window !== 'undefined' && window.__AUTOMATS__) {
+ window.__AUTOMATS__.delete(this.#name);
+ }
}
/** @private */
diff --git a/src/lib/index.js b/src/lib/index.js
index 081eae7..8adcb04 100644
--- a/src/lib/index.js
+++ b/src/lib/index.js
@@ -1,15 +1,21 @@
/**
* @module automat
*
- * Observable state management for React PureComponents.
+ * Observable state management for React PureComponents with optional persistence.
*
* @example
* import { Automat } from './lib/index.js';
* import { PureComponent } from 'react';
*
- * const counterAutomat = new Automat({ count: 0 }, {
- * increment() { counterAutomat.setState({ count: counterAutomat.state.count + 1 }); },
- * });
+ * // Create an automat with optional persistence:
+ * // persist: false (default) = window object; persist: true = IndexedDB
+ * const counterAutomat = new Automat(
+ * { count: 0 },
+ * {
+ * increment() { counterAutomat.setState({ count: counterAutomat.state.count + 1 }); },
+ * },
+ * { name: 'counter', persist: false }
+ * );
*
* class Display extends PureComponent {
* constructor(props) {