diff --git a/README.LLM.md b/README.LLM.md index d5b32d2..73a9170 100644 --- a/README.LLM.md +++ b/README.LLM.md @@ -9,11 +9,12 @@ This document is optimized for LLMs and AI coding assistants implementing or con `automat` is a lightweight (~1.1 kB minified, zero-dependency) observable state container designed specifically for React `PureComponent`. ### Key Invariants -1. **Instance Lifetime (Module Singleton or Dynamic Map Registry)**: An `Automat` instance lives outside the React render tree. While commonly instantiated as module-level singletons, instances can also be stored dynamically in a `Map` (e.g. `window.automatRegistry = new Map()` or an in-memory entity cache) keyed by ID or index. State persists in memory across component mounts, unmounts, and subscription transfers. +1. **Instance Lifetime (Module Singleton or Dynamic Map Registry)**: An `Automat` instance lives outside the React render tree. While commonly instantiated as module-level singletons, instances can also be stored dynamically in a `Map` (e.g. `window.automats = new Map()` or an in-memory entity cache) keyed by ID or index. State persists in memory across component mounts, unmounts, and subscription transfers. 2. **Direct Constructor Read**: Components read `automat.state` directly in their `constructor(props)`. State is never stale upon mounting. 3. **Lifecycle Subscription**: Components register with `automat.subscribe(this)` in `componentDidMount()` and call `this.unsubscribe()` in `componentWillUnmount()`. 4. **Hybrid State by Default**: When `automat.setState()` notifies a component, it calls `component.setState(partial)`. React's class component `setState` performs a shallow merge, preserving any component-local state fields. -5. **No Wrappers or Hooks**: No HOCs, no context providers, no hooks, no `connect()`. +5. **Fine-Grained Slice Subscriptions**: By default, `automat.subscribe(this)` notifies on any state change. When a subscriber only cares about a subset of state, passing a selector (`'key'`, `['keyA', 'keyB']`, or `(state) => ({ ... })`) enables internal shallow equality checking (`shallowEqual`). Updates to unrelated state fields will NOT trigger `setState` or re-renders. +6. **No Wrappers or Hooks**: No HOCs, no context providers, no hooks, no `connect()`. --- @@ -41,7 +42,8 @@ new Automat>( | `getState()` | `getState(): T` | Method returning current state snapshot. | | `actions` | `get actions(): A` | Getter returning the actions object passed into the constructor. | | `setState()` | `setState(partial: Partial): T` | Shallow-merges `partial` into current state and synchronously notifies all subscribers. Returns new state. | -| `subscribe()` | `subscribe(target: PureComponent \| ((state: T) => void), selector?: (state: T) => object \| null): () => void` | Subscribes either a React component instance (`this`) or a callback function. Returns an `unsubscribe` function. | +| `subscribe()` | `subscribe(target: PureComponent \| ((state: T) => void), selector?: string \| string[] \| ((state: T) => object \| null)): () => void` | Subscribes either a React component instance (`this`) or a callback function. When `selector` is provided, performs shallow equality checking to ensure updates to unrelated state fields never trigger `setState` or re-renders. | +| `select()` | `select(selector: string \| string[] \| ((state: T) => any)): { readonly state: any, subscribe(target): () => void }` | Slices an automat to a specific subset of state for direct constructor reads and scoped subscriptions. | | `unsubscribe()` | `unsubscribe(target: PureComponent \| Function): void` | Manually unregisters a subscriber. | | `subscribeTo()` | `subscribeTo(upstream: Automat, transform: (upstreamState: U, myState: T) => Partial \| null): this` | Reactive pipeline: Derives state from an upstream automat. Returns `this` for chaining. | | `dispose()` | `dispose(): void` | Unsubscribes all upstream listeners and clears all subscribers. | @@ -125,26 +127,61 @@ export class CounterController extends PureComponent { --- -### Pattern C: Passive Reader Component with State Selector +### Pattern C: Partial & Slice Subscriptions (Subscribing to Part of the State) -Use a selector function when a component only cares about a subset of the automat's state: +When an automat manages multiple state fields (e.g. `{ count, filter, theme, user }`), subscribing with `automat.subscribe(this)` causes any state change in the automat to notify the component. When a component only cares about a subset of the automat's state, subscribe using a **slice selector**. + +`Automat` performs an internal shallow equality check (`shallowEqual(lastSlice, nextSlice)`). Updates to other, unrelated fields in the automat **will NOT trigger `setState` or re-renders** for that subscriber. + +#### Supported Selector Forms: + +1. **Single key string** (most concise): +```jsx +// Subscribes ONLY to 'count'. Injects { count } into component setState. +this.unsubscribe = myAutomat.subscribe(this, 'count'); +``` + +2. **Array of keys**: +```jsx +// Subscribes ONLY to 'count' and 'step'. Injects { count, step } into component setState. +this.unsubscribe = myAutomat.subscribe(this, ['count', 'step']); +``` + +3. **Selector function**: +```jsx +// Computes a custom slice. Returning null or undefined skips updates. +this.unsubscribe = myAutomat.subscribe(this, (state) => ({ + count: state.count, + isEven: state.count % 2 === 0, +})); +``` + +4. **Scoping via `.select()`**: +```jsx +// Slicing helper for both constructor reading and subscription: +const countSlice = myAutomat.select('count'); +this.state = countSlice.state; // { count: 0 } +this.unsubscribe = countSlice.subscribe(this); +``` + +#### Example: Passive Reader Subscribing Only to a Slice ```jsx // src/components/CountDisplay.jsx import { PureComponent } from 'react'; -import { counterAutomat } from '../automats/counterAutomat.js'; +import { appAutomat } from '../automats/appAutomat.js'; export class CountDisplay extends PureComponent { constructor(props) { super(props); - this.state = { count: counterAutomat.state.count }; + // Initialize with only the slice needed: + this.state = { count: appAutomat.state.count }; } componentDidMount() { - // Selector maps state to target object. Returning null skips setState. - this.unsubscribe = counterAutomat.subscribe(this, (state) => ({ - count: state.count, - })); + // 💡 Partial subscription: only re-renders when `count` changes. + // Mutations to appAutomat.theme, .user, etc. will NOT trigger setState! + this.unsubscribe = appAutomat.subscribe(this, 'count'); } componentWillUnmount() { @@ -359,12 +396,14 @@ export class DynamicSlotObserver extends PureComponent { - **DO** create `Automat` instances in module scope or outside React components. - **DO** initialize component state synchronously in `constructor(props)` using `this.state = myAutomat.state;` or `{ ...myAutomat.state, localField: 'val' }`. - **DO** register subscriptions in `componentDidMount()` via `this.unsubscribe = myAutomat.subscribe(this);`. +- **DO** use slice selectors (`myAutomat.subscribe(this, 'fieldName')` or `['fieldA', 'fieldB']`) when a component only needs part of the state, preventing unnecessary `setState` triggers and re-renders when unrelated fields change. - **DO** clean up subscriptions in `componentWillUnmount()` via `this.unsubscribe();`. - **DO** invoke actions directly from event handlers (e.g. `onClick={() => myAutomat.actions.doSomething()}`). - **DO** return `null` in `subscribeTo` transforms when an update should be filtered out. ### ❌ DON'Ts - **DON'T** instantiate `new Automat()` inside a React component's `render()`, `constructor()`, or lifecycle method. +- **DON'T** subscribe with bare `myAutomat.subscribe(this)` if the component only depends on a specific subset of fields in a multi-field automat; use a slice selector instead. - **DON'T** mutate state directly (e.g. `myAutomat.state.count = 5` is forbidden). Always call `myAutomat.setState({ count: 5 })` or an action. - **DON'T** wrap components in React Context providers, HOCs, or `connect()`. - **DON'T** use React Hooks (`useState`, `useEffect`) when targeting the `automat` class component architecture. Use `PureComponent`. diff --git a/README.md b/README.md index f2f7d51..e67d7d4 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,8 @@ const counterAutomat = new Automat( | `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 either a component instance (`this`) or a callback `(state) => ...`. Returns unsub function. | +| `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` | @@ -231,6 +232,42 @@ const counterAutomat = new Automat( --- +## 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) diff --git a/src/lib/Automat.js b/src/lib/Automat.js index ec039d0..71ce433 100644 --- a/src/lib/Automat.js +++ b/src/lib/Automat.js @@ -30,6 +30,26 @@ * } * ``` */ +/** + * Performs a shallow equality check between two values or objects. + * Used by Automat subscribers to avoid unnecessary setState calls when + * a subscribed slice of state has not changed. + */ +function shallowEqual(a, b) { + if (Object.is(a, b)) return true; + if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (let i = 0; i < keysA.length; i++) { + const key = keysA[i]; + if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) { + return false; + } + } + return true; +} + export class Automat { /** @type {object} */ #state; @@ -75,43 +95,89 @@ export class Automat { */ setState(partial) { this.#state = { ...this.#state, ...partial }; - this.#notify(); + this.#notify(partial); return this.#state; } /** - * Subscriber handling for React lifecycle management or listener callbacks. + * Subscribes a React PureComponent instance or a listener callback. * - * Supports: - * 1. A React component instance (has `.setState`): - * `this.unsubscribe = automat.subscribe(this);` - * 2. A React component instance with an optional selector: - * `this.unsubscribe = automat.subscribe(this, state => ({ count: state.count }));` - * 3. A listener function: - * `this.unsubscribe = automat.subscribe((state) => { ... });` + * Partial / Slice Subscriptions: + * By default, subscribing to an automat triggers updates when any state changes. + * Passing a `selector` allows subscribing to only a slice of state. + * Changes to unrelated state fields will NOT trigger setState or re-renders. * - * @param {object|function} target React component instance or callback function. - * @param {function} [selector] Optional selector function mapping state. - * @returns {function} Unsubscribe function for componentWillUnmount. + * Selector forms: + * 1. Single property key (string): + * `automat.subscribe(this, 'count')` + * 2. Multiple property keys (array of strings): + * `automat.subscribe(this, ['count', 'step'])` + * 3. Selector function: + * `automat.subscribe(this, state => ({ count: state.count }))` + * (Return null/undefined to conditionally skip updates) + * 4. Full subscription (omitted): + * `automat.subscribe(this)` + * + * @param {object|function} target React component (`this`) or callback function. + * @param {string|string[]|function} [selector] Property key, key array, or selector function. + * @returns {function} Unsubscribe function for componentWillUnmount. */ subscribe(target, selector) { - let notifyFn; + const isComponent = target && typeof target.setState === 'function'; + const isFunction = typeof target === 'function'; - if (typeof target === 'function') { - notifyFn = target; - } else if (target && typeof target.setState === 'function') { - notifyFn = (state) => { - const next = typeof selector === 'function' ? selector(state) : state; - if (next != null) { - target.setState(next); - } - }; - } else { + if (!isComponent && !isFunction) { throw new TypeError( 'Automat.subscribe expects a callback function or a React component instance with a setState method.' ); } + let getSlice; + if (typeof selector === 'string') { + getSlice = isComponent + ? (state) => ({ [selector]: state[selector] }) + : (state) => state[selector]; + } else if (Array.isArray(selector)) { + getSlice = (state) => { + const slice = {}; + for (let i = 0; i < selector.length; i++) { + const key = selector[i]; + slice[key] = state[key]; + } + return slice; + }; + } else if (typeof selector === 'function') { + getSlice = selector; + } + + let lastSlice = getSlice ? getSlice(this.#state) : undefined; + + const notifyFn = (state, partial) => { + if (getSlice) { + const nextSlice = getSlice(state); + if (nextSlice == null) return; + if (shallowEqual(lastSlice, nextSlice)) return; + lastSlice = nextSlice; + + if (isComponent) { + if (typeof nextSlice !== 'object') { + throw new TypeError( + 'Automat: selector for a React component must return a state object, e.g. state => ({ count: state.count }).' + ); + } + target.setState(nextSlice); + } else { + target(nextSlice); + } + } else { + if (isComponent) { + target.setState(partial ?? state); + } else { + target(state); + } + } + }; + this.#subscribers.set(target, notifyFn); return () => { @@ -119,6 +185,40 @@ export class Automat { }; } + /** + * Slices this automat to a subset of state for reading and subscription. + * + * @param {string|string[]|function} selector + * @returns {{ readonly state: any, subscribe(target: object|function): function }} + */ + select(selector) { + let getSlice; + if (typeof selector === 'string') { + getSlice = (state) => ({ [selector]: state[selector] }); + } else if (Array.isArray(selector)) { + getSlice = (state) => { + const slice = {}; + for (let i = 0; i < selector.length; i++) { + const key = selector[i]; + slice[key] = state[key]; + } + return slice; + }; + } else if (typeof selector === 'function') { + getSlice = selector; + } else { + getSlice = (state) => state; + } + + const self = this; + return { + get state() { + return getSlice(self.state); + }, + subscribe: (target) => self.subscribe(target, selector), + }; + } + /** * Unsubscribes a component or listener function. * @@ -171,9 +271,9 @@ export class Automat { } /** @private */ - #notify() { + #notify(partial) { for (const notifyFn of this.#subscribers.values()) { - notifyFn(this.#state); + notifyFn(this.#state, partial); } } }