@jucie-state/on-change
v1.0.8
Published
OnChange plugin for @jucie-state/core - simple change listeners
Maintainers
Readme
@jucie-state/on-change
Simple global change listener plugin for @jucie-state/core that notifies you of all state changes with automatic batching and consolidation.
Features
- 🔔 Global Change Tracking: Listen to all state changes in one place
- 📦 Automatic Batching: Changes are batched and delivered asynchronously
- 🔄 Smart Consolidation: Multiple changes to the same path are consolidated
- 🎯 Simple API: Just add listeners and remove them when done
- ⚡ Performance Optimized: Only active when listeners are registered
- 🔌 Zero Configuration: Works out of the box
Installation
npm install @jucie-state/on-changeNote: Requires @jucie-state/core as a peer dependency.
Quick Start
import { createState } from '@jucie-state/core';
import { OnChange } from '@jucie-state/on-change';
// Create state and install OnChange plugin
const state = createState({
user: { name: 'Alice', age: 30 },
settings: { theme: 'dark' }
});
state.install(OnChange);
// Add a change listener
const unsubscribe = state.onChange.addListener((changes) => {
console.log('State changed:', changes);
});
// Make some changes
state.set(['user', 'name'], 'Bob');
state.set(['settings', 'theme'], 'light');
// Console: "State changed: [{ path: ['user', 'name'], from: 'Alice', to: 'Bob', ... }, ...]"
// Remove listener when done
unsubscribe();Configuration
The OnChange plugin can be configured with custom options:
import { createState } from '@jucie-state/core';
import { OnChange } from '@jucie-state/on-change';
const state = createState({
user: { name: 'Alice', age: 30 }
});
// Install with custom configuration
state.install(OnChange.configure({
debounce: 100 // Debounce change notifications by 100ms (default: 0)
}));Options
debounce(number): Delay in milliseconds before notifying listeners. Default:0(no debounce)
API Reference
Actions
state.onChange.addListener(callback)
Add a listener that will be called whenever state changes occur.
const unsubscribe = state.onChange.addListener((changes) => {
changes.forEach(change => {
console.log('Changed:', change.path);
console.log('From:', change.from);
console.log('To:', change.to);
});
});Parameters:
callback(Function): Function that receives an array of change objects
Returns: Unsubscribe function to remove the listener
Change Object Structure:
{
path: ['user', 'name'], // Path that changed
from: 'Alice', // Previous value
to: 'Bob', // New value
method: 'set', // Method used (set, delete, push, etc.)
timestamp: 1234567890 // Timestamp of change (if available)
}state.onChange.removeListener(callback)
Manually remove a listener.
function myListener(changes) {
console.log('Changes:', changes);
}
state.onChange.addListener(myListener);
// Later...
state.onChange.removeListener(myListener);Parameters:
callback(Function): The listener function to remove
How It Works
Automatic Batching
Multiple synchronous changes are automatically batched and delivered together:
state.onChange.addListener((changes) => {
console.log('Received', changes.length, 'changes');
});
// These three changes are batched together
state.set(['a'], 1);
state.set(['b'], 2);
state.set(['c'], 3);
// Console: "Received 3 changes"Smart Consolidation
Multiple changes to the same path are consolidated, keeping only the most recent:
state.onChange.addListener((changes) => {
console.log('Changes:', changes);
});
// Rapid changes to the same path
state.set(['counter'], 1);
state.set(['counter'], 2);
state.set(['counter'], 3);
// Console: "Changes: [{ path: ['counter'], from: 0, to: 3, ... }]"
// Only the final change is reportedPerformance Optimization
The plugin only tracks changes when at least one listener is registered:
// No listeners = no overhead
state.set(['value'], 1); // Fast, no tracking
// Add listener
const unsubscribe = state.onChange.addListener(handler);
// Now tracking is active
state.set(['value'], 2); // Tracked and reported
// Remove listener
unsubscribe();
// Back to no overhead
state.set(['value'], 3); // Fast, no trackingCommon Use Cases
Logging
state.onChange.addListener((changes) => {
if (process.env.NODE_ENV === 'development') {
console.group('State Changes');
changes.forEach(change => {
console.log(`${change.path.join('.')}: ${change.from} → ${change.to}`);
});
console.groupEnd();
}
});Persistence
state.onChange.addListener((changes) => {
// Save entire state to localStorage
localStorage.setItem('appState', JSON.stringify(state.get([])));
});Analytics
state.onChange.addListener((changes) => {
changes.forEach(change => {
analytics.track('state_changed', {
path: change.path.join('.'),
method: change.method,
timestamp: Date.now()
});
});
});DevTools Integration
if (window.__REDUX_DEVTOOLS_EXTENSION__) {
const devTools = window.__REDUX_DEVTOOLS_EXTENSION__.connect();
state.onChange.addListener((changes) => {
changes.forEach(change => {
devTools.send({
type: `SET ${change.path.join('.')}`,
payload: change.to
}, state.get([]));
});
});
}Sync to Server
let syncTimeout;
state.onChange.addListener((changes) => {
// Debounce server sync
clearTimeout(syncTimeout);
syncTimeout = setTimeout(async () => {
await fetch('/api/sync', {
method: 'POST',
body: JSON.stringify({
changes,
state: state.get([])
})
});
}, 1000);
});Change History
const changeHistory = [];
state.onChange.addListener((changes) => {
changeHistory.push({
timestamp: Date.now(),
changes: changes.map(c => ({ ...c }))
});
// Keep last 100 change batches
if (changeHistory.length > 100) {
changeHistory.shift();
}
});Validation
state.onChange.addListener((changes) => {
changes.forEach(change => {
if (change.path[0] === 'user' && change.path[1] === 'email') {
const email = change.to;
if (!email.includes('@')) {
console.warn('Invalid email address');
// Optionally revert the change
state.set(change.path, change.from);
}
}
});
});Advanced Patterns
Conditional Listeners
let unsubscribe = null;
function enableChangeTracking() {
if (!unsubscribe) {
unsubscribe = state.onChange.addListener((changes) => {
console.log('Tracking changes:', changes);
});
}
}
function disableChangeTracking() {
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
}
// Enable tracking based on some condition
if (userPreferences.debugMode) {
enableChangeTracking();
}Multiple Listeners
// Logger
state.onChange.addListener((changes) => {
console.log('LOG:', changes.length, 'changes');
});
// Validator
state.onChange.addListener((changes) => {
changes.forEach(validateChange);
});
// Sync
state.onChange.addListener((changes) => {
syncToServer(changes);
});
// All listeners receive the same changesFiltered Listeners
// Create a filtered listener helper
function createFilteredListener(pathPrefix, callback) {
return state.onChange.addListener((changes) => {
const filtered = changes.filter(change => {
return change.path.length >= pathPrefix.length &&
pathPrefix.every((segment, i) => change.path[i] === segment);
});
if (filtered.length > 0) {
callback(filtered);
}
});
}
// Only listen to user changes
const unsubscribe = createFilteredListener(['user'], (changes) => {
console.log('User changed:', changes);
});Change Replay
const recordedChanges = [];
let isRecording = false;
const unsubscribe = state.onChange.addListener((changes) => {
if (isRecording) {
recordedChanges.push(...changes);
}
});
function startRecording() {
isRecording = true;
recordedChanges.length = 0;
}
function stopRecording() {
isRecording = false;
return [...recordedChanges];
}
function replay(changes) {
changes.forEach(change => {
state.set(change.path, change.to);
});
}Comparison with Matcher Plugin
| Feature | OnChange | Matcher | |---------|----------|---------| | Scope | All changes | Specific paths | | Batching | Automatic | Automatic | | Consolidation | By address | By path | | Filtering | Manual | Built-in | | Use Case | Global tracking | Specific watchers | | Performance | Tracks everything | Optimized for paths |
When to use OnChange:
- Debug logging
- Global persistence
- Analytics tracking
- DevTools integration
- You need to see all changes
When to use Matcher:
- Watch specific data
- React to specific paths
- Need hierarchical matching
- Better performance for selective watching
Performance Tips
- Remove listeners when done - Always unsubscribe to prevent memory leaks
- Avoid heavy computations - Listeners are called frequently, keep them fast
- Use debouncing for expensive operations like server sync
- Filter early if you only care about specific changes
- Batch operations when making multiple changes to reduce listener calls
// Good: Remove when done
const unsub = state.onChange.addListener(handler);
cleanup(() => unsub());
// Bad: Memory leak
state.onChange.addListener(handler);License
See the root LICENSE file for license information.
Related Packages
- @jucie-state/core - Core state management system
- @jucie-state/history - Undo/redo functionality
- @jucie-state/matcher - Path-based change tracking
