Persistence
This commit is contained in:
410
README.LLM.md
410
README.LLM.md
@@ -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<T extends object, A extends Record<string, Function>>(
|
||||
initialState: T,
|
||||
actions?: A
|
||||
): Automat<T, A>
|
||||
```
|
||||
|
||||
- `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>): 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<U>(upstream: Automat<U>, transform: (upstreamState: U, myState: T) => Partial<T> \| 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 (
|
||||
<div>
|
||||
<p>Count: {count}</p>
|
||||
<button onClick={this.handleDecrement}>−{step}</button>
|
||||
<button onClick={this.handleIncrement}>+{step}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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 <h1>Current Count: {this.state.count}</h1>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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 (
|
||||
<div>
|
||||
<h3>Slot #{currentIndex}</h3>
|
||||
<p>Recalled Clicks: {slotState.clicks}</p>
|
||||
<button onClick={() => getOrCreateSlotAutomat(currentIndex).actions.click()}>
|
||||
Click Slot #{currentIndex}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
726
README.md
726
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 (
|
||||
<div className="card">
|
||||
<p>Count: {count} · Local Clicks: {localClicks}</p>
|
||||
|
||||
{/* 💡 WIRED ONCLICK: calls handlers directly */}
|
||||
<button onClick={this.handleDecrement}>−{step}</button>
|
||||
<button onClick={this.handleIncrement}>+{step}</button>
|
||||
<button onClick={this.handleReset}>Reset</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 <h1>Display: {this.state.count}</h1>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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 (
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={this.handleInput}
|
||||
placeholder="Search items…"
|
||||
/>
|
||||
{/* 💡 Click triggers handleSubmit → searchAutomat.actions.search() */}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
|
||||
<ul>
|
||||
{results?.map((item) => (
|
||||
<li key={item.id}>{item.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</form>
|
||||
<div>
|
||||
<span>{this.state.count}</span>
|
||||
<button onClick={() => counterAutomat.actions.increment()}>+1</button>
|
||||
<button onClick={() => counterAutomat.actions.decrement()}>-1</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
// 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 (
|
||||
<div>
|
||||
<button onClick={() => this.handleTrigger(1)}>Trigger (+1)</button>
|
||||
<button onClick={() => this.handleTrigger(5)}>Trigger (+5)</button>
|
||||
<button onClick={this.handleClear}>Clear Stream</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<ul>
|
||||
{messages.map((m) => (
|
||||
<li key={m.id}>{m.text} ({m.time})</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<h3>Counter #{index}: {count}</h3>
|
||||
<button onClick={() => syncCounterAutomat.actions.increment(1)}>+1</button>
|
||||
<span>Status: {syncStatus === 'syncing' ? 'POST in flight…' : `Synced (${lastSyncedAt})`}</span>
|
||||
<h3>Shopping Cart ({items.length} items)</h3>
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>{item.name} - ${item.price}</li>
|
||||
))}
|
||||
</ul>
|
||||
<button onClick={() => cartAutomat.actions.addItem({ id: Date.now(), name: 'New Item', price: 20 })}>
|
||||
Add Item (Persists on Reload)
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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()`.
|
||||
|
||||
51
src/App.jsx
51
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{' '}
|
||||
<code className="inline-code">PureComponent</code>
|
||||
<br />
|
||||
State lives independently of mounting — zero prop drilling.
|
||||
State lives independently of mounting — zero prop drilling · Optional Window and IndexedDB persistence.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -169,6 +171,20 @@ class App extends PureComponent {
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node flow-node-component">Recall State for Slot #N</span>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<div className="info-box-title">
|
||||
<span>💾</span> Built-in Persistence: <code>persist: false</code> (Window) & <code>persist: true</code> (IndexedDB)
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Window Object (<code>persist: false</code>):</strong> When an automat is configured with <code>name</code>, it registers in the <code>window</code> object. Its state persists across component unmounts and HMR reloads. Global lookup is available via <code>Automat.get(name)</code>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>IndexedDB (<code>persist: true</code>):</strong> Setting <code>persist: true</code> automatically synchronizes state snapshots to IndexedDB under the automat's <code>name</code>, surviving full page reloads and browser restarts.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="two-columns">
|
||||
@@ -176,6 +192,39 @@ class App extends PureComponent {
|
||||
<DynamicAutomatSubscriber />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* ── Example 5: Partial State Subscription ────────────── */}
|
||||
<section className="example-section" aria-labelledby="ex5-title">
|
||||
<div className="section-header">
|
||||
<div className="section-title-row">
|
||||
<span className="section-number">05</span>
|
||||
<h2 id="ex5-title">Partial State Subscription (Cart Length vs Content)</h2>
|
||||
</div>
|
||||
<p>
|
||||
A subscriber can subscribe to only part of an automat’s state. The <code className="inline-code">CartBadgeDisplay</code> on
|
||||
the right subscribes <strong>only to list length</strong> (<code className="inline-code">items.length</code>) using a slice selector.
|
||||
Modifying item quantities, changing names, or typing coupon codes mutates <code className="inline-code">cartAutomat</code> state,
|
||||
but because the list length is unchanged, <code className="inline-code">Automat</code>’s internal shallow equality check skips <code className="inline-code">setState()</code> — producing <strong>zero badge re-renders</strong>.
|
||||
With <code className="inline-code">{`{ name: 'cart', persist: true }`}</code>, this cart state also automatically survives full page reloads via IndexedDB.
|
||||
</p>
|
||||
<div className="flow-diagram" aria-label="Partial subscription data flow">
|
||||
<span className="flow-node">cartAutomat.setState()</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">selector(state)</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node">shallowEqual(last, next)</span>
|
||||
<span className="flow-arrow">→</span>
|
||||
<span className="flow-node flow-node-component">Render ONLY on Length Change</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="two-columns">
|
||||
<CartManager />
|
||||
<CartBadgeDisplay />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
|
||||
78
src/examples/automats/cartAutomat.js
Normal file
78
src/examples/automats/cartAutomat.js
Normal file
@@ -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 }
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
200
src/examples/components/CartBadgeDisplay.jsx
Normal file
200
src/examples/components/CartBadgeDisplay.jsx
Normal file
@@ -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 (
|
||||
<div className="card card-display">
|
||||
<div className="card-header">
|
||||
<span className="card-icon">🏷️</span>
|
||||
<h3>Cart Badge (Partial Subscriber)</h3>
|
||||
<span className="badge badge-api">Selector: items.length</span>
|
||||
</div>
|
||||
|
||||
<div className="card-body">
|
||||
{/* Render count & efficiency indicator */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
Badge Renders: <strong style={{ color: 'var(--green)' }}>#{this._renderCount}</strong>
|
||||
</span>
|
||||
<span className="state-tag-automat" style={{ fontSize: 11 }}>
|
||||
shallowEqual guarded
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Visual Navbar / Cart Badge Mockup */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface-0)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
padding: '24px 20px',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 12 }}>
|
||||
Simulated App Navigation Bar
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
background: 'var(--surface-1)',
|
||||
padding: '10px 20px',
|
||||
borderRadius: 'var(--r-xl)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-2)' }}>
|
||||
Store Header
|
||||
</span>
|
||||
|
||||
<div style={{ width: 1, height: 20, background: 'var(--border)' }} />
|
||||
|
||||
{/* Shopping Cart Icon with Badge */}
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<span style={{ fontSize: 26, lineHeight: 1 }} role="img" aria-label="cart">
|
||||
🛒
|
||||
</span>
|
||||
|
||||
{/* Animated Badge Count */}
|
||||
<span
|
||||
ref={(el) => { 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}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 14, fontSize: 13, color: 'var(--text-1)' }}>
|
||||
Badge displays <strong style={{ color: 'var(--orange)' }}>{badgeCount} unique items</strong> in cart
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explanation Callout */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface-1)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: '12px',
|
||||
marginBottom: 16,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-bright)', marginBottom: 6 }}>
|
||||
🔍 Why Partial State Subscription Matters:
|
||||
</div>
|
||||
<ul style={{ paddingLeft: 16, color: 'var(--text-2)' }}>
|
||||
<li>
|
||||
<strong>Quantity changes (+ / −)</strong> modify item content, but <code>items.length</code> remains {badgeCount}. <code>Automat</code> detects that <code>lastSlice</code> is shallow-equal to <code>nextSlice</code> and <strong>skips <code>this.setState()</code> completely</strong>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Typing coupon codes</strong> updates unrelated state, also triggering <strong>0 renders</strong> here.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Adding or removing an item</strong> alters <code>items.length</code>, immediately updating the badge number!
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Canonical Code Snippet */}
|
||||
<div className="code-snippet">
|
||||
<pre>{`// 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 <span>Cart: {this.state.badgeCount}</span>;
|
||||
}
|
||||
}`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CartBadgeDisplay;
|
||||
227
src/examples/components/CartManager.jsx
Normal file
227
src/examples/components/CartManager.jsx
Normal file
@@ -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 (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-icon">🛒</span>
|
||||
<h3>Shopping Cart Manager</h3>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span className="badge badge-success" title="State persists across full page reloads">💾 IndexedDB</span>
|
||||
<span className="badge badge-local">Full Subscriber</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-body">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
Component Renders: <strong style={{ color: 'var(--accent)' }}>#{this._renderCount}</strong>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={this.handleReset}
|
||||
title="Reset items to initial state"
|
||||
>
|
||||
Reset List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={async () => {
|
||||
await cartAutomat.clearPersistence();
|
||||
this.handleReset();
|
||||
}}
|
||||
title="Clear persisted state from IndexedDB"
|
||||
>
|
||||
Clear IndexedDB
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Add Buttons (Changes length) */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||
Add Item (Changes List Length):
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => this.handleAddItem('Keycaps Set', 45)}
|
||||
>
|
||||
+ Keycaps ($45)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => this.handleAddItem('Wrist Rest', 25)}
|
||||
>
|
||||
+ Wrist Rest ($25)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => this.handleAddItem('Coiled Cable', 32)}
|
||||
>
|
||||
+ Cable ($32)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||
Cart Items ({items.length}):
|
||||
</span>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--text-3)', background: 'var(--surface-1)', borderRadius: 'var(--r-md)' }}>
|
||||
Cart is empty. Click an add button above.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--surface-1)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, marginRight: 8 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-1)' }}>
|
||||
{item.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
|
||||
${item.price} each
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity controls (Changes content, NOT length!) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-3)', marginRight: 2 }}>Qty:</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={{ padding: '2px 8px', fontSize: 12, minWidth: 26 }}
|
||||
onClick={() => this.handleQuantityChange(item.id, -1)}
|
||||
title="Decrease quantity (content changed, length unchanged)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span style={{ fontWeight: 700, minWidth: 20, textAlign: 'center', fontSize: 13 }}>
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={{ padding: '2px 8px', fontSize: 12, minWidth: 26 }}
|
||||
onClick={() => this.handleQuantityChange(item.id, 1)}
|
||||
title="Increase quantity (content changed, length unchanged)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
||||
{/* Remove item (Changes length!) */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger"
|
||||
style={{ padding: '2px 8px', fontSize: 12, marginLeft: 6 }}
|
||||
onClick={() => this.handleRemoveItem(item.id)}
|
||||
title="Remove item (changes length)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unrelated field: Coupon Code */}
|
||||
<div style={{ background: 'var(--surface-1)', padding: '10px 12px', borderRadius: 'var(--r-md)', border: '1px solid var(--border)' }}>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block', marginBottom: 6 }}>
|
||||
Unrelated State Field (Coupon Code):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={couponCode}
|
||||
onChange={this.handleCouponChange}
|
||||
placeholder="Type coupon (e.g. GRUVBOX20)..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 10px',
|
||||
background: 'var(--surface-0)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
color: 'var(--text-1)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4, display: 'block' }}>
|
||||
Typing here calls <code>cartAutomat.setState({`{ couponCode }`})</code>. Notice how the badge on the right ignores this completely!
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CartManager;
|
||||
@@ -94,7 +94,7 @@ class DynamicAutomatSubscriber extends PureComponent {
|
||||
<div className="card-header">
|
||||
<span className="card-icon">⚡</span>
|
||||
<h3>Dynamic Slot Automat (Resubscribed by Index)</h3>
|
||||
<span className="badge badge-cascade">window.automats.get({currentIndex})</span>
|
||||
<span className="badge badge-cascade">Automat.get('slot_{currentIndex}') (window)</span>
|
||||
</div>
|
||||
|
||||
<div className="card-body">
|
||||
|
||||
@@ -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<function>} */
|
||||
#upstreamUnsubscribers = [];
|
||||
/** @type {string|null} */
|
||||
#name = null;
|
||||
/** @type {boolean} */
|
||||
#persist = false;
|
||||
/** @type {Promise<object>} */
|
||||
#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<object>}
|
||||
*/
|
||||
get ready() {
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears persisted state from window or IndexedDB.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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 */
|
||||
|
||||
@@ -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 }, {
|
||||
* // 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) {
|
||||
|
||||
Reference in New Issue
Block a user