observator
v0.1.2
Published
Type-safe observable store that emits events for each top-level field change with JSON Patch arrays.
Maintainers
Readme
observator
A type-safe store that emits events for each top-level field change. Uses patch-recorder by default for mutative updates with JSON Patch generation, but you can use any compatible library (e.g., mutative or immer). Uses radiate for type-safe event emission.
Features
- 🔒 Type-safe event names - Only valid field names can be used for updates and subscriptions
- 📝 Patches included - Event callbacks receive JSON Patch arrays
- 🎯 Patch mechanism agnostic - Use any patch generation library (patch-recorder, mutative, immer, etc.)
- 🎯 Fine-grained subscriptions - Subscribe to specific keys within Record/Map fields using keyed events
- 💡 Value-based subscriptions - Use the convenient
subscribeAPI to receive current values immediately and on every change - 🎯 Minimal events - Events are emitted only for the specific field being updated
- 📦 Minimal dependencies - Only depends on
patch-recorderandradiate - 🚀 Lightweight - Small footprint with powerful features
Installation
npm install observator
# or
pnpm add observator
# or
yarn add observatorBy default, observator uses patch-recorder for patch generation. To use a different library, install it as well:
# Using mutative
npm install mutative
# Using immer
npm install immerUsage
Quick Start
The simplest way to use observe-store is to import createObservableStore and call it with your initial state. It uses patch-recorder by default:
import {createObservableStore} from 'observator';
type State = {
counter: number;
user: { name: string };
};
const store = createObservableStore<State>({
counter: 0,
user: { name: 'John' }
});
// Subscribe to counter updates
store.on('counter:updated', (patches) => {
console.log('Counter changed:', patches);
});
// Update counter
store.update((state) => {
state.counter += 1;
});
console.log(store.get('counter')); // 1Basic Example with Primitives
import {createObservableStore} from 'observator';
type State = {
counter: number;
name: string;
};
const store = createObservableStore<State>({
counter: 0,
name: 'John'
});
// Subscribe to counter updates
store.on('counter:updated', (patches) => {
console.log('Counter changed:', patches);
// Output: [{ op: 'replace', path: ['counter'], value: 1 }]
});
// Update counter
store.update((state) => {
state.counter += 1;
});
console.log(store.get('counter')); // 1Example with Complex Objects
import {createObservableStore} from 'observator';
type State = {
user: {
name: string;
age: number;
email: string;
};
settings: {
theme: 'light' | 'dark';
notifications: boolean;
};
};
const store = createObservableStore<State>({
user: {
name: 'John Doe',
age: 30,
email: '[email protected]'
},
settings: {
theme: 'light',
notifications: true
}
});
// Subscribe to user changes
store.on('user:updated', (patches) => {
console.log('User updated:', patches);
// Output: [{ op: 'replace', path: ['user', 'name'], value: 'Jane Doe' }]
});
// Update user
store.update((state) => {
state.user.name = 'Jane Doe';
state.user.age = 31;
});
// Subscribe to settings changes
const unsubscribe = store.on('settings:updated', (patches) => {
console.log('Settings changed:', patches);
});
// Update settings
store.update((state) => {
state.settings.theme = 'dark';
state.settings.notifications = false;
});
// Unsubscribe later
unsubscribe();Value-based Subscriptions
The subscribe API provides a convenient way to subscribe to field values. Unlike the patch-based on() API, subscribe:
- Executes immediately with the current value
- Provides the full value on every change (not just patches)
- Returns an unsubscribe function for easy cleanup
import {createObservableStore} from 'observator';
type State = {
counter: number;
user: { name: string };
};
const store = createObservableStore<State>({
counter: 0,
user: { name: 'John' }
});
// Subscribe to counter - callback fires immediately with current value
store.subscriptions.counter((counter) => {
console.log('Counter value:', counter);
});
// Output: Counter value: 0
// Update counter - callback fires again with new value
store.update((state) => {
state.counter += 1;
});
// Output: Counter value: 1
// Subscribe to user
const unsubscribe = store.subscriptions.user((user) => {
console.log('User name:', user.name);
});
// Output: User name: John
// Unsubscribe from user updates
unsubscribe();
store.update((state) => {
state.user.name = 'Jane';
});
// No callback fired (unsubscribed)Benefits of Value-based Subscriptions
- Simpler API: No need to manually get current values or apply patches
- Immediate execution: Always receive the current value right away
- Cleaner code: Less boilerplate compared to patch-based subscriptions
- Same type safety: Full TypeScript support with field name inference
Comparison: Patch-based vs Value-based
// Patch-based subscription
store.on('counter:updated', (patches) => {
const current = store.get('counter');
console.log('Counter:', current);
// Need to manually get current value
});
// Value-based subscription
store.subscriptions.counter((counter) => {
console.log('Counter:', counter);
// Automatically receives latest value
});Example with Arrays
import {createObservableStore} from 'observator';
type State = {
items: number[];
todos: Array<{ id: number; text: string; done: boolean }>;
};
const store = createObservableStore<State>({
items: [1, 2, 3],
todos: [
{ id: 1, text: 'Learn TypeScript', done: false }
]
});
// Subscribe to items changes
store.on('items:updated', (patches) => {
console.log('Items changed:', patches);
});
// Add item
store.update((state) => {
state.items.push(4);
});
// Update todos
store.update((state) => {
state.todos.push({ id: 2, text: 'Build apps', done: false });
state.todos[0].done = true;
});Wildcard Event - Subscribe to All Updates
Subscribe to all updates across the entire store using the wildcard '*' event:
import {createObservableStore} from 'observator';
type State = {
counter: number;
user: { name: string };
settings: { theme: string };
};
const store = createObservableStore<State>({
counter: 0,
user: { name: 'John' },
settings: { theme: 'light' }
});
// Subscribe to all updates
const unsubscribe = store.on('*', (patches) => {
console.log('State changed with patches:', patches);
});
// Update counter - wildcard event fires
store.update((state) => {
state.counter += 1;
});
// Output: State changed with patches: [{ op: 'replace', path: ['counter'], value: 1 }]
// Update user - wildcard event fires again
store.update((state) => {
state.user.name = 'Jane';
});
// Output: State changed with patches: [{ op: 'replace', path: ['user', 'name'], value: 'Jane' }]
// Update multiple fields at once - wildcard event fires once with all patches
store.update((state) => {
state.counter += 1;
state.settings.theme = 'dark';
});
// Output: State changed with patches: [
// { op: 'replace', path: ['counter'], value: 2 },
// { op: 'replace', path: ['settings', 'theme'], value: 'dark' }
// ]
unsubscribe();Use Cases
The wildcard event is useful for:
- Logging/debugging - Track all state changes
- Persistence - Save state to localStorage/database on any change
- Analytics - Track user interactions across the app
- Undo/redo systems - Maintain a history of all changes
Combining with Field-Specific Events
You can use the wildcard event alongside field-specific events:
// Wildcard listener for logging
store.on('*', (patches) => {
console.log('State changed:', patches);
});
// Field-specific listener for specific logic
store.on('user:updated', (patches) => {
console.log('User specifically changed:', patches);
});
store.update((state) => {
state.user.name = 'Jane';
});
// Both listeners fireSingle Emission
Subscribe to all updates for a single emission only:
// Subscribe for single emission
store.once('*', (patches) => {
console.log('State changed once:', patches);
});
store.update((state) => {
state.counter += 1;
});
// Callback fires once
store.update((state) => {
state.counter += 1;
});
// Callback does NOT fire againUnsubscribe Specific Listener
Remove a specific wildcard listener:
const callback1 = (patches) => console.log('Listener 1:', patches);
const callback2 = (patches) => console.log('Listener 2:', patches);
store.on('*', callback1);
store.on('*', callback2);
store.update((state) => {
state.counter += 1;
});
// Both callbacks fire
store.off('*', callback1);
store.update((state) => {
state.counter += 1;
});
// Only callback2 firesKeyed Events - Subscribe to Specific Keys
For fields containing records or arrays, you can subscribe to changes for specific keys:
import {createObservableStore} from 'observator';
type State = {
users: Record<string, { name: string; email: string }>;
};
const store = createObservableStore<State>({
users: {
'user-1': { name: 'John', email: '[email protected]' },
'user-2': { name: 'Jane', email: '[email protected]' }
}
});
// Subscribe to specific user changes
const unsubscribe1 = store.onKeyed('users:updated', 'user-1', (patches) => {
console.log('User 1 changed:', patches);
});
// Update user-1
store.update((state) => {
state.users['user-1'].name = 'Johnny';
});
// Only the user-1 callback fires
// Update user-2
store.update((state) => {
state.users['user-2'].name = 'Janet';
});
// The user-1 callback does NOT fire
unsubscribe1();Wildcard Subscription
Subscribe to all keys in a field using the wildcard '*':
// Subscribe to all user changes
const unsubscribe = store.onKeyed('users:updated', '*', (userId, patches) => {
console.log(`User ${userId} changed:`, patches);
});
store.update((state) => {
state.users['user-1'].name = 'Johnny';
state.user['user-2'].name = 'Janet';
});
unsubscribe();Array Index Subscription
Subscribe to specific array indices:
type State = {
todos: Array<{ id: number; text: string; done: boolean }>;
};
const store = createObservableStore<State>({
todos: [
{ id: 1, text: 'Task 1', done: false },
{ id: 2, text: 'Task 2', done: false }
]
});
// Subscribe to first todo changes
store.onKeyed('todos:updated', 0, (patches) => {
console.log('First todo changed:', patches);
});
store.update((state) => {
state.todos[0].done = true;
});
// Only the first todo callback firesSingle Emission
Subscribe for a single event using onceKeyed:
// Subscribe for single emission
store.onceKeyed('users:updated', 'user-1', (patches) => {
console.log('User 1 changed once:', patches);
});
store.update((state) => {
state.users['user-1'].name = 'Johnny';
});
// Callback fires once
store.update((state) => {
state.users['user-1'].name = 'John';
});
// Callback does NOT fire againUnsubscribe Specific Listener
Remove a specific listener from a keyed event:
const callback1 = (patches) => console.log('Callback 1:', patches);
const callback2 = (patches) => console.log('Callback 2:', patches);
store.onKeyed('users:updated', 'user-1', callback1);
store.onKeyed('users:updated', 'user-1', callback2);
store.update((state) => {
state.users['user-1'].name = 'Johnny';
});
// Both callbacks fire
store.offKeyed('users:updated', 'user-1', callback1);
store.update((state) => {
state.users['user-1'].name = 'John';
});
// Only callback2 firesMultiple Subscribers
import {createObservableStore} from 'observator';
type State = {
count: number;
};
const store = createObservableStore<State>({
count: 0
});
// Multiple subscribers to the same event
const unsubscribe1 = store.on('count:updated', (patches) => {
console.log('Subscriber 1:', patches);
});
const unsubscribe2 = store.on('count:updated', (patches) => {
console.log('Subscriber 2:', patches);
});
store.update((state) => {
state.count += 1;
});
// Both subscribers receive the event
// Output:
// Subscriber 1: [{ op: 'replace', path: ['value'], value: 1 }]
// Subscriber 2: [{ op: 'replace', path: ['value'], value: 1 }]Get Entire State
import {createObservableStore} from 'observator';
type State = {
user: { name: string };
counter: number;
};
const store = createObservableStore<State>({
user: { name: 'John' },
counter: 0
});
const entireState = store.getState();
console.log(entireState);
// { user: { name: 'John' }, counter: 0 }
// Returns a shallow copy, so modifications don't affect the store
const stateCopy = store.getState();
stateCopy.counter = 999;
console.log(store.get('counter')); // Still 0API
createObservableStore<T>(state: T, options?: ObservableStoreOptions): ObservableStore<T>
Creates a new ObservableStore instance with the given initial state. Uses patch-recorder by default, but you can pass a custom create function.
Type Parameter:
T- The state type, must beRecord<string, unknown> & NonPrimitive
Parameters:
state- Initial state objectoptions- Optional configuration objectcreateFunction?: CreateFunction- Custom create function for patch generation
Returns:
- A new
ObservableStore<T>instance
Example (default usage):
import {createObservableStore} from 'observator';
const store = createObservableStore({
user: { name: 'John' },
counter: 0
});Example (with custom create function):
import {createObservableStore} from 'observator';
import {create} from 'mutative';
const store = createObservableStore(
{
user: { name: 'John' },
counter: { value: 0 }
},
{createFunction: (state, mutate) => create(state, mutate, {enablePatches: true})},
);ObservableStore<T>
update(mutate: (state: T) => void): Patches
Updates the full state and emits events for each field that changed.
Parameters:
mutate- Mutation function that receives the full state
Returns:
- Array of patches for the full state
Example:
store.update((state) => {
state.user.name = 'Jane';
});get<K extends keyof T>(name: K): T[K]
Gets the current value of a field.
Type Parameters:
K- The field key to retrieve
Parameters:
name- The field key to retrieve
Returns:
- The current value of the field
Example:
const user = store.get('user');
console.log(user.name); // 'John'on<K extends keyof T>(event: EventName<K>, callback: (patches: Patches) => void): () => void
Subscribes to updates for a specific field.
Type Parameters:
K- The field key to subscribe to
Parameters:
event- The event name in format${fieldName}:updatedcallback- Callback function that receives the patches array
Returns:
- Unsubscribe function
Example:
const unsubscribe = store.on('user:updated', (patches) => {
console.log('User changed:', patches);
});
// Later: unsubscribe();
unsubscribe();off<K extends keyof T>(event: EventName<K>, callback: (patches: Patches) => void): void
Removes a specific event listener.
Type Parameters:
K- The field key
Parameters:
event- The event name in format${fieldName}:updatedcallback- The exact callback function to remove
Example:
const callback = (patches) => console.log('User changed:', patches);
store.on('user:updated', callback);
// Later:
store.off('user:updated', callback);once<K extends keyof T>(event: EventName<K>, callback: (patches: Patches) => void): () => void
Subscribes to a single emission of an event.
Type Parameters:
K- The field key to subscribe to
Parameters:
event- The event name in format${fieldName}:updatedcallback- Callback function that receives the patches array
Returns:
- Unsubscribe function to remove listener before it fires
Example:
const unsubscribe = store.once('user:updated', (patches) => {
console.log('User changed once:', patches);
});
// Callback will fire once, then automatically unsubscribeon(event: EventNames<T>, callback: (patches: Patches) => void): () => void
Subscribes to updates for a specific field or all updates.
Parameters:
event- The event name in format${fieldName}:updatedor'*'for all updatescallback- Callback function that receives the patches array
Returns:
- Unsubscribe function
Examples:
// Subscribe to field-specific updates
const unsubscribe = store.on('user:updated', (patches) => {
console.log('User changed:', patches);
});
// Subscribe to all updates (wildcard)
const unsubscribeAll = store.on('*', (patches) => {
console.log('State updated:', patches);
});off(event: EventNames<T>, callback: (patches: Patches) => void): void
Unsubscribes from an event.
Parameters:
event- The event name in format${fieldName}:updatedor'*'callback- The exact callback function to remove
Examples:
const callback = (patches) => console.log('Updated:', patches);
store.on('user:updated', callback);
// Later:
store.off('user:updated', callback);
// Or for wildcard:
store.off('*', callback);once(event: EventNames<T>, callback: (patches: Patches) => void): () => void
Subscribes to an event for a single emission only.
Parameters:
event- The event name in format${fieldName}:updatedor'*'for all updatescallback- Callback function that receives the patches array
Returns:
- Unsubscribe function to remove listener before it fires
Examples:
// Subscribe for single emission to specific field
const unsubscribe = store.once('user:updated', (patches) => {
console.log('User changed once:', patches);
});
// Subscribe for single emission to all updates
const unsubscribeAll = store.once('*', (patches) => {
console.log('State updated once:', patches);
});onKeyed<K extends keyof T>(event: EventName<K>, key: Key, callback: (patches: Patches) => void): () => void
Subscribes to updates for a specific key within a field.
Type Parameters:
K- The field key to subscribe to
Parameters:
event- The event name in format${fieldName}:updatedkey- The specific key to listen for (e.g., user ID, array index)callback- Callback function that receives the patches array
Returns:
- Unsubscribe function
Example:
const unsubscribe = store.onKeyed('users:updated', 'user-123', (patches) => {
console.log('User 123 changed:', patches);
});
unsubscribe();onKeyed<K extends keyof T>(event: EventName<K>, key: '*', callback: (key: ExtractKeyType<T[K]>, patches: Patches) => void): () => void
Subscribes to all keys within a field (wildcard subscription).
Type Parameters:
K- The field key to subscribe to
Parameters:
event- The event name in format${fieldName}:updatedkey- Use'*'to listen to all keyscallback- Callback function that receives the key and patches array
Returns:
- Unsubscribe function
Example:
const unsubscribe = store.onKeyed('users:updated', '*', (userId, patches) => {
console.log(`User ${userId} changed:`, patches);
});
unsubscribe();offKeyed<K extends keyof T>(event: EventName<K>, key: Key, callback: (patches: Patches) => void): void
Unsubscribes a specific listener from a keyed event.
Parameters:
event- The event name in format${fieldName}:updatedkey- The specific key to unsubscribe fromcallback- The exact callback function to remove
Example:
const callback = (patches) => console.log('Changed:', patches);
store.onKeyed('users:updated', 'user-123', callback);
store.offKeyed('users:updated', 'user-123', callback);onceKeyed<K extends keyof T>(event: EventName<K>, key: Key, callback: (patches: Patches) => void): () => void
Subscribes to a keyed event for a single emission only.
Parameters:
event- The event name in format${fieldName}:updatedkey- The specific key to listen forcallback- Callback function that receives the patches array
Returns:
- Unsubscribe function to remove listener before it fires
Example:
const unsubscribe = store.onceKeyed('users:updated', 'user-123', (patches) => {
console.log('User 123 changed once:', patches);
});
// Callback will fire once, then automatically unsubscribeonceKeyed<K extends keyof T>(event: EventName<K>, key: '*', callback: (key: ExtractKeyType<T[K]>, patches: Patches) => void): () => void
Subscribes to all keys within a field for a single emission only (wildcard).
Parameters:
event- The event name in format${fieldName}:updatedkey- Use'*'to listen to all keyscallback- Callback function that receives the key and patches array
Returns:
- Unsubscribe function to remove listener before it fires
Example:
const unsubscribe = store.onceKeyed('users:updated', '*', (userId, patches) => {
console.log(`User ${userId} changed once:`, patches);
});
// Callback will fire once, then automatically unsubscribegetState(): T
Gets the entire current state.
Returns:
- A shallow copy of the current state
Example:
const state = store.getState();
console.log(state);subscriptions: SubscriptionsMap<T>
A convenient object with keys matching all state fields. Each field provides a subscribe function that:
- Executes the callback immediately with the current field value
- Executes the callback on every field change
- Returns an unsubscribe function
Example:
// Subscribe to counter field
const unsubscribe = store.subscriptions.counter((counter) => {
console.log('Counter value:', counter);
});
// Unsubscribe later
unsubscribe();Type-safe access:
type State = {
counter: number;
user: { name: string };
};
const store = createObservableStore<State>({ ... });
// ✅ Valid - TypeScript knows these are the available fields
store.subscriptions.counter((counter) => { /* counter: number */ });
store.subscriptions.user((user) => { /* user: { name: string } */ });
// ❌ Type error - Invalid field name
store.subscriptions.invalid((value) => { /* Type error */ });keyedSubscriptions: KeyedSubscriptionsMap<T>
A convenient object with keys matching all state fields. Each field provides a function that takes a key and returns a subscribe function. The subscribe function:
- Executes the callback immediately with the current field value
- Executes the callback on every field change for that specific key
- Returns an unsubscribe function
Example:
// Subscribe to specific user updates
const unsubscribe = store.keyedSubscriptions.users('user-1')((users) => {
console.log('User 1:', users['user-1'].name);
});
// Unsubscribe later
unsubscribe();Type-safe access:
type State = {
users: Record<string, { name: string }>;
todos: Array<{ id: number; text: string }>;
};
const store = createObservableStore<State>({ ... });
// ✅ Valid - TypeScript knows these are the available fields
store.keyedSubscriptions.users('user-1')((users) => { /* users: Record<string, { name: string }> */ });
store.keyedSubscriptions.todos(0)((todos) => { /* todos: Array<{ id: number; text: string }> */ });
// ❌ Type error - Invalid field name
store.keyedSubscriptions.invalid('key')((value) => { /* Type error */ });Type Safety
The library provides full TypeScript type safety:
type State = {
user: { name: string };
counter: { value: number };
};
const store = createObservableStore<State>({ ... });
// ✅ Valid
store.update((state) => {
state.user.name = 'Jane';
});
// ❌ Type error: Invalid field name
store.update('invalid', (state) => {
// Type error
});
// ✅ Valid
store.on('user:updated', (patches) => {
console.log(patches);
});
// ❌ Type error: Invalid event name
store.on('invalid:updated', (patches) => {
// Type error
});
// ❌ Type error: Primitive types not allowed
const invalidStore = createObservableStore<{
count: number;
}>({ count: 0 });
// ✅ Keyed events with type safety
type State = {
users: Record<string, { name: string }>;
};
const store = createObservableStore<State>({ users: {} });
// ✅ Valid
store.onKeyed('users:updated', 'user-1', (patches) => {
console.log(patches);
});
// ❌ Type error: Invalid event name
store.onKeyed('invalid:updated', 'user-1', (patches) => {
// Type error
});
// ✅ Subscribe API with type safety
type State = {
counter: number;
user: { name: string };
};
const store = createObservableStore<State>({ ... });
// ✅ Valid - TypeScript infers correct types
store.subscriptions.counter((counter) => {
// counter is typed as number
console.log(counter);
});
store.subscriptions.user((user) => {
// user is typed as { name: string }
console.log(user.name);
});
// ❌ Type error: Invalid field name
store.subscriptions.invalid((value) => {
// Type error
});Performance Considerations
Keyed events use conditional emission - they are only emitted when there are active keyed listeners. This means there's no performance overhead when you're not using keyed events:
type State = {
users: Record<string, { name: string }>;
};
const store = createObservableStore<State>({ users: {} });
// No keyed listeners - no performance overhead
store.on('users:updated', (patches) => {
console.log('Regular event:', patches);
});
store.update('users', (state) => {
state['user-1'] = { name: 'John' };
});
// Only regular event is emitted
// Add keyed listener - now keyed events are emitted
store.onKeyed('users:updated', 'user-1', (patches) => {
console.log('Keyed event:', patches);
});
store.update('users', (state) => {
state['user-1'].name = 'Jane';
});
// Both regular and keyed events are emittedNote: Numeric keys in Record objects are converted to strings by most patch generation libraries in patch paths, so subscribe using the string version of the key.
Working with Primitives
The top-level state must be a non-primitive object or array (enforced by TypeScript), but field values can be primitives when using patch-recorder (the default). For maximum compatibility across all patch generation libraries (including mutative/immer), wrap primitives in objects:
// ✅ Works with patch-recorder (default)
type State1 = {
count: number; // Primitive at field level works
name: string;
flag: boolean;
};
const store1 = createObservableStore<State1>({
count: 0,
name: 'John',
flag: false
});
store1.update((state) => {
state.count += 1;
});
// For maximum compatibility across all libraries (mutative, immer)
type State2 = {
count: { value: number }; // Wrapped primitive
name: { value: string };
flag: { value: boolean };
};
// You can create a utility type for consistency
type PrimitiveField<T> = { value: T };
type BetterState = {
count: PrimitiveField<number>;
name: PrimitiveField<string>;
flag: PrimitiveField<boolean>;
};
const store2 = createObservableStore<BetterState>({
count: { value: 0 },
name: { value: 'John' },
flag: { value: false }
});
store2.update((state) => {
state.count.value += 1;
});Understanding Patches
Patches follow the JSON Patch (RFC 6902) format. The exact patch format depends on the patch generation library you use:
- patch-recorder (default): Generates patches with minimal overhead while keeping references
- mutative: Generates high-performance JSON patches with array optimizations
- immer: Generates standard JSON patches
store.on('user:updated', (patches) => {
patches.forEach(patch => {
console.log(`Operation: ${patch.op}`);
console.log(`Path: ${JSON.stringify(patch.path)}`);
console.log(`Value: ${JSON.stringify(patch.value)}`);
});
});Common operations:
replace- Replace a value at a pathadd- Add a value to an array or objectremove- Remove a value from an array or object
License
MIT
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
