15 KiB
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
- Instance Lifetime (Module Singleton or Dynamic Map Registry): An
Automatinstance lives outside the React render tree. While commonly instantiated as module-level singletons, instances can also be stored dynamically in aMap(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. - Direct Constructor Read: Components read
automat.statedirectly in theirconstructor(props). State is never stale upon mounting. - Lifecycle Subscription: Components register with
automat.subscribe(this)incomponentDidMount()and callthis.unsubscribe()incomponentWillUnmount(). - Hybrid State by Default: When
automat.setState()notifies a component, it callscomponent.setState(partial). React's class componentsetStateperforms a shallow merge, preserving any component-local state fields. - 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 triggersetStateor re-renders. - No Wrappers or Hooks: No HOCs, no context providers, no hooks, no
connect().
2. API Reference
Constructor
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 viaautomat.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
// 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
// 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:
- Single key string (most concise):
// Subscribes ONLY to 'count'. Injects { count } into component setState.
this.unsubscribe = myAutomat.subscribe(this, 'count');
- Array of keys:
// Subscribes ONLY to 'count' and 'step'. Injects { count, step } into component setState.
this.unsubscribe = myAutomat.subscribe(this, ['count', 'step']);
- Selector function:
// Computes a custom slice. Returning null or undefined skips updates.
this.unsubscribe = myAutomat.subscribe(this, (state) => ({
count: state.count,
isEven: state.count % 2 === 0,
}));
- Scoping via
.select():
// 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
// 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):
// 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():
upstream: Snapshot of the observed automat after its update.my: Snapshot of the current (downstream) automat before this update. Use this as an accumulator.- Filtering (
return null): Returnnullorundefinedto bypasssetState(), 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:
// 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:
// 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:
// 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
Automatinstances in module scope or outside React components. - DO initialize component state synchronously in
constructor(props)usingthis.state = myAutomat.state;or{ ...myAutomat.state, localField: 'val' }. - DO register subscriptions in
componentDidMount()viathis.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 unnecessarysetStatetriggers and re-renders when unrelated fields change. - DO clean up subscriptions in
componentWillUnmount()viathis.unsubscribe();. - DO invoke actions directly from event handlers (e.g.
onClick={() => myAutomat.actions.doSomething()}). - DO return
nullinsubscribeTotransforms when an update should be filtered out.
❌ DON'Ts
- DON'T instantiate
new Automat()inside a React component'srender(),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 = 5is forbidden). Always callmyAutomat.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 theautomatclass component architecture. UsePureComponent. - DON'T forget to store the return value of
subscribe(this)and call it incomponentWillUnmount(), as this will cause memory leaks.