From c34719513cbc878be9810f24061147e44007ae5e Mon Sep 17 00:00:00 2001
From: seb
Date: Thu, 17 Sep 2026 22:19:21 +0200
Subject: [PATCH 1/4] Genesis
---
.gitignore | 2 +
README.LLM.md | 371 ++++++
README.md | 500 ++++++++
index.html | 22 +
package-lock.json | 890 ++++++++++++++
package.json | 23 +
src/App.jsx | 192 +++
src/examples/automats/counterAutomat.js | 31 +
src/examples/automats/indexAutomat.js | 76 ++
src/examples/automats/notificationAutomat.js | 52 +
src/examples/automats/syncCounterAutomat.js | 166 +++
src/examples/components/CascadeControls.jsx | 163 +++
src/examples/components/CounterButton.jsx | 182 +++
src/examples/components/CounterDisplay.jsx | 172 +++
.../components/DynamicAutomatSubscriber.jsx | 195 ++++
src/examples/components/IndexSelector.jsx | 118 ++
src/examples/components/NotificationBar.jsx | 155 +++
.../components/SyncBackendMonitor.jsx | 115 ++
.../components/SyncCounterControls.jsx | 178 +++
src/index.css | 1037 +++++++++++++++++
src/lib/Automat.js | 179 +++
src/lib/index.js | 30 +
src/main.jsx | 10 +
vite.config.js | 87 ++
vite.config.lib.js | 24 +
25 files changed, 4970 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.LLM.md
create mode 100644 README.md
create mode 100644 index.html
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 src/App.jsx
create mode 100644 src/examples/automats/counterAutomat.js
create mode 100644 src/examples/automats/indexAutomat.js
create mode 100644 src/examples/automats/notificationAutomat.js
create mode 100644 src/examples/automats/syncCounterAutomat.js
create mode 100644 src/examples/components/CascadeControls.jsx
create mode 100644 src/examples/components/CounterButton.jsx
create mode 100644 src/examples/components/CounterDisplay.jsx
create mode 100644 src/examples/components/DynamicAutomatSubscriber.jsx
create mode 100644 src/examples/components/IndexSelector.jsx
create mode 100644 src/examples/components/NotificationBar.jsx
create mode 100644 src/examples/components/SyncBackendMonitor.jsx
create mode 100644 src/examples/components/SyncCounterControls.jsx
create mode 100644 src/index.css
create mode 100644 src/lib/Automat.js
create mode 100644 src/lib/index.js
create mode 100644 src/main.jsx
create mode 100644 vite.config.js
create mode 100644 vite.config.lib.js
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b2d59d1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/node_modules
+/dist
\ No newline at end of file
diff --git a/README.LLM.md b/README.LLM.md
new file mode 100644
index 0000000..d5b32d2
--- /dev/null
+++ b/README.LLM.md
@@ -0,0 +1,371 @@
+# 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.automatRegistry = 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. **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?: (state: T) => object \| null): () => void` | Subscribes either a React component instance (`this`) or a callback function. Returns an `unsubscribe` function. |
+| `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: Passive Reader Component with State Selector
+
+Use a selector function when a component only cares about a subset of the automat's state:
+
+```jsx
+// src/components/CountDisplay.jsx
+import { PureComponent } from 'react';
+import { counterAutomat } from '../automats/counterAutomat.js';
+
+export class CountDisplay extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { count: counterAutomat.state.count };
+ }
+
+ componentDidMount() {
+ // Selector maps state to target object. Returning null skips setState.
+ this.unsubscribe = counterAutomat.subscribe(this, (state) => ({
+ count: state.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** 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** 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
new file mode 100644
index 0000000..f2f7d51
--- /dev/null
+++ b/README.md
@@ -0,0 +1,500 @@
+# β 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
+```
+
+---
+
+## Motivation
+
+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';
+import { Automat } from 'automat';
+
+// 1. Instantiate the automat outside React:
+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 });
+ },
+ }
+);
+
+// 2. Controller component: buttons trigger actions, hybrid state tracks local clicks
+class CounterButton 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:
+ this.state = counterAutomat.state;
+ }
+
+ componentDidMount() {
+ // Automatically re-renders when CounterButton triggers an increment/decrement
+ this.unsubscribe = counterAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ 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 (
+
+ );
+ }
+}
+```
+
+---
+
+## Core API
+
+### `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 either a component instance (`this`) or a callback `(state) => ...`. Returns unsub function. |
+| `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 |
+
+---
+
+## Notification Cascade (Wired Example)
+
+### 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:
+
+```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
+ ],
+ }
+);
+```
+
+#### 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)
+
+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:
+
+```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;
+
+ // 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 });
+ }
+ },
+ }
+);
+```
+
+### Component Wiring:
+
+```jsx
+class SyncCounterControls extends PureComponent {
+ state = syncCounterAutomat.state;
+
+ componentDidMount() {
+ this.unsubscribe = syncCounterAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ render() {
+ const { index, count, syncStatus, lastSyncedAt } = this.state;
+ return (
+
+
Counter #{index}: {count}
+ syncCounterAutomat.actions.increment(1)}>+1
+ Status: {syncStatus === 'syncing' ? 'POST in flightβ¦' : `Synced (${lastSyncedAt})`}
+
+ );
+ }
+}
+```
+
+---
+
+## Building the Standalone Library
+
+```bash
+npm run build:lib
+```
+
+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
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..822d66a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Automat β React PureComponent State Manager
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..39de314
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,890 @@
+{
+ "name": "automat",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "automat",
+ "version": "0.1.0",
+ "dependencies": {
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^6.1.1",
+ "vite": "^8.3.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.150.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
+ "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
+ "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz",
+ "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz",
+ "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz",
+ "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
+ "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz",
+ "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz",
+ "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz",
+ "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz",
+ "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz",
+ "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz",
+ "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz",
+ "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz",
+ "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz",
+ "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz",
+ "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
+ "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.145.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
+ "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.150.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.9",
+ "@rolldown/binding-android-arm64": "1.2.9",
+ "@rolldown/binding-darwin-arm64": "1.2.9",
+ "@rolldown/binding-darwin-x64": "1.2.9",
+ "@rolldown/binding-freebsd-x64": "1.2.9",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.9",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.9",
+ "@rolldown/binding-linux-arm64-musl": "1.2.9",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.9",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.9",
+ "@rolldown/binding-linux-x64-gnu": "1.2.9",
+ "@rolldown/binding-linux-x64-musl": "1.2.9",
+ "@rolldown/binding-openharmony-arm64": "1.2.9",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.9",
+ "@rolldown/binding-win32-x64-msvc": "1.2.9"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
+ "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.28",
+ "rolldown": "~1.2.6",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9000ad8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "automat",
+ "version": "0.1.0",
+ "type": "module",
+ "description": "Observable state management for React PureComponents β state independent of component lifecycle",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "build:lib": "vite build --config vite.config.lib.js",
+ "preview": "vite preview"
+ },
+ "exports": {
+ ".": "./src/lib/index.js"
+ },
+ "dependencies": {
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^6.1.1",
+ "vite": "^8.3.0"
+ }
+}
diff --git a/src/App.jsx b/src/App.jsx
new file mode 100644
index 0000000..ae1bee2
--- /dev/null
+++ b/src/App.jsx
@@ -0,0 +1,192 @@
+import { PureComponent } from 'react';
+import CounterButton from './examples/components/CounterButton.jsx';
+import CounterDisplay from './examples/components/CounterDisplay.jsx';
+import CascadeControls from './examples/components/CascadeControls.jsx';
+import NotificationBar from './examples/components/NotificationBar.jsx';
+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';
+
+class App extends PureComponent {
+ render() {
+ return (
+
+ {/* ββ Header βββββββββββββββββββββββββββββββββββββββββββββββ */}
+
+
+ β
+ automat
+ v0.1.0
+
+
+ Observable state management for React{' '}
+ PureComponent
+
+ State lives independently of mounting β zero prop drilling.
+
+
+
+
+ {/* ββ Example 1: Counter ββββββββββββββββββββββββββββββββββ */}
+
+
+
+ 01
+
Counter
+
+
+ Two independent PureComponents
+ synchronized by a single counterAutomat.
+ Neither knows the other exists β they both subscribe to the same state container.
+
+
+ counterAutomat
+ β
+ CounterButton
+ &
+ CounterDisplay
+
+
+
+
+
+
+
+
+
+
+
+ {/* ββ Example 2: Cascade βββββββββββββββββββββββββββββββββ */}
+
+
+
+ 02
+
Notification Cascade
+
+
+ subscribeTo() connects two automats into a
+ reactive pipeline . Clicking triggers in CascadeControls mutates{' '}
+ counterAutomat (upstream). This automatically invokes the{' '}
+ subscribeTo() transform callback, updating{' '}
+ notificationAutomat (downstream), which then re-renders{' '}
+ NotificationBar without any prop drilling or shared component parent.
+
+
+ User Click
+ β
+ counterAutomat.actions.increment()
+ β
+ subscribeTo(upstream, transform)
+ β
+ notificationAutomat.setState()
+ β
+ NotificationBar
+
+
+
+
+ βΉοΈ How subscribeTo(upstreamAutomat, transform) Works
+
+
+
+ upstreamState: The newly emitted state from the upstream automat (e.g. {`{ count: 1 }`}).
+
+
+ myState: The current state of this downstream automat right before updating (e.g. {`{ messages: [...] }`}). Acts as an accumulator to prepend events and cap history.
+
+
+ Return Value: The returned partial state object is automatically applied via this.setState(partial), notifying downstream UI subscribers.
+
+
+ Conditional Filtering: Return null or undefined to selectively skip updates and avoid triggering renders.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ββ Example 3: API Auto-Sync βββββββββββββββββββββββββββ */}
+
+
+
+ 03
+
API-Backed Auto-Sync Counter (POST)
+
+
+ syncCounterAutomat manages an indexed counter that automatically
+ synchronizes its mutations to the backend via HTTP POST /api/counter.
+ State updates are applied optimistically for instant UI response, followed by asynchronous
+ background synchronization and status tracking without any component-level fetch or lifecycle glue.
+
+
+ User Click
+ β
+ Optimistic setState()
+ β
+ POST /api/counter
+ β
+ Backend Database Updated
+ β
+ Sync Status 200 OK
+
+
+
+
+
+
+
+
+
+
+
+ {/* ββ Example 4: Dynamic Map Resubscription ββββββββββββ */}
+
+
+
+ 04
+
Dynamic Window Map (Index-Driven Resubscription)
+
+
+ The left side is a standard indexAutomat counter (like Example 1).
+ On the right side, that counterβs value is used to dynamically lookup, instantiate, and resubscribe to
+ a different Automat stored in window.automats = new Map().
+ Stepping through indexes unhooks from the old Automat, subscribes to the new one, and instantly recalls its preserved state.
+
+
+ Shared Index: N
+ β
+ window.automats.get(N)
+ β
+ Unsub Old / Sub New
+ β
+ Recall State for Slot #N
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default App;
diff --git a/src/examples/automats/counterAutomat.js b/src/examples/automats/counterAutomat.js
new file mode 100644
index 0000000..2e222da
--- /dev/null
+++ b/src/examples/automats/counterAutomat.js
@@ -0,0 +1,31 @@
+import { Automat } from '../../lib/index.js';
+
+/**
+ * A local-memory automat with no API backend.
+ *
+ * State: { count: number }
+ *
+ * Actions close over the automat instance and drive all state transitions.
+ * Multiple React components can subscribe β they all stay in sync through
+ * the automat's subscriber set, never through React's prop drilling.
+ */
+const counterAutomat = new Automat(
+ { count: 0 },
+ {
+ increment(amount = 1) {
+ const { count } = counterAutomat.getState();
+ const step = typeof amount === 'number' ? amount : 1;
+ counterAutomat.setState({ count: count + step });
+ },
+ decrement(amount = 1) {
+ const { count } = counterAutomat.getState();
+ const step = typeof amount === 'number' ? amount : 1;
+ counterAutomat.setState({ count: count - step });
+ },
+ reset() {
+ counterAutomat.setState({ count: 0 });
+ },
+ }
+);
+
+export default counterAutomat;
diff --git a/src/examples/automats/indexAutomat.js b/src/examples/automats/indexAutomat.js
new file mode 100644
index 0000000..eb7ab91
--- /dev/null
+++ b/src/examples/automats/indexAutomat.js
@@ -0,0 +1,76 @@
+import { Automat } from '../../lib/index.js';
+
+/**
+ * Normal shared Automat (like Example 1 counter), holding an index value.
+ * The right-side component uses this value to lookup, dynamically instantiate,
+ * and resubscribe to a different Automat stored in window.automats Map.
+ */
+export const indexAutomat = new Automat(
+ { index: 0 },
+ {
+ increment(step = 1) {
+ indexAutomat.setState({ index: indexAutomat.state.index + step });
+ },
+ decrement(step = 1) {
+ const next = Math.max(0, indexAutomat.state.index - step);
+ indexAutomat.setState({ index: next });
+ },
+ setIndex(index) {
+ indexAutomat.setState({ index: Math.max(0, Number(index) || 0) });
+ },
+ }
+);
+
+/**
+ * Global Map on window holding dynamically instantiated Automats per index.
+ */
+if (typeof window !== 'undefined' && !window.automats) {
+ window.automats = new Map();
+}
+
+/**
+ * Accesses or dynamically instantiates an Automat in window.automats for the given index.
+ *
+ * @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}` });
+ }
+
+ if (!map.has(index)) {
+ const slotAutomat = new Automat(
+ {
+ index,
+ clicks: 0,
+ notes: `Notes for slot #${index}`,
+ lastModified: new Date().toLocaleTimeString(),
+ },
+ {
+ click() {
+ slotAutomat.setState({
+ clicks: slotAutomat.state.clicks + 1,
+ lastModified: new Date().toLocaleTimeString(),
+ });
+ },
+ setNotes(notes) {
+ slotAutomat.setState({
+ notes,
+ lastModified: new Date().toLocaleTimeString(),
+ });
+ },
+ reset() {
+ slotAutomat.setState({
+ clicks: 0,
+ lastModified: new Date().toLocaleTimeString(),
+ });
+ },
+ }
+ );
+ map.set(index, slotAutomat);
+ }
+
+ return map.get(index);
+}
diff --git a/src/examples/automats/notificationAutomat.js b/src/examples/automats/notificationAutomat.js
new file mode 100644
index 0000000..da48131
--- /dev/null
+++ b/src/examples/automats/notificationAutomat.js
@@ -0,0 +1,52 @@
+import { Automat } from '../../lib/index.js';
+import counterAutomat from './counterAutomat.js';
+
+/**
+ * Cascade example β notificationAutomat subscribes to counterAutomat.
+ *
+ * Every time the counter's state changes, the transform callback fires and
+ * prepends a new notification message. This in turn calls notificationAutomat.setState,
+ * which notifies all of notificationAutomat's own subscribers (e.g. ).
+ *
+ * State: { messages: Array<{ id, text, time, count }> }
+ */
+const notificationAutomat = new Automat(
+ { messages: [] },
+ {
+ clear() {
+ notificationAutomat.setState({ messages: [] });
+ },
+ addNotice(text, count = 0) {
+ const { messages } = notificationAutomat.getState();
+ notificationAutomat.setState({
+ messages: [
+ {
+ id: Date.now() + Math.random(),
+ text,
+ time: new Date().toLocaleTimeString(),
+ count,
+ },
+ ...messages.slice(0, 9),
+ ],
+ });
+ },
+ }
+);
+
+// Wire the cascade: notificationAutomat derives state from counterAutomat:
+notificationAutomat.subscribeTo(
+ counterAutomat,
+ (upstream, my) => ({
+ messages: [
+ {
+ id: Date.now() + Math.random(),
+ text: `Cascade trigger: counter changed to ${upstream.count}`,
+ time: new Date().toLocaleTimeString(),
+ count: upstream.count,
+ },
+ ...my.messages.slice(0, 9),
+ ],
+ })
+);
+
+export default notificationAutomat;
diff --git a/src/examples/automats/syncCounterAutomat.js b/src/examples/automats/syncCounterAutomat.js
new file mode 100644
index 0000000..c0bcceb
--- /dev/null
+++ b/src/examples/automats/syncCounterAutomat.js
@@ -0,0 +1,166 @@
+import { Automat } from '../../lib/index.js';
+
+/**
+ * Helper to sync the indexed counter to the backend via POST /api/counter.
+ * Includes offline/fallback logic so it gracefully handles production previews.
+ */
+async function postCounterSync(index, count) {
+ try {
+ const res = await fetch('/api/counter', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ index, count, timestamp: Date.now() }),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Server returned ${res.status}`);
+ }
+
+ return await res.json();
+ } catch (err) {
+ // Simulated fallback if running in an environment without the dev server backend:
+ return {
+ success: true,
+ index,
+ count,
+ savedAt: new Date().toLocaleTimeString(),
+ allRecords: [
+ { index, count, updatedAt: new Date().toLocaleTimeString() },
+ ],
+ fallback: true,
+ };
+ }
+}
+
+/**
+ * Helper to fetch a specific index's state from backend via GET /api/counter?index=...
+ */
+async function fetchCounterIndex(index) {
+ try {
+ const res = await fetch(`/api/counter?index=${index}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return await res.json();
+ } catch {
+ return { index, count: 0, updatedAt: 'Local' };
+ }
+}
+
+/**
+ * API-backed indexed counter automat.
+ *
+ * Automatically syncs every mutation to the backend via HTTP POST /api/counter.
+ * Uses optimistic updates for instant UI response, followed by background POST synchronization.
+ */
+export const syncCounterAutomat = new Automat(
+ {
+ index: 0,
+ count: 0,
+ syncStatus: 'synced', // 'idle' | 'syncing' | 'synced' | 'error'
+ lastSyncedAt: 'Initial',
+ error: null,
+ backendRecords: [
+ { index: 0, count: 0, updatedAt: 'Initial' },
+ { index: 1, count: 5, updatedAt: 'Initial' },
+ { index: 2, count: 10, updatedAt: 'Initial' },
+ ],
+ },
+ {
+ /**
+ * Increment count for current index & auto-sync to backend via POST
+ */
+ async increment(step = 1) {
+ const { index, count } = syncCounterAutomat.state;
+ const nextCount = count + step;
+
+ // 1. Optimistic update:
+ syncCounterAutomat.setState({
+ count: nextCount,
+ syncStatus: 'syncing',
+ error: null,
+ });
+
+ // 2. Auto-sync via POST /api/counter:
+ try {
+ const result = await postCounterSync(index, nextCount);
+ syncCounterAutomat.setState({
+ syncStatus: 'synced',
+ lastSyncedAt: result.savedAt,
+ backendRecords: result.allRecords || syncCounterAutomat.state.backendRecords,
+ });
+ } catch (err) {
+ syncCounterAutomat.setState({
+ syncStatus: 'error',
+ error: err.message,
+ });
+ }
+ },
+
+ /**
+ * Decrement count for current index & auto-sync to backend via POST
+ */
+ async decrement(step = 1) {
+ const { index, count } = syncCounterAutomat.state;
+ const nextCount = Math.max(0, count - step);
+
+ // 1. Optimistic update:
+ syncCounterAutomat.setState({
+ count: nextCount,
+ syncStatus: 'syncing',
+ error: null,
+ });
+
+ // 2. Auto-sync via POST /api/counter:
+ try {
+ const result = await postCounterSync(index, nextCount);
+ syncCounterAutomat.setState({
+ syncStatus: 'synced',
+ lastSyncedAt: result.savedAt,
+ backendRecords: result.allRecords || syncCounterAutomat.state.backendRecords,
+ });
+ } catch (err) {
+ syncCounterAutomat.setState({
+ syncStatus: 'error',
+ error: err.message,
+ });
+ }
+ },
+
+ /**
+ * Switch to a different counter index and load its backend state
+ */
+ async setIndex(newIndex) {
+ const targetIndex = Number(newIndex);
+ syncCounterAutomat.setState({
+ index: targetIndex,
+ syncStatus: 'syncing',
+ error: null,
+ });
+
+ const serverData = await fetchCounterIndex(targetIndex);
+ syncCounterAutomat.setState({
+ count: serverData.count ?? 0,
+ syncStatus: 'synced',
+ lastSyncedAt: serverData.updatedAt || new Date().toLocaleTimeString(),
+ });
+ },
+
+ /**
+ * Reset current counter to 0 & auto-sync via POST
+ */
+ async reset() {
+ const { index } = syncCounterAutomat.state;
+ syncCounterAutomat.setState({
+ count: 0,
+ syncStatus: 'syncing',
+ error: null,
+ });
+
+ const result = await postCounterSync(index, 0);
+ syncCounterAutomat.setState({
+ syncStatus: 'synced',
+ lastSyncedAt: result.savedAt,
+ backendRecords: result.allRecords || syncCounterAutomat.state.backendRecords,
+ });
+ },
+ }
+);
diff --git a/src/examples/components/CascadeControls.jsx b/src/examples/components/CascadeControls.jsx
new file mode 100644
index 0000000..72e59f7
--- /dev/null
+++ b/src/examples/components/CascadeControls.jsx
@@ -0,0 +1,163 @@
+import { PureComponent } from 'react';
+import counterAutomat from '../automats/counterAutomat.js';
+import notificationAutomat from '../automats/notificationAutomat.js';
+
+/**
+ * CascadeControls β left column of Section 02 (Notification Cascade).
+ *
+ * Demonstrates:
+ * 1. Driving upstream transitions that cascade automatically into notificationAutomat.
+ * 2. Hybrid state: upstream count from counterAutomat + local cascade triggers counter.
+ * 3. Shows the complete wiring of `subscribeTo()`.
+ */
+class CascadeControls extends PureComponent {
+ constructor(props) {
+ super(props);
+ // Hybrid initialization:
+ this.state = {
+ // 1. From upstream automat:
+ upstreamCount: counterAutomat.state.count,
+ // 2. Component-local state:
+ customText: 'System heartbeat',
+ cascadeFiredCount: 0,
+ };
+ }
+
+ componentDidMount() {
+ // Subscribe to upstream automat to display its current state
+ this.unsubscribe = counterAutomat.subscribe(this, (state) => ({
+ upstreamCount: state.count,
+ }));
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ handleFireTrigger = (delta) => {
+ this.setState((prev) => ({ cascadeFiredCount: prev.cascadeFiredCount + 1 }));
+ counterAutomat.actions.increment(delta);
+ };
+
+ handleCustomNotice = () => {
+ const { customText, upstreamCount } = this.state;
+ this.setState((prev) => ({ cascadeFiredCount: prev.cascadeFiredCount + 1 }));
+ notificationAutomat.actions.addNotice(customText, upstreamCount);
+ };
+
+ handleClear = () => {
+ notificationAutomat.actions.clear();
+ };
+
+ render() {
+ const { upstreamCount, customText, cascadeFiredCount } = this.state;
+
+ return (
+
+
+ π
+
Cascade Trigger
+ upstream
+
+
+
+ {/* Hybrid state display */}
+
+
+
+ upstream
+ counter count
+
+
{upstreamCount}
+
+
+
+ local
+ triggers fired
+
+
{cascadeFiredCount}
+
+
+
+
+ Clicking a trigger button invokes counterAutomat.actions.increment().
+ Because notificationAutomat observes it with{' '}
+ subscribeTo(), the downstream log derives a new
+ message automatically without any component glue.
+
+
+ {/* Trigger buttons */}
+
+ this.handleFireTrigger(1)}
+ >
+ Trigger (+1)
+
+ this.handleFireTrigger(5)}
+ >
+ Trigger (+5)
+
+
+
+
+ this.setState({ customText: e.target.value })}
+ placeholder="Custom notification message..."
+ />
+
+ Post Notice
+
+
+
+
+ Clear Stream
+
+
+
+
{`// Concise reactive pipeline (ternary returns null to skip, or object to update):
+notificationAutomat.subscribeTo(
+ counterAutomat,
+ (upstream, my) =>
+ upstream.count === 0
+ ? null
+ : {
+ messages: [
+ {
+ id: Date.now(),
+ text: \`Cascade: counter shifted to \${upstream.count}\`,
+ time: new Date().toLocaleTimeString(),
+ count: upstream.count,
+ },
+ ...my.messages.slice(0, 9),
+ ],
+ }
+);`}
+
+
+
+ );
+ }
+}
+
+export default CascadeControls;
diff --git a/src/examples/components/CounterButton.jsx b/src/examples/components/CounterButton.jsx
new file mode 100644
index 0000000..beef4ea
--- /dev/null
+++ b/src/examples/components/CounterButton.jsx
@@ -0,0 +1,182 @@
+import { PureComponent } from 'react';
+import counterAutomat from '../automats/counterAutomat.js';
+
+/**
+ * CounterButton β left column.
+ *
+ * HYBRID STATE PATTERN:
+ * - Shared state: `count` comes from counterAutomat.
+ * - Local state: `step` (increment size) and `localClicks` (click counter)
+ * are private to this component.
+ *
+ * 1. Constructor combines direct automat access with local component state.
+ * 2. componentDidMount registers with counterAutomat.subscribe(this).
+ * 3. React's setState shallow-merges updates, so local fields remain untouched
+ * when the automat notifies this component of count updates.
+ */
+class CounterButton extends PureComponent {
+ constructor(props) {
+ super(props);
+ // Hybrid initialization:
+ this.state = {
+ // 1. Initialized directly from the automat:
+ count: counterAutomat.state.count,
+ // 2. Component-local state:
+ step: 1,
+ localClicks: 0,
+ };
+ }
+
+ componentDidMount() {
+ // Subscriber handling for React lifecycle management
+ this.unsubscribe = counterAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ handleSetStep = (step) => {
+ // Purely local state update
+ this.setState({ step });
+ };
+
+ handleIncrement = () => {
+ const { step, localClicks } = this.state;
+ // Update local clicks counter
+ this.setState({ localClicks: localClicks + 1 });
+ // Trigger automat action with local step
+ counterAutomat.actions.increment(step);
+ };
+
+ handleDecrement = () => {
+ const { step, localClicks } = this.state;
+ this.setState({ localClicks: localClicks + 1 });
+ counterAutomat.actions.decrement(step);
+ };
+
+ handleReset = () => {
+ const { localClicks } = this.state;
+ this.setState({ localClicks: localClicks + 1 });
+ counterAutomat.actions.reset();
+ };
+
+ render() {
+ const { count, step, localClicks } = this.state;
+
+ return (
+
+
+ β‘
+
Counter Controls
+ hybrid state
+
+
+
+ {/* Hybrid State breakdown */}
+
+
+
+ automat
+ shared
+
+
{count}
+
+
+
+ local
+ button clicks
+
+
{localClicks}
+
+
+
+ {/* Local step selector */}
+
+
+ Step size local
+
+
+ {[1, 5, 10].map((s) => (
+ this.handleSetStep(s)}
+ >
+ Β±{s}
+
+ ))}
+
+
+
+ {/* +/- controls */}
+
+
+ β
+
+
+
+ +
+
+
+
+
+ Reset Count to 0
+
+
+
+
{`// 1. Instantiate the automat outside React:
+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 });
+ },
+ }
+);
+
+// 2. PureComponent with hybrid state:
+class CounterButton extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ count: counterAutomat.state.count, // β from automat
+ step: 1, // β component-local
+ localClicks: 0, // β component-local
+ };
+ }
+
+ componentDidMount() {
+ this.unsub = counterAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ this.unsub();
+ }
+}`}
+
+
+
+ );
+ }
+}
+
+export default CounterButton;
diff --git a/src/examples/components/CounterDisplay.jsx b/src/examples/components/CounterDisplay.jsx
new file mode 100644
index 0000000..319187b
--- /dev/null
+++ b/src/examples/components/CounterDisplay.jsx
@@ -0,0 +1,172 @@
+import { PureComponent } from 'react';
+import counterAutomat from '../automats/counterAutomat.js';
+
+/**
+ * CounterDisplay β right column.
+ *
+ * HYBRID STATE PATTERN:
+ * - Shared state: `count` comes from counterAutomat.
+ * - Local state: `format` ('dec' | 'hex') is private to this component.
+ *
+ * Demonstrates that CounterDisplay can maintain its own presentation preferences
+ * while staying in sync with the shared automat state.
+ */
+class CounterDisplay extends PureComponent {
+ /** @type {HTMLElement|null} */
+ _numRef = null;
+
+ constructor(props) {
+ super(props);
+ // Hybrid initialization:
+ this.state = {
+ // 1. Initialized directly from automat:
+ count: counterAutomat.state.count,
+ // 2. Component-local presentation state:
+ format: 'dec',
+ };
+ }
+
+ componentDidMount() {
+ // Subscriber handling for React lifecycle
+ this.unsubscribe = counterAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ // Pop animation whenever shared automat count changes
+ if (prevState.count !== this.state.count) {
+ if (this._numRef) {
+ this._numRef.classList.remove('count-animate');
+ void this._numRef.offsetWidth;
+ this._numRef.classList.add('count-animate');
+ }
+ }
+ }
+
+ handleFormatChange = (format) => {
+ // Purely local state update
+ this.setState({ format });
+ };
+
+ formatValue(count, format) {
+ if (format === 'hex') {
+ return (count < 0 ? '-' : '') + '0x' + Math.abs(count).toString(16).toUpperCase();
+ }
+ return count;
+ }
+
+ render() {
+ const { count, format } = this.state;
+ const formatted = this.formatValue(count, format);
+
+ return (
+
+
+ π
+
Counter Display
+ hybrid state
+
+
+
+ {/* Hybrid State breakdown */}
+
+
+
+ automat
+ shared count
+
+
{count}
+
+
+
+ local
+ format preference
+
+
{format.toUpperCase()}
+
+
+
+ {/* Local format selector */}
+
+
+ Display format local
+
+
+ {['dec', 'hex'].map((f) => (
+ this.handleFormatChange(f)}
+ >
+ {f.toUpperCase()}
+
+ ))}
+
+
+
+
+ {/* Gradient number with pop animation on change */}
+ { this._numRef = el; }}
+ aria-live="polite"
+ aria-atomic="true"
+ >
+ {formatted}
+
+ {format.toUpperCase()} format
+
+ Independent PureComponent β no shared parent
+
+
+
+
+
{`// 1. Instantiate automat outside React:
+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 });
+ },
+ }
+);
+
+// 2. PureComponent with hybrid state:
+class CounterDisplay extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ count: counterAutomat.state.count, // β from automat
+ format: 'dec', // β component-local state
+ };
+ }
+
+ componentDidMount() {
+ this.unsub = counterAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ this.unsub();
+ }
+
+ render() {
+ const { count, format } = this.state;
+ const value = format === 'hex' ? '0x' + count.toString(16) : count;
+ return {value} ;
+ }
+}`}
+
+
+
+ );
+ }
+}
+
+export default CounterDisplay;
diff --git a/src/examples/components/DynamicAutomatSubscriber.jsx b/src/examples/components/DynamicAutomatSubscriber.jsx
new file mode 100644
index 0000000..82a7672
--- /dev/null
+++ b/src/examples/components/DynamicAutomatSubscriber.jsx
@@ -0,0 +1,195 @@
+import { PureComponent } from 'react';
+import { indexAutomat, getOrCreateSlotAutomat } from '../automats/indexAutomat.js';
+
+/**
+ * DynamicAutomatSubscriber β right column.
+ *
+ * 1. Subscribes to the shared `indexAutomat`.
+ * 2. Whenever `index` changes, it:
+ * - Unsubscribes from the previous Automat instance
+ * - Uses `index` to lookup or dynamically instantiate an Automat in `window.automats` Map
+ * - Immediately recalls its preserved state
+ * - Subscribes to the new Automat instance
+ */
+class DynamicAutomatSubscriber extends PureComponent {
+ constructor(props) {
+ super(props);
+ const initialIndex = indexAutomat.state.index;
+ const initialSlot = getOrCreateSlotAutomat(initialIndex);
+
+ this.state = {
+ currentIndex: initialIndex,
+ slotState: initialSlot.state,
+ };
+ }
+
+ componentDidMount() {
+ // 1. Subscribe to the shared index automat
+ this.unsubIndex = indexAutomat.subscribe((indexState) => {
+ this.handleIndexChange(indexState.index);
+ });
+
+ // 2. Subscribe to the initial slot automat in window.automats
+ this.subscribeToSlot(this.state.currentIndex);
+ }
+
+ componentWillUnmount() {
+ if (this.unsubIndex) this.unsubIndex();
+ if (this.unsubSlot) this.unsubSlot();
+ }
+
+ handleIndexChange(newIndex) {
+ if (newIndex === this.state.currentIndex) return;
+
+ // π‘ DYNAMIC RESUBSCRIPTION WORKFLOW:
+ // 1. Unsubscribe from the previous Automat
+ if (this.unsubSlot) {
+ this.unsubSlot();
+ }
+
+ // 2. Lookup or dynamically instantiate the Automat for this index in window.automats:
+ const slotAutomat = getOrCreateSlotAutomat(newIndex);
+
+ // 3. Immediately recall the stored state:
+ this.setState({
+ currentIndex: newIndex,
+ slotState: slotAutomat.state,
+ });
+
+ // 4. Resubscribe to the new instance:
+ this.unsubSlot = slotAutomat.subscribe((slotState) => {
+ this.setState({ slotState });
+ });
+ }
+
+ subscribeToSlot(index) {
+ const slotAutomat = getOrCreateSlotAutomat(index);
+ this.unsubSlot = slotAutomat.subscribe((slotState) => {
+ this.setState({ slotState });
+ });
+ }
+
+ handleClickSlot = () => {
+ const slotAutomat = getOrCreateSlotAutomat(this.state.currentIndex);
+ slotAutomat.actions.click();
+ };
+
+ handleNotesChange = (e) => {
+ const slotAutomat = getOrCreateSlotAutomat(this.state.currentIndex);
+ slotAutomat.actions.setNotes(e.target.value);
+ };
+
+ handleResetSlot = () => {
+ const slotAutomat = getOrCreateSlotAutomat(this.state.currentIndex);
+ slotAutomat.actions.reset();
+ };
+
+ render() {
+ const { currentIndex, slotState } = this.state;
+ const map = typeof window !== 'undefined' ? window.automats : null;
+ const allIndexes = map ? Array.from(map.keys()).sort((a, b) => a - b) : [];
+
+ return (
+
+
+ β‘
+
Dynamic Slot Automat (Resubscribed by Index)
+ window.automats.get({currentIndex})
+
+
+
+ {/* Active slot recalled state */}
+
+
+ Recalled State for Automat #{currentIndex}
+
+
+ {slotState.clicks}
+
+ Clicks recorded on Slot #{currentIndex}
+
+
+ {/* Slot mutation controls */}
+
+
+ + Click Slot #{currentIndex}
+
+
+ Reset Slot
+
+
+
+
+
+
+
+ {/* Table showing all entries in window.automats */}
+
+
+ All Instantiated Automats in window.automats Map:
+
+
+ {allIndexes.map((idx) => {
+ const aut = map.get(idx);
+ const st = aut ? aut.state : {};
+ const isActive = idx === currentIndex;
+ return (
+
+
+ {isActive ? 'βΈ ' : ' '}window.automats.get({idx})
+
+
+ clicks: {st.clicks} Β· "{st.notes}"
+
+
+ );
+ })}
+
+
+
+ {/* Code snippet showing resubscription logic */}
+
+
{`// Resubscribing to different Automat driven by shared index:
+handleIndexChange(newIndex) {
+ this.unsubSlot?.(); // 1. Unsub old
+ const slotAutomat = window.automats.get(newIndex)// 2. Access / create in Map
+ ?? getOrCreateSlotAutomat(newIndex);
+ this.setState({ slotState: slotAutomat.state }); // 3. Recall state!
+ this.unsubSlot = slotAutomat.subscribe(this); // 4. Resubscribe!
+}`}
+
+
+
+ );
+ }
+}
+
+export default DynamicAutomatSubscriber;
diff --git a/src/examples/components/IndexSelector.jsx b/src/examples/components/IndexSelector.jsx
new file mode 100644
index 0000000..fe97a9e
--- /dev/null
+++ b/src/examples/components/IndexSelector.jsx
@@ -0,0 +1,118 @@
+import { PureComponent } from 'react';
+import { indexAutomat } from '../automats/indexAutomat.js';
+
+/**
+ * IndexSelector β left column.
+ *
+ * A standard PureComponent driving indexAutomat (like Example 1).
+ * Clicking +/- changes the shared `index` value.
+ */
+class IndexSelector extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = indexAutomat.state;
+ }
+
+ componentDidMount() {
+ this.unsubscribe = indexAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ if (this.unsubscribe) this.unsubscribe();
+ }
+
+ handleIncrement = () => {
+ indexAutomat.actions.increment(1);
+ };
+
+ handleDecrement = () => {
+ indexAutomat.actions.decrement(1);
+ };
+
+ handleSelect = (i) => {
+ indexAutomat.actions.setIndex(i);
+ };
+
+ render() {
+ const { index } = this.state;
+
+ return (
+
+
+ π’
+
Shared Index Counter
+ shared index
+
+
+
+ {/* Current Index Display */}
+
+ Shared Automat Index
+ {index}
+
+
+ {/* +/- Controls */}
+
+
+ β
+
+
+
+ +
+
+
+
+ {/* Quick jump pills */}
+
+
+ Quick jump to index:
+
+
+ {[0, 1, 2, 3, 4].map((i) => (
+ this.handleSelect(i)}
+ >
+ #{i}
+
+ ))}
+
+
+
+
+ Changing this index causes the right side to dynamically lookup, instantiate, and resubscribe to{' '}
+ {`window.automats.get(${index})`}.
+
+
+
+
{`// 1. Shared index automat:
+const indexAutomat = new Automat({ index: 0 }, {
+ increment() { indexAutomat.setState({ index: indexAutomat.state.index + 1 }); },
+ decrement() { indexAutomat.setState({ index: indexAutomat.state.index - 1 }); },
+});
+
+// 2. Left component triggers the index change:
+indexAutomat.actions.increment();`}
+
+
+
+ );
+ }
+}
+
+export default IndexSelector;
diff --git a/src/examples/components/NotificationBar.jsx b/src/examples/components/NotificationBar.jsx
new file mode 100644
index 0000000..f7aa746
--- /dev/null
+++ b/src/examples/components/NotificationBar.jsx
@@ -0,0 +1,155 @@
+import { PureComponent } from 'react';
+import notificationAutomat from '../automats/notificationAutomat.js';
+
+/**
+ * NotificationBar β right column of Section 02 (Notification Cascade).
+ *
+ * HYBRID STATE PATTERN:
+ * - Shared state: `messages` from notificationAutomat (derived from counterAutomat via cascade).
+ * - Local state: `filter` ('all' | 'positive' | 'negative') private to this component.
+ *
+ * Displays the resulting notifications in real-time.
+ */
+class NotificationBar extends PureComponent {
+ constructor(props) {
+ super(props);
+ // Hybrid initialization:
+ this.state = {
+ // 1. Initialized directly from automat:
+ messages: notificationAutomat.state.messages,
+ // 2. Component-local filter state:
+ filter: 'all',
+ };
+ }
+
+ componentDidMount() {
+ // Subscriber handling for React lifecycle
+ this.unsubscribe = notificationAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ handleFilterChange = (filter) => {
+ // Purely local state update
+ this.setState({ filter });
+ };
+
+ render() {
+ const { messages, filter } = this.state;
+
+ const filteredMessages = messages.filter((m) => {
+ if (filter === 'positive') return m.count >= 0;
+ if (filter === 'negative') return m.count < 0;
+ return true;
+ });
+
+ return (
+
+
+ π
+
Notification Stream
+ downstream
+ {messages.length > 0 && (
+ {messages.length} total
+ )}
+
+
+
+ {/* Hybrid state display */}
+
+
+
+ cascade
+ items in stream
+
+
{messages.length}
+
+
+
+ local
+ active filter
+
+
{filter.toUpperCase()}
+
+
+
+ {/* Local filter control */}
+
+
+ Filter stream local
+
+
+ {['all', 'positive', 'negative'].map((f) => (
+ this.handleFilterChange(f)}
+ >
+ {f.charAt(0).toUpperCase() + f.slice(1)}
+
+ ))}
+
+
+
+ {filteredMessages.length === 0 ? (
+
+
π€
+
No notifications matching "{filter}".
+
Trigger counter changes to see cascade notifications.
+
+ ) : (
+
+ {filteredMessages.map((msg) => (
+
+ {msg.time}
+ {msg.text}
+ = 0 ? 'var(--green)' : 'var(--red)',
+ }}
+ >
+ {msg.count}
+
+
+ ))}
+
+ )}
+
+
+
{`// 4. Downstream component consumes the cascade:
+class NotificationBar extends PureComponent {
+ constructor(props) {
+ super(props);
+ // Reads directly from downstream notificationAutomat:
+ this.state = {
+ messages: notificationAutomat.state.messages, // β derived from cascade
+ filter: 'all', // β component-local state
+ };
+ }
+
+ componentDidMount() {
+ // Re-renders automatically whenever the cascade emits new messages:
+ this.unsub = notificationAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ this.unsub();
+ }
+
+ render() {
+ const { messages, filter } = this.state;
+ return {messages.map((m) => {m.text} )} ;
+ }
+}`}
+
+
+
+ );
+ }
+}
+
+export default NotificationBar;
diff --git a/src/examples/components/SyncBackendMonitor.jsx b/src/examples/components/SyncBackendMonitor.jsx
new file mode 100644
index 0000000..86a9df4
--- /dev/null
+++ b/src/examples/components/SyncBackendMonitor.jsx
@@ -0,0 +1,115 @@
+import { PureComponent } from 'react';
+import { syncCounterAutomat } from '../automats/syncCounterAutomat.js';
+
+/**
+ * SyncBackendMonitor β right column.
+ *
+ * Observes syncCounterAutomat and inspects the real-time server database state
+ * and HTTP POST payloads transmitted during auto-sync.
+ */
+class SyncBackendMonitor extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = syncCounterAutomat.state;
+ }
+
+ componentDidMount() {
+ this.unsubscribe = syncCounterAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ if (this.unsubscribe) this.unsubscribe();
+ }
+
+ render() {
+ const { index, count, syncStatus, lastSyncedAt, backendRecords } = this.state;
+ const isSyncing = syncStatus === 'syncing';
+
+ return (
+
+
+ π‘
+
Backend Server Database Monitor
+ POST /api/counter
+
+
+
+ {/* Last Transmitted Payload */}
+
+
+ HTTP POST Payload (Sent Automatically on Change):
+
+
+
+ POST /api/counter
+
+
+ {JSON.stringify({ index, count }, null, 2)}
+
+
+ {isSyncing ? 'β³ Transmitting request to serverβ¦' : `β 200 OK β Saved at ${lastSyncedAt}`}
+
+
+
+
+ {/* Server-Side Database Records across all indexes */}
+
+
+ Backend Server Database (Persisted Records):
+
+
+ {backendRecords && backendRecords.length > 0 ? (
+ backendRecords.map((rec) => {
+ const isActive = rec.index === index;
+ return (
+
+
+ {isActive ? 'βΈ ' : ' '}Index #{rec.index}
+
+
+ Server Count: {rec.count}
+
+
+ {rec.updatedAt}
+
+
+ );
+ })
+ ) : (
+
+ No backend records yet.
+
+ )}
+
+
+
+ {/* Code Snippet */}
+
+
{`// Component rendering API-backed automat state:
+class SyncBackendMonitor extends PureComponent {
+ componentDidMount() {
+ // Re-renders automatically on both optimistic update and POST resolution!
+ this.unsubscribe = syncCounterAutomat.subscribe(this);
+ }
+}`}
+
+
+
+ );
+ }
+}
+
+export default SyncBackendMonitor;
diff --git a/src/examples/components/SyncCounterControls.jsx b/src/examples/components/SyncCounterControls.jsx
new file mode 100644
index 0000000..a763a2c
--- /dev/null
+++ b/src/examples/components/SyncCounterControls.jsx
@@ -0,0 +1,178 @@
+import { PureComponent } from 'react';
+import { syncCounterAutomat } from '../automats/syncCounterAutomat.js';
+
+/**
+ * SyncCounterControls β left column.
+ *
+ * Controls the indexed counter. Every mutation optimistically updates the UI
+ * and automatically dispatches an HTTP POST /api/counter in the background.
+ */
+class SyncCounterControls extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = syncCounterAutomat.state;
+ }
+
+ componentDidMount() {
+ this.unsubscribe = syncCounterAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ if (this.unsubscribe) this.unsubscribe();
+ }
+
+ handleIncrement = (step = 1) => {
+ syncCounterAutomat.actions.increment(step);
+ };
+
+ handleDecrement = (step = 1) => {
+ syncCounterAutomat.actions.decrement(step);
+ };
+
+ handleReset = () => {
+ syncCounterAutomat.actions.reset();
+ };
+
+ handleIndexChange = (idx) => {
+ syncCounterAutomat.actions.setIndex(idx);
+ };
+
+ render() {
+ const { index, count, syncStatus, lastSyncedAt, error } = this.state;
+ const isSyncing = syncStatus === 'syncing';
+
+ return (
+
+
+ π
+
Auto-Sync Indexed Counter
+
+ {isSyncing ? 'POST syncingβ¦' : 'POST synced'}
+
+
+
+
+ {/* Index Selector */}
+
+
+ Select Counter Index:
+
+
+ {[0, 1, 2, 3].map((i) => (
+ this.handleIndexChange(i)}
+ disabled={isSyncing}
+ >
+ Counter #{i}
+
+ ))}
+
+
+
+ {/* Current Value Display */}
+
+ Counter #{index} (Local UI Value)
+ {count}
+
+
+ {/* Action Buttons */}
+
+ this.handleDecrement(1)}
+ aria-label="Decrement"
+ >
+ β
+
+ this.handleIncrement(1)}
+ aria-label="Increment"
+ >
+ +
+
+
+
+
+ this.handleIncrement(5)}
+ >
+ +5 Quick Add
+
+
+ Reset to 0
+
+
+
+ {/* Sync Status Banner */}
+
+
+ {isSyncing ? (
+ <>
+
+ POST /api/counter in flightβ¦
+ >
+ ) : (
+ <>
+ β
+ Backend in sync
+ >
+ )}
+
+
+ {lastSyncedAt}
+
+
+
+ {error && (
+
+ β οΈ {error}
+
+ )}
+
+ {/* Code Snippet */}
+
+
{`// Optimistic local update + automatic background POST sync:
+async increment(step = 1) {
+ const next = syncCounterAutomat.state.count + step;
+ syncCounterAutomat.setState({ count: next, syncStatus: 'syncing' });
+
+ // Auto-sync via HTTP POST /api/counter:
+ const res = await fetch('/api/counter', {
+ method: 'POST',
+ body: JSON.stringify({ index, count: next }),
+ });
+ syncCounterAutomat.setState({ syncStatus: 'synced' });
+}`}
+
+
+
+ );
+ }
+}
+
+export default SyncCounterControls;
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..0077794
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,1037 @@
+/* ============================================================
+ Design System β Automat Dev App (Gruvbox Dark Theme)
+ Inter (sans) + JetBrains Mono loaded from index.html
+ Clean, solid colors β no gradients, no purplish tones.
+ ============================================================ */
+
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+:root {
+ /* Gruvbox Dark Palette */
+ --bg: #282828;
+ --surface-0: #1d2021;
+ --surface-1: #32302f;
+ --surface-2: #3c3836;
+ --surface-3: #504945;
+ --surface-4: #665c54;
+
+ --border: #3c3836;
+ --border-hover: #504945;
+ --border-focus: #fabd2f;
+
+ /* Gruvbox Accents (Warm & Earthy, No Purple) */
+ --accent: #fabd2f;
+ --accent-hover: #d79921;
+ --yellow: #fabd2f;
+ --orange: #fe8019;
+ --green: #b8bb26;
+ --aqua: #8ec07c;
+ --blue: #83a598;
+ --red: #fb4934;
+ --purple: #fabd2f; /* Aliased to accent to prevent purplish fallback */
+
+ /* Fallback aliases for components that might read gradient vars */
+ --gradient: var(--accent);
+ --gradient-text: var(--accent);
+ --gradient-warm: var(--orange);
+
+ /* Gruvbox Text */
+ --text-1: #ebdbb2;
+ --text-2: #d5c4a1;
+ --text-3: #928374;
+ --text-4: #665c54;
+ --text-bright: #fbf1c7;
+ --text-dark: #282828;
+
+ /* Typography */
+ --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
+
+ /* Geometry */
+ --r-sm: 4px;
+ --r-md: 6px;
+ --r-lg: 8px;
+ --r-xl: 12px;
+ --r-2xl: 16px;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
+ --shadow-md: 0 3px 8px rgba(0, 0, 0, 0.35);
+ --shadow-lg: 0 6px 16px rgba(0, 0, 0, 0.45);
+}
+
+/* ββ Global ββββββββββββββββββββββββββββββββββββββββββββββββββ */
+
+html {
+ scroll-behavior: smooth;
+ color-scheme: dark;
+}
+
+body {
+ background: var(--bg);
+ color: var(--text-1);
+ font-family: var(--font-sans);
+ font-size: 15px;
+ line-height: 1.6;
+ min-height: 100vh;
+ overflow-x: hidden;
+ -webkit-font-smoothing: antialiased;
+}
+
+#root {
+ position: relative;
+}
+
+/* ββ App Shell βββββββββββββββββββββββββββββββββββββββββββββββ */
+
+.app {
+ max-width: 1160px;
+ margin: 0 auto;
+ padding: 0 24px 80px;
+}
+
+/* ββ Header ββββββββββββββββββββββββββββββββββββββββββββββββββ */
+
+.app-header {
+ padding: 48px 0 36px;
+ text-align: center;
+}
+
+.logo {
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.logo-icon {
+ font-size: 34px;
+ color: var(--accent);
+ display: inline-block;
+ line-height: 1;
+}
+
+.logo-text {
+ font-size: 38px;
+ font-weight: 800;
+ letter-spacing: -1.5px;
+ color: var(--accent);
+}
+
+.logo-version {
+ align-self: flex-end;
+ margin-bottom: 4px;
+ font-size: 12px;
+ font-family: var(--font-mono);
+ color: var(--text-3);
+ padding: 2px 8px;
+ border: 1px solid var(--border);
+ background: var(--surface-0);
+ border-radius: var(--r-sm);
+}
+
+.tagline {
+ color: var(--text-2);
+ font-size: 15px;
+ line-height: 1.7;
+}
+
+.inline-code {
+ font-family: var(--font-mono);
+ font-size: 0.85em;
+ background: var(--surface-0);
+ padding: 1px 6px;
+ border-radius: var(--r-sm);
+ color: var(--aqua);
+ border: 1px solid var(--border);
+}
+
+.header-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: center;
+ margin-top: 16px;
+}
+
+.pill {
+ font-size: 12px;
+ font-family: var(--font-mono);
+ padding: 3px 10px;
+ border-radius: var(--r-sm);
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ color: var(--text-2);
+}
+
+/* ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+
+.app-main {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+/* ββ Example Section βββββββββββββββββββββββββββββββββββββββββ */
+
+.example-section {
+ padding: 8px 0 24px;
+}
+
+.section-header {
+ margin-bottom: 20px;
+}
+
+.section-title-row {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.section-number {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--accent);
+ padding: 2px 8px;
+ background: var(--surface-0);
+ border: 1px solid var(--border);
+ border-radius: var(--r-sm);
+}
+
+.section-header h2 {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-bright);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.section-header h2::before {
+ content: '';
+ display: inline-block;
+ width: 4px;
+ height: 20px;
+ border-radius: 2px;
+ background: var(--accent);
+ flex-shrink: 0;
+}
+
+.section-header p {
+ color: var(--text-2);
+ font-size: 14px;
+ line-height: 1.65;
+ margin-bottom: 12px;
+ max-width: 680px;
+}
+
+/* ββ Flow diagram ββββββββββββββββββββββββββββββββββββ */
+
+.flow-diagram {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.flow-node {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ padding: 4px 10px;
+ background: var(--surface-0);
+ border: 1px solid var(--border);
+ border-radius: var(--r-sm);
+ color: var(--text-2);
+ white-space: nowrap;
+}
+
+.flow-node-component {
+ background: rgba(250, 189, 47, 0.1);
+ border-color: rgba(250, 189, 47, 0.35);
+ color: var(--accent);
+}
+
+.flow-arrow {
+ color: var(--orange);
+ font-size: 15px;
+ font-weight: 700;
+}
+
+.flow-sep {
+ color: var(--text-3);
+ font-size: 13px;
+}
+
+/* ββ Info / Documentation Callout ββββββββββββββββββββ */
+
+.info-box {
+ background: var(--surface-0);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--accent);
+ border-radius: var(--r-md);
+ padding: 14px 16px;
+ margin: 16px 0 20px;
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--text-2);
+}
+
+.info-box-title {
+ font-weight: 700;
+ color: var(--accent);
+ margin-bottom: 8px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: .06em;
+}
+
+.info-box ul {
+ margin-left: 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.info-box li strong {
+ color: var(--text-bright);
+}
+
+.info-box code {
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+ color: var(--aqua);
+ background: var(--surface-2);
+ padding: 1px 5px;
+ border-radius: 3px;
+}
+
+/* ββ Two-column grid βββββββββββββββββββββββββββββββββ */
+
+.two-columns {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+}
+
+@media (max-width: 680px) {
+ .two-columns {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* ββ Card ββββββββββββββββββββββββββββββββββββββββββββ */
+
+.card {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ overflow: hidden;
+ box-shadow: var(--shadow-sm);
+ transition: border-color .15s, box-shadow .15s;
+ display: flex;
+ flex-direction: column;
+}
+
+.card:hover {
+ border-color: var(--border-hover);
+ box-shadow: var(--shadow-md);
+}
+
+.card-display {
+ min-height: 340px;
+}
+
+.card-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 18px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface-0);
+ flex-shrink: 0;
+}
+
+.card-icon {
+ font-size: 16px;
+}
+
+.card-header h3 {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-bright);
+ flex: 1;
+ letter-spacing: .01em;
+}
+
+.card-body {
+ padding: 20px 18px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+/* ββ Badges ββββββββββββββββββββββββββββββββββββββββββ */
+
+.badge {
+ font-size: 10px;
+ font-family: var(--font-mono);
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: var(--r-sm);
+ text-transform: uppercase;
+ letter-spacing: .05em;
+}
+
+.badge-local {
+ background: rgba(184, 187, 38, 0.15);
+ color: var(--green);
+ border: 1px solid rgba(184, 187, 38, 0.3);
+}
+
+.badge-cascade {
+ background: rgba(254, 128, 25, 0.15);
+ color: var(--orange);
+ border: 1px solid rgba(254, 128, 25, 0.3);
+}
+
+.badge-api {
+ background: rgba(131, 165, 152, 0.15);
+ color: var(--blue);
+ border: 1px solid rgba(131, 165, 152, 0.3);
+}
+
+/* ββ Buttons βββββββββββββββββββββββββββββββββββββββββ */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 10px 20px;
+ border-radius: var(--r-md);
+ border: 1px solid transparent;
+ font-family: var(--font-sans);
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background-color .15s, border-color .15s, transform .1s;
+ user-select: none;
+ white-space: nowrap;
+}
+
+.btn:active:not(:disabled) {
+ transform: translateY(1px);
+}
+
+.btn:disabled {
+ opacity: .45;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: var(--text-dark);
+ font-weight: 700;
+ border-color: var(--accent);
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent-hover);
+ border-color: var(--accent-hover);
+}
+
+.btn-secondary {
+ background: var(--surface-2);
+ color: var(--text-1);
+ border: 1px solid var(--border);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: var(--surface-3);
+ border-color: var(--border-hover);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-2);
+ border: 1px solid var(--border);
+}
+
+.btn-ghost:hover:not(:disabled) {
+ background: var(--surface-2);
+ color: var(--text-1);
+ border-color: var(--border-hover);
+}
+
+.btn-full {
+ width: 100%;
+}
+
+/* Circle variant for +/- */
+.btn-circle {
+ width: 52px;
+ height: 52px;
+ border-radius: 50%;
+ font-size: 22px;
+ font-weight: 400;
+ padding: 0;
+ flex-shrink: 0;
+}
+
+/* ββ Counter Controls ββββββββββββββββββββββββββββββββ */
+
+.ctrl-current-value {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: 18px;
+ padding: 12px;
+ background: var(--surface-0);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+}
+
+.ctrl-current-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: .1em;
+ color: var(--text-3);
+ margin-bottom: 4px;
+ font-weight: 700;
+}
+
+.ctrl-current-num {
+ font-family: var(--font-mono);
+ font-size: 32px;
+ font-weight: 700;
+ color: var(--accent);
+}
+
+.counter-controls {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 18px;
+ margin-bottom: 12px;
+}
+
+/* ββ Counter Display βββββββββββββββββββββββββββββββββ */
+
+.count-display-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 16px 0;
+}
+
+.count-number {
+ font-family: var(--font-mono);
+ font-size: 96px;
+ font-weight: 800;
+ line-height: 1;
+ color: var(--accent);
+ display: inline-block;
+ min-width: 3ch;
+ text-align: center;
+ transition: transform .1s;
+}
+
+.count-number.count-animate {
+ animation: pop .2s ease-out;
+}
+
+@keyframes pop {
+ 0% { transform: scale(1.15); }
+ 100% { transform: scale(1); }
+}
+
+.count-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: .12em;
+ color: var(--text-3);
+ font-weight: 600;
+}
+
+.count-hint {
+ font-size: 12px;
+ color: var(--text-3);
+ text-align: center;
+ margin-top: 4px;
+}
+
+/* ββ Notification card βββββββββββββββββββββββββββββββ */
+
+.notification-card {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ overflow: hidden;
+ box-shadow: var(--shadow-sm);
+ transition: border-color .15s;
+}
+
+.notification-card:hover {
+ border-color: var(--border-hover);
+}
+
+.notif-count-badge {
+ background: var(--accent);
+ color: var(--text-dark);
+ font-size: 11px;
+ font-weight: 700;
+ font-family: var(--font-mono);
+ padding: 1px 8px;
+ border-radius: var(--r-sm);
+ min-width: 20px;
+ text-align: center;
+}
+
+.notification-list {
+ list-style: none;
+ max-height: 360px;
+ overflow-y: auto;
+ padding: 4px 0;
+}
+
+.notification-list::-webkit-scrollbar {
+ width: 4px;
+}
+
+.notification-list::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.notification-list::-webkit-scrollbar-thumb {
+ background: var(--surface-3);
+ border-radius: 2px;
+}
+
+.notification-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 18px;
+ border-bottom: 1px solid var(--border);
+}
+
+.notification-item:last-child {
+ border-bottom: none;
+}
+
+.notif-time {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-3);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.notif-text {
+ flex: 1;
+ font-size: 13px;
+ color: var(--text-2);
+}
+
+.notif-count-pill {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: var(--r-sm);
+ color: var(--text-dark);
+ flex-shrink: 0;
+}
+
+/* ββ Empty state βββββββββββββββββββββββββββββββββββββ */
+
+.empty-state {
+ padding: 36px 18px;
+ text-align: center;
+ color: var(--text-3);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+}
+
+.empty-state-icon {
+ font-size: 32px;
+ display: block;
+ margin-bottom: 4px;
+ opacity: .75;
+}
+
+.empty-state-sub {
+ font-size: 13px;
+ color: var(--text-3);
+ max-width: 280px;
+}
+
+/* ββ Weather Controls ββββββββββββββββββββββββββββββββ */
+
+.weather-inputs {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.input-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.input-group label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: var(--text-3);
+}
+
+.input-field {
+ width: 100%;
+ padding: 10px 12px;
+ background: var(--surface-0);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+ color: var(--text-1);
+ font-family: var(--font-sans);
+ font-size: 14px;
+ outline: none;
+ transition: border-color .15s;
+}
+
+.input-field:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(250, 189, 47, 0.2);
+}
+
+.input-field::placeholder {
+ color: var(--text-3);
+}
+
+.input-field:disabled {
+ opacity: .5;
+}
+
+.city-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.city-chip {
+ padding: 4px 10px;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--r-sm);
+ font-size: 12px;
+ font-family: var(--font-sans);
+ color: var(--text-2);
+ cursor: pointer;
+ transition: background-color .15s, border-color .15s, color .15s;
+}
+
+.city-chip:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--text-bright);
+ background: var(--surface-3);
+}
+
+.city-chip:disabled {
+ opacity: .4;
+ cursor: not-allowed;
+}
+
+.spinner-sm {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 2px solid rgba(40, 40, 40, 0.3);
+ border-top-color: var(--text-dark);
+ border-radius: 50%;
+ animation: spin .7s linear infinite;
+}
+
+/* ββ Weather Panel βββββββββββββββββββββββββββββββββββ */
+
+.weather-panel-body {
+ align-items: stretch;
+ justify-content: center;
+}
+
+.weather-loading {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ color: var(--text-3);
+ font-size: 14px;
+}
+
+.spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid var(--surface-2);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin .75s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.weather-error {
+ margin: auto 0;
+ padding: 14px;
+ background: rgba(251, 73, 52, 0.12);
+ border: 1px solid rgba(251, 73, 52, 0.25);
+ border-radius: var(--r-md);
+ color: var(--red);
+ font-size: 14px;
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ line-height: 1.5;
+}
+
+.weather-data {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 10px 0;
+}
+
+.weather-location {
+ text-align: center;
+}
+
+.weather-location-name {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--text-bright);
+}
+
+.weather-location-country {
+ font-size: 13px;
+ color: var(--text-3);
+ margin-top: 2px;
+}
+
+.weather-icon {
+ font-size: 56px;
+ line-height: 1;
+}
+
+.weather-temp {
+ display: flex;
+ align-items: flex-start;
+ gap: 2px;
+}
+
+.weather-temp-value {
+ font-family: var(--font-mono);
+ font-size: 56px;
+ font-weight: 800;
+ line-height: 1;
+ color: var(--accent);
+}
+
+.weather-temp-unit {
+ font-size: 24px;
+ color: var(--text-3);
+ padding-top: 8px;
+ font-weight: 400;
+}
+
+.weather-condition {
+ font-size: 14px;
+ color: var(--text-2);
+ font-weight: 500;
+}
+
+.weather-wind {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ color: var(--text-3);
+}
+
+.wind-arrow {
+ display: inline-block;
+ font-size: 16px;
+ color: var(--aqua);
+ font-weight: bold;
+}
+
+.weather-fetched-at {
+ font-size: 11px;
+ color: var(--text-3);
+ font-family: var(--font-mono);
+}
+
+/* ββ Code Snippet ββββββββββββββββββββββββββββββββββββ */
+
+.code-snippet {
+ margin-top: auto;
+ padding-top: 14px;
+}
+
+.code-snippet pre {
+ background: var(--surface-0);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+ padding: 12px 14px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-2);
+ line-height: 1.6;
+ overflow-x: auto;
+ white-space: pre;
+}
+
+/* ββ Section divider βββββββββββββββββββββββββββββββββ */
+
+.section-divider {
+ height: 1px;
+ background: var(--border);
+ margin: 36px 0;
+}
+
+/* ββ Footer ββββββββββββββββββββββββββββββββββββββββββ */
+
+.app-footer {
+ margin-top: 48px;
+ padding: 20px 0;
+ border-top: 1px solid var(--border);
+ text-align: center;
+ color: var(--text-3);
+ font-size: 13px;
+}
+
+.external-link {
+ color: var(--aqua);
+ text-decoration: none;
+}
+
+.external-link:hover {
+ text-decoration: underline;
+}
+
+/* ββ Hybrid State Indicators & Controls ββββββββββββββ */
+
+.hybrid-state-bar {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ margin-bottom: 14px;
+ padding: 8px 10px;
+ background: var(--surface-0);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+ font-size: 12px;
+}
+
+.state-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.state-item-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ color: var(--text-3);
+ font-weight: 600;
+}
+
+.state-tag-automat {
+ display: inline-block;
+ padding: 1px 5px;
+ border-radius: var(--r-sm);
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ background: rgba(250, 189, 47, 0.15);
+ color: var(--accent);
+ border: 1px solid rgba(250, 189, 47, 0.35);
+}
+
+.state-tag-local {
+ display: inline-block;
+ padding: 1px 5px;
+ border-radius: var(--r-sm);
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ background: rgba(142, 192, 124, 0.15);
+ color: var(--aqua);
+ border: 1px solid rgba(142, 192, 124, 0.35);
+}
+
+.state-item-val {
+ font-family: var(--font-mono);
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-1);
+}
+
+.segmented-control {
+ display: inline-flex;
+ background: var(--surface-0);
+ border: 1px solid var(--border);
+ border-radius: var(--r-sm);
+ padding: 2px;
+ gap: 2px;
+}
+
+.seg-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-2);
+ font-size: 11px;
+ font-family: var(--font-sans);
+ font-weight: 600;
+ padding: 3px 8px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: background-color .15s, color .15s;
+}
+
+.seg-btn:hover:not(.active) {
+ color: var(--text-1);
+ background: var(--surface-2);
+}
+
+.seg-btn.active {
+ background: var(--accent);
+ color: var(--text-dark);
+ font-weight: 700;
+}
diff --git a/src/lib/Automat.js b/src/lib/Automat.js
new file mode 100644
index 0000000..ec039d0
--- /dev/null
+++ b/src/lib/Automat.js
@@ -0,0 +1,179 @@
+/**
+ * @module Automat
+ *
+ * An observable state container whose lifetime is independent of any React component.
+ * 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.
+ *
+ * Direct PureComponent usage:
+ * ```jsx
+ * class CounterDisplay extends PureComponent {
+ * constructor(props) {
+ * super(props);
+ * // 1. Direct access in constructor:
+ * this.state = counterAutomat.state;
+ * }
+ *
+ * componentDidMount() {
+ * // 2. Subscriber handling for React lifecycle management:
+ * this.unsubscribe = counterAutomat.subscribe(this);
+ * }
+ *
+ * componentWillUnmount() {
+ * // 3. Clean up on unmount:
+ * this.unsubscribe(); // or counterAutomat.unsubscribe(this);
+ * }
+ *
+ * render() {
+ * return {this.state.count} ;
+ * }
+ * }
+ * ```
+ */
+export class Automat {
+ /** @type {object} */
+ #state;
+ /** @type {Map} Map of subscriber target -> notification callback */
+ #subscribers = new Map();
+ /** @type {object} */
+ #callbacks;
+ /** @type {Array} */
+ #upstreamUnsubscribers = [];
+
+ /**
+ * @param {object} initialState Initial state snapshot.
+ * @param {object} [callbacks] Named action callbacks. Exposed via `.actions`.
+ */
+ constructor(initialState = {}, callbacks = {}) {
+ this.#state = { ...initialState };
+ this.#callbacks = callbacks;
+ }
+
+ /**
+ * Direct property access to the current state snapshot.
+ * Use in PureComponent constructor: `this.state = automat.state;`
+ * @returns {object}
+ */
+ get state() {
+ return this.#state;
+ }
+
+ /**
+ * Returns the current state snapshot.
+ * Use in PureComponent constructor: `this.state = automat.getState();`
+ * @returns {object}
+ */
+ getState() {
+ return this.#state;
+ }
+
+ /**
+ * Shallow-merges `partial` into the current state and notifies all subscribers.
+ *
+ * @param {object} partial Fields to update.
+ * @returns {object} The new full state.
+ */
+ setState(partial) {
+ this.#state = { ...this.#state, ...partial };
+ this.#notify();
+ return this.#state;
+ }
+
+ /**
+ * Subscriber handling for React lifecycle management or listener callbacks.
+ *
+ * Supports:
+ * 1. A React component instance (has `.setState`):
+ * `this.unsubscribe = automat.subscribe(this);`
+ * 2. A React component instance with an optional selector:
+ * `this.unsubscribe = automat.subscribe(this, state => ({ count: state.count }));`
+ * 3. A listener function:
+ * `this.unsubscribe = automat.subscribe((state) => { ... });`
+ *
+ * @param {object|function} target React component instance or callback function.
+ * @param {function} [selector] Optional selector function mapping state.
+ * @returns {function} Unsubscribe function for componentWillUnmount.
+ */
+ subscribe(target, selector) {
+ let notifyFn;
+
+ if (typeof target === 'function') {
+ notifyFn = target;
+ } else if (target && typeof target.setState === 'function') {
+ notifyFn = (state) => {
+ const next = typeof selector === 'function' ? selector(state) : state;
+ if (next != null) {
+ target.setState(next);
+ }
+ };
+ } else {
+ throw new TypeError(
+ 'Automat.subscribe expects a callback function or a React component instance with a setState method.'
+ );
+ }
+
+ this.#subscribers.set(target, notifyFn);
+
+ return () => {
+ this.unsubscribe(target);
+ };
+ }
+
+ /**
+ * Unsubscribes a component or listener function.
+ *
+ * Can be called directly in `componentWillUnmount`:
+ * `automat.unsubscribe(this);`
+ *
+ * @param {object|function} target
+ */
+ unsubscribe(target) {
+ this.#subscribers.delete(target);
+ }
+
+ /**
+ * Wires this automat to derive state from an upstream automat.
+ *
+ * Whenever `upstreamAutomat` changes, `transform` is called with the upstream state
+ * and this automat's current state. The returned partial object is applied via
+ * `setState`, notifying this automat's own subscribers.
+ *
+ * @param {Automat} upstreamAutomat
+ * @param {function(upstreamState: object, myState: object): object|null} transform
+ * @returns {this} Chainable.
+ */
+ subscribeTo(upstreamAutomat, transform) {
+ const unsub = upstreamAutomat.subscribe((upstreamState) => {
+ const partial = transform(upstreamState, this.#state);
+ if (partial != null) {
+ this.setState(partial);
+ }
+ });
+ this.#upstreamUnsubscribers.push(unsub);
+ return this;
+ }
+
+ /**
+ * The named action callbacks passed in the constructor.
+ * @type {object}
+ */
+ get actions() {
+ return this.#callbacks;
+ }
+
+ /**
+ * Tears down all upstream subscriptions and clears the subscriber map.
+ */
+ dispose() {
+ this.#upstreamUnsubscribers.forEach((fn) => fn());
+ this.#upstreamUnsubscribers = [];
+ this.#subscribers.clear();
+ }
+
+ /** @private */
+ #notify() {
+ for (const notifyFn of this.#subscribers.values()) {
+ notifyFn(this.#state);
+ }
+ }
+}
diff --git a/src/lib/index.js b/src/lib/index.js
new file mode 100644
index 0000000..081eae7
--- /dev/null
+++ b/src/lib/index.js
@@ -0,0 +1,30 @@
+/**
+ * @module automat
+ *
+ * Observable state management for React PureComponents.
+ *
+ * @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 }); },
+ * });
+ *
+ * class Display extends PureComponent {
+ * constructor(props) {
+ * super(props);
+ * this.state = counterAutomat.state;
+ * }
+ * componentDidMount() {
+ * this.unsubscribe = counterAutomat.subscribe(this);
+ * }
+ * componentWillUnmount() {
+ * this.unsubscribe();
+ * }
+ * render() {
+ * return {this.state.count} ;
+ * }
+ * }
+ */
+export { Automat } from './Automat.js';
diff --git a/src/main.jsx b/src/main.jsx
new file mode 100644
index 0000000..d9cc93c
--- /dev/null
+++ b/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import './index.css';
+import App from './App.jsx';
+
+createRoot(document.getElementById('root')).render(
+
+
+
+);
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..1637a2c
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,87 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+/**
+ * Embedded in-memory backend plugin for Vite dev server.
+ * Handles POST /api/counter and GET /api/counter to support automatic
+ * backend synchronization for the indexed counter demo without external APIs.
+ */
+function counterBackendPlugin() {
+ const serverStore = new Map([
+ [0, { count: 0, updatedAt: 'Initial' }],
+ [1, { count: 5, updatedAt: 'Initial' }],
+ [2, { count: 10, updatedAt: 'Initial' }],
+ ]);
+
+ return {
+ name: 'counter-backend-plugin',
+ configureServer(server) {
+ server.middlewares.use('/api/counter', (req, res, next) => {
+ if (req.method === 'POST') {
+ let body = '';
+ req.on('data', (chunk) => {
+ body += chunk;
+ });
+ req.on('end', () => {
+ try {
+ const data = JSON.parse(body || '{}');
+ const index = Number(data.index) || 0;
+ const count = Number(data.count) || 0;
+ const updatedAt = new Date().toLocaleTimeString();
+
+ serverStore.set(index, { count, updatedAt });
+
+ // 180ms simulated server latency to clearly illustrate syncing status
+ setTimeout(() => {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(
+ JSON.stringify({
+ success: true,
+ index,
+ count,
+ savedAt: updatedAt,
+ allRecords: Array.from(serverStore.entries()).map(([idx, val]) => ({
+ index: idx,
+ ...val,
+ })),
+ })
+ );
+ }, 180);
+ } catch (err) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: err.message }));
+ }
+ });
+ return;
+ }
+
+ if (req.method === 'GET') {
+ const url = new URL(req.url, 'http://localhost');
+ const indexParam = url.searchParams.get('index');
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ if (indexParam !== null) {
+ const index = Number(indexParam);
+ const item = serverStore.get(index) || { count: 0, updatedAt: 'Unsynced' };
+ return res.end(JSON.stringify({ index, ...item }));
+ }
+
+ return res.end(
+ JSON.stringify({
+ allRecords: Array.from(serverStore.entries()).map(([idx, val]) => ({
+ index: idx,
+ ...val,
+ })),
+ })
+ );
+ }
+
+ next();
+ });
+ },
+ };
+}
+
+export default defineConfig({
+ plugins: [react(), counterBackendPlugin()],
+});
diff --git a/vite.config.lib.js b/vite.config.lib.js
new file mode 100644
index 0000000..b5b5d53
--- /dev/null
+++ b/vite.config.lib.js
@@ -0,0 +1,24 @@
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ build: {
+ outDir: 'dist',
+ emptyOutDir: false,
+ lib: {
+ entry: resolve(import.meta.dirname, 'src/lib/index.js'),
+ name: 'Automat',
+ fileName: (format) => `automat.${format}.js`,
+ formats: ['es', 'umd'],
+ },
+ rollupOptions: {
+ external: ['react', 'react-dom'],
+ output: {
+ globals: {
+ react: 'React',
+ 'react-dom': 'ReactDOM',
+ },
+ },
+ },
+ },
+});
From 406243b2f10f0faff52df1d55b920ee91564f6b5 Mon Sep 17 00:00:00 2001
From: seb
Date: Fri, 18 Sep 2026 05:08:27 +0200
Subject: [PATCH 2/4] slice subscription
---
README.LLM.md | 61 ++++++++++++++----
README.md | 39 +++++++++++-
src/lib/Automat.js | 150 +++++++++++++++++++++++++++++++++++++--------
3 files changed, 213 insertions(+), 37 deletions(-)
diff --git a/README.LLM.md b/README.LLM.md
index d5b32d2..73a9170 100644
--- a/README.LLM.md
+++ b/README.LLM.md
@@ -9,11 +9,12 @@ This document is optimized for LLMs and AI coding assistants implementing or con
`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.automatRegistry = new Map()` or an in-memory entity cache) keyed by ID or index. State persists in memory across component mounts, unmounts, and subscription transfers.
+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. **No Wrappers or Hooks**: No HOCs, no context providers, no hooks, no `connect()`.
+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()`.
---
@@ -41,7 +42,8 @@ new Automat>(
| `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?: (state: T) => object \| null): () => void` | Subscribes either a React component instance (`this`) or a callback function. Returns an `unsubscribe` function. |
+| `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. |
@@ -125,26 +127,61 @@ export class CounterController extends PureComponent {
---
-### Pattern C: Passive Reader Component with State Selector
+### Pattern C: Partial & Slice Subscriptions (Subscribing to Part of the State)
-Use a selector function when a component only cares about a subset of the automat's 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 { counterAutomat } from '../automats/counterAutomat.js';
+import { appAutomat } from '../automats/appAutomat.js';
export class CountDisplay extends PureComponent {
constructor(props) {
super(props);
- this.state = { count: counterAutomat.state.count };
+ // Initialize with only the slice needed:
+ this.state = { count: appAutomat.state.count };
}
componentDidMount() {
- // Selector maps state to target object. Returning null skips setState.
- this.unsubscribe = counterAutomat.subscribe(this, (state) => ({
- count: state.count,
- }));
+ // π‘ 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() {
@@ -359,12 +396,14 @@ export class DynamicSlotObserver extends PureComponent {
- **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`.
diff --git a/README.md b/README.md
index f2f7d51..e67d7d4 100644
--- a/README.md
+++ b/README.md
@@ -223,7 +223,8 @@ const counterAutomat = new Automat(
| `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 either a component instance (`this`) or a callback `(state) => ...`. Returns unsub function. |
+| `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` |
@@ -231,6 +232,42 @@ const counterAutomat = new Automat(
---
+## Subscribing to Part of the State (Slice Subscriptions)
+
+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.
+
+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**:
+
+### 1. Single Key String
+```jsx
+// Subscribes only to changes in 'count'. Unrelated fields will NOT trigger setState:
+this.unsubscribe = myAutomat.subscribe(this, 'count');
+```
+
+### 2. Array of Keys
+```jsx
+// Subscribes only to 'count' and 'step':
+this.unsubscribe = myAutomat.subscribe(this, ['count', 'step']);
+```
+
+### 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,
+}));
+```
+
+### 4. Automat Slicing with `.select()`
+```jsx
+const countSlice = myAutomat.select('count');
+this.state = countSlice.state; // { count: 0 }
+this.unsubscribe = countSlice.subscribe(this);
+```
+
+---
+
## Notification Cascade (Wired Example)
### How `subscribeTo()` Works (Reactive Pipeline)
diff --git a/src/lib/Automat.js b/src/lib/Automat.js
index ec039d0..71ce433 100644
--- a/src/lib/Automat.js
+++ b/src/lib/Automat.js
@@ -30,6 +30,26 @@
* }
* ```
*/
+/**
+ * Performs a shallow equality check between two values or objects.
+ * Used by Automat subscribers to avoid unnecessary setState calls when
+ * a subscribed slice of state has not changed.
+ */
+function shallowEqual(a, b) {
+ if (Object.is(a, b)) return true;
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
+ const keysA = Object.keys(a);
+ const keysB = Object.keys(b);
+ if (keysA.length !== keysB.length) return false;
+ for (let i = 0; i < keysA.length; i++) {
+ const key = keysA[i];
+ if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) {
+ return false;
+ }
+ }
+ return true;
+}
+
export class Automat {
/** @type {object} */
#state;
@@ -75,43 +95,89 @@ export class Automat {
*/
setState(partial) {
this.#state = { ...this.#state, ...partial };
- this.#notify();
+ this.#notify(partial);
return this.#state;
}
/**
- * Subscriber handling for React lifecycle management or listener callbacks.
+ * Subscribes a React PureComponent instance or a listener callback.
*
- * Supports:
- * 1. A React component instance (has `.setState`):
- * `this.unsubscribe = automat.subscribe(this);`
- * 2. A React component instance with an optional selector:
- * `this.unsubscribe = automat.subscribe(this, state => ({ count: state.count }));`
- * 3. A listener function:
- * `this.unsubscribe = automat.subscribe((state) => { ... });`
+ * Partial / Slice Subscriptions:
+ * By default, subscribing to an automat triggers updates when any state changes.
+ * Passing a `selector` allows subscribing to only a slice of state.
+ * Changes to unrelated state fields will NOT trigger setState or re-renders.
*
- * @param {object|function} target React component instance or callback function.
- * @param {function} [selector] Optional selector function mapping state.
- * @returns {function} Unsubscribe function for componentWillUnmount.
+ * Selector forms:
+ * 1. Single property key (string):
+ * `automat.subscribe(this, 'count')`
+ * 2. Multiple property keys (array of strings):
+ * `automat.subscribe(this, ['count', 'step'])`
+ * 3. Selector function:
+ * `automat.subscribe(this, state => ({ count: state.count }))`
+ * (Return null/undefined to conditionally skip updates)
+ * 4. Full subscription (omitted):
+ * `automat.subscribe(this)`
+ *
+ * @param {object|function} target React component (`this`) or callback function.
+ * @param {string|string[]|function} [selector] Property key, key array, or selector function.
+ * @returns {function} Unsubscribe function for componentWillUnmount.
*/
subscribe(target, selector) {
- let notifyFn;
+ const isComponent = target && typeof target.setState === 'function';
+ const isFunction = typeof target === 'function';
- if (typeof target === 'function') {
- notifyFn = target;
- } else if (target && typeof target.setState === 'function') {
- notifyFn = (state) => {
- const next = typeof selector === 'function' ? selector(state) : state;
- if (next != null) {
- target.setState(next);
- }
- };
- } else {
+ if (!isComponent && !isFunction) {
throw new TypeError(
'Automat.subscribe expects a callback function or a React component instance with a setState method.'
);
}
+ let getSlice;
+ if (typeof selector === 'string') {
+ getSlice = isComponent
+ ? (state) => ({ [selector]: state[selector] })
+ : (state) => state[selector];
+ } else if (Array.isArray(selector)) {
+ getSlice = (state) => {
+ const slice = {};
+ for (let i = 0; i < selector.length; i++) {
+ const key = selector[i];
+ slice[key] = state[key];
+ }
+ return slice;
+ };
+ } else if (typeof selector === 'function') {
+ getSlice = selector;
+ }
+
+ let lastSlice = getSlice ? getSlice(this.#state) : undefined;
+
+ const notifyFn = (state, partial) => {
+ if (getSlice) {
+ const nextSlice = getSlice(state);
+ if (nextSlice == null) return;
+ if (shallowEqual(lastSlice, nextSlice)) return;
+ lastSlice = nextSlice;
+
+ if (isComponent) {
+ if (typeof nextSlice !== 'object') {
+ throw new TypeError(
+ 'Automat: selector for a React component must return a state object, e.g. state => ({ count: state.count }).'
+ );
+ }
+ target.setState(nextSlice);
+ } else {
+ target(nextSlice);
+ }
+ } else {
+ if (isComponent) {
+ target.setState(partial ?? state);
+ } else {
+ target(state);
+ }
+ }
+ };
+
this.#subscribers.set(target, notifyFn);
return () => {
@@ -119,6 +185,40 @@ export class Automat {
};
}
+ /**
+ * Slices this automat to a subset of state for reading and subscription.
+ *
+ * @param {string|string[]|function} selector
+ * @returns {{ readonly state: any, subscribe(target: object|function): function }}
+ */
+ select(selector) {
+ let getSlice;
+ if (typeof selector === 'string') {
+ getSlice = (state) => ({ [selector]: state[selector] });
+ } else if (Array.isArray(selector)) {
+ getSlice = (state) => {
+ const slice = {};
+ for (let i = 0; i < selector.length; i++) {
+ const key = selector[i];
+ slice[key] = state[key];
+ }
+ return slice;
+ };
+ } else if (typeof selector === 'function') {
+ getSlice = selector;
+ } else {
+ getSlice = (state) => state;
+ }
+
+ const self = this;
+ return {
+ get state() {
+ return getSlice(self.state);
+ },
+ subscribe: (target) => self.subscribe(target, selector),
+ };
+ }
+
/**
* Unsubscribes a component or listener function.
*
@@ -171,9 +271,9 @@ export class Automat {
}
/** @private */
- #notify() {
+ #notify(partial) {
for (const notifyFn of this.#subscribers.values()) {
- notifyFn(this.#state);
+ notifyFn(this.#state, partial);
}
}
}
From 264e8f839da6d4a6e201ef332fcd4649f6c3893e Mon Sep 17 00:00:00 2001
From: seb
Date: Sat, 19 Sep 2026 01:43:24 +0200
Subject: [PATCH 3/4] 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}
-
β{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) {
From 0f9321ac34db099320b662d88dfd1d60c5c3e0b4 Mon Sep 17 00:00:00 2001
From: sebseb7
Date: Tue, 22 Sep 2026 11:38:20 +0200
Subject: [PATCH 4/4] u
---
README.md | 144 ++++++-
src/App.jsx | 75 ++++
src/examples/automats/combinedMultiAutomat.js | 146 +++++++
src/examples/automats/profileAsyncAutomat.js | 93 +++++
.../components/AsyncApiCollapsibleView.jsx | 304 ++++++++++++++
.../components/AsyncApiPrimaryView.jsx | 327 +++++++++++++++
.../components/MultiApiCombinedDashboard.jsx | 389 ++++++++++++++++++
.../components/MultiApiIndividualControls.jsx | 204 +++++++++
src/index.css | 35 ++
src/lib/Automat.js | 153 +++++++
10 files changed, 1865 insertions(+), 5 deletions(-)
create mode 100644 src/examples/automats/combinedMultiAutomat.js
create mode 100644 src/examples/automats/profileAsyncAutomat.js
create mode 100644 src/examples/components/AsyncApiCollapsibleView.jsx
create mode 100644 src/examples/components/AsyncApiPrimaryView.jsx
create mode 100644 src/examples/components/MultiApiCombinedDashboard.jsx
create mode 100644 src/examples/components/MultiApiIndividualControls.jsx
diff --git a/README.md b/README.md
index 674db90..9f01b61 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,7 @@ Creates an observable state container with optional persistence.
- `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).
+ - `loader` *(function, optional)*: Asynchronous loader `(automat) => Promise`. Invoked when `getData()` is called while state is `'idle'`.
```js
// Window-persisted (session memory)
@@ -92,16 +93,57 @@ const sessionStore = new Automat(
{ 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 }
+// Async API-backed with loader
+const userStore = new Automat(
+ { status: 'idle', data: null },
+ {},
+ {
+ name: 'user',
+ loader: async (store) => {
+ const res = await fetch('/api/user');
+ return { data: await res.json() };
+ },
+ }
);
```
---
+### `automat.getData(options?)` / `automat.load(options?)`
+
+Reads state for PureComponent constructor initialization, triggering asynchronous loading if state is idle:
+
+1. **First Mount (Data Not Loaded)**:
+ - Sets `{ status: 'pending' }` and kicks off the async loader.
+ - Synchronously returns the pending state for the first render.
+ - When the fetch completes, `setState()` notifies subscribers, causing a 2nd render with data.
+2. **Subsequent Mounts / Remounts (Data Already Loaded)**:
+ - Directly returns `{ status: 'success', data }` synchronously in the constructor.
+ - Component renders completed data immediately. **Zero 2nd render occurs!**
+
+```jsx
+class UserProfile extends PureComponent {
+ constructor(props) {
+ super(props);
+ // π‘ Synchronous read in constructor:
+ this.state = userStore.getData();
+ }
+ componentDidMount() {
+ this.unsubscribe = userStore.subscribe(this);
+ }
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+ render() {
+ const { status, data } = this.state;
+ if (status === 'pending') return ;
+ return {data.name}
;
+ }
+}
+```
+
+---
+
### `automat.state` / `automat.getState()`
Returns the current state snapshot.
@@ -245,6 +287,28 @@ const filterStore = Automat.get('filter');
---
+### `Automat.combine(upstreamAutomats, combiner, options?)`
+
+Combines multiple upstream automats into a single derived container.
+
+- **Concurrent Orchestration**: When accessed via `.getData()` while any upstream is idle, triggers all idle upstreams to fetch concurrently in parallel.
+- **Instant Constructor Resolution**: If all upstream automats have already resolved (`status === 'success'`), `.getData()` computes the projection synchronously in the component constructor, returning complete combined data with zero delay and a single render.
+
+```js
+const dashboardAutomat = Automat.combine(
+ [userStatsAutomat, systemMetricsAutomat],
+ (statsState, metricsState) => ({
+ stats: statsState.data,
+ metrics: metricsState.data,
+ })
+);
+
+// In component constructor:
+this.state = dashboardAutomat.getData();
+```
+
+---
+
### `automat.dispose()`
Tears down all upstream subscriptions set up via `subscribeTo()`, clears all subscribers, and removes named instance registrations from the window registry.
@@ -365,6 +429,76 @@ export class CartView extends PureComponent {
---
+### 4. Async Fetch with Constructor Caching (Instant Re-Mount)
+
+When a component accesses `myAutomat.getData()` in its constructor while data is loading, it receives `{ status: 'pending' }` and renders the pending state. When the fetch completes, it renders again (2 renders total).
+
+When another component mounts (or the component remounts via a collapsible toggle) with data already loaded, `getData()` returns `{ status: 'success', data }` synchronously in the constructor β displaying final data in **1 render with zero 2nd render**:
+
+```jsx
+class UserCard extends PureComponent {
+ constructor(props) {
+ super(props);
+ // π‘ Synchronous read in constructor:
+ // If already loaded, returns immediately -> 1 render only!
+ this.state = profileAsyncAutomat.getData();
+ }
+ componentDidMount() {
+ this.unsubscribe = profileAsyncAutomat.subscribe(this);
+ }
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+ render() {
+ const { status, data } = this.state;
+ if (status === 'pending') return
;
+ return {data.name}
;
+ }
+}
+```
+
+---
+
+### 5. Combined Multi-API Automats (`Automat.combine`)
+
+Combines two delayed APIs. If accessed while upstreams are idle, it triggers both to load concurrently in parallel. If both had already been accessed earlier, it resolves instantly in the constructor:
+
+```js
+const combinedDashboard = Automat.combine(
+ [userStatsAutomat, systemMetricsAutomat],
+ (stats, metrics) => ({
+ stats: stats.data,
+ metrics: metrics.data,
+ })
+);
+
+class Dashboard extends PureComponent {
+ constructor(props) {
+ super(props);
+ // π‘ If both upstreams were loaded earlier, resolves instantly in constructor:
+ this.state = combinedDashboard.getData();
+ }
+ componentDidMount() {
+ this.unsubscribe = combinedDashboard.subscribe(this);
+ }
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+ render() {
+ const { status, stats, metrics } = this.state;
+ if (status === 'pending') return Loading dual sources in parallel...
;
+ return (
+
+
{stats.name}
+
Cluster: {metrics.clusterHealth}
+
+ );
+ }
+}
+```
+
+---
+
## Best Practices
### β
DO
diff --git a/src/App.jsx b/src/App.jsx
index 98ec4d3..8fd884b 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -9,6 +9,10 @@ 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';
+import AsyncApiPrimaryView from './examples/components/AsyncApiPrimaryView.jsx';
+import AsyncApiCollapsibleView from './examples/components/AsyncApiCollapsibleView.jsx';
+import MultiApiIndividualControls from './examples/components/MultiApiIndividualControls.jsx';
+import MultiApiCombinedDashboard from './examples/components/MultiApiCombinedDashboard.jsx';
class App extends PureComponent {
render() {
@@ -225,6 +229,77 @@ class App extends PureComponent {
+
+
+
+ {/* ββ Example 6: Async Fetch & Constructor Cache ββββββββ */}
+
+
+
+ 06
+
Async API Fetch & Constructor Cache (Instant Re-Mount)
+
+
+ An automat fetching asynchronous data via a simulated delayed API. When the primary component mounts,
+ calling profileAsyncAutomat.getData() in its constructor returns{' '}
+ {`{ status: 'pending' }`}, rendering a loading state (1st render), then re-rendering
+ when the fetch finishes (2nd render). When a second component mounts (or when unmounted and remounted via the collapsible toggle),
+ the data is already loaded in memory β the constructor receives the resolved payload synchronously, rendering in{' '}
+ 1 render with zero 2nd render .
+
+
+ Mount 1 Constructor
+ β
+ status: 'pending' (Render #1)
+ β
+ API Resolves
+ β
+ Render #2 (Data)
+ |
+ Mount 2 (Collapsible)
+ β
+ Constructor Has Data (1 Render Only)
+
+
+
+
+
+
+
+
+ {/* ββ Example 7: Combined Multi-API Automats ββββββββββββββ */}
+
+
+
+ 07
+
Combined Multi-API Automats (Concurrent Orchestration)
+
+
+ Three automats in an orchestrated graph: two with delayed APIs (userStatsAutomat at ~700ms and{' '}
+ systemMetricsAutomat at ~1300ms), and one combined automat created with{' '}
+ Automat.combine(). When accessed while upstreams are idle, it triggers both to load in parallel,
+ merging results when both finish. However, if both have already been accessed earlier , the combined automat resolves{' '}
+ instantly in the component constructor with zero delay and a single render.
+
+
+ combinedDashboardAutomat.getData()
+ β
+ Check Upstreams
+ β
+ If Idle: Fetch Concurrently (~1300ms parallel)
+ |
+ If Cached: Instant Constructor Return (1 Render)
+
+
+
+
+
+
+
+
diff --git a/src/examples/automats/combinedMultiAutomat.js b/src/examples/automats/combinedMultiAutomat.js
new file mode 100644
index 0000000..45140eb
--- /dev/null
+++ b/src/examples/automats/combinedMultiAutomat.js
@@ -0,0 +1,146 @@
+import { Automat } from '../../lib/index.js';
+
+/**
+ * Simulated delayed API #1: User Stats (~700ms latency)
+ */
+export async function fetchUserStats(delayMs = 700) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ return {
+ name: 'Sarah Connor',
+ handle: '@sconnor_ops',
+ avatar: 'π‘οΈ',
+ level: 'Tier-1 Defense Architect',
+ deploymentsCount: 94,
+ uptimeRating: '99.98%',
+ lastAuditScore: '98/100',
+ fetchedAt: new Date().toLocaleTimeString(),
+ };
+}
+
+/**
+ * Simulated delayed API #2: System Metrics & Events (~1300ms latency)
+ */
+export async function fetchSystemMetrics(delayMs = 1300) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ return {
+ clusterHealth: 'Healthy',
+ activeNodes: 16,
+ avgResponseMs: 19,
+ events: [
+ { id: 1, type: 'firewall', text: 'Firewall updated: VPC ingress port 8443 opened', time: '2m ago' },
+ { id: 2, type: 'kernel', text: 'Kernel security patch automat_v0.1.0 verified', time: '14m ago' },
+ { id: 3, type: 'backup', text: 'Automated disk snapshot created (3.2 GB)', time: '40m ago' },
+ ],
+ fetchedAt: new Date().toLocaleTimeString(),
+ };
+}
+
+/**
+ * Automat A: User Stats (Delayed API)
+ */
+export const userStatsAutomat = new Automat(
+ {
+ status: 'idle', // 'idle' | 'pending' | 'success' | 'error'
+ data: null,
+ error: null,
+ delayMs: 700,
+ fetchCount: 0,
+ },
+ {
+ reset() {
+ userStatsAutomat.setState({ status: 'idle', data: null, error: null });
+ },
+ },
+ {
+ name: 'user_stats_multi',
+ persist: false,
+ loader: async (automat) => {
+ const { delayMs, fetchCount } = automat.state;
+ const data = await fetchUserStats(delayMs);
+ return { data, fetchCount: fetchCount + 1 };
+ },
+ }
+);
+
+/**
+ * Automat B: System Metrics (Delayed API)
+ */
+export const systemMetricsAutomat = new Automat(
+ {
+ status: 'idle', // 'idle' | 'pending' | 'success' | 'error'
+ data: null,
+ error: null,
+ delayMs: 1300,
+ fetchCount: 0,
+ },
+ {
+ reset() {
+ systemMetricsAutomat.setState({ status: 'idle', data: null, error: null });
+ },
+ },
+ {
+ name: 'system_metrics_multi',
+ persist: false,
+ loader: async (automat) => {
+ const { delayMs, fetchCount } = automat.state;
+ const data = await fetchSystemMetrics(delayMs);
+ return { data, fetchCount: fetchCount + 1 };
+ },
+ }
+);
+
+/**
+ * Automat C: Combined Dashboard Automat
+ *
+ * Combines `userStatsAutomat` and `systemMetricsAutomat`.
+ *
+ * Invariants:
+ * 1. If accessed when both upstreams are idle:
+ * Triggers BOTH upstreams to fetch concurrently in parallel.
+ * Tracks live progress (0/2 -> 1/2 -> 2/2) and transitions to 'success'
+ * when both finish (total time ~1300ms instead of 2000ms sequential).
+ * 2. If accessed when both upstreams were ALREADY loaded earlier:
+ * Resolves INSTANTLY in the component constructor!
+ * State has `status: 'success'` and complete data on the 1st render (no 2nd render).
+ */
+export const combinedDashboardAutomat = Automat.combine(
+ [userStatsAutomat, systemMetricsAutomat],
+ (statsState, metricsState) => {
+ const statsDone = statsState?.status === 'success';
+ const metricsDone = metricsState?.status === 'success';
+ const doneCount = (statsDone ? 1 : 0) + (metricsDone ? 1 : 0);
+
+ return {
+ stats: statsState?.data || null,
+ metrics: metricsState?.data || null,
+ statsStatus: statsState?.status || 'idle',
+ metricsStatus: metricsState?.status || 'idle',
+ doneCount,
+ totalCount: 2,
+ progressText: `${doneCount}/2 sources resolved`,
+ lastCombinedAt: new Date().toLocaleTimeString(),
+ };
+ },
+ {
+ name: 'combined_dashboard',
+ persist: false,
+ }
+);
+
+/**
+ * Helper to reset all three automats back to idle for testing.
+ */
+export function resetMultiApiDemo() {
+ userStatsAutomat.actions.reset();
+ systemMetricsAutomat.actions.reset();
+ combinedDashboardAutomat.setState({
+ status: 'idle',
+ stats: null,
+ metrics: null,
+ statsStatus: 'idle',
+ metricsStatus: 'idle',
+ doneCount: 0,
+ totalCount: 2,
+ progressText: '0/2 sources resolved',
+ });
+}
diff --git a/src/examples/automats/profileAsyncAutomat.js b/src/examples/automats/profileAsyncAutomat.js
new file mode 100644
index 0000000..ffd78c0
--- /dev/null
+++ b/src/examples/automats/profileAsyncAutomat.js
@@ -0,0 +1,93 @@
+import { Automat } from '../../lib/index.js';
+
+/**
+ * Simulated dummy API with realistic asynchronous latency.
+ *
+ * @param {number} delayMs
+ * @param {boolean} shouldFail
+ * @returns {Promise}
+ */
+export async function fetchDummyProfile(delayMs = 800, shouldFail = false) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+
+ if (shouldFail) {
+ throw new Error('Simulated upstream gateway timeout (HTTP 504 Gateway Timeout)');
+ }
+
+ return {
+ id: 'usr_8492',
+ name: 'Elena Rostova',
+ handle: '@elena_dev',
+ role: 'Principal Systems Architect',
+ avatar: 'π©βπ»',
+ statusMessage: 'Optimizing reactive state pipelines',
+ quota: { used: 74, total: 100, unit: 'GB' },
+ endpoint: 'us-east-1.cloud.automat.internal',
+ pingMs: Math.floor(18 + Math.random() * 15),
+ tags: ['Production', 'Kubernetes', 'WebSockets', 'GraphQL'],
+ lastFetchedAt: new Date().toLocaleTimeString(),
+ };
+}
+
+/**
+ * Async API-backed Automat.
+ *
+ * Demonstrates:
+ * 1. An Automat that fetches an API with simulated latency.
+ * 2. When a component accesses `getData()` in its constructor while status is 'idle',
+ * it receives `{ status: 'pending' }` immediately and renders the pending state (1st render).
+ * 3. Once the API completes, `setState` notifies subscribers, re-rendering with data (2nd render).
+ * 4. When a different component (or the same component remounted via a collapsible)
+ * calls `getData()`, the data is ALREADY in memory (`status === 'success'`).
+ * It returns synchronously in constructor, rendering immediately with NO 2nd render needed!
+ */
+export const profileAsyncAutomat = new Automat(
+ {
+ status: 'idle', // 'idle' | 'pending' | 'success' | 'error'
+ data: null,
+ error: null,
+ delayMs: 800,
+ fetchCount: 0,
+ lastFetchedAt: null,
+ },
+ {
+ setDelay(delayMs) {
+ profileAsyncAutomat.setState({ delayMs: Math.max(100, Number(delayMs) || 800) });
+ },
+
+ reset() {
+ profileAsyncAutomat.setState({
+ status: 'idle',
+ data: null,
+ error: null,
+ });
+ },
+
+ async triggerError() {
+ const { delayMs, fetchCount } = profileAsyncAutomat.state;
+ profileAsyncAutomat.setState({ status: 'pending', error: null });
+ try {
+ await fetchDummyProfile(delayMs, true);
+ } catch (err) {
+ profileAsyncAutomat.setState({
+ status: 'error',
+ error: err.message,
+ fetchCount: fetchCount + 1,
+ });
+ }
+ },
+ },
+ {
+ name: 'profile_async',
+ persist: false,
+ loader: async (automat) => {
+ const { delayMs, fetchCount } = automat.state;
+ const data = await fetchDummyProfile(delayMs, false);
+ return {
+ data,
+ fetchCount: fetchCount + 1,
+ lastFetchedAt: data.lastFetchedAt,
+ };
+ },
+ }
+);
diff --git a/src/examples/components/AsyncApiCollapsibleView.jsx b/src/examples/components/AsyncApiCollapsibleView.jsx
new file mode 100644
index 0000000..f8cc181
--- /dev/null
+++ b/src/examples/components/AsyncApiCollapsibleView.jsx
@@ -0,0 +1,304 @@
+import { PureComponent } from 'react';
+import { profileAsyncAutomat } from '../automats/profileAsyncAutomat.js';
+
+let secondaryMountSequence = 0;
+const mountHistoryLog = [];
+
+/**
+ * Secondary Consumer Component.
+ * Unmounted and remounted by the parent collapsible toggle.
+ *
+ * Demonstrates:
+ * When this component mounts while data is already in memory:
+ * 1. `profileAsyncAutomat.getData()` returns { status: 'success', data } synchronously in `constructor()`.
+ * 2. 1st render displays final data immediately with ZERO delay.
+ * 3. `componentDidMount()` subscribes, but no state changes occur.
+ * 4. RENDER COUNT STAYS AT 1! No 2nd render is necessary!
+ */
+class SecondaryProfileConsumer extends PureComponent {
+ _renderCount = 0;
+ _constructorStatus = 'uninitialized';
+ _mountId = 0;
+
+ constructor(props) {
+ super(props);
+ this._mountId = ++secondaryMountSequence;
+
+ // 1. π‘ Synchronous read in constructor:
+ this.state = profileAsyncAutomat.getData();
+ this._constructorStatus = this.state.status;
+
+ mountHistoryLog.unshift({
+ mountId: this._mountId,
+ time: new Date().toLocaleTimeString(),
+ statusInConstructor: this._constructorStatus,
+ hadDataImmediately: this._constructorStatus === 'success',
+ });
+ }
+
+ componentDidMount() {
+ // 2. Subscribe for updates (e.g. if Primary triggers a refetch or reset):
+ this.unsubscribe = profileAsyncAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ if (this.unsubscribe) {
+ this.unsubscribe();
+ }
+ }
+
+ render() {
+ this._renderCount++;
+ const { status, data } = this.state;
+ const isSingleRenderSuccess = this._renderCount === 1 && status === 'success';
+
+ return (
+
+ {/* Mount Diagnostic Bar */}
+
+
+
+ Lifecycle Stats
+ Session Mount
+
+
+ Mount #{this._mountId}
+
+
+
+
+
+ Render Efficiency
+ Renders this Mount
+
+
+ #{this._renderCount}
+
+ {this._renderCount === 1 ? '(Single render!)' : '(Pending + Complete)'}
+
+
+
+
+
+ {/* Big visual banner when 0 second renders occurred */}
+ {isSingleRenderSuccess && (
+
+ β‘
+
+ Zero 2nd render! Data was resolved in the constructor (status: 'success').
+
+
+ )}
+
+ {/* Content View */}
+ {status === 'pending' && (
+
+
+
Loading API in background...
+
+ )}
+
+ {status === 'idle' && (
+
+ Automat is currently idle. (Open primary view or click refetch).
+
+ )}
+
+ {status === 'success' && data && (
+
+
+
{data.avatar}
+
+
+ {data.name} ({data.handle})
+
+
{data.role}
+
+
+
+
+ Connected to: {data.endpoint} (Ping: {data.pingMs}ms )
+
+
+ )}
+
+ );
+ }
+}
+
+/**
+ * AsyncApiCollapsibleView β Right column of Section 06.
+ *
+ * Provides a collapsible panel that dynamically mounts and unmounts the
+ * `SecondaryProfileConsumer` component.
+ */
+export default class AsyncApiCollapsibleView extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isMounted: false,
+ };
+ }
+
+ toggleMount = () => {
+ this.setState((prev) => ({ isMounted: !prev.isMounted }));
+ };
+
+ render() {
+ const { isMounted } = this.state;
+ const automatState = profileAsyncAutomat.state;
+
+ return (
+
+
+ π
+
Collapsible Consumer (Unmount Test)
+ No 2nd Render
+
+
+
+
+ Toggle the collapsible below to completely unmount and remount this
+ component. When data is already loaded in profileAsyncAutomat,
+ its constructor immediately receives the cached payload β rendering in 1 render with no 2nd render .
+
+
+ {/* Current Automat Status Hint */}
+
+
+ Automat Cache State:
+
+
+ {automatState.status.toUpperCase()}
+ {automatState.status === 'success' ? ' (Data Ready)' : ''}
+
+
+
+ {/* Collapsible Toggle Button */}
+
+ {isMounted ? 'πΌ' : 'π½'}
+
+ {isMounted
+ ? 'Unmount Secondary Component (Collapse)'
+ : 'Mount Secondary Component (Expand)'}
+
+
+
+ {/* Dynamically Mounted Child Component */}
+ {isMounted ? (
+
+ ) : (
+
+ Component is currently UNMOUNTED from the React tree.
+
+ Click the button above to mount it and observe the constructor read.
+
+ )}
+
+ {/* Session Mount History Log */}
+ {mountHistoryLog.length > 0 && (
+
+
+ Session Mount History:
+
+
+ {mountHistoryLog.slice(0, 5).map((entry) => (
+
+ Mount #{entry.mountId} ({entry.time})
+
+ {entry.hadDataImmediately ? 'β‘ 1 render (instant)' : 'β³ 2 renders (pending)'}
+
+
+ ))}
+
+
+ )}
+
+
+ );
+ }
+}
diff --git a/src/examples/components/AsyncApiPrimaryView.jsx b/src/examples/components/AsyncApiPrimaryView.jsx
new file mode 100644
index 0000000..5d38ee6
--- /dev/null
+++ b/src/examples/components/AsyncApiPrimaryView.jsx
@@ -0,0 +1,327 @@
+import { PureComponent } from 'react';
+import { profileAsyncAutomat } from '../automats/profileAsyncAutomat.js';
+
+/**
+ * AsyncApiPrimaryView β Left column of Section 06.
+ *
+ * Demonstrates:
+ * 1. Initial access to an asynchronous Automat: constructor calls `profileAsyncAutomat.getData()`.
+ * 2. On first mount when data is not yet loaded, status is 'pending'.
+ * 3. The component renders `pending` (Render #1).
+ * 4. In `componentDidMount()`, it subscribes to updates.
+ * 5. When the simulated API fetch completes, `setState` runs and the component renders again (Render #2).
+ */
+export default class AsyncApiPrimaryView extends PureComponent {
+ _renderCount = 0;
+ _constructorStatus = 'uninitialized';
+
+ constructor(props) {
+ super(props);
+ // 1. Initial read in constructor triggers the async loader if idle:
+ this.state = profileAsyncAutomat.getData();
+ this._constructorStatus = this.state.status;
+ }
+
+ componentDidMount() {
+ // 2. Subscribe to receive update when API fetch completes:
+ this.unsubscribe = profileAsyncAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ if (this.unsubscribe) this.unsubscribe();
+ }
+
+ handleReload = () => {
+ profileAsyncAutomat.reload();
+ };
+
+ handleReset = () => {
+ profileAsyncAutomat.actions.reset();
+ };
+
+ handleSimulateError = () => {
+ profileAsyncAutomat.actions.triggerError();
+ };
+
+ handleDelayChange = (ms) => {
+ profileAsyncAutomat.actions.setDelay(ms);
+ };
+
+ render() {
+ this._renderCount++;
+ const { status, data, error, delayMs, fetchCount, lastFetchedAt } = this.state;
+
+ return (
+
+
+ β‘
+
Primary API Consumer
+ Constructor getData()
+
+
+
+ {/* Diagnostic render & constructor status bar */}
+
+
+
+ Mount Renders
+ Render Count
+
+
+ #{this._renderCount}
+
+ ({this._renderCount === 1 ? '1st: Pending' : '2nd: Completed'})
+
+
+
+
+
+
+ Constructor Read
+ Initial Status
+
+
+ {this._constructorStatus}
+
+
+
+
+ {/* Main Visual Display Area */}
+
+ {/* Status: IDLE */}
+ {status === 'idle' && (
+
+
π€
+
+ Cache Cleared (State: Idle)
+
+
+ Calling getData() will launch the delayed API request.
+
+
+ Fetch Profile Now ({delayMs}ms delay)
+
+
+ )}
+
+ {/* Status: PENDING */}
+ {status === 'pending' && (
+
+
+
+
+
+ Simulating API fetch... ({delayMs}ms delay)
+
+
+ Constructor received status: 'pending'. Rendering 1st pass.
+
+
+
+
+ {/* Animated Gruvbox skeleton loaders */}
+
+
+
+
+
+ )}
+
+ {/* Status: ERROR */}
+ {status === 'error' && (
+
+
β οΈ
+
+ API Fetch Failed
+
+
+ {error}
+
+
+ Retry Fetch
+
+
+ )}
+
+ {/* Status: SUCCESS */}
+ {status === 'success' && data && (
+
+
+
+
{data.avatar}
+
+
+ {data.name}
+
+
+ {data.handle} Β· {data.role}
+
+
+
+
+ 200 OK
+
+
+
+
+ “{data.statusMessage}”
+
+
+ {/* Quota bar & latency ping */}
+
+
+
+ Cloud Quota ({data.quota.used}/{data.quota.total} {data.quota.unit})
+
+
+
+
+
+
Cluster Latency
+
+ β‘ {data.pingMs}ms to internal VPC
+
+
+
+
+ {/* Tags */}
+
+ {data.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ )}
+
+
+ {/* Latency Simulator & Control Actions */}
+
+
+ Simulated Network Latency:
+ {delayMs}ms
+
+
+ this.handleDelayChange(300)}
+ >
+ Fast (300ms)
+
+ this.handleDelayChange(800)}
+ >
+ Normal (800ms)
+
+ this.handleDelayChange(1800)}
+ >
+ Slow (1.8s)
+
+
+
+
+ {/* Action Buttons */}
+
+
+ π Force Reload
+
+
+ ποΈ Clear Cache (Idle)
+
+
+ π₯ Timeout
+
+
+
+ {/* Footer Metadata */}
+
+ API Fetch Calls: {fetchCount}
+ Last Sync: {lastFetchedAt || 'Never'}
+
+
+
+ );
+ }
+}
diff --git a/src/examples/components/MultiApiCombinedDashboard.jsx b/src/examples/components/MultiApiCombinedDashboard.jsx
new file mode 100644
index 0000000..d399444
--- /dev/null
+++ b/src/examples/components/MultiApiCombinedDashboard.jsx
@@ -0,0 +1,389 @@
+import { PureComponent } from 'react';
+import { combinedDashboardAutomat } from '../automats/combinedMultiAutomat.js';
+
+let combinedMountCounter = 0;
+
+/**
+ * CombinedDashboardView β dynamically mounted child component.
+ *
+ * Demonstrates:
+ * 1. If upstreams were already fetched earlier:
+ * Constructor immediately receives { status: 'success', stats, metrics }.
+ * Renders in 1 render with 0ms delay!
+ * 2. If upstreams were idle:
+ * Constructor receives { status: 'pending' }, triggering both in parallel.
+ * Renders progress (0/2 -> 1/2 -> 2/2) and then the complete dashboard.
+ */
+class CombinedDashboardView extends PureComponent {
+ _renderCount = 0;
+ _constructorStatus = 'uninitialized';
+ _mountId = 0;
+
+ constructor(props) {
+ super(props);
+ this._mountId = ++combinedMountCounter;
+
+ // 1. Synchronous read in constructor:
+ this.state = combinedDashboardAutomat.getData();
+ this._constructorStatus = this.state.status;
+ }
+
+ componentDidMount() {
+ this.unsubscribe = combinedDashboardAutomat.subscribe(this);
+ }
+
+ componentWillUnmount() {
+ if (this.unsubscribe) this.unsubscribe();
+ }
+
+ render() {
+ this._renderCount++;
+ const {
+ status,
+ stats,
+ metrics,
+ statsStatus,
+ metricsStatus,
+ doneCount,
+ totalCount,
+ progressText,
+ } = this.state;
+
+ const isInstantSuccess = this._constructorStatus === 'success' && this._renderCount === 1;
+
+ return (
+
+ {/* Diagnostic Bar */}
+
+
+
+ Dashboard Mount
+ Mount Number
+
+
+ Mount #{this._mountId}
+
+
+
+
+
+ Render Efficiency
+ Renders this Mount
+
+
+ #{this._renderCount}
+
+ {this._renderCount === 1 ? '(Single render!)' : `(Progressive update)`}
+
+
+
+
+
+ {/* Instant Constructor Resolution Callout */}
+ {isInstantSuccess && (
+
+ β‘
+
+ Instant Resolution! Both upstreams were already in memory. Constructor received full payload immediately β 1 render total !
+
+
+ )}
+
+ {/* Pending / Concurrent Loading Dual Progress View */}
+ {status === 'pending' && (
+
+
+
+
+
+ Loading dual sources in parallel...
+
+
+
+ {progressText || `${doneCount || 0}/${totalCount || 2} loaded`}
+
+
+
+ {/* Dual Source Progress Cards */}
+
+
+
Source A (Stats, ~700ms)
+
+ {statsStatus === 'success' ? 'β
Loaded' : 'β³ Fetching...'}
+
+
+
+
+
Source B (Metrics, ~1300ms)
+
+ {metricsStatus === 'success' ? 'β
Loaded' : 'β³ Fetching...'}
+
+
+
+
+ {/* Overall Progress Bar */}
+
+
+ )}
+
+ {/* Combined Dashboard View (Success) */}
+ {status === 'success' && stats && metrics && (
+
+ {/* Merged Header */}
+
+
+
{stats.avatar}
+
+
+ {stats.name}
+
+
{stats.level}
+
+
+
+ Combined 200 OK
+
+
+
+ {/* Quick Metrics Grid */}
+
+
+
Deployments
+
{stats.deploymentsCount}
+
+
+
Uptime
+
{stats.uptimeRating}
+
+
+
Active Nodes
+
{metrics.activeNodes}
+
+
+
+ {/* Recent Events from Source B */}
+
+ Live Infrastructure Events (from Source B):
+
+
+ {metrics.events.map((evt) => (
+
+ {evt.text}
+ {evt.time}
+
+ ))}
+
+
+ )}
+
+ );
+ }
+}
+
+/**
+ * MultiApiCombinedDashboard β Right column of Section 07.
+ *
+ * Provides a collapsible panel to mount and unmount the combined dashboard.
+ */
+export default class MultiApiCombinedDashboard extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isMounted: false,
+ };
+ }
+
+ toggleMount = () => {
+ this.setState((prev) => ({ isMounted: !prev.isMounted }));
+ };
+
+ render() {
+ const { isMounted } = this.state;
+ const combinedState = combinedDashboardAutomat.state;
+
+ return (
+
+
+ β‘
+
Combined Dashboard Automat
+ Automat.combine()
+
+
+
+
+ Combines two upstream automats. If both are already loaded, accessing this
+ automat resolves instantly in constructor with 1 render . If not, it triggers
+ both to load concurrently in parallel.
+
+
+ {/* Current Upstream Status Indicator */}
+
+
+ Upstream Readiness:
+
+
+ {combinedState.status === 'success'
+ ? 'BOTH READY (Instant Constructor)'
+ : combinedState.status === 'pending'
+ ? 'SYNCING UPSTREAMS'
+ : 'IDLE (Will trigger parallel fetch)'}
+
+
+
+ {/* Mount / Unmount Toggle Button */}
+
+ {isMounted ? 'πΌ' : 'π½'}
+
+ {isMounted
+ ? 'Unmount Combined Dashboard (Collapse)'
+ : 'Mount Combined Dashboard (Expand)'}
+
+
+
+ {/* Mounted View or Unmounted Hint */}
+ {isMounted ? (
+
+ ) : (
+
+ Dashboard component is currently UNMOUNTED .
+
+ Click the button above to mount and inspect constructor resolution.
+
+ )}
+
+
+ );
+ }
+}
diff --git a/src/examples/components/MultiApiIndividualControls.jsx b/src/examples/components/MultiApiIndividualControls.jsx
new file mode 100644
index 0000000..7c5ead0
--- /dev/null
+++ b/src/examples/components/MultiApiIndividualControls.jsx
@@ -0,0 +1,204 @@
+import { PureComponent } from 'react';
+import {
+ userStatsAutomat,
+ systemMetricsAutomat,
+ resetMultiApiDemo,
+} from '../automats/combinedMultiAutomat.js';
+
+/**
+ * MultiApiIndividualControls β Left column of Section 07.
+ *
+ * Controls and monitors the two individual upstream automats:
+ * 1. userStatsAutomat (delayed API, ~700ms)
+ * 2. systemMetricsAutomat (delayed API, ~1300ms)
+ */
+export default class MultiApiIndividualControls extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ stats: userStatsAutomat.state,
+ metrics: systemMetricsAutomat.state,
+ };
+ }
+
+ componentDidMount() {
+ this.unsubStats = userStatsAutomat.subscribe((stats) => {
+ this.setState({ stats });
+ });
+ this.unsubMetrics = systemMetricsAutomat.subscribe((metrics) => {
+ this.setState({ metrics });
+ });
+ }
+
+ componentWillUnmount() {
+ if (this.unsubStats) this.unsubStats();
+ if (this.unsubMetrics) this.unsubMetrics();
+ }
+
+ handleFetchStats = () => {
+ userStatsAutomat.getData({ reload: true });
+ };
+
+ handleFetchMetrics = () => {
+ systemMetricsAutomat.getData({ reload: true });
+ };
+
+ handleResetAll = () => {
+ resetMultiApiDemo();
+ };
+
+ render() {
+ const { stats, metrics } = this.state;
+
+ return (
+
+
+ π§©
+
Individual Delayed Automats
+ 2 Upstream APIs
+
+
+
+
+ Two independent automats with simulated delayed APIs. You can trigger each individually,
+ or let the combined automat trigger both concurrently.
+
+
+ {/* Upstream A: User Stats */}
+
+
+
+ π‘οΈ
+ Automat A: User Stats
+ (~700ms)
+
+
+ {stats.status.toUpperCase()}
+
+
+
+ {stats.status === 'pending' && (
+
+
+
Fetching User Stats API...
+
+ )}
+
+ {stats.status === 'success' && stats.data && (
+
+ {stats.data.name} Β· {stats.data.level} (Uptime: {stats.data.uptimeRating})
+
+ )}
+
+
+ {stats.status === 'success' ? 'π Reload Stats (~700ms)' : 'βΆοΈ Fetch Stats API (~700ms)'}
+
+
+
+ {/* Upstream B: System Metrics */}
+
+
+
+ π
+ Automat B: System Metrics
+ (~1300ms)
+
+
+ {metrics.status.toUpperCase()}
+
+
+
+ {metrics.status === 'pending' && (
+
+
+
Fetching System Metrics API...
+
+ )}
+
+ {metrics.status === 'success' && metrics.data && (
+
+ Cluster: {metrics.data.clusterHealth} Β· {metrics.data.activeNodes} Nodes Β· {metrics.data.events.length} Events
+
+ )}
+
+
+ {metrics.status === 'success' ? 'π Reload Metrics (~1300ms)' : 'βΆοΈ Fetch Metrics API (~1300ms)'}
+
+
+
+ {/* Reset All Button */}
+
+ ποΈ Reset Both to Idle (Clear Cache)
+
+
+ {/* Guided Testing Flow Hints */}
+
+
+ π‘ Two Verification Flows:
+
+
+
+ Flow 1 (Instant Constructor Resolution): Click both fetch buttons above. Once both show SUCCESS, expand the Combined Dashboard on the right. Notice it resolves instantly in constructor with 1 render !
+
+
+ Flow 2 (Concurrent Orchestration): Click Reset Both to Idle , then expand the Combined Dashboard directly. It triggers both APIs in parallel, finishing in ~1300ms total.
+
+
+
+
+
+ );
+ }
+}
diff --git a/src/index.css b/src/index.css
index 0077794..78fac7f 100644
--- a/src/index.css
+++ b/src/index.css
@@ -411,6 +411,41 @@ body {
border: 1px solid rgba(131, 165, 152, 0.3);
}
+.badge-success {
+ background: rgba(184, 187, 38, 0.15);
+ color: var(--green);
+ border: 1px solid rgba(184, 187, 38, 0.3);
+}
+
+/* ββ Spinner & Skeleton Animations βββββββββββββββββββ */
+
+.spinner-gruvbox {
+ width: 18px;
+ height: 18px;
+ border: 2px solid rgba(250, 189, 47, 0.25);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin-gruvbox 0.75s linear infinite;
+ display: inline-block;
+ flex-shrink: 0;
+}
+
+@keyframes spin-gruvbox {
+ to { transform: rotate(360deg); }
+}
+
+.skeleton-bar {
+ background: linear-gradient(90deg, var(--surface-1) 25%, var(--surface-2) 50%, var(--surface-1) 75%);
+ background-size: 200% 100%;
+ animation: skeleton-pulse 1.4s ease-in-out infinite;
+ border-radius: var(--r-sm);
+}
+
+@keyframes skeleton-pulse {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
/* ββ Buttons βββββββββββββββββββββββββββββββββββββββββ */
.btn {
diff --git a/src/lib/Automat.js b/src/lib/Automat.js
index b77e94c..d8347e8 100644
--- a/src/lib/Automat.js
+++ b/src/lib/Automat.js
@@ -144,6 +144,8 @@ export class Automat {
#persist = false;
/** @type {Promise} */
#ready = Promise.resolve();
+ /** @type {function|null} */
+ #loader = null;
/**
* @param {object} initialState Initial state snapshot.
@@ -151,11 +153,13 @@ export class Automat {
* @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).
+ * @param {function} [options.loader] Async loader function (automat) => Promise.
*/
constructor(initialState = {}, callbacks = {}, options = {}) {
this.#callbacks = callbacks;
this.#name = options.name ?? null;
this.#persist = Boolean(options.persist);
+ this.#loader = typeof options.loader === 'function' ? options.loader : null;
if (this.#name && typeof window !== 'undefined') {
window.__AUTOMATS__ = window.__AUTOMATS__ || new Map();
@@ -203,6 +207,88 @@ export class Automat {
return undefined;
}
+ /**
+ * Creates a combined Automat derived from multiple upstream Automats.
+ *
+ * When accessed via `.getData()`:
+ * - If all upstream automats are already resolved (`status === 'success'`),
+ * it immediately returns the combined state in the constructor (instant resolution).
+ * - If any upstream automat is idle, it triggers `.getData()` on them concurrently,
+ * transitions itself to pending, and synchronizes automatically as each completes.
+ *
+ * @param {Automat[]} upstreamAutomats Array of upstream Automat instances.
+ * @param {function(...states: object[]): object} combiner Function that maps upstream states to combined state.
+ * @param {object} [options] Optional Automat options (name, persist).
+ * @returns {Automat}
+ */
+ static combine(upstreamAutomats, combiner, options = {}) {
+ const computeInitial = () => {
+ const upstreamStates = upstreamAutomats.map((a) => a.state);
+ return combiner(...upstreamStates);
+ };
+
+ const combined = new Automat(
+ computeInitial(),
+ {
+ getData(opts = {}) {
+ const allResolved = upstreamAutomats.every(
+ (a) => a.state?.status === 'success'
+ );
+
+ if (allResolved && !opts.reload) {
+ const currentCombined = combiner(...upstreamAutomats.map((a) => a.state));
+ if (combined.state.status !== 'success') {
+ combined.setState({ ...currentCombined, status: 'success' });
+ }
+ return combined.state;
+ }
+
+ // Trigger loading on any uncompleted or reload-requested upstreams
+ upstreamAutomats.forEach((a) => {
+ if (typeof a.getData === 'function' && (a.state?.status === 'idle' || opts.reload)) {
+ a.getData(opts);
+ }
+ });
+
+ const checkAllResolved = upstreamAutomats.every(
+ (a) => a.state?.status === 'success'
+ );
+
+ if (checkAllResolved) {
+ const res = combiner(...upstreamAutomats.map((a) => a.state));
+ combined.setState({ ...res, status: 'success' });
+ return combined.state;
+ }
+
+ const partialCombined = combiner(...upstreamAutomats.map((a) => a.state));
+ combined.setState({ ...partialCombined, status: 'pending' });
+ return combined.state;
+ },
+ },
+ options
+ );
+
+ // Subscribe to all upstreams
+ upstreamAutomats.forEach((upstream) => {
+ combined.subscribeTo(upstream, () => {
+ const upstreamStates = upstreamAutomats.map((a) => a.state);
+ const allDone = upstreamStates.every((s) => s?.status === 'success');
+ const anyError = upstreamStates.find((s) => s?.status === 'error');
+ const combinedSlice = combiner(...upstreamStates);
+
+ if (anyError) {
+ return { ...combinedSlice, status: 'error', error: anyError.error };
+ }
+ if (allDone) {
+ return { ...combinedSlice, status: 'success', error: null };
+ }
+ return { ...combinedSlice, status: 'pending' };
+ });
+ });
+
+ return combined;
+ }
+
/**
* The registered name of the automat, or null if unnamed.
* @returns {string|null}
@@ -258,6 +344,73 @@ export class Automat {
return this.#state;
}
+ /**
+ * Reads state for constructor initialization, optionally triggering an action or loader.
+ *
+ * Lifecycle & Caching Semantics:
+ * 1. If loader or load action is configured and state is 'idle' (or reload is true):
+ * - Transitions state to { status: 'pending', error: null }.
+ * - Launches loader asynchronously in the background.
+ * - Synchronously returns the pending state for the first render.
+ * 2. When the async fetch finishes, automat.setState notifies subscribers, triggering
+ * a second render with completed data.
+ * 3. When called again from another component or after remounting with data already loaded
+ * (status === 'success'), returns the resolved state immediately in the constructor.
+ * No secondary render is needed!
+ *
+ * @param {object} [options]
+ * @param {boolean} [options.reload=false]
+ * @returns {object}
+ */
+ getData(options = {}) {
+ const shouldReload = Boolean(options.reload);
+ const isIdle = !this.#state.status || this.#state.status === 'idle' || shouldReload;
+
+ if (this.#loader && isIdle) {
+ this.setState({ status: 'pending', error: null });
+ Promise.resolve(this.#loader(this))
+ .then((result) => {
+ if (result && typeof result === 'object') {
+ this.setState({ status: 'success', ...result, error: null });
+ } else {
+ this.setState({ status: 'success', error: null });
+ }
+ })
+ .catch((err) => {
+ this.setState({ status: 'error', error: err?.message || String(err) });
+ });
+ return this.#state;
+ }
+
+ if (this.#callbacks?.getData && typeof this.#callbacks.getData === 'function') {
+ return this.#callbacks.getData(options);
+ }
+
+ if (this.#callbacks?.load && typeof this.#callbacks.load === 'function' && isIdle) {
+ this.#callbacks.load(options);
+ return this.#state;
+ }
+
+ return this.#state;
+ }
+
+ /**
+ * Alias for getData(options).
+ * @param {object} [options]
+ * @returns {object}
+ */
+ load(options) {
+ return this.getData(options);
+ }
+
+ /**
+ * Forces a reload via getData({ reload: true }).
+ * @returns {object}
+ */
+ reload() {
+ return this.getData({ reload: true });
+ }
+
/**
* Shallow-merges `partial` into the current state and notifies all subscribers.
*