diff --git a/README.md b/README.md
index 93c4dfc..ab1ff36 100644
--- a/README.md
+++ b/README.md
@@ -287,6 +287,55 @@ photo1.invalidateWith(photoHub);
// 3. If photo1 is mounted, re-fetches immediately; if unmounted, defers fetch!
```
+### G. Dynamic / Keyed Entities (`KeyedAutomat`)
+
+For entity collections loaded by ID (e.g. categories, articles, users), `KeyedAutomat` provides per-ID reactive entries, concurrent in-flight deduplication, React Suspense, and fine-grained per-ID subscriptions:
+
+```jsx
+import { KeyedAutomat } from 'react-automat';
+
+const categoryAutomat = new KeyedAutomat(null, {}, {
+ name: 'categories',
+ loader: async (id) => {
+ const res = await fetch(`/api/categories/${id}`);
+ return res.json();
+ },
+});
+
+// 1. Scoped handle for a specific ID:
+const techHandle = categoryAutomat.key('tech');
+
+// 2. React Suspense (auto-triggers load, throws in-flight promise):
+const TechCategory = () => {
+ const category = categoryAutomat.read('tech'); // or techHandle.read()
+ return
{category.name}
;
+};
+
+// 3. PureComponent subscription (O(1) updates, 0 cross-talk between IDs):
+class CategoryView extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.handle = categoryAutomat.key(props.id);
+ this.state = this.handle.state; // { data, loading, loaded, error }
+ }
+ componentDidMount() {
+ this.unsub = this.handle.subscribe(this, (item) => ({
+ data: item.data,
+ loading: item.loading,
+ }));
+ this.handle.load(); // Deduplicated across concurrent mounts
+ }
+ componentWillUnmount() {
+ this.unsub();
+ }
+ render() {
+ const { loading, data } = this.state;
+ if (loading) return Loading category...
;
+ return {data?.name}
;
+ }
+}
+```
+
---
## 3. Core Invariants
diff --git a/src/lib/Automat.js b/src/lib/Automat.js
index b2ecb4f..1b20b5b 100644
--- a/src/lib/Automat.js
+++ b/src/lib/Automat.js
@@ -26,9 +26,11 @@ export function shallowEqual(a, b) {
return true;
}
+import { KeyedAutomat } from './KeyedAutomat.js';
+
// ── Global Window & Registry Aliases ──────────────────────────────
const W = typeof window !== 'undefined' ? window : null;
-const getGlobalMap = (key) => W && (W[key] ||= new Map());
+export const getGlobalMap = (key) => W && (W[key] ||= new Map());
// ── Unified IndexedDB Storage ─────────────────────────────────────
const IDB_NAME = 'automat_db';
@@ -106,7 +108,7 @@ function idbDelete(key) {
}
// ── Selector Normalizer ───────────────────────────────────────────
-function resolveSelector(s, isComponent = false) {
+export function resolveSelector(s, isComponent = false) {
if (typeof s === 'function') {
return s;
}
@@ -304,6 +306,17 @@ export class Automat {
throw new TypeError('Automat.combine expects an array or dictionary object of Automat instances.');
}
+ /**
+ * Creates a KeyedAutomat instance for dynamic ID-based state.
+ * @param {any} [initialItemState=null]
+ * @param {any} [callbacks={}]
+ * @param {any} [options={}]
+ * @returns {KeyedAutomat}
+ */
+ static keyed(initialItemState = null, callbacks = {}, options = {}) {
+ return new KeyedAutomat(initialItemState, callbacks, options);
+ }
+
get name() {
return this._name;
}
diff --git a/src/lib/KeyedAutomat.js b/src/lib/KeyedAutomat.js
new file mode 100644
index 0000000..da4408c
--- /dev/null
+++ b/src/lib/KeyedAutomat.js
@@ -0,0 +1,603 @@
+/**
+ * @module KeyedAutomat
+ *
+ * Observable dynamic/keyed entity state container for React PureComponents.
+ * Manages collections of items by ID with concurrent fetch deduplication,
+ * React Suspense support, and fine-grained per-ID subscriptions.
+ */
+
+import { shallowEqual, resolveSelector, getGlobalMap } from './Automat.js';
+
+/**
+ * Creates a blank item state container.
+ * @template T
+ * @param {T | null} [initialData=null]
+ * @returns {import('./index.d.ts').ItemState}
+ */
+export function createDefaultItemState(initialData = null) {
+ return {
+ data: initialData,
+ loading: false,
+ loaded: false,
+ error: null,
+ };
+}
+
+/**
+ * Lightweight scoped handle for a specific key/ID within a KeyedAutomat.
+ * Implements an interface consistent with standard Automat instances.
+ * @template T
+ */
+export class KeyHandle {
+ /**
+ * @param {KeyedAutomat} automat
+ * @param {string | number} id
+ */
+ constructor(automat, id) {
+ this._automat = automat;
+ this._id = id;
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get automat() {
+ return this._automat;
+ }
+
+ get state() {
+ return this._automat.get(this._id);
+ }
+
+ get data() {
+ return this._automat.getData(this._id);
+ }
+
+ get loading() {
+ return this._automat.get(this._id).loading;
+ }
+
+ get loaded() {
+ return this._automat.isLoaded(this._id);
+ }
+
+ get error() {
+ return this._automat.get(this._id).error;
+ }
+
+ load(options) {
+ return this._automat.load(this._id, options);
+ }
+
+ read() {
+ return this._automat.read(this._id);
+ }
+
+ reload() {
+ return this._automat.reload(this._id);
+ }
+
+ setDirty(keep, now) {
+ return this._automat.setDirty(this._id, keep, now);
+ }
+
+ set(data) {
+ return this._automat.set(this._id, data);
+ }
+
+ evict() {
+ return this._automat.evict(this._id);
+ }
+
+ subscribe(target, selector) {
+ return this._automat.subscribeKey(this._id, target, selector);
+ }
+}
+
+/**
+ * KeyedAutomat manages a dynamic dictionary of reactive entity states by ID.
+ * @template T
+ * @template A
+ */
+export class KeyedAutomat {
+ /**
+ * @param {T | null} [initialItemState=null] Default data value for new items.
+ * @param {A | ((automat: KeyedAutomat) => A)} [callbacks={}] Actions dictionary or factory.
+ * @param {object} [options={}]
+ * @param {string} [options.name] Identifier for global registry and devtools.
+ * @param {(id: string | number, automat: KeyedAutomat) => Promise | T} [options.loader]
+ */
+ constructor(initialItemState = null, callbacks = {}, options = {}) {
+ this._initialItem = initialItemState !== undefined ? initialItemState : null;
+ this._options = options || {};
+ this._name = options.name ?? null;
+ this._loader = options.loader ?? null;
+
+ /** @type {Map>} */
+ this._items = new Map();
+
+ /** @type {Map>} */
+ this._inFlight = new Map();
+
+ /** @type {Map>} */
+ this._keyedSubs = new Map();
+
+ /** @type {Map