Files
smartstate/readme.md

337 lines
11 KiB
Markdown
Raw Normal View History

2023-07-27 15:20:24 +02:00
# @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.
2019-02-21 21:48:39 +01:00
2024-04-14 18:24:08 +02:00
## Install
```bash
2025-07-01 06:50:15 +00:00
pnpm install @push.rocks/smartstate --save
```
Or with npm:
```bash
npm install @push.rocks/smartstate --save
2024-04-14 18:24:08 +02:00
```
2019-02-21 21:48:39 +01:00
## Usage
### Quick Start
2024-04-14 18:24:08 +02:00
```typescript
import { Smartstate } from '@push.rocks/smartstate';
2024-04-14 18:24:08 +02:00
// 1. Define your state part names
type AppParts = 'user' | 'settings';
2024-04-14 18:24:08 +02:00
// 2. Create the root instance
const state = new Smartstate<AppParts>();
2024-04-14 18:24:08 +02:00
// 3. Create state parts with initial values
const userState = await state.getStatePart<{ name: string; loggedIn: boolean }>('user', {
name: '',
loggedIn: false,
});
2025-07-01 06:50:15 +00:00
// 4. Subscribe to changes
userState.select((s) => s.name).subscribe((name) => {
console.log('Name changed:', name);
});
2025-07-01 06:50:15 +00:00
// 5. Update state
await userState.setState({ name: 'Alice', loggedIn: true });
```
2025-07-01 06:50:15 +00:00
### State Parts & Init Modes
2024-04-14 18:24:08 +02:00
State parts are isolated, typed units of state. Create them with `getStatePart()`:
2024-04-14 18:24:08 +02:00
```typescript
const part = await state.getStatePart<IMyState>(name, initialState, initMode);
2024-04-14 18:24:08 +02:00
```
| 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 |
2024-04-14 18:24:08 +02:00
#### Persistent State
2024-04-14 18:24:08 +02:00
```typescript
const settings = await state.getStatePart('settings', { theme: 'dark' }, 'persistent');
// Automatically saved to IndexedDB. On next app load, persisted values override defaults.
2024-04-14 18:24:08 +02:00
```
### Selecting State
2024-04-14 18:24:08 +02:00
`select()` returns an RxJS Observable that emits the current value immediately and on every change:
2024-04-14 18:24:08 +02:00
```typescript
// Full state
userState.select().subscribe((state) => console.log(state));
2024-04-14 18:24:08 +02:00
// Derived value via selector
userState.select((s) => s.name).subscribe((name) => console.log(name));
2024-04-14 18:24:08 +02:00
```
Selectors are **memoized** — calling `select(fn)` with the same function reference returns the same cached Observable, shared across all subscribers.
#### AbortSignal Support
2024-04-14 18:24:08 +02:00
Clean up subscriptions without manual `unsubscribe()`:
2024-04-14 18:24:08 +02:00
```typescript
const controller = new AbortController();
2024-04-14 18:24:08 +02:00
userState.select((s) => s.name, { signal: controller.signal }).subscribe((name) => {
console.log(name); // stops receiving when aborted
2024-04-14 18:24:08 +02:00
});
// Later: clean up
controller.abort();
2025-07-01 06:50:15 +00:00
```
### 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:
2025-07-01 06:50:15 +00:00
```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() };
2025-07-01 06:50:15 +00:00
});
// Removal — addMiddleware returns a dispose function
const remove = userState.addMiddleware(myMiddleware);
remove(); // middleware no longer runs
2024-04-14 18:24:08 +02:00
```
Middleware runs sequentially in insertion order. If any middleware throws, the state is unchanged (atomic).
2024-04-14 18:24:08 +02:00
### Computed / Derived State
2024-04-14 18:24:08 +02:00
Derive reactive values from one or more state parts:
2024-04-14 18:24:08 +02:00
```typescript
import { computed } from '@push.rocks/smartstate';
2025-07-01 06:50:15 +00:00
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) => /* ... */);
```
Computed observables are **lazy** — they only subscribe to sources when someone subscribes to them.
2025-07-01 06:50:15 +00:00
### Batch Updates
2025-07-01 06:50:15 +00:00
Update multiple state parts without intermediate notifications:
2025-07-01 06:50:15 +00:00
```typescript
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
2025-07-01 06:50:15 +00:00
});
// Both subscribers now fire with their new values
2025-07-01 06:50:15 +00:00
// 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 });
2025-07-01 06:50:15 +00:00
});
// Still deferred
});
// Now both fire
2025-07-01 06:50:15 +00:00
```
2024-04-14 18:24:08 +02:00
### Waiting for State
Wait for a specific state condition to be met:
```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 (backward compatible)
const name = await userState.waitUntilPresent((s) => s.name || undefined, 5000);
// With AbortSignal
const controller = new AbortController();
try {
const name = await userState.waitUntilPresent(
(s) => s.name || undefined,
{ timeoutMs: 5000, signal: controller.signal },
);
} catch (e) {
// 'Aborted' or timeout error
}
```
### 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):
```typescript
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();
```
This works with Lit's `@consume()` decorator, FAST, or any framework implementing the Context Protocol.
### State Validation
Built-in null/undefined validation. Extend for custom rules:
```typescript
class ValidatedPart<T> extends StatePart<string, T> {
protected validateState(stateArg: any): stateArg is T {
return super.validateState(stateArg) && typeof stateArg.name === 'string';
}
}
```
2024-04-14 18:24:08 +02:00
### Performance Features
- **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
## API Reference
### `Smartstate<T>`
| 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 |
### `StatePart<TName, TPayload>`
| 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 |
### `StateAction<TState, TPayload>`
| Method | Description |
|--------|-------------|
| `trigger(payload)` | Dispatch the action |
### Standalone Functions
| Function | Description |
|----------|-------------|
| `computed(sources, fn)` | Create computed observable from state parts |
| `attachContextProvider(element, options)` | Bridge state to Context Protocol |
2024-04-14 18:24:08 +02:00
## 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.
2024-04-14 18:24:08 +02:00
**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.
2020-05-18 04:10:36 +00:00
2024-04-14 18:24:08 +02:00
### Company Information
2020-05-18 04:10:36 +00:00
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
2020-05-18 04:10:36 +00:00
For any legal inquiries or further information, please contact us via email at hello@task.vc.
2019-02-21 21:48:39 +01:00
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.