react-vibe-state
v0.3.0
Published
Reactive state management with cross-tab sync and persistence powered by Valtio, Yjs, IndexedDB and BroadcastChannel
Maintainers
Readme
🌊 react-vibe-state
Reactive state management with automatic persistence and cross-tab synchronization. Use state almost like a plain JavaScript object - persistence and sync happen transparently in the background.
Features
- Zero-config persistence - state automatically saved to IndexedDB
- Cross-tab sync - changes instantly propagate between browser tabs via BroadcastChannel
- Reactive - powered by Valtio proxy, React components re-render on relevant changes only
- Type-safe - full TypeScript support with inferred types for state, selectors, and actions
- Modular - organize state with slices, each with own selectors and actions
- Conflict-free - CRDT-based sync via Yjs handles concurrent edits gracefully
Under the hood:
- Valtio - reactive proxy-based state
- Yjs - CRDT for conflict-free merging
- valtio-y - two-way binding between Valtio proxy and Yjs doc
- y-indexeddb - IndexedDB persistence provider
- y-webrtc - BroadcastChannel sync (WebRTC disabled)
Installation
npm install react-vibe-statePeer dependency: React 18 or 19
Quick Start
import { createState } from 'react-vibe-state';
// Create state (singleton, typically in a separate file)
const appState = createState({
name: 'app',
initial: { count: 0 },
selectors: {
isPositive() { return this.count > 0; },
doubled() { return this.count * 2; },
},
actions: {
increment() { this.count++; },
decrement() { this.count--; },
},
});
// Use in React component
function Counter() {
const { state, selectors, actions } = appState.useSnapshot();
return (
<div>
<span>{state.count} (doubled: {selectors.doubled()})</span>
<button onClick={actions.increment}>+</button>
<button onClick={actions.decrement} disabled={!selectors.isPositive()}>-</button>
</div>
);
}Open the app in multiple tabs - counter stays in sync automatically.
API Reference
createState(config)
Creates a reactive state instance. Instances are cached by name (safe for HMR).
const state = createState({
name: 'my-state',
initial: { /* ... */ },
selectors: { /* ... */ },
actions: { /* ... */ },
slices: { /* key: slice, ... */ },
// ... options
});Config Options
| Option | Type | Default | Description |
| ------------------------------ | ---------------------------- | -------------- | -------------------------------------------------------- |
| persistAndSync | boolean | true | Enable browser storage persistence and cross-tab sync |
| storage | "indexed-db" | "indexed-db" | Storage backend |
| name | string | required | Unique identifier (letters, numbers, _, -) |
| generation | string \| null | null | Version identifier; changing it purges old data |
| initial | T \| () => T | required | Initial state object or factory function |
| selectors | object | {} | Methods for derived values (this = readonly state) |
| actions | object | {} | Methods for mutations (this = mutable state) |
| slices | { [key]: Slice } | {} | Object of slices created with createSlice() |
| readyTimeout | number | 5000 | Max ms to wait for storage initialization |
| validate | (state) => boolean | null | Validation function for state structure |
| onReady | () => void | null | Called when initialization completes |
| onError | (error) => void | null | Called on initialization failure |
| onUpdateValidationFail | (state, origin, sliceKey?) => void | null | Called when storage or remote update fails validation |
Returned State Instance
Properties:
| Property | Type | Description |
| ----------- | --------------------------- | ------------------------------------------------------------ |
| state | object | Mutable proxy - read/write directly or via selectors/actions |
| selectors | object | Bound selector methods (this = readonly state) |
| actions | object | Bound action methods (this = mutable state) |
| ready | Promise<void> | Resolves when persistence is loaded |
| isReady | boolean | Whether initialization completed |
| storage | "indexed-db" \| undefined | Used storage type or undefined if disabled |
Methods:
| Method | Description |
| ----------------------- | ------------------------------------------------------------ |
| useSnapshot() | React hook returning { state, selectors, actions } |
| useSnapshot(sliceKey) | React hook scoped to a specific slice { state, selectors, actions } |
| reset() | Reset entire state to initial values |
| reset(sliceKey) | Reset specific slice to initial values |
createSlice(config)
Creates a modular slice of state with its own selectors and actions.
const todosSlice = createSlice({
initial: { items: [], filter: 'all' },
selectors: {
filtered() {
return this.filter === 'all'
? this.items
: this.items.filter(t => t.status === this.filter);
},
},
actions: {
add(text: string) {
this.items.push({ id: Date.now(), text, status: 'active' });
},
},
});
// Use in createState
const appState = createState({
name: 'app',
initial: {},
slices: { todos: todosSlice },
});
// Access slice
appState.state.todos.items;
appState.actions.todos.add('Buy milk');
appState.selectors.todos.filtered();Slice Config Options
| Option | Type | Default | Description |
| ------------------------------ | -------------------- | ----------- | ------------------------------------------------ |
| initial | T \| () => T | required | Initial slice state object or factory function |
| selectors | object | {} | Slice-scoped selectors (this = slice state) |
| actions | object | {} | Slice-scoped actions (this = slice state) |
| validate | (state) => boolean | undefined | Slice-specific validation |
| onUpdateValidationFail | (state, origin) => void | undefined | Called when slice storage or remote validation fails |
useSnapshot(state) / useSnapshot(state, sliceKey)
Standalone hook alternative to state.useSnapshot().
import { useSnapshot } from 'react-vibe-state';
function Component() {
const { state, selectors, actions } = useSnapshot(appState);
// or scoped to slice:
const { state, selectors, actions } = useSnapshot(appState, 'todos');
}Note on
actions: Actions are included inuseSnapshot()for convenience, even though semantically they don't operate on the snapshot. Actions always mutate the actual state (not the snapshot), and these mutations trigger reactivity updates across all subscribers.
Validation & Recovery
const appState = createState({
name: 'app',
initial: { version: 1, data: [] },
validate: (state) => {
return typeof state.version === 'number' && Array.isArray(state.data);
},
onUpdateValidationFail: (invalidData, origin) => {
if (origin === 'storage') {
console.warn('Stored data invalid', JSON.stringify(invalidData));
}
else {
// Storage / Remote data incompatible - probably has newer schema
location.reload();
}
},
});Validation behavior:
- Initial state - if invalid then throws error (fix your code)
- Storage/remote updates - if invalid then calls
onUpdateValidationFail& throws error (always, whenvalidateis set)
Generation (Schema Versioning)
Useful for session-scoped page state. When your state schema changes incompatibly, change the generation to purge old data:
const appState = createState({
name: 'app',
generation: 'v2', // Changed from 'v1' - old data will be deleted
initial: { /* new schema */ },
});SSR / Server Components
State works in SSR environments - persistence and sync are automatically disabled server-side. The ready promise resolves immediately.
TypeScript
Full type inference for state, selectors, and actions:
interface User {
id: string;
name: string;
role: 'admin' | 'user';
}
interface UsersState {
list: User[];
selectedId: string | null;
}
const usersSlice = createSlice({
initial: { list: [], selectedId: null } as UsersState,
selectors: {
selected() {
return this.list.find(u => u.id === this.selectedId);
}, // ^? User | undefined
admins() {
return this.list.filter(u => u.role === 'admin');
}, // ^? User[]
},
actions: {
add(user: User) {
this.list.push(user);
},
select(id: string | null) {
this.selectedId = id;
},
},
});
interface AppState {
theme: 'dark' | 'light';
}
const appState = createState({
name: 'app',
initial: { theme: 'dark' } as AppState,
slices: { users: usersSlice },
});
// All types are inferred
appState.state.theme; // 'dark' | 'light'
appState.state.users.list; // User[]
appState.selectors.users.selected(); // User | undefined
appState.actions.users.add(user); // add requires (user: User) => voidLimitations
When persistence is enabled (persistAndSync: true), state is synced via Yjs CRDT which has some constraints:
- No
undefinedvalues - usenullinstead, or omit/delete the property - No functions or class instances - state must be JSON-serializable (primitives, objects, arrays)
- Truncating arrays - use
array.splice(index)or assign a new array instead ofarray.length = N
// Good
const slice = createSlice({
initial: {
selectedId: null, // use null instead of undefined
items: [],
count: 0,
},
actions: {
clearItems() {
this.items.splice(0); // use splice to clear
// or: this.items = [];
},
},
});
// Bad - will throw error
const slice = createSlice({
initial: {
selectedId: undefined, // undefined not allowed
callback: () => {}, // functions not serializable
},
actions: {
clearItems() {
this.items.length = 0; // don't truncate via length
},
},
});Browser Support
Requires IndexedDB and BroadcastChannel support. Works in all modern browsers. For older browsers without indexedDB.databases() API, an error is thrown - you can conditionally disable persistence:
createState({
persistAndSync: typeof indexedDB?.databases === 'function',
// ...
});Demo App
Note: Demo app is only available in the GitHub repository, not in the npm package.
The test-server/ directory contains a demo application showcasing all library features:
- Counter - basic counter state
- Todos - array management with filtering
- Messages - real-time message sync
- Users - complex slice with selectors
# Install dependencies
npm install
# Run demo app
npm run devOpen the app in multiple browser tabs to see cross-tab synchronization in action.
License
ISC
