Automat
Observable state management for React PureComponent — state independent of component lifecycle. Lightweight (~1.1 kB minified) and zero-dependency.
Quick Start
// counterAutomat.js
import { Automat } from 'automat';
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 });
},
}
);
// Counter.jsx
import { PureComponent } from 'react';
import { counterAutomat } from './counterAutomat.js';
export class Counter extends PureComponent {
constructor(props) {
super(props);
// 1. Read state directly in constructor (never stale)
this.state = counterAutomat.state;
}
componentDidMount() {
// 2. Subscribe component to updates
this.unsubscribe = counterAutomat.subscribe(this);
}
componentWillUnmount() {
// 3. Clean up on unmount
this.unsubscribe();
}
render() {
return (
<div>
<span>{this.state.count}</span>
<button onClick={() => counterAutomat.actions.increment()}>+1</button>
<button onClick={() => counterAutomat.actions.decrement()}>-1</button>
</div>
);
}
}
Core Invariants
- Lifecycle-Independent:
Automatinstances live outside the React tree (typically in module scope), persisting state across component mounts and unmounts. - Synchronous Constructor Reads: Components initialize with
this.state = myAutomat.state;directly inconstructor(props). - Automatic Shallow Merges: When subscribed with
subscribe(this),automat.setState(partial)callscomponent.setState(partial), preserving any component-local state. - Fine-Grained Selectors: Passing a selector to
subscribe(this, selector)runs shallow equality checks; updates to unrelated fields skipsetStateand avoid re-renders. - Zero Wrappers: No hooks, HOCs, Context Providers, or
connect(). - Optional Persistence: Automats can specify a
nameand persist in thewindowobject (persist: false) for session memory across unmounts/HMR, or inIndexedDB(persist: true) to survive full page reloads.
API Reference
new Automat(initialState, actions?, options?)
Creates an observable state container with optional persistence.
initialState(object): Initial state snapshot (shallow copied).actions(object, optional): Action methods stored onautomat.actions.options(object, optional):name(string): Unique identifier used for persistence andAutomat.get(name)registry lookup.persist(boolean): Persistence strategy whennameis provided:false(default): Persists in thewindowobject (session memory, survives component unmounts and HMR).true: Persists inIndexedDB(survives page reloads and browser restarts).
// Window-persisted (session memory)
const sessionStore = new Automat(
{ filter: 'all' },
{ setFilter(filter) { sessionStore.setState({ filter }); } },
{ name: 'filter', persist: false }
);
// IndexedDB-persisted (survives browser reload)
const cartStore = new Automat(
{ items: [] },
{ addItem(item) { cartStore.setState({ items: [...cartStore.state.items, item] }); } },
{ name: 'cart', persist: true }
);
automat.state / automat.getState()
Returns the current state snapshot.
const current = automat.state;
// or
const current = automat.getState();
automat.setState(partial)
Shallow-merges partial into current state and synchronously notifies subscribers. Returns the updated state.
automat.setState({ count: 5 });
automat.actions
Provides direct access to the actions object supplied in the constructor.
automat.actions.reset();
automat.subscribe(target, selector?)
Subscribes a React PureComponent instance (this) or a callback function. Returns an unsubscribe function.
subscribe(
target: PureComponent | ((state: T) => void),
selector?: string | string[] | ((state: T) => object | null)
): () => void
Selector forms:
- Single Key (string):
// Injects { count } into component setState only when count changes this.unsubscribe = myAutomat.subscribe(this, 'count'); - Multiple Keys (array):
// Injects { count, text } only when either property changes this.unsubscribe = myAutomat.subscribe(this, ['count', 'text']); - Selector Function:
// Custom slice with shallow equality check; return null/undefined to skip update this.unsubscribe = myAutomat.subscribe(this, (state) => ({ badgeCount: state.items.length, })); - Callback Function (non-React):
const unsub = myAutomat.subscribe((state) => console.log('State changed:', state));
automat.select(selector)
Creates a scoped slice containing a .state getter and a pre-scoped .subscribe() helper.
const countSlice = myAutomat.select('count');
// In constructor:
this.state = countSlice.state; // { count: 0 }
// In componentDidMount:
this.unsubscribe = countSlice.subscribe(this);
automat.unsubscribe(target)
Unregisters a subscriber. Prefer calling the function returned by subscribe().
automat.unsubscribe(this);
automat.subscribeTo(upstreamAutomat, transform)
Derives state reactively from an upstream automat. Whenever upstreamAutomat updates, transform(upstreamState, myState) runs. Return partial state to update, or null / undefined to skip. Returns this for chaining.
const auditAutomat = new Automat({ logs: [] });
auditAutomat.subscribeTo(counterAutomat, (upstream, my) => {
if (upstream.count === 0) return null; // skip update
return {
logs: [`Count changed to ${upstream.count}`, ...my.logs.slice(0, 19)],
};
});
automat.ready
A promise resolving with current state when initial rehydration finishes (resolves immediately for non-persisted and window-persisted instances; resolves once IndexedDB data loads).
await cartStore.ready;
console.log('Cart rehydrated:', cartStore.state);
automat.clearPersistence()
Deletes the persisted state entry from window or IndexedDB.
await cartStore.clearPersistence();
Automat.get(name)
Static registry method to retrieve any named Automat instance stored in the window object.
const filterStore = Automat.get('filter');
automat.dispose()
Tears down all upstream subscriptions set up via subscribeTo(), clears all subscribers, and removes named instance registrations from the window registry.
automat.dispose();
Examples: Named Automats & Persistence
1. Named Automat in Window Object (persist: false)
Useful for shared app settings, tab management, or devtools inspection. State persists in memory across component unmounts and Hot Module Replacement (HMR) reloads:
// settingsAutomat.js
import { Automat } from 'automat';
export const settingsAutomat = new Automat(
{ theme: 'dark', soundEnabled: true },
{
setTheme(theme) {
settingsAutomat.setState({ theme });
},
toggleSound() {
settingsAutomat.setState({ soundEnabled: !settingsAutomat.state.soundEnabled });
},
},
{ name: 'settings', persist: false } // Saved in window object
);
// Any other file or devtools console can lookup the instance by name:
const settings = Automat.get('settings');
settings?.actions.setTheme('light');
2. Reload-Resilient Persistence with IndexedDB (persist: true)
Useful for shopping carts, drafts, and user form progress that must survive full page refreshes and browser restarts:
// cartAutomat.js
import { Automat } from 'automat';
export const cartAutomat = new Automat(
{ items: [], lastUpdated: null },
{
addItem(item) {
cartAutomat.setState({
items: [...cartAutomat.state.items, item],
lastUpdated: Date.now(),
});
},
clearCart() {
cartAutomat.setState({ items: [], lastUpdated: null });
// Optional: wipe stored record from IndexedDB
cartAutomat.clearPersistence();
},
},
{ name: 'cart', persist: true } // Automatically syncs with IndexedDB
);
// Optional: wait for saved data to finish hydrating before proceeding
await cartAutomat.ready;
console.log('Hydrated cart items from IndexedDB:', cartAutomat.state.items);
3. PureComponent Consuming an IndexedDB-Persisted Automat
Components mount immediately with initial state. When IndexedDB finishes loading persisted data in the background, subscribers are notified automatically:
// CartView.jsx
import { PureComponent } from 'react';
import { cartAutomat } from './cartAutomat.js';
export class CartView extends PureComponent {
constructor(props) {
super(props);
// 1. Mount immediately with current/initial state
this.state = cartAutomat.state;
}
componentDidMount() {
// 2. Subscribe — receives automatic update once IndexedDB hydrates
this.unsubscribe = cartAutomat.subscribe(this);
}
componentWillUnmount() {
// 3. Clean up subscription
this.unsubscribe();
}
render() {
const { items } = this.state;
return (
<div>
<h3>Shopping Cart ({items.length} items)</h3>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name} - ${item.price}</li>
))}
</ul>
<button onClick={() => cartAutomat.actions.addItem({ id: Date.now(), name: 'New Item', price: 20 })}>
Add Item (Persists on Reload)
</button>
</div>
);
}
}
Best Practices
✅ DO
- Define
Automatinstances in module scope. - Read
automat.statedirectly inconstructor(props). - Subscribe in
componentDidMount()and unsubscribe incomponentWillUnmount(). - Use selectors (
'key',['keys'], or function) on multi-field automats to avoid unnecessary re-renders. - Return
nullinsubscribeTotransforms when an update should be skipped.
❌ DON'T
- Do not instantiate
Automatinside React component lifecycle or render methods. - Do not mutate state directly (
automat.state.count = 1); usesetState()or actions. - Do not wrap components in React Context providers or HOCs.
- Do not forget to unsubscribe in
componentWillUnmount().