Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ node_modules/
.cache
dist/
coverage/
.npmrc
./npmrc
8 changes: 0 additions & 8 deletions .npmrc

This file was deleted.

5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- v11+ `sync(path?, config?)` proxy mode for mutable-like reads/writes (e.g. `user.name = 'Thomas'`)
- Proxy helpers: `.batch()/.commit()/.cancel()` and `.with()` for tagging and scoped middleware

### Changed
- `sync()` now supports two modes:
- `sync(path?, config?)` returns a reactive proxy by default (recommended)
- `sync(configObject)` remains supported for v10 unidirectional binding (legacy; warns once per store instance)

### Fixed

Expand Down
92 changes: 91 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,97 @@ console.log(userStore.getProp('profile.preferences')); // { theme: 'light', noti
<a id="sync---unidirectional-data-binding"></a>
## 🔗 Sync - Unidirectional Data Binding

One of Substate's most powerful features is the `sync` method, which provides unidirectional data binding between your store and any target object (like UI models, form objects, or API payloads).
Substate supports two `sync()` modes:

- **Proxy mode (v11+, recommended)**: `sync(path?, config?)` returns a reactive proxy for a state slice. Reads always reflect the latest store state, and writes auto-commit via `updateState()`.
- **Legacy binding mode (v10 compatible)**: `sync(configObject)` keeps the unidirectional binding behavior. It remains supported, but logs a one-time `console.warn` per store instance to encourage migration.

### Proxy Sync (v11+): Reactive Proxy

```typescript
import { createStore } from 'substate';

const store = createStore({
name: 'UserStore',
state: { user: { name: 'John', settings: { theme: 'light' } } }
});

const user = store.sync('user'); // reactive proxy

console.log(user.name); // 'John'
user.name = 'Thomas'; // updateState({ 'user.name': 'Thomas' })

// Nested writes work
user.settings.theme = 'dark';

// Batch multiple writes
const batch = user.batch();
batch.name = 'Thomas R.';
batch.settings.theme = 'light';
batch.commit(); // one updateState call

// Tag/type/deep and scoped middleware for next write(s)
user.with({ $tag: 'profile-save', $type: 'USER_EDIT', $deep: true }).name = 'Tom';

// Or callback form (auto-batch + auto-commit once)
user.with({ $tag: 'profile-save' }, (draft) => {
draft.name = 'Tom';
});
```

### Primitive Sync (v11+): `.value`

For single primitive fields, use `.value` on the proxy returned by `sync()`:

```typescript
const age = store.sync<number>('age');
console.log(age.value); // 25
age.value = 30;
```

### Root Sync (v11+): `sync()` with no args

Calling `sync()` without a path returns a proxy for the **entire state**.

```typescript
const state = store.sync(); // root proxy
console.log(state.value); // full current state snapshot
console.log(state.user.name); // nested read
state.user.name = 'Thomas'; // nested write
```

### `with()` semantics (v11+)

`with()` applies **tags/metadata + scoped middleware** to the **next write** (one assignment) or to the single commit produced by the callback form.

```typescript
// Applies to this one write only
store
.sync('user')
.with(
{
$tag: 'profile-save',
before: [(store, action) => {}],
after: [(store, action) => {}],
}
)
.name = 'Tom';

// Callback form: auto-batch and apply once at commit
store.sync('user').with({ $tag: 'profile-save' }, (draft) => {
draft.name = 'Tom';
draft.settings.theme = 'dark';
});
```

### `batch()` + `with()` (v11+)

- If you call `with(...)` and then do **one immediate assignment**, it applies to that assignment and is cleared.
- If you call `batch()` and then `with(...)`, the attributes apply to the **commit** (the grouped update).

### Legacy Sync (v10): Unidirectional Data Binding

This is the classic sync API that binds store state to a target object (unidirectional). It remains supported.

### Basic Sync Example

Expand Down
102 changes: 101 additions & 1 deletion documentation/_sync.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,107 @@
<a id="sync---unidirectional-data-binding"></a>
## 🔗 Sync - Unidirectional Data Binding

One of Substate's most powerful features is the `sync` method, which provides unidirectional data binding between your store and any target object (like UI models, form objects, or API payloads).
Substate supports two `sync()` modes:

- **Proxy mode (v11+, recommended)**: `sync(path?, config?)` returns a reactive proxy for a state slice. Reads always reflect the latest store state, and writes auto-commit via `updateState()`.
- **Legacy binding mode (v10 compatible)**: `sync(configObject)` keeps the unidirectional binding behavior. It remains supported, but logs a one-time `console.warn` per store instance to encourage migration.

### Proxy Sync (v11+): Reactive Proxy

```typescript
import { createStore } from 'substate';

const store = createStore({
name: 'UserStore',
state: { user: { name: 'John', settings: { theme: 'light' } } }
});

const user = store.sync('user'); // reactive proxy

console.log(user.name); // 'John'
user.name = 'Thomas'; // updateState({ 'user.name': 'Thomas' })

// Nested writes work
user.settings.theme = 'dark';

// Batch multiple writes
const batch = user.batch();
batch.name = 'Thomas R.';
batch.settings.theme = 'light';
batch.commit(); // one updateState call

// Tag/type/deep and scoped middleware for next write(s)
user.with({ $tag: 'profile-save', $type: 'USER_EDIT', $deep: true }).name = 'Tom';

// Or callback form (auto-batch + auto-commit once)
user.with({ $tag: 'profile-save' }, (draft) => {
draft.name = 'Tom';
});

### Primitive Sync (v11+): `.value`

For single primitive fields, use `.value` on the proxy returned by `sync()`:

```typescript
const age = store.sync<number>('age');
console.log(age.value); // 25
age.value = 30;
```

### Proxy Sync Config

```typescript
type TProxySyncConfig = {
beforeUpdate?: UpdateMiddleware[];
afterUpdate?: UpdateMiddleware[];
};
```

### Root Sync (v11+): `sync()` with no args

Calling `sync()` without a path returns a proxy for the **entire state**.

```typescript
const state = store.sync(); // root proxy
console.log(state.value); // full current state snapshot
console.log(state.user.name); // nested read
state.user.name = 'Thomas'; // nested write
```

### `with()` semantics (v11+)

`with()` applies **tags/metadata + scoped middleware** to the **next write** (one assignment) or to the single commit produced by the callback form.

```typescript
// Applies to this one write only
store
.sync('user')
.with(
{
$tag: 'profile-save',
before: [(store, action) => {}],
after: [(store, action) => {}],
}
)
.name = 'Tom';

// Callback form: auto-batch and apply once at commit
store.sync('user').with({ $tag: 'profile-save' }, (draft) => {
draft.name = 'Tom';
draft.settings.theme = 'dark';
});
```

### `batch()` + `with()` (v11+)

- If you call `with(...)` and then do **one immediate assignment**, it applies to that assignment and is cleared.
- If you call `batch()` and then `with(...)`, the attributes apply to the **commit** (the grouped update).

---

### Legacy Sync Example (v10): Unidirectional Data Binding

This is the classic sync API that binds store state to a target object (unidirectional). It remains supported.

### Basic Sync Example

Expand Down
2 changes: 1 addition & 1 deletion integration-tests/lit-vite/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion integration-tests/preact-vite/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion integration-tests/react-vite/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "substate",
"version": "10.0.3",
"version": "10.1.0",
"description": "Pub/Sub pattern with State Management",
"type": "module",
"main": "dist/index.umd.js",
Expand Down
6 changes: 5 additions & 1 deletion src/core/Substate/Substate.interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { IPubSub } from '../PubSub/PubSub.interface';
import type { TSyncConfig, TUpdateMiddleware, TUserState } from './interfaces';
import type { TProxySyncConfig, TSyncConfig, TUpdateMiddleware, TUserState } from './interfaces';
import type { TSubstateSyncProxy } from './helpers/createSliceProxy';

interface ISyncInstance {
unsync: () => void;
Expand Down Expand Up @@ -29,7 +30,10 @@ interface ISubstate<TState extends TUserState = TUserState> extends IPubSub {
getProp(prop: string): unknown;
resetState(): void;
updateState(action: Partial<TState>): void;
// v10 legacy overload
sync(config: TSyncConfig): ISyncInstance;
// v11+ proxy overload
sync<T = unknown>(path?: string, config?: TProxySyncConfig): TSubstateSyncProxy<T>;
clearHistory(): void;
limitHistory(maxSize: number): void;
getMemoryUsage(): { stateCount: number; taggedCount: number; estimatedSizeKB: number | null };
Expand Down
33 changes: 32 additions & 1 deletion src/core/Substate/Substate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { EVENTS } from '../consts';
import { PubSub } from '../PubSub/PubSub';
import { isDeep } from './helpers/isDeep';
import { requiresByString } from './helpers/requiresByString';
import type { TSubstateSyncProxy } from './helpers/createSliceProxy';
import { createSliceProxy } from './helpers/createSliceProxy';
import type {
ISyncContext,
TProxySyncConfig,
TSyncConfig,
TSyncMiddleware,
TUpdateMiddleware,
Expand All @@ -30,6 +33,7 @@ class Substate<TState extends TUserState = TUserState> extends PubSub implements
taggedStates: Map<string, { stateIndex: number; state: TState }>;
private _hasMiddleware: boolean;
private _hasTaggedStates: boolean;
private _hasWarnedLegacySync: boolean;

constructor(conf: ISubstateConfig<TState> = {} as ISubstateConfig<TState>) {
super();
Expand All @@ -46,6 +50,7 @@ class Substate<TState extends TUserState = TUserState> extends PubSub implements
// Pre-compute middleware flags for performance
this._hasMiddleware = this.beforeUpdate.length > 0 || this.afterUpdate.length > 0;
this._hasTaggedStates = false;
this._hasWarnedLegacySync = false;

if (conf.state) this.stateStorage.push(conf.state);

Expand Down Expand Up @@ -248,7 +253,33 @@ class Substate<TState extends TUserState = TUserState> extends PubSub implements
*
* @since 10.0.0
*/
public sync(config: TSyncConfig): ISyncInstance {
public sync<T = unknown>(path?: string, config?: TProxySyncConfig): TSubstateSyncProxy<T>;
public sync(config: TSyncConfig): ISyncInstance;
public sync<T = unknown>(
pathOrConfig?: string | TSyncConfig,
config?: TProxySyncConfig
): TSubstateSyncProxy<T> | ISyncInstance {
// Backward compatibility: if first argument is an object, use legacy sync implementation
if (pathOrConfig && typeof pathOrConfig === 'object') {
// Warn once per store instance to encourage migration
if (!this._hasWarnedLegacySync) {
this._hasWarnedLegacySync = true;
// eslint-disable-next-line no-console
console.warn(
`[Substate v11+] Detected legacy sync(configObject) usage. ` +
`This remains supported, but the recommended API is sync(path?, config?) which returns a reactive proxy.`
);
}
return this.syncLegacy(pathOrConfig as TSyncConfig);
}

const path = typeof pathOrConfig === 'string' ? pathOrConfig : undefined;
const proxyConfig = config ?? {};

return createSliceProxy<T>(this as unknown as ISubstate, path, proxyConfig);
}

private syncLegacy(config: TSyncConfig): ISyncInstance {
// Destructure configuration with defaults
const {
readerObj,
Expand Down
Loading
Loading