# β automat
> Observable state management for React `PureComponent`.
> State lives **independently of component lifecycle** β mounts and unmounts freely without losing state.
> **No wrappers, no HOCs, no `connect()` β purely direct access and lifecycle subscriber handling.**
```bash
npm run dev
```
---
## Motivation
Redux separates state from UI, but brings boilerplate and pushes toward hooks. Higher-order wrappers and `connect()` introduce indirection, wrapper nesting, and tricky state hydration.
`automat` provides a clean, direct approach centered on standard `React.PureComponent`:
1. **Instantiate first**: The Automat instance is created outside React's render tree.
2. **Direct constructor access**: Components initialize directly from `automat.state` (or `getState()`) β never stale, even after transitions prior to mounting.
3. **Lifecycle subscriber handling**: In `componentDidMount`, register the component with `automat.subscribe(this)`. In `componentWillUnmount`, call `this.unsubscribe()` or `automat.unsubscribe(this)`.
4. **Direct event wiring**: Call `automat.actions.actionName()` or `automat.setState(...)` directly in `onClick` handlers. No dispatchers, actions creators, or prop drilling.
5. **Render minimization**: Standard `PureComponent` shallow state comparison prevents unnecessary re-renders automatically without extra layers.
---
## Direct PureComponent Pattern (Wired Click Example)
Here is a complete, two-component example showing how clicks trigger actions and synchronize independent components:
```jsx
import { PureComponent } from 'react';
import { Automat } from 'automat';
// 1. Instantiate the automat outside React:
const counterAutomat = new Automat(
{ count: 0 },
{
increment(step = 1) {
counterAutomat.setState({ count: counterAutomat.state.count + step });
},
decrement(step = 1) {
counterAutomat.setState({ count: counterAutomat.state.count - step });
},
reset() {
counterAutomat.setState({ count: 0 });
},
}
);
// 2. Controller component: buttons trigger actions, hybrid state tracks local clicks
class CounterButton extends PureComponent {
constructor(props) {
super(props);
// π‘ HYBRID STATE:
// Shared count comes from the automat; step & localClicks are local
this.state = {
count: counterAutomat.state.count, // β from automat
step: 1, // β component-local state
localClicks: 0, // β component-local state
};
}
componentDidMount() {
// Subscribe component to automat updates
this.unsubscribe = counterAutomat.subscribe(this);
}
componentWillUnmount() {
this.unsubscribe();
}
// π‘ CLICK HANDLERS: update local state AND trigger automat actions
handleIncrement = () => {
const { step, localClicks } = this.state;
this.setState({ localClicks: localClicks + 1 });
counterAutomat.actions.increment(step); // β Triggers automat!
};
handleDecrement = () => {
const { step, localClicks } = this.state;
this.setState({ localClicks: localClicks + 1 });
counterAutomat.actions.decrement(step); // β Triggers automat!
};
handleReset = () => {
this.setState({ localClicks: 0 });
counterAutomat.actions.reset(); // β Triggers automat!
};
render() {
const { count, step, localClicks } = this.state;
return (
Count: {count} Β· Local Clicks: {localClicks}
{/* π‘ WIRED ONCLICK: calls handlers directly */}
);
}
}
// 3. Independent Display component: reads same automat with ZERO props passed
class CounterDisplay extends PureComponent {
constructor(props) {
super(props);
// Reads directly from automat in constructor:
this.state = counterAutomat.state;
}
componentDidMount() {
// Automatically re-renders when CounterButton triggers an increment/decrement
this.unsubscribe = counterAutomat.subscribe(this);
}
componentWillUnmount() {
this.unsubscribe();
}
render() {
return Display: {this.state.count}
;
}
}
```
---
### Why Hybrid State works seamlessly with React PureComponent
When `counterAutomat.setState({ count: 42 })` notifies the subscriber:
1. It calls `this.setState({ count: 42 })` on the component instance.
2. React's class component `setState` performs a **shallow merge** into `this.state`.
3. Local fields (`step`, `localClicks`, `inputValue`) remain untouched.
4. `PureComponent`'s shallow comparison ensures renders happen only when values change.
```jsx
class SearchBox extends PureComponent {
constructor(props) {
super(props);
this.state = {
...searchAutomat.state, // results, loading, etc.
inputValue: '', // component-local input
};
}
componentDidMount() {
this.unsubscribe = searchAutomat.subscribe(this);
}
componentWillUnmount() {
this.unsubscribe();
}
handleInput = (e) => {
this.setState({ inputValue: e.target.value });
};
// π‘ Wired form submission / click:
handleSubmit = (e) => {
e.preventDefault();
const query = this.state.inputValue.trim();
if (query) {
searchAutomat.actions.search(query); // β Triggers async search action
}
};
render() {
const { loading, results, inputValue } = this.state;
return (
);
}
}
```
---
## Core API
### `new Automat(initialState, actions?)`
```js
import { Automat } from './src/lib/index.js';
const counterAutomat = new Automat(
{ count: 0 },
{
increment(step = 1) {
counterAutomat.setState({ count: counterAutomat.state.count + step });
},
decrement(step = 1) {
counterAutomat.setState({ count: counterAutomat.state.count - step });
},
reset() {
counterAutomat.setState({ count: 0 });
},
}
);
```
| Member | Description |
|---|---|
| `automat.state` | Direct getter for current state snapshot (ideal for `constructor`) |
| `automat.getState()` | Returns current state snapshot |
| `automat.setState(partial)` | Merges partial into state and notifies all subscribers |
| `automat.subscribe(target, selector?)` | Subscribes either a component instance (`this`) or a callback `(state) => ...`. Returns unsub function. |
| `automat.unsubscribe(target)` | Unsubscribes a component instance or callback function |
| `automat.subscribeTo(upstream, transform)` | Notification cascade: derives state from an upstream automat |
| `automat.actions` | Named action callbacks passed to constructor β call directly from `onClick` |
| `automat.dispose()` | Tears down all upstream subscriptions and clears all subscribers |
---
## Notification Cascade (Wired Example)
### How `subscribeTo()` Works (Reactive Pipeline)
`subscribeTo()` establishes a **reactive pipeline between two automats** without React components in the middle. Think of it like a database trigger or spreadsheet formula: when the upstream changes, the downstream automatically derives new state.
```
βββββββββββββββββββ setState() βββββββββββββββββββββββββββ
β counterAutomat β βββββββββββββββββββ> β notificationAutomat β
β (Upstream) β β (Downstream) β
βββββββββββββββββββ ββββββββββββββ¬βββββββββββββ
β notifies
βΌ
βββββββββββββββββββββββββββ
β NotificationBar β
β (PureComponent UI) β
βββββββββββββββββββββββββββ
```
#### Code Anatomy:
```js
// 1. Upstream automat (e.g. holds raw counter)
const counterAutomat = new Automat({ count: 0 }, {
increment(step = 1) {
counterAutomat.setState({ count: counterAutomat.state.count + step });
},
});
// 2. Downstream automat (e.g. maintains an event/audit log)
const notificationAutomat = new Automat(
{ messages: [] },
{
clear() { notificationAutomat.setState({ messages: [] }); },
}
);
// 3. Connect downstream to upstream (returns null to filter, or state object):
notificationAutomat.subscribeTo(
counterAutomat,
(upstream, my) =>
upstream.count === 0
? null
: {
messages: [
{
id: Date.now(),
text: `Counter changed to ${upstream.count}`,
time: new Date().toLocaleTimeString(),
count: upstream.count,
},
...my.messages.slice(0, 9), // Caps list at 10 items
],
}
);
```
#### Parameter Breakdown:
| Parameter | What it receives | Purpose |
|---|---|---|
| `upstreamAutomat` | `counterAutomat` | The automat to watch. Any time it calls `setState()`, the transform runs. |
| `upstreamState` | `{ count: 42 }` | The **new state snapshot** of the upstream automat. |
| `myState` | `{ messages: [...] }` | The **current state snapshot** of *this* downstream automat right before updating. Essential for accumulating history, comparing previous values, or merging. |
| **Return value** | `{ messages: [...] }` | A **partial state object** passed to `this.setState(partial)`. Returning `null` skips the update. |
#### Filtering Updates (Conditional Derivation):
You can selectively ignore upstream events by returning `null`:
```js
// Only log notifications when count exceeds 10:
notificationAutomat.subscribeTo(counterAutomat, (upstreamState, myState) => {
if (upstreamState.count < 10) {
return null; // π‘ Returning null skips setState β no re-renders!
}
return {
messages: [{ id: Date.now(), text: `High value reached: ${upstreamState.count}` }, ...myState.messages],
};
});
```
#### Multiple Upstream Sources & Chaining:
`subscribeTo()` returns `this`, so an automat can aggregate from multiple independent sources:
```js
dashboardAutomat
.subscribeTo(userAutomat, (user) => ({ username: user.name }))
.subscribeTo(cartAutomat, (cart) => ({ cartItemCount: cart.items.length }));
```
#### Teardown:
Calling `notificationAutomat.dispose()` unsubscribes all upstream listeners automatically to prevent memory leaks when an automat is torn down.
### Wiring the Cascade in UI:
```jsx
// 4. Controller component: buttons trigger the UPSTREAM automat
class CascadeControls extends PureComponent {
handleTrigger = (step) => {
// π‘ CLICK WIRED HERE:
// Calling counterAutomat triggers notificationAutomat downstream!
counterAutomat.actions.increment(step);
};
handleClear = () => {
notificationAutomat.actions.clear();
};
render() {
return (
);
}
}
// 5. Downstream component: automatically receives derived cascade messages
class NotificationBar extends PureComponent {
constructor(props) {
super(props);
this.state = {
messages: notificationAutomat.state.messages, // β from cascade
filter: 'all', // β component-local
};
}
componentDidMount() {
this.unsub = notificationAutomat.subscribe(this);
}
componentWillUnmount() {
this.unsub();
}
render() {
const { messages } = this.state;
return (
{messages.map((m) => (
- {m.text} ({m.time})
))}
);
}
}
```
When `counterAutomat.setState()` fires β `transform` runs β `notificationAutomat.setState()` fires β `NotificationBar` automatically re-renders.
---
## API-Backed Auto-Sync Counter (POST)
An automat can perform optimistic state updates immediately for instant UI feedback, while automatically synchronizing mutations to the backend via HTTP POST in the background:
```js
// syncCounterAutomat.js
const syncCounterAutomat = new Automat(
{
index: 0,
count: 0,
syncStatus: 'synced', // 'syncing' | 'synced' | 'error'
lastSyncedAt: null,
},
{
async increment(step = 1) {
const { index, count } = syncCounterAutomat.state;
const nextCount = count + step;
// 1. Optimistic update (UI updates immediately):
syncCounterAutomat.setState({ count: nextCount, syncStatus: 'syncing' });
// 2. Automatic background sync via POST /api/counter:
try {
const res = await fetch('/api/counter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index, count: nextCount }),
});
const data = await res.json();
syncCounterAutomat.setState({ syncStatus: 'synced', lastSyncedAt: data.savedAt });
} catch (err) {
syncCounterAutomat.setState({ syncStatus: 'error', error: err.message });
}
},
}
);
```
### Component Wiring:
```jsx
class SyncCounterControls extends PureComponent {
state = syncCounterAutomat.state;
componentDidMount() {
this.unsubscribe = syncCounterAutomat.subscribe(this);
}
componentWillUnmount() {
this.unsubscribe();
}
render() {
const { index, count, syncStatus, lastSyncedAt } = this.state;
return (
Counter #{index}: {count}
Status: {syncStatus === 'syncing' ? 'POST in flightβ¦' : `Synced (${lastSyncedAt})`}
);
}
}
```
---
## Building the Standalone Library
```bash
npm run build:lib
```
Produces minified, zero-dependency bundles in `dist/`:
- `dist/automat.es.js` (~1.14 kB raw / **545 B** gzipped)
- `dist/automat.umd.js` (~1.09 kB raw / **552 B** gzipped)
---
## Project Structure
```
src/
βββ lib/
β βββ Automat.js β core observable state class
β βββ index.js β public re-exports
β
βββ examples/
βββ automats/
β βββ counterAutomat.js β local memory automat
β βββ notificationAutomat.js β subscribes to counter (cascade)
β βββ syncCounterAutomat.js β API-backed auto-sync indexed counter (POST)
β βββ indexAutomat.js β shared index + dynamic window.automats Map
βββ components/
βββ CounterButton.jsx β PureComponent with direct constructor & subscribe
βββ CounterDisplay.jsx β independent PureComponent synced via counterAutomat
βββ CascadeControls.jsx β PureComponent driving and demonstrating upstream cascade
βββ NotificationBar.jsx β independent PureComponent displaying cascade stream
βββ SyncCounterControls.jsx β PureComponent driving auto-sync indexed counter
βββ SyncBackendMonitor.jsx β PureComponent inspecting backend DB and POST payload
βββ IndexSelector.jsx β PureComponent driving shared index counter
βββ DynamicAutomatSubscriber.jsx β dynamically resubscribes to window.automats by index
```
---
## License
MIT