Files
automat/README.md
2026-09-19 01:43:24 +02:00

382 lines
10 KiB
Markdown

# Automat
Observable state management for React `PureComponent` — state independent of component lifecycle. Lightweight (~1.1 kB minified) and zero-dependency.
---
## Quick Start
```js
// 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 });
},
}
);
```
```jsx
// 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
1. **Lifecycle-Independent**: `Automat` instances live outside the React tree (typically in module scope), persisting state across component mounts and unmounts.
2. **Synchronous Constructor Reads**: Components initialize with `this.state = myAutomat.state;` directly in `constructor(props)`.
3. **Automatic Shallow Merges**: When subscribed with `subscribe(this)`, `automat.setState(partial)` calls `component.setState(partial)`, preserving any component-local state.
4. **Fine-Grained Selectors**: Passing a selector to `subscribe(this, selector)` runs shallow equality checks; updates to unrelated fields skip `setState` and avoid re-renders.
5. **Zero Wrappers**: No hooks, HOCs, Context Providers, or `connect()`.
6. **Optional Persistence**: Automats can specify a `name` and persist in the `window` object (`persist: false`) for session memory across unmounts/HMR, or in `IndexedDB` (`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 on `automat.actions`.
- `options` *(object, optional)*:
- `name` *(string)*: Unique identifier used for persistence and `Automat.get(name)` registry lookup.
- `persist` *(boolean)*: Persistence strategy when `name` is provided:
- `false` (default): Persists in the `window` object (session memory, survives component unmounts and HMR).
- `true`: Persists in `IndexedDB` (survives page reloads and browser restarts).
```js
// 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.
```js
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.
```js
automat.setState({ count: 5 });
```
---
### `automat.actions`
Provides direct access to the actions object supplied in the constructor.
```js
automat.actions.reset();
```
---
### `automat.subscribe(target, selector?)`
Subscribes a React `PureComponent` instance (`this`) or a callback function. Returns an unsubscribe function.
```ts
subscribe(
target: PureComponent | ((state: T) => void),
selector?: string | string[] | ((state: T) => object | null)
): () => void
```
#### Selector forms:
- **Single Key (string)**:
```js
// Injects { count } into component setState only when count changes
this.unsubscribe = myAutomat.subscribe(this, 'count');
```
- **Multiple Keys (array)**:
```js
// Injects { count, text } only when either property changes
this.unsubscribe = myAutomat.subscribe(this, ['count', 'text']);
```
- **Selector Function**:
```js
// 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)**:
```js
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.
```js
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()`.
```js
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.
```js
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).
```js
await cartStore.ready;
console.log('Cart rehydrated:', cartStore.state);
```
---
### `automat.clearPersistence()`
Deletes the persisted state entry from `window` or `IndexedDB`.
```js
await cartStore.clearPersistence();
```
---
### `Automat.get(name)`
Static registry method to retrieve any named `Automat` instance stored in the `window` object.
```js
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.
```js
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:
```js
// 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:
```js
// 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:
```jsx
// 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 `Automat` instances in module scope.
- Read `automat.state` directly in `constructor(props)`.
- Subscribe in `componentDidMount()` and unsubscribe in `componentWillUnmount()`.
- Use selectors (`'key'`, `['keys']`, or function) on multi-field automats to avoid unnecessary re-renders.
- Return `null` in `subscribeTo` transforms when an update should be skipped.
### ❌ DON'T
- Do not instantiate `Automat` inside React component lifecycle or render methods.
- Do not mutate state directly (`automat.state.count = 1`); use `setState()` or actions.
- Do not wrap components in React Context providers or HOCs.
- Do not forget to unsubscribe in `componentWillUnmount()`.