18 KiB
⚙ automat
Observable state management for React
PureComponent.
State lives independently of component lifecycle — mounts and unmounts freely without losing state.
No wrappers, no HOCs, noconnect()— purely direct access and lifecycle subscriber handling.
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:
- Instantiate first: The Automat instance is created outside React's render tree.
- Direct constructor access: Components initialize directly from
automat.state(orgetState()) — never stale, even after transitions prior to mounting. - Lifecycle subscriber handling: In
componentDidMount, register the component withautomat.subscribe(this). IncomponentWillUnmount, callthis.unsubscribe()orautomat.unsubscribe(this). - Direct event wiring: Call
automat.actions.actionName()orautomat.setState(...)directly inonClickhandlers. No dispatchers, actions creators, or prop drilling. - Render minimization: Standard
PureComponentshallow 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:
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 (
<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:
this.state = counterAutomat.state;
}
componentDidMount() {
// Automatically re-renders when CounterButton triggers an increment/decrement
this.unsubscribe = counterAutomat.subscribe(this);
}
componentWillUnmount() {
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:
- It calls
this.setState({ count: 42 })on the component instance. - React's class component
setStateperforms a shallow merge intothis.state. - Local fields (
step,localClicks,inputValue) remain untouched. PureComponent's shallow comparison ensures renders happen only when values change.
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>
);
}
}
Core API
new Automat(initialState, actions?)
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 |
Subscribing to Part of the State (Slice Subscriptions)
When an automat has multiple fields (e.g. { count, filter, theme, user }), subscribing without a selector will cause any state change to trigger this.setState() on the subscriber.
When a component only cares about a subset of the automat's state, subscribe with a slice selector. Automat performs an internal shallow equality check (shallowEqual(lastSlice, nextSlice)), ensuring updates to other unrelated fields never trigger setState or re-renders:
1. Single Key String
// Subscribes only to changes in 'count'. Unrelated fields will NOT trigger setState:
this.unsubscribe = myAutomat.subscribe(this, 'count');
2. Array of Keys
// Subscribes only to 'count' and 'step':
this.unsubscribe = myAutomat.subscribe(this, ['count', 'step']);
3. Custom Selector Function
// Computes a derived slice; returning null/undefined skips updates:
this.unsubscribe = myAutomat.subscribe(this, (state) => ({
count: state.count,
isEven: state.count % 2 === 0,
}));
4. Automat Slicing with .select()
const countSlice = myAutomat.select('count');
this.state = countSlice.state; // { count: 0 }
this.unsubscribe = countSlice.subscribe(this);
Notification Cascade (Wired Example)
How subscribeTo() Works (Reactive Pipeline)
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:
// 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:
// 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:
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:
// 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)
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:
// 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:
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 (
<div>
<h3>Counter #{index}: {count}</h3>
<button onClick={() => syncCounterAutomat.actions.increment(1)}>+1</button>
<span>Status: {syncStatus === 'syncing' ? 'POST in flight…' : `Synced (${lastSyncedAt})`}</span>
</div>
);
}
}
Building the Standalone Library
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