411 lines
15 KiB
Markdown
411 lines
15 KiB
Markdown
# 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.
|