;
}
diff --git a/packages/classy-store/src/frameworks/react/react.ts b/packages/classy-store/src/frameworks/react/react.ts
index 2339361..cf8d46e 100644
--- a/packages/classy-store/src/frameworks/react/react.ts
+++ b/packages/classy-store/src/frameworks/react/react.ts
@@ -8,22 +8,32 @@ import {
import {snapshot} from '../../snapshot/snapshot';
import type {Snapshot} from '../../types';
+type EqualityFn = (a: T, b: T) => boolean;
+
+export type UseClassyStoreOptions = {
+ sync?: boolean;
+};
+
+export type UseClassyStoreSelectorOptions = UseClassyStoreOptions & {
+ isEqual?: EqualityFn;
+};
+
// ── Overloads ─────────────────────────────────────────────────────────────────
/**
* Subscribe to a store proxy with an explicit selector.
*
* Re-renders only when the selected value changes (compared via `Object.is`
- * by default, or a custom `isEqual`).
+ * by default, or `options.isEqual`).
*
* @param proxyStore - A reactive proxy created by `createClassyStore()`.
* @param selector - Picks data from the immutable snapshot.
- * @param isEqual - Optional custom equality function (default: `Object.is`).
+ * @param options - Controls equality and subscriber notification timing.
*/
export function useClassyStore(
proxyStore: T,
selector: (snap: Snapshot) => S,
- isEqual?: (a: S, b: S) => boolean,
+ options?: UseClassyStoreSelectorOptions,
): S;
/**
@@ -33,23 +43,40 @@ export function useClassyStore(
* The component only re-renders when a property it actually read changes.
*
* @param proxyStore - A reactive proxy created by `createClassyStore()`.
+ * @param options - Controls subscriber notification timing.
*/
-export function useClassyStore(proxyStore: T): Snapshot;
+export function useClassyStore(
+ proxyStore: T,
+ options?: UseClassyStoreOptions,
+): Snapshot;
// ── Implementation ────────────────────────────────────────────────────────────
export function useClassyStore(
proxyStore: T,
- selector?: (snap: Snapshot) => S,
- isEqual?: (a: S, b: S) => boolean,
+ selectorOrOptions?: ((snap: Snapshot) => S) | UseClassyStoreOptions,
+ selectorOptions?: UseClassyStoreSelectorOptions,
): Snapshot | S {
// Validate that the argument is actually a store proxy (throws if not).
getInternal(proxyStore);
+ const selector =
+ typeof selectorOrOptions === 'function' ? selectorOrOptions : undefined;
+ const options:
+ | UseClassyStoreOptions
+ | UseClassyStoreSelectorOptions
+ | undefined =
+ typeof selectorOrOptions === 'function'
+ ? selectorOptions
+ : selectorOrOptions;
+ const sync = options?.sync === true;
+ const isEqual = selectorOptions?.isEqual;
+
// Stable subscribe function (internal identity never changes for a given store).
const subscribe = useCallback(
- (onStoreChange: () => void) => coreSubscribe(proxyStore, onStoreChange),
- [proxyStore],
+ (onStoreChange: () => void) =>
+ coreSubscribe(proxyStore, onStoreChange, sync ? {sync: true} : undefined),
+ [proxyStore, sync],
);
// ── Refs used by both modes (always allocated to satisfy Rules of Hooks) ──
@@ -97,7 +124,7 @@ export function useClassyStore(
*
* Fast-paths when the snapshot reference hasn't changed (O(1)). Otherwise
* runs the selector against the new snapshot and compares the result to the
- * previous one via `Object.is` (or a custom `isEqual`). Returns the previous
+ * previous one via `Object.is` (or `options.isEqual`). Returns the previous
* result reference when equal, preventing unnecessary React re-renders.
*
* Pure function -- no hooks, safe to call from `useSyncExternalStore`.
@@ -108,7 +135,7 @@ function getSelectorSnapshot(
resultRef: React.RefObject,
hasResultRef: React.RefObject,
selector: (snap: Snapshot) => S,
- isEqual?: (a: S, b: S) => boolean,
+ isEqual?: EqualityFn,
): S {
const nextSnap = snapshot(proxyStore);
@@ -207,19 +234,19 @@ export function createStoreHook(proxyStore: T) {
// Fail fast at creation time rather than on first render.
getInternal(proxyStore);
- function useStore(): Snapshot;
+ function useStore(options?: UseClassyStoreOptions): Snapshot;
function useStore(
selector: (snap: Snapshot) => S,
- isEqual?: (a: S, b: S) => boolean,
+ options?: UseClassyStoreSelectorOptions,
): S;
function useStore(
- selector?: (snap: Snapshot) => S,
- isEqual?: (a: S, b: S) => boolean,
- ) {
+ selectorOrOptions?: ((snap: Snapshot) => S) | UseClassyStoreOptions,
+ options?: UseClassyStoreSelectorOptions,
+ ): Snapshot | S {
return useClassyStore(
proxyStore,
- selector as (snap: Snapshot) => S,
- isEqual,
+ selectorOrOptions as (snap: Snapshot) => S,
+ options,
);
}
return useStore;
diff --git a/packages/classy-store/src/index.ts b/packages/classy-store/src/index.ts
index 9b86642..18b9fe0 100644
--- a/packages/classy-store/src/index.ts
+++ b/packages/classy-store/src/index.ts
@@ -7,5 +7,5 @@
*/
export {createClassyStore, getVersion, subscribe} from './core/core';
export {snapshot} from './snapshot/snapshot';
-export type {Snapshot} from './types';
+export type {Snapshot, SubscribeOptions} from './types';
export {shallowEqual} from './utils/equality/equality';
diff --git a/packages/classy-store/src/types.ts b/packages/classy-store/src/types.ts
index 3dc944c..02ebe5f 100644
--- a/packages/classy-store/src/types.ts
+++ b/packages/classy-store/src/types.ts
@@ -73,6 +73,12 @@ export type ComputedEntry = {
deps: DepEntry[];
};
+/** Options that control how store subscribers are notified. */
+export type SubscribeOptions = {
+ /** Notify this subscriber immediately instead of waiting for the batched microtask. */
+ sync?: boolean;
+};
+
// ── Store internal bookkeeping ───────────────────────────────────────────────
/** Internal bookkeeping for a store proxy, stored in a WeakMap keyed by the proxy. */
@@ -81,8 +87,10 @@ export type StoreInternal = {
target: object;
/** Monotonically increasing version counter. */
version: number;
- /** Set of subscriber callbacks notified on mutation. */
- listeners: Set<() => void>;
+ /** Subscriber callbacks notified once per batched mutation turn. */
+ batchedListeners: Set<() => void>;
+ /** Subscriber callbacks notified immediately on each mutation. */
+ syncListeners: Set<() => void>;
/** Cached child proxies for nested plain objects/arrays (keyed by property name). */
childProxies: Map;
/** Cached child internals for propagating version bumps up the tree. */
diff --git a/packages/classy-store/src/utils/index.ts b/packages/classy-store/src/utils/index.ts
index 4eaa362..3a9cd0a 100644
--- a/packages/classy-store/src/utils/index.ts
+++ b/packages/classy-store/src/utils/index.ts
@@ -6,6 +6,7 @@
* @module @codebelt/classy-store/utils
*/
+export type {SubscribeOptions} from '../types';
export type {DevtoolsOptions} from './devtools/devtools';
export {devtools} from './devtools/devtools';
export type {HistoryHandle, HistoryOptions} from './history/history';
diff --git a/packages/classy-store/src/utils/subscribe-key/subscribe-key.test.ts b/packages/classy-store/src/utils/subscribe-key/subscribe-key.test.ts
index 9243587..e8eec3a 100644
--- a/packages/classy-store/src/utils/subscribe-key/subscribe-key.test.ts
+++ b/packages/classy-store/src/utils/subscribe-key/subscribe-key.test.ts
@@ -217,6 +217,25 @@ describe('subscribeKey', () => {
expect(cb2).toHaveBeenCalledTimes(1);
});
+ it('supports sync notifications', async () => {
+ class Store {
+ count = 0;
+ }
+
+ const store = createClassyStore(new Store());
+ const cb = mock((_value: number, _prev: number) => {});
+
+ subscribeKey(store, 'count', cb, {sync: true});
+
+ store.count = 1;
+
+ expect(cb).toHaveBeenCalledTimes(1);
+ expect(cb).toHaveBeenCalledWith(1, 0);
+
+ await tick();
+ expect(cb).toHaveBeenCalledTimes(1);
+ });
+
it('unsubscribing one subscriber does not affect others', async () => {
class Store {
count = 0;
diff --git a/packages/classy-store/src/utils/subscribe-key/subscribe-key.ts b/packages/classy-store/src/utils/subscribe-key/subscribe-key.ts
index 20dd488..06dfb9e 100644
--- a/packages/classy-store/src/utils/subscribe-key/subscribe-key.ts
+++ b/packages/classy-store/src/utils/subscribe-key/subscribe-key.ts
@@ -1,6 +1,6 @@
import {subscribe} from '../../core/core';
import {snapshot} from '../../snapshot/snapshot';
-import type {Snapshot} from '../../types';
+import type {Snapshot, SubscribeOptions} from '../../types';
/**
* Subscribe to changes on a single property of a store proxy.
@@ -12,6 +12,7 @@ import type {Snapshot} from '../../types';
* @param proxyStore - A reactive proxy created by `createClassyStore()`.
* @param key - The property key to watch.
* @param callback - Called with `(value, previousValue)` when the property changes.
+ * @param options - Controls subscriber notification timing.
* @returns An unsubscribe function.
*/
export function subscribeKey<
@@ -21,17 +22,22 @@ export function subscribeKey<
proxyStore: T,
key: K,
callback: (value: Snapshot[K], previousValue: Snapshot[K]) => void,
+ options?: SubscribeOptions,
): () => void {
let previousValue = snapshot(proxyStore)[key];
- return subscribe(proxyStore, () => {
- const snap = snapshot(proxyStore);
- const currentValue = snap[key];
+ return subscribe(
+ proxyStore,
+ () => {
+ const snap = snapshot(proxyStore);
+ const currentValue = snap[key];
- if (!Object.is(currentValue, previousValue)) {
- const prev = previousValue;
- previousValue = currentValue;
- callback(currentValue, prev);
- }
- });
+ if (!Object.is(currentValue, previousValue)) {
+ const prev = previousValue;
+ previousValue = currentValue;
+ callback(currentValue, prev);
+ }
+ },
+ options,
+ );
}
diff --git a/website/docs/ARCHITECTURE.md b/website/docs/ARCHITECTURE.md
index bfb3893..0176b1e 100644
--- a/website/docs/ARCHITECTURE.md
+++ b/website/docs/ARCHITECTURE.md
@@ -14,10 +14,11 @@ flowchart TB
subgraph layer1 ["Layer 1: Write Proxy (core.ts)"]
storeFn["createClassyStore(instance)"]
- SetTrap["SET trap: forward write → bump version → schedule notify"]
+ SetTrap["SET trap: forward write → bump version → notify subscribers"]
GetTrap["GET trap: return value, bind methods, lazy-wrap nested objects, memoize getters"]
DeleteTrap["DELETE trap: same as SET"]
Batch["queueMicrotask: coalesce synchronous mutations → 1 notification"]
+ SyncNotify["sync listeners: notify immediately per mutation"]
DeepProxy["Lazy deep proxy: nested objects/arrays wrapped on first access"]
end
@@ -46,6 +47,7 @@ flowchart TB
storeFn --> SetTrap
storeFn --> GetTrap
storeFn --> DeleteTrap
+ SetTrap --> SyncNotify
SetTrap --> Batch
Batch --> SnapshotFn
GetTrap --> DeepProxy
@@ -118,7 +120,7 @@ PERSIST_ARCHITECTURE.md # Persist utility internals
### Overview
-The `createClassyStore()` function wraps a class instance in an ES6 Proxy. All mutations — property writes, array operations, nested object changes — are intercepted and batched into a single notification per microtask.
+The `createClassyStore()` function wraps a class instance in an ES6 Proxy. All mutations — property writes, array operations, nested object changes — are intercepted. Subscribers are batched by default into a single notification per microtask, with an opt-in synchronous path for controlled React inputs and other code that must observe each mutation immediately.
### Data Flow: Mutation → Notification
@@ -128,20 +130,23 @@ sequenceDiagram
participant Proxy as Write Proxy
participant Target as Raw Target
participant Internal as StoreInternal
+ participant Sync as Sync Subscribers
participant Micro as queueMicrotask
- participant Listeners as Subscribers
+ participant Batched as Batched Subscribers
User->>Proxy: store.count = 5
Proxy->>Target: Reflect.set(target, 'count', 5)
Proxy->>Internal: bumpVersion(internal) up to root
Proxy->>Internal: snapshotCache = null (invalidate)
- Proxy->>Micro: scheduleNotify() (if not already scheduled)
+ Proxy->>Sync: notify sync listeners immediately
+ Proxy->>Micro: schedule batched notify (if not already scheduled)
Note over Micro: Coalesces all sync mutations
User->>Proxy: store.name = 'new'
Proxy->>Target: Reflect.set(target, 'name', 'new')
Proxy->>Internal: bumpVersion (version increments again)
+ Proxy->>Sync: notify sync listeners again
Note over Micro: Still same microtask batch
- Micro->>Listeners: notify all (once)
+ Micro->>Batched: notify batched listeners once
```
### Internal State Storage
@@ -152,12 +157,12 @@ All internal bookkeeping is stored in a `WeakMap`:
type StoreInternal = {
target: object; // Raw class instance
version: number; // Monotonically increasing
- listeners: Set<() => void>; // Subscriber callbacks
+ batchedListeners: Set<() => void>; // Subscribers notified once per microtask
+ syncListeners: Set<() => void>; // Subscribers notified immediately per mutation
childProxies: Map; // Cached child proxies
childInternals: Map;
parent: StoreInternal | null; // For version propagation
notifyScheduled: boolean; // Batch dedup flag
- snapshotCache: [number, object] | null; // Version-stamped snapshot cache
computedCache: Map; // Memoized getter cache
};
```
@@ -169,7 +174,7 @@ type StoreInternal = {
2. Clean up child proxy if the property is being replaced
3. Forward write to raw target via `Reflect.set`
4. Bump version for this node and all ancestors
-5. Schedule notification via `queueMicrotask` (deduped)
+5. Notify sync listeners immediately, then schedule batched listeners via `queueMicrotask` (deduped)
**GET trap (priority order):**
1. **Memoized getter detection** — walk prototype chain with `Object.getOwnPropertyDescriptor`. If a getter is found, call `evaluateComputed()` which checks dependency validity and returns the cached result or re-evaluates with dependency tracking.
@@ -177,7 +182,7 @@ type StoreInternal = {
3. **Nested objects/arrays** — if value passes `canProxy()` (plain object or array), lazily wrap in a child proxy. Child proxies are cached in `childProxies` Map. Also records a dependency if a getter is currently being tracked.
4. **Primitives** — return as-is. Also records a dependency if a getter is currently being tracked.
-**DELETE trap:** Same pattern as SET — clean up child proxy, delete from target, bump version, schedule notify.
+**DELETE trap:** Same pattern as SET — clean up child proxy, delete from target, bump version, notify sync listeners, and schedule batched listeners.
### Batching via `queueMicrotask`
@@ -190,6 +195,8 @@ store.items.push('a')
→ microtask fires → notifies listeners ONCE
```
+Subscribers opt into immediate timing with `{sync: true}`. Sync subscribers run after version propagation and before the batched microtask. They are intentionally not deduped: two writes mean two sync notifications. React controlled inputs use this timing so `useSyncExternalStore` can observe the new external-store value during the input event turn, preserving caret position and IME composition.
+
### Version Propagation
When a nested property mutates, versions bump from the mutated node up to the root:
@@ -336,7 +343,7 @@ flowchart TD
### Overview
-`useClassyStore` uses `useSyncExternalStore` for tear-free React integration. It supports two modes:
+`useClassyStore` uses `useSyncExternalStore` for tear-free React integration. Subscriptions are batched by default, and can opt into synchronous notification with `{sync: true}` for controlled inputs that need React to observe the store update during the input event turn. It supports two modes:
### Mode 1: Selector
@@ -367,9 +374,17 @@ sequenceDiagram
**Equality chain:**
1. Same snapshot reference? → skip selector entirely (O(1))
-2. Run selector → compare result with `Object.is` (or custom `isEqual`)
+2. Run selector → compare result with `Object.is` (or `options.isEqual`)
3. Same result → return previous reference (no re-render)
+**Controlled input timing:**
+
+```typescript
+const name = useClassyStore(formStore, (state) => state.name, {sync: true});
+```
+
+The core store still batches subscribers by default with `queueMicrotask`. A controlled input subscription can opt into sync timing so the `useSyncExternalStore` callback runs in the same mutation turn as the input event, preserving caret position and IME composition.
+
### Mode 2: Auto-tracked (selectorless)
```typescript
diff --git a/website/docs/TUTORIAL.md b/website/docs/TUTORIAL.md
index 4f065a0..2740d70 100644
--- a/website/docs/TUTORIAL.md
+++ b/website/docs/TUTORIAL.md
@@ -138,7 +138,7 @@ import {useClassyStore} from '@codebelt/classy-store/react';
const active = useClassyStore(
todoStore,
(state) => state.items.filter((item) => !item.done),
- shallowEqual,
+ {isEqual: shallowEqual},
);
```
@@ -162,6 +162,41 @@ const remaining = useClassyStore(todoStore, (state) => state.remaining);
- **Use auto-tracked mode** when you'd need 3+ selectors in one component or when you're exploring the API.
- **Add `shallowEqual`** when your selector returns objects/arrays and you're seeing unnecessary re-renders.
+### Controlled inputs with `sync`
+
+Classy Store batches subscriber notifications by default. That is the right default for most UI because multiple synchronous mutations collapse into one settled update.
+
+Controlled React inputs are the important exception. React needs the latest external-store value during the input event turn so it can preserve the DOM value, caret position, and IME composition state. If the store waits until the next microtask to notify React, typing in the middle of a controlled input can move the caret or interrupt composition for languages such as Korean, Japanese, and Chinese.
+
+Use `{sync: true}` for the specific store reads that control form fields:
+
+```tsx
+class ProfileFormStore {
+ name = '';
+
+ setName(value: string) {
+ this.name = value;
+ }
+}
+
+const profileFormStore = createClassyStore(new ProfileFormStore());
+
+function NameInput() {
+ const name = useClassyStore(profileFormStore, (state) => state.name, {
+ sync: true,
+ });
+
+ return (
+ profileFormStore.setName(event.target.value)}
+ />
+ );
+}
+```
+
+Use `sync` narrowly for controlled ``, `