# βš™ 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 (
{/* πŸ’‘ Click triggers handleSubmit β†’ searchAutomat.actions.search() */}
); } } ``` --- ## 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 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 ```jsx // Subscribes only to changes in 'count'. Unrelated fields will NOT trigger setState: this.unsubscribe = myAutomat.subscribe(this, 'count'); ``` ### 2. Array of Keys ```jsx // Subscribes only to 'count' and 'step': this.unsubscribe = myAutomat.subscribe(this, ['count', 'step']); ``` ### 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, })); ``` ### 4. Automat Slicing with `.select()` ```jsx 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: ```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 ( ); } } ``` 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