# @push.rocks/smartstate A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support 🚀 ## Issue Reporting and Security For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly. ## Install ```bash pnpm install @push.rocks/smartstate --save ``` Or with npm: ```bash npm install @push.rocks/smartstate --save ``` ## Usage ### Quick Start ```typescript 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(); // 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 }); ``` ### 🧩 State Parts & Init Modes State parts are isolated, typed units of state. They are the building blocks of your application's state tree. Create them via `getStatePart()`: ```typescript const part = await state.getStatePart(name, initialState, initMode); ``` | Init Mode | Behavior | |-----------|----------| | `'soft'` (default) | Returns existing if found, creates new otherwise | | `'mandatory'` | Throws if state part already exists — useful for ensuring single-initialization | | `'force'` | Always creates a new state part, overwriting any existing one | | `'persistent'` | Like `'soft'` but automatically persists state to IndexedDB via WebStore | You can use either enums or string literal types for state part names: ```typescript // String literal types (simpler) type AppParts = 'user' | 'settings' | 'cart'; // Enums (more explicit) enum AppParts { User = 'user', Settings = 'settings', Cart = 'cart', } ``` #### 💾 Persistent State ```typescript const settings = await state.getStatePart('settings', { theme: 'dark', fontSize: 14 }, 'persistent'); // ✅ Automatically saved to IndexedDB on every setState() // ✅ On next app load, persisted values override defaults // ✅ Persistence writes complete before in-memory updates (atomic) ``` ### 🔭 Selecting State `select()` returns an RxJS Observable that emits the current value immediately and on every subsequent change: ```typescript // Full state userState.select().subscribe((state) => console.log(state)); // Derived value via selector function 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 via `shareReplay`. This means you can call `select(mySelector)` in multiple places without creating duplicate subscriptions. #### ✂️ AbortSignal Support Clean up subscriptions without manual `.unsubscribe()` — the modern way: ```typescript const controller = new AbortController(); userState.select((s) => s.name, { signal: controller.signal }).subscribe((name) => { console.log(name); // automatically stops receiving when aborted }); // Later: clean up all subscriptions tied to this signal controller.abort(); ``` ### ⚡ Actions Actions provide controlled, named state mutations with full async support: ```typescript interface ILoginPayload { username: string; email: string; } const loginAction = userState.createAction(async (statePart, payload) => { // You have access to the current state via statePart.getState() const current = statePart.getState(); return { ...current, name: payload.username, loggedIn: true }; }); // Two equivalent ways to dispatch: await loginAction.trigger({ username: 'Alice', email: 'alice@example.com' }); // or await userState.dispatchAction(loginAction, { username: 'Alice', email: 'alice@example.com' }); ``` Both `trigger()` and `dispatchAction()` return a Promise with the new state. ### 🛡️ Middleware Intercept every `setState()` call to transform, validate, log, or reject state changes: ```typescript // Logging middleware userState.addMiddleware((newState, oldState) => { console.log('State changing:', oldState, '→', 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() }; }); // Async middleware userState.addMiddleware(async (newState, oldState) => { await auditLog('state-change', { from: oldState, to: newState }); return newState; }); // 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 remains unchanged — the operation is **atomic**. ### 🧮 Computed / Derived State Derive reactive values from one or more state parts using `combineLatest` under the hood: ```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 convenience method on the Smartstate instance: const greeting2$ = state.computed( [userState, settingsState], (user, settings) => `${user.firstName} - ${settings.locale}`, ); ``` Computed observables are **lazy** — they only subscribe to their sources when someone subscribes to them, and they automatically unsubscribe when all subscribers disconnect. ### 📦 Batch Updates Update multiple state parts at once while deferring all notifications until the entire batch completes: ```typescript const partA = await state.getStatePart('a', { value: 1 }); const partB = await state.getStatePart('b', { value: 2 }); await state.batch(async () => { await partA.setState({ value: 10 }); await partB.setState({ value: 20 }); // No notifications fire inside the batch }); // Both subscribers now fire with their new values simultaneously // Nested batches are supported — flush happens at the outermost level only await state.batch(async () => { await partA.setState({ value: 100 }); await state.batch(async () => { await partB.setState({ value: 200 }); }); // Still deferred — inner batch doesn't trigger flush }); // Now both fire ``` ### ⏳ Waiting for State Wait for a specific state condition to be met before proceeding: ```typescript // Wait for any truthy state const currentState = await userState.waitUntilPresent(); // Wait for a specific condition const name = await userState.waitUntilPresent((s) => s.name || undefined); // With timeout (milliseconds) const name = await userState.waitUntilPresent((s) => s.name || undefined, 5000); // With AbortSignal and/or timeout via options object const controller = new AbortController(); try { const name = await userState.waitUntilPresent( (s) => s.name || undefined, { timeoutMs: 5000, signal: controller.signal }, ); } catch (e) { // e.message is 'Aborted' or 'waitUntilPresent timed out after 5000ms' } ``` ### 🌐 Context Protocol Bridge (Web Components) Expose state parts to web components via the [W3C Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md). This lets any web component framework (Lit, FAST, Stencil, or vanilla) consume your state without coupling: ```typescript import { attachContextProvider } from '@push.rocks/smartstate'; // Define a context key (use Symbol for uniqueness) const themeContext = Symbol('theme'); // Attach a provider to a DOM element — any descendant can consume it const cleanup = attachContextProvider(document.body, { context: themeContext, statePart: settingsState, selectorFn: (s) => s.theme, // optional: provide a derived value instead of full state }); // A consumer dispatches a context-request event: myComponent.dispatchEvent( new CustomEvent('context-request', { bubbles: true, composed: true, detail: { context: themeContext, callback: (theme) => console.log('Got theme:', theme), subscribe: true, // receive updates whenever the state changes }, }), ); // Works seamlessly with Lit's @consume() decorator, FAST's context, etc. // Cleanup when the provider is no longer needed cleanup(); ``` ### ✅ State Validation Built-in validation prevents `null` and `undefined` from being set as state. For custom validation, extend `StatePart`: ```typescript import { StatePart } from '@push.rocks/smartstate'; class ValidatedUserPart extends StatePart { protected validateState(stateArg: any): stateArg is IUserState { return ( super.validateState(stateArg) && typeof stateArg.name === 'string' && typeof stateArg.loggedIn === 'boolean' ); } } ``` If validation fails, `setState()` throws and the state remains unchanged. ### ⚙️ Async State Setup Initialize state with async operations while ensuring actions wait for setup to complete: ```typescript await userState.stateSetup(async (statePart) => { const userData = await fetchUserFromAPI(); return { ...statePart.getState(), ...userData }; }); // Any dispatchAction() calls will automatically wait for stateSetup() to finish ``` ### 🏎️ Performance Smartstate is built with performance in mind: - **🔒 SHA256 Change Detection** — Uses content hashing to detect actual changes. Identical state values don't trigger notifications, even with different object references. - **♻️ Selector Memoization** — `select(fn)` caches observables by function reference and shares them via `shareReplay({ refCount: true })`. Multiple subscribers share one upstream subscription. - **📦 Cumulative Notifications** — `notifyChangeCumulative()` debounces rapid changes into a single notification at the end of the call stack. - **🔐 Concurrent Safety** — Simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation or race conditions. - **💾 Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency even if the process crashes mid-write. - **⏸️ Batch Deferred Notifications** — `batch()` suppresses all subscriber notifications until every update in the batch completes. ## API Reference ### `Smartstate` | Method / Property | Description | |-------------------|-------------| | `getStatePart(name, initial?, initMode?)` | Get or create a typed state part | | `batch(fn)` | Batch state updates, defer all notifications until complete | | `computed(sources, fn)` | Create a computed observable from multiple state parts | | `isBatching` | `boolean` — whether a batch is currently active | | `statePartMap` | Registry of all created state parts | ### `StatePart` | Method | Description | |--------|-------------| | `getState()` | Get current state (returns `TPayload \| undefined`) | | `setState(newState)` | Set state — runs middleware → validates → persists → notifies | | `select(selectorFn?, options?)` | Returns an Observable of state or derived values. Options: `{ signal?: AbortSignal }` | | `createAction(actionDef)` | Create a reusable, typed state action | | `dispatchAction(action, payload)` | Dispatch an action and return the new state | | `addMiddleware(fn)` | Add a middleware interceptor. Returns a removal function | | `waitUntilPresent(selectorFn?, opts?)` | Wait for a state condition. Opts: `number` (timeout) or `{ timeoutMs?, signal? }` | | `notifyChange()` | Manually trigger a change notification (with hash dedup) | | `notifyChangeCumulative()` | Debounced notification — fires at end of call stack | | `stateSetup(fn)` | Async state initialization with action serialization | ### `StateAction` | Method | Description | |--------|-------------| | `trigger(payload)` | Dispatch the action on its associated state part | ### Standalone Functions | Function | Description | |----------|-------------| | `computed(sources, fn)` | Create a computed observable from multiple state parts | | `attachContextProvider(element, options)` | Bridge a state part to the W3C Context Protocol | ### Exported Types | Type | Description | |------|-------------| | `TInitMode` | `'soft' \| 'mandatory' \| 'force' \| 'persistent'` | | `TMiddleware` | `(newState, oldState) => TPayload \| Promise` | | `IActionDef` | Action definition function signature | | `IContextProviderOptions` | Options for `attachContextProvider` | ## License and Legal Information This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. ### Trademarks This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar. ### Company Information Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany For any legal inquiries or further information, please contact us via email at hello@task.vc. By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.