# Conflicts: # README.md # index.html # package-lock.json # package.json # src/App.jsx # src/examples/automats/indexAutomat.js # src/examples/components/DynamicAutomatSubscriber.jsx # src/index.css # src/lib/Automat.js # src/lib/index.js # src/main.jsx # vite.config.js # vite.config.lib.js
11 KiB
11 KiB
⚙ react-automat
Zero-dependency higher-order observable state container for React
PureComponent. State lives in module scope (independent of component mounting). Components read state synchronously inconstructorand subscribe incomponentDidMount. No Context, hooks, or HOCs.
npm install react-automat
# or: pnpm add react-automat | yarn add react-automat | bun add react-automat
Quick Start
// counterAutomat.js
import { Automat } from 'react-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>
);
}
}
1. Complete Type Signatures
import type { PureComponent } from 'react';
export interface AutomatOptions<T> {
name?: string | null; // window.__AUTOMATS__ key & Automat.get(name)
persist?: boolean; // false = window (default), true = IndexedDB
loader?: (automat: Automat<T, any>) => Promise<Partial<T> | void>;
url?: string | null; // Auto-triggers fetcher(url) on init
fetcher?: (url: string, automat?: Automat<T, any>) => Promise<T>;
}
export type Selector<T, S = any> = keyof T | (keyof T)[] | ((state: T) => S | null | undefined);
export interface ScopedSlice<S> {
readonly state: S;
subscribe(target: PureComponent | ((slice: S) => void)): () => void;
}
export interface BlobResource {
blob: Blob; url: string; size: number; type: string; loadedAt: string;
revoke: () => void; // Auto-called on setDirty() / dispose() to release memory
}
export interface SSEOptions<T = any, A = any> {
onMessage?: (data: any, event: string, automat: Automat<T, A>) => void;
events?: Record<string, (data: any, automat: Automat<T, A>) => void>;
onError?: (err: any, automat: Automat<T, A>) => void;
}
export type CombinedState<M extends Record<string, Automat<any, any>>> = {
[K in keyof M]: M[K] extends Automat<infer T, any> ? T : never;
};
export type CombinedActions<M extends Record<string, Automat<any, any>>> = {
[K in keyof M]: M[K] extends Automat<any, infer A> ? A : never;
};
export class Automat<T = any, A = Record<string, Function>> {
constructor(initialState?: T | null, actions?: A, options?: AutomatOptions<T>);
readonly state: T;
getState(): T;
setState(partial: Partial<T> | ((prev: T) => Partial<T>)): T;
readonly actions: A;
readonly isReady: boolean;
readonly isDirty: boolean;
readonly ready: Promise<T>; // Re-awaitable after setDirty()
readonly name: string | null;
readonly persist: boolean;
readonly url: string | null;
readonly subscriberCount: number;
getData(options?: { reload?: boolean }): T;
load(options?: { reload?: boolean }): T;
subscribe(target: PureComponent | ((state: T, partial?: Partial<T>) => void), selector?: Selector<T>): () => void;
select<S = any>(selector: Selector<T, S>): ScopedSlice<S>;
unsubscribe(target: any): void;
subscribeTo<U = any>(
upstream: Automat<U, any>,
transform: (upstreamState: U, currentState: T) => Partial<T> | null | undefined,
options?: { cascadeDirty?: boolean | ((upstream: U, current: T) => boolean) }
): this;
invalidateWith<U = any>(upstream: Automat<U, any>, filterFn?: (upstream: U, current: T) => boolean): this;
onDirty(fn: (automat: this) => void): () => void;
setDirty(): Promise<T>; // Resets state to null/default, revokes blobs, cascades, defers if unmounted
reload(): Promise<T>; // Silent background re-fetch; preserves state & isReady=true
refresh(): Promise<T>; // Alias for reload()
read(): T; // For Suspense: throws ready promise if unready; triggers deferred load if dirty
clearPersistence(): Promise<void>;
dispose(): void; // Unsubs all, revokes blobs, deletes from window registry
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
// Dictionary Combination:
static combine<M extends Record<string, Automat<any, any>>>(automats: M): Automat<CombinedState<M>, CombinedActions<M>>;
// Array Combiner Function Combination:
static combine<R = any>(
upstreamAutomats: Automat<any, any>[],
combiner: (...states: any[]) => R,
options?: AutomatOptions<R>
): Automat<R, Record<string, Function>>;
}
// Fetchers & SSE
export function fetchJson<T = any>(url: string, options?: RequestInit): Promise<T>;
export function fetchBlob(url: string, options?: RequestInit): Promise<BlobResource>;
export const fetchImageBlob: typeof fetchBlob;
export const fetchPdfBlob: typeof fetchBlob;
export function connectSSE<T, A>(automat: Automat<T, A>, url: string, options?: SSEOptions<T, A>): { eventSource: EventSource | null; close: () => void };
export function createSSEFetcher(sseUrl: string, baseFetcher?: Function, sseOptions?: SSEOptions): (url: string, automat?: any) => Promise<any>;
2. Canonical Usage Patterns
A. PureComponent Subscription
import { PureComponent } from 'react';
import { Automat } from 'react-automat';
const store = new Automat({ count: 0 }, { inc: () => store.setState({ count: store.state.count + 1 }) });
class Counter extends PureComponent {
constructor(props) { super(props); this.state = store.state; } // Sync read, never stale
componentDidMount() { this.unsub = store.subscribe(this); } // Auto-calls this.setState(partial)
componentWillUnmount() { this.unsub(); }
render() { return <button onClick={store.actions.inc}>{this.state.count}</button>; }
}
B. Fine-Grained Selectors (Shallow-guarded, 0 renders if slice unchanged)
store.subscribe(this, 'count'); // Single key
store.subscribe(this, ['theme', 'locale']); // Multi key
store.subscribe(this, (s) => ({ items: s.list.length }));// Projection function
C. Async Fetch & Constructor Cache (Instant Re-Mount)
class UserCard extends PureComponent {
constructor(props) {
super(props);
// 💡 Synchronous read in constructor:
// If already loaded, returns immediately -> 1 render only!
this.state = profileAsyncAutomat.getData();
}
componentDidMount() {
this.unsubscribe = profileAsyncAutomat.subscribe(this);
}
componentWillUnmount() {
this.unsubscribe();
}
render() {
const { status, data } = this.state;
if (status === 'pending') return <div className="spinner" />;
return <div>{data.name}</div>;
}
}
D. Multi-API Orchestration (Automat.combine)
const combinedDashboard = Automat.combine(
[userStatsAutomat, systemMetricsAutomat],
(stats, metrics) => ({
stats: stats.data,
metrics: metrics.data,
})
);
class Dashboard extends PureComponent {
constructor(props) {
super(props);
// Instant resolution in constructor if both upstreams loaded earlier:
this.state = combinedDashboard.getData();
}
componentDidMount() {
this.unsubscribe = combinedDashboard.subscribe(this);
}
componentWillUnmount() {
this.unsubscribe();
}
render() {
const { status, stats, metrics } = this.state;
if (status === 'pending') return <div>Loading dual sources in parallel...</div>;
return (
<div>
<h1>{stats.name}</h1>
<div>Cluster: {metrics.clusterHealth}</div>
</div>
);
}
}
E. Backend API: React Suspense vs Standard
const user = new Automat(null, {}, { url: '/api/user' });
// Suspense: throws promise while unready
const Profile = () => <div>{user.read().name}</div>;
// Standard: check isReady
class ProfileStandard extends PureComponent {
constructor(props) { super(props); this.state = { u: user.state }; }
componentDidMount() { this.unsub = user.subscribe(this, (u) => ({ u })); }
componentWillUnmount() { this.unsub(); }
render() {
if (!user.isReady) return <div>Loading...</div>;
return <div>{this.state.u.name}</div>;
}
}
F. Cascading Invalidation & Memory Freeing with Blobs
const photoHub = new Automat(null, {}, { url: '/api/photos' });
const photo1 = new Automat(null, {}, { url: '/api/photos/1', fetcher: fetchImageBlob });
// Automatically cascades setDirty when hub is dirtied
photo1.invalidateWith(photoHub);
// When photoHub.setDirty() is triggered:
// 1. photo1 calls URL.revokeObjectURL(photo1.state.url) to free browser image memory
// 2. photo1 resets to null/initial state
// 3. If photo1 is mounted, re-fetches immediately; if unmounted, defers fetch!
3. 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.
Best Practices
✅ DO
- Define
Automatinstances in module scope. - Read
automat.stateorautomat.getData()directly 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().