feat(smartstate): Add middleware, computed, batching, selector memoization, AbortSignal support, and Web Component Context Protocol provider
This commit is contained in:
465
readme.md
465
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartstate
|
||||
|
||||
A powerful TypeScript library for elegant state management using RxJS and reactive programming patterns 🚀
|
||||
A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support 🚀
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -8,306 +8,311 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
|
||||
## Install
|
||||
|
||||
To install `@push.rocks/smartstate`, you can use pnpm, npm, or yarn:
|
||||
```bash
|
||||
pnpm install @push.rocks/smartstate --save
|
||||
```
|
||||
|
||||
Or with npm:
|
||||
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm install @push.rocks/smartstate --save
|
||||
|
||||
# Using npm
|
||||
npm install @push.rocks/smartstate --save
|
||||
|
||||
# Using yarn
|
||||
yarn add @push.rocks/smartstate
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The `@push.rocks/smartstate` library provides an elegant way to handle state within your JavaScript or TypeScript projects, leveraging the power of Reactive Extensions (RxJS) and a structured state management strategy.
|
||||
|
||||
### Getting Started
|
||||
|
||||
Import the necessary components from the library:
|
||||
### Quick Start
|
||||
|
||||
```typescript
|
||||
import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate';
|
||||
import { Smartstate } from '@push.rocks/smartstate';
|
||||
|
||||
// 1. Define your state part names
|
||||
type AppParts = 'user' | 'settings';
|
||||
|
||||
// 2. Create the root instance
|
||||
const state = new Smartstate<AppParts>();
|
||||
|
||||
// 3. Create state parts with initial values
|
||||
const userState = await state.getStatePart<{ name: string; loggedIn: boolean }>('user', {
|
||||
name: '',
|
||||
loggedIn: false,
|
||||
});
|
||||
|
||||
// 4. Subscribe to changes
|
||||
userState.select((s) => s.name).subscribe((name) => {
|
||||
console.log('Name changed:', name);
|
||||
});
|
||||
|
||||
// 5. Update state
|
||||
await userState.setState({ name: 'Alice', loggedIn: true });
|
||||
```
|
||||
|
||||
### Creating a SmartState Instance
|
||||
### State Parts & Init Modes
|
||||
|
||||
`Smartstate` acts as the container for your state parts. Think of it as the root of your state management structure:
|
||||
State parts are isolated, typed units of state. Create them with `getStatePart()`:
|
||||
|
||||
```typescript
|
||||
const myAppSmartState = new Smartstate<YourStatePartNamesEnum>();
|
||||
const part = await state.getStatePart<IMyState>(name, initialState, initMode);
|
||||
```
|
||||
|
||||
### Understanding Init Modes
|
||||
| Init Mode | Behavior |
|
||||
|-----------|----------|
|
||||
| `'soft'` (default) | Returns existing if found, creates new otherwise |
|
||||
| `'mandatory'` | Throws if state part already exists |
|
||||
| `'force'` | Always creates new, overwrites existing |
|
||||
| `'persistent'` | Like `'soft'` but persists to IndexedDB via WebStore |
|
||||
|
||||
When creating state parts, you can specify different initialization modes:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `'soft'` | Default. Returns existing state part if it exists, creates new if not |
|
||||
| `'mandatory'` | Requires state part to not exist, throws error if it does |
|
||||
| `'force'` | Always creates new state part, overwriting any existing one |
|
||||
| `'persistent'` | Like 'soft' but with WebStore persistence using IndexedDB |
|
||||
|
||||
### Defining State Parts
|
||||
|
||||
State parts represent separable sections of your state, making it easier to manage and modularize. Define state part names using either enums or string literal types:
|
||||
#### Persistent State
|
||||
|
||||
```typescript
|
||||
// Option 1: Using enums
|
||||
enum AppStateParts {
|
||||
UserState = 'UserState',
|
||||
SettingsState = 'SettingsState'
|
||||
}
|
||||
|
||||
// Option 2: Using string literal types (simpler approach)
|
||||
type AppStateParts = 'UserState' | 'SettingsState';
|
||||
const settings = await state.getStatePart('settings', { theme: 'dark' }, 'persistent');
|
||||
// Automatically saved to IndexedDB. On next app load, persisted values override defaults.
|
||||
```
|
||||
|
||||
Create a state part within your `Smartstate` instance:
|
||||
### Selecting State
|
||||
|
||||
`select()` returns an RxJS Observable that emits the current value immediately and on every change:
|
||||
|
||||
```typescript
|
||||
interface IUserState {
|
||||
isLoggedIn: boolean;
|
||||
username?: string;
|
||||
}
|
||||
// Full state
|
||||
userState.select().subscribe((state) => console.log(state));
|
||||
|
||||
const userStatePart = await myAppSmartState.getStatePart<IUserState>(
|
||||
AppStateParts.UserState,
|
||||
{ isLoggedIn: false }, // Initial state
|
||||
'soft' // Init mode (optional, defaults to 'soft')
|
||||
// Derived value via selector
|
||||
userState.select((s) => s.name).subscribe((name) => console.log(name));
|
||||
```
|
||||
|
||||
Selectors are **memoized** — calling `select(fn)` with the same function reference returns the same cached Observable, shared across all subscribers.
|
||||
|
||||
#### AbortSignal Support
|
||||
|
||||
Clean up subscriptions without manual `unsubscribe()`:
|
||||
|
||||
```typescript
|
||||
const controller = new AbortController();
|
||||
|
||||
userState.select((s) => s.name, { signal: controller.signal }).subscribe((name) => {
|
||||
console.log(name); // stops receiving when aborted
|
||||
});
|
||||
|
||||
// Later: clean up
|
||||
controller.abort();
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
Actions provide controlled, named state mutations:
|
||||
|
||||
```typescript
|
||||
const login = userState.createAction<{ name: string }>(async (statePart, payload) => {
|
||||
return { ...statePart.getState(), name: payload.name, loggedIn: true };
|
||||
});
|
||||
|
||||
// Two equivalent ways to dispatch:
|
||||
await login.trigger({ name: 'Alice' });
|
||||
await userState.dispatchAction(login, { name: 'Alice' });
|
||||
```
|
||||
|
||||
### Middleware
|
||||
|
||||
Intercept every `setState()` call to transform, validate, or reject state changes:
|
||||
|
||||
```typescript
|
||||
// Logging middleware
|
||||
userState.addMiddleware((newState, oldState) => {
|
||||
console.log('State changing from', oldState, 'to', newState);
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Validation middleware — throw to reject the change
|
||||
userState.addMiddleware((newState) => {
|
||||
if (!newState.name) throw new Error('Name is required');
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Transform middleware
|
||||
userState.addMiddleware((newState) => {
|
||||
return { ...newState, name: newState.name.trim() };
|
||||
});
|
||||
|
||||
// Removal — addMiddleware returns a dispose function
|
||||
const remove = userState.addMiddleware(myMiddleware);
|
||||
remove(); // middleware no longer runs
|
||||
```
|
||||
|
||||
Middleware runs sequentially in insertion order. If any middleware throws, the state is unchanged (atomic).
|
||||
|
||||
### Computed / Derived State
|
||||
|
||||
Derive reactive values from one or more state parts:
|
||||
|
||||
```typescript
|
||||
import { computed } from '@push.rocks/smartstate';
|
||||
|
||||
const userState = await state.getStatePart('user', { firstName: 'Jane', lastName: 'Doe' });
|
||||
const settingsState = await state.getStatePart('settings', { locale: 'en' });
|
||||
|
||||
// Standalone function
|
||||
const greeting$ = computed(
|
||||
[userState, settingsState],
|
||||
(user, settings) => `Hello, ${user.firstName} (${settings.locale})`,
|
||||
);
|
||||
|
||||
greeting$.subscribe((msg) => console.log(msg));
|
||||
// => "Hello, Jane (en)"
|
||||
|
||||
// Also available as a method on Smartstate:
|
||||
const greeting2$ = state.computed([userState, settingsState], (user, settings) => /* ... */);
|
||||
```
|
||||
|
||||
### Subscribing to State Changes
|
||||
Computed observables are **lazy** — they only subscribe to sources when someone subscribes to them.
|
||||
|
||||
Subscribe to changes in a state part to perform actions accordingly:
|
||||
### Batch Updates
|
||||
|
||||
Update multiple state parts without intermediate notifications:
|
||||
|
||||
```typescript
|
||||
// The select() method automatically filters out undefined states
|
||||
userStatePart.select().subscribe((currentState) => {
|
||||
console.log(`User Logged In: ${currentState.isLoggedIn}`);
|
||||
const partA = await state.getStatePart('a', { value: 1 });
|
||||
const partB = await state.getStatePart('b', { value: 2 });
|
||||
|
||||
// Subscribers see no updates during the batch — only after it completes
|
||||
await state.batch(async () => {
|
||||
await partA.setState({ value: 10 });
|
||||
await partB.setState({ value: 20 });
|
||||
// Notifications are deferred here
|
||||
});
|
||||
```
|
||||
// Both subscribers now fire with their new values
|
||||
|
||||
Select a specific part of your state with a selector function:
|
||||
|
||||
```typescript
|
||||
userStatePart.select(state => state.username).subscribe((username) => {
|
||||
if (username) {
|
||||
console.log(`Current user: ${username}`);
|
||||
}
|
||||
// Nested batches are supported — flush happens at the outermost level
|
||||
await state.batch(async () => {
|
||||
await partA.setState({ value: 100 });
|
||||
await state.batch(async () => {
|
||||
await partB.setState({ value: 200 });
|
||||
});
|
||||
// Still deferred
|
||||
});
|
||||
// Now both fire
|
||||
```
|
||||
|
||||
### Modifying State with Actions
|
||||
### Waiting for State
|
||||
|
||||
Create actions to modify the state in a controlled manner:
|
||||
Wait for a specific state condition to be met:
|
||||
|
||||
```typescript
|
||||
interface ILoginPayload {
|
||||
username: string;
|
||||
}
|
||||
// Wait for any truthy state
|
||||
const currentState = await userState.waitUntilPresent();
|
||||
|
||||
const loginUserAction = userStatePart.createAction<ILoginPayload>(async (statePart, payload) => {
|
||||
return { ...statePart.getState(), isLoggedIn: true, username: payload.username };
|
||||
});
|
||||
// Wait for a specific condition
|
||||
const name = await userState.waitUntilPresent((s) => s.name || undefined);
|
||||
|
||||
// Dispatch the action to update the state
|
||||
const newState = await loginUserAction.trigger({ username: 'johnDoe' });
|
||||
```
|
||||
// With timeout (backward compatible)
|
||||
const name = await userState.waitUntilPresent((s) => s.name || undefined, 5000);
|
||||
|
||||
### Dispatching Actions
|
||||
|
||||
There are two ways to dispatch actions:
|
||||
|
||||
```typescript
|
||||
// Method 1: Using trigger on the action (returns promise)
|
||||
const newState = await loginUserAction.trigger({ username: 'johnDoe' });
|
||||
|
||||
// Method 2: Using dispatchAction on the state part (returns promise)
|
||||
const newState = await userStatePart.dispatchAction(loginUserAction, { username: 'johnDoe' });
|
||||
```
|
||||
|
||||
Both methods return a Promise with the new state payload.
|
||||
|
||||
### Additional State Methods
|
||||
|
||||
`StatePart` provides several useful methods for state management:
|
||||
|
||||
```typescript
|
||||
// Get current state (may be undefined initially)
|
||||
const currentState = userStatePart.getState();
|
||||
if (currentState) {
|
||||
console.log('Current user:', currentState.username);
|
||||
}
|
||||
|
||||
// Wait for state to be present
|
||||
await userStatePart.waitUntilPresent();
|
||||
|
||||
// Wait for a specific property to be present
|
||||
await userStatePart.waitUntilPresent(state => state.username);
|
||||
|
||||
// Wait with a timeout (throws error if condition not met within timeout)
|
||||
// With AbortSignal
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
await userStatePart.waitUntilPresent(state => state.username, 5000); // 5 second timeout
|
||||
} catch (error) {
|
||||
console.error('Timed out waiting for username');
|
||||
const name = await userState.waitUntilPresent(
|
||||
(s) => s.name || undefined,
|
||||
{ timeoutMs: 5000, signal: controller.signal },
|
||||
);
|
||||
} catch (e) {
|
||||
// 'Aborted' or timeout error
|
||||
}
|
||||
|
||||
// Setup initial state with async operations
|
||||
await userStatePart.stateSetup(async (statePart) => {
|
||||
const userData = await fetchUserData();
|
||||
return { ...statePart.getState(), ...userData };
|
||||
});
|
||||
|
||||
// Defer notification to end of call stack (debounced)
|
||||
userStatePart.notifyChangeCumulative();
|
||||
```
|
||||
|
||||
### Persistent State with WebStore
|
||||
### Context Protocol Bridge (Web Components)
|
||||
|
||||
`Smartstate` supports persistent states using WebStore (IndexedDB-based storage), allowing you to maintain state across sessions:
|
||||
Expose state parts to web components via the [W3C Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md):
|
||||
|
||||
```typescript
|
||||
const settingsStatePart = await myAppSmartState.getStatePart<ISettingsState>(
|
||||
AppStateParts.SettingsState,
|
||||
{ theme: 'light' }, // Initial/default state
|
||||
'persistent' // Mode
|
||||
import { attachContextProvider } from '@push.rocks/smartstate';
|
||||
|
||||
// Define a context key
|
||||
const themeContext = Symbol('theme');
|
||||
|
||||
// Attach a provider to a DOM element
|
||||
const cleanup = attachContextProvider(myElement, {
|
||||
context: themeContext,
|
||||
statePart: settingsState,
|
||||
selectorFn: (s) => s.theme, // optional: provide derived value
|
||||
});
|
||||
|
||||
// Any descendant can request this context:
|
||||
myElement.dispatchEvent(
|
||||
new CustomEvent('context-request', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
context: themeContext,
|
||||
callback: (theme) => console.log('Theme:', theme),
|
||||
subscribe: true, // receive updates on state changes
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Cleanup when done
|
||||
cleanup();
|
||||
```
|
||||
|
||||
Persistent state automatically:
|
||||
- Saves state changes to IndexedDB
|
||||
- Restores state on application restart
|
||||
- Merges persisted values with defaults (persisted values take precedence)
|
||||
- Ensures atomic writes (persistence happens before memory update)
|
||||
This works with Lit's `@consume()` decorator, FAST, or any framework implementing the Context Protocol.
|
||||
|
||||
### State Validation
|
||||
|
||||
`Smartstate` includes built-in state validation to ensure data integrity:
|
||||
Built-in null/undefined validation. Extend for custom rules:
|
||||
|
||||
```typescript
|
||||
// Basic validation (built-in) ensures state is not null or undefined
|
||||
await userStatePart.setState(null); // Throws error: Invalid state structure
|
||||
|
||||
// Custom validation by extending StatePart
|
||||
class ValidatedStatePart<T> extends StatePart<string, T> {
|
||||
class ValidatedPart<T> extends StatePart<string, T> {
|
||||
protected validateState(stateArg: any): stateArg is T {
|
||||
return super.validateState(stateArg) && /* your validation */;
|
||||
return super.validateState(stateArg) && typeof stateArg.name === 'string';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
### Performance Features
|
||||
|
||||
`Smartstate` includes advanced performance optimizations:
|
||||
- **SHA256 Change Detection** — identical state values don't trigger notifications, even with different object references
|
||||
- **Selector Memoization** — `select(fn)` caches observables by function reference, sharing one upstream subscription across all subscribers
|
||||
- **Cumulative Notifications** — `notifyChangeCumulative()` debounces rapid changes into a single notification
|
||||
- **Concurrent Safety** — simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation
|
||||
- **Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency
|
||||
- **Batch Deferred Notifications** — `batch()` suppresses all notifications until the batch completes
|
||||
|
||||
- **🔒 Async State Hash Detection**: Uses SHA256 hashing to detect actual state changes, preventing unnecessary notifications when state values haven't truly changed
|
||||
- **🚫 Duplicate Prevention**: Identical state updates are automatically filtered out
|
||||
- **📦 Cumulative Notifications**: Batch multiple state changes into a single notification using `notifyChangeCumulative()` with automatic debouncing
|
||||
- **🎯 Selective Subscriptions**: Use selectors to subscribe only to specific state properties
|
||||
- **✨ Undefined State Filtering**: The `select()` method automatically filters out undefined states
|
||||
- **⚡ Concurrent Access Safety**: Prevents race conditions when multiple calls request the same state part simultaneously
|
||||
## API Reference
|
||||
|
||||
### RxJS Integration
|
||||
### `Smartstate<T>`
|
||||
|
||||
`Smartstate` leverages RxJS for reactive state management:
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `getStatePart(name, initial?, initMode?)` | Get or create a state part |
|
||||
| `batch(fn)` | Batch updates, defer notifications |
|
||||
| `computed(sources, fn)` | Create computed observable |
|
||||
| `isBatching` | Whether a batch is active |
|
||||
|
||||
```typescript
|
||||
// State is exposed as an RxJS Subject
|
||||
const stateObservable = userStatePart.select();
|
||||
### `StatePart<TName, TPayload>`
|
||||
|
||||
// Automatically starts with current state value
|
||||
stateObservable.subscribe((state) => {
|
||||
console.log('Current state:', state);
|
||||
});
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `getState()` | Get current state (or undefined) |
|
||||
| `setState(newState)` | Set state (runs middleware, validates, persists, notifies) |
|
||||
| `select(selectorFn?, options?)` | Subscribe to state changes |
|
||||
| `createAction(actionDef)` | Create a named action |
|
||||
| `dispatchAction(action, payload)` | Dispatch an action |
|
||||
| `addMiddleware(fn)` | Add middleware, returns removal function |
|
||||
| `waitUntilPresent(selectorFn?, options?)` | Wait for state condition |
|
||||
| `notifyChange()` | Manually trigger notification |
|
||||
| `notifyChangeCumulative()` | Debounced notification |
|
||||
| `stateSetup(fn)` | Async state initialization |
|
||||
|
||||
// Use selectors for specific properties
|
||||
userStatePart.select(state => state.username)
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
filter(username => username !== undefined)
|
||||
)
|
||||
.subscribe(username => {
|
||||
console.log('Username changed:', username);
|
||||
});
|
||||
```
|
||||
### `StateAction<TState, TPayload>`
|
||||
|
||||
### Complete Example
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `trigger(payload)` | Dispatch the action |
|
||||
|
||||
Here's a comprehensive example showcasing the power of `@push.rocks/smartstate`:
|
||||
### Standalone Functions
|
||||
|
||||
```typescript
|
||||
import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate';
|
||||
|
||||
// Define your state structure
|
||||
type AppStateParts = 'user' | 'settings' | 'cart';
|
||||
|
||||
interface IUserState {
|
||||
isLoggedIn: boolean;
|
||||
username?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface ICartState {
|
||||
items: Array<{ id: string; quantity: number }>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Create the smartstate instance
|
||||
const appState = new Smartstate<AppStateParts>();
|
||||
|
||||
// Initialize state parts
|
||||
const userState = await appState.getStatePart<IUserState>('user', {
|
||||
isLoggedIn: false
|
||||
});
|
||||
|
||||
const cartState = await appState.getStatePart<ICartState>('cart', {
|
||||
items: [],
|
||||
total: 0
|
||||
}, 'persistent'); // Persists across sessions
|
||||
|
||||
// Create actions
|
||||
const loginAction = userState.createAction<{ username: string; email: string }>(
|
||||
async (statePart, payload) => {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
return {
|
||||
isLoggedIn: true,
|
||||
username: payload.username,
|
||||
email: payload.email
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to changes
|
||||
userState.select(state => state.isLoggedIn).subscribe(isLoggedIn => {
|
||||
console.log('Login status changed:', isLoggedIn);
|
||||
});
|
||||
|
||||
// Dispatch actions
|
||||
await loginAction.trigger({ username: 'john', email: 'john@example.com' });
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| 🎯 **Type-safe** | Full TypeScript support with intelligent type inference |
|
||||
| ⚡ **Performance optimized** | Async state hash detection prevents unnecessary re-renders |
|
||||
| 💾 **Persistent state** | Built-in IndexedDB support for state persistence |
|
||||
| 🔄 **Reactive** | Powered by RxJS for elegant async handling |
|
||||
| 🧩 **Modular** | Organize state into logical, reusable parts |
|
||||
| ✅ **Validated** | Built-in state validation with extensible validation logic |
|
||||
| 🎭 **Flexible init modes** | Choose how state parts are initialized |
|
||||
| 📦 **Zero config** | Works out of the box with sensible defaults |
|
||||
| 🛡️ **Race condition safe** | Concurrent state part creation is handled safely |
|
||||
| ⏱️ **Timeout support** | `waitUntilPresent` supports optional timeouts |
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `computed(sources, fn)` | Create computed observable from state parts |
|
||||
| `attachContextProvider(element, options)` | Bridge state to Context Protocol |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
|
||||
Reference in New Issue
Block a user