Matcher plugin for @jucie-state/core - path-based change tracking
npm install @jucie-state/matcherPath-based change tracking plugin for @jucie-state/core that allows you to watch specific paths in your state tree and react to changes with automatic batching and smart consolidation.
- 🎯 Path Matching: Watch specific paths in your state tree
- 🌳 Hierarchical Watching: Match exact paths, parent paths, or child paths
- 📦 Automatic Batching: Changes are automatically batched and debounced
- 🔄 Smart Consolidation: Multiple changes to the same path are consolidated
- 🎬 Declarative Setup: Define matchers during state initialization
- 🔌 Plugin Architecture: Seamlessly integrates with @jucie-state/core
``bash`
npm install @jucie-state/matcher
Note: Requires @jucie-state/core as a peer dependency.
`javascript
import { createState } from '@jucie-state/core';
import { Matcher, createMatcher } from '@jucie-state/matcher';
// Create a matcher
const userMatcher = createMatcher(['user'], (changes) => {
console.log('User changed:', changes);
});
// Create state and install matcher plugin
const state = createState({
user: { name: 'Alice', age: 30 },
settings: { theme: 'dark' }
});
// Install with initial matchers
state.install(Matcher.configure({
matchers: [userMatcher]
}));
// Change user data - matcher fires
state.set(['user', 'name'], 'Bob');
// Console: "User changed: { name: 'Bob', age: 30 }"
// Change settings - matcher doesn't fire
state.set(['settings', 'theme'], 'light');
`
#### createMatcher(path, handler)
Create a matcher that watches a specific path in the state tree.
`javascript
import { createMatcher } from '@jucie-state/matcher';
const matcher = createMatcher(['users', 'profile'], (changes) => {
console.log('Profile changed:', changes);
});
`
Parameters:
- path (Array): Path to watch (e.g., ['user'], ['users', 'profile'])handler
- (Function): Callback function that receives the changed value(s)
Returns: Matcher function that can be added to the plugin
When using the Matcher plugin with a state instance, you get access to these actions:
#### state.matcher.createMatcher(path, handler)
Create and automatically register a matcher.
`javascript
import { createState } from '@jucie-state/core';
import { Matcher } from '@jucie-state/matcher';
const state = createState({ user: { name: 'Alice' } });
state.install(Matcher);
const unsubscribe = state.matcher.createMatcher(['user'], (changes) => {
console.log('User changed:', changes);
});
// Later: remove the matcher
unsubscribe();
`
Returns: Unsubscribe function
#### state.matcher.addMatcher(matcher)
Add an existing matcher to the plugin.
`javascript
const matcher = createMatcher(['user'], (changes) => {
console.log('User:', changes);
});
state.matcher.addMatcher(matcher);
`
#### state.matcher.removeMatcher(matcher)
Remove a matcher from the plugin.
`javascript`
state.matcher.removeMatcher(matcher);
Matchers use hierarchical matching with three types:
Watches the exact path specified:
`javascript
const matcher = createMatcher(['user', 'profile'], (changes) => {
console.log('Exact profile change:', changes);
});
state.set(['user', 'profile'], { bio: 'Hello' }); // ✅ Fires
state.set(['user', 'profile', 'bio'], 'Hi'); // ✅ Fires (parent changed)
state.set(['user'], { profile: { bio: 'Hi' } }); // ✅ Fires (child changed)
state.set(['user', 'settings'], {}); // ❌ Doesn't fire
`
Fires when a parent path changes:
`javascript
const matcher = createMatcher(['user'], (changes) => {
console.log('User or descendants changed:', changes);
});
state.set(['user', 'name'], 'Alice'); // ✅ Fires
state.set(['user', 'profile', 'bio'], 'Hello'); // ✅ Fires
state.set(['user'], { name: 'Bob' }); // ✅ Fires
`
When a parent path is matched, child changes are consolidated:
`javascript
const matcher = createMatcher(['users'], (changes) => {
console.log('Users changed:', changes);
});
state.set(['users', 'alice'], { name: 'Alice' });
state.set(['users', 'bob'], { name: 'Bob' });
// Both changes are batched and consolidated:
// Console: "Users changed: { alice: { name: 'Alice' }, bob: { name: 'Bob' } }"
`
`javascript
import { createState } from '@jucie-state/core';
import { Matcher, createMatcher } from '@jucie-state/matcher';
const userMatcher = createMatcher(['user'], (user) => {
console.log('User:', user);
});
const settingsMatcher = createMatcher(['settings'], (settings) => {
console.log('Settings:', settings);
});
const state = createState({ user: {}, settings: {} });
state.install(Matcher.configure({
matchers: [userMatcher, settingsMatcher]
}));
`
`javascript
import { createState } from '@jucie-state/core';
import { Matcher } from '@jucie-state/matcher';
const state = createState({ user: {}, settings: {} });
state.install(Matcher.configure({
matchers: [
{
path: ['user'],
handler: (user) => console.log('User:', user)
},
{
path: ['settings'],
handler: (settings) => console.log('Settings:', settings)
}
]
}));
`
`javascript
const logger = createMatcher(['user'], (user) => {
console.log('User changed:', user);
});
const validator = createMatcher(['user'], (user) => {
if (!user.email) {
console.warn('User has no email!');
}
});
state.matcher.addMatcher(logger);
state.matcher.addMatcher(validator);
state.set(['user'], { name: 'Alice' });
// Both matchers fire
`
`javascript
// Add matcher conditionally
if (process.env.NODE_ENV === 'development') {
const debugMatcher = state.matcher.createMatcher(['*'], (changes) => {
console.log('DEBUG: State changed:', changes);
});
}
// Add/remove based on user settings
function toggleAuditLog(enabled) {
if (enabled) {
const auditMatcher = createMatcher(['data'], (data) => {
logToServer('data-change', data);
});
state.matcher.addMatcher(auditMatcher);
return () => state.matcher.removeMatcher(auditMatcher);
}
}
`
`javascript
// Watch different levels of nesting
const userMatcher = createMatcher(['user'], (user) => {
console.log('Entire user object:', user);
});
const profileMatcher = createMatcher(['user', 'profile'], (profile) => {
console.log('Just profile:', profile);
});
const nameMatcher = createMatcher(['user', 'profile', 'name'], (name) => {
console.log('Just name:', name);
});
state.set(['user', 'profile', 'name'], 'Alice');
// All three matchers fire with their respective scopes
`
`javascript
const matcher = createMatcher(['items'], (items) => {
console.log('Items changed:', Object.keys(items));
});
// Multiple rapid changes are batched
state.set(['items', 'item1'], { value: 1 });
state.set(['items', 'item2'], { value: 2 });
state.set(['items', 'item3'], { value: 3 });
// Single callback with all changes:
// Console: "Items changed: ['item1', 'item2', 'item3']"
`
`javascript
const emailMatcher = createMatcher(['form', 'email'], (email) => {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
state.set(['form', 'errors', 'email'], isValid ? null : 'Invalid email');
});
state.matcher.addMatcher(emailMatcher);
`
`javascript
const persistMatcher = createMatcher(['user', 'preferences'], (preferences) => {
localStorage.setItem('preferences', JSON.stringify(preferences));
});
state.matcher.addMatcher(persistMatcher);
`
`javascript
const analyticsMatcher = createMatcher(['analytics', 'events'], (events) => {
Object.entries(events).forEach(([key, event]) => {
trackEvent(event.name, event.properties);
});
});
state.matcher.addMatcher(analyticsMatcher);
`
`javascript
// Update derived state when source changes
const cartMatcher = createMatcher(['cart', 'items'], (items) => {
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
state.set(['cart', 'total'], total);
});
state.matcher.addMatcher(cartMatcher);
`
`javascript
const syncMatcher = createMatcher(['user'], async (user) => {
try {
await fetch('/api/user', {
method: 'PUT',
body: JSON.stringify(user)
});
console.log('User synced to server');
} catch (error) {
console.error('Failed to sync user:', error);
}
});
state.matcher.addMatcher(syncMatcher);
`
1. Automatic Batching: Matchers automatically batch changes using setTimeout(fn, 0), so multiple synchronous changes trigger the handler only once
2. Smart Consolidation: Multiple changes to the same path are consolidated into a single update
3. Efficient Matching: Uses marker comparison for fast path matching
4. Cleanup: Always unsubscribe matchers when they're no longer needed to prevent memory leaks
`javascript``
// Good: Clean up when done
const unsubscribe = state.matcher.createMatcher(['temp'], handler);
// ... later
unsubscribe();
| Feature | Matcher | OnChange |
|---------|---------|----------|
| Scope | Specific paths | Global changes |
| Batching | Automatic | Manual |
| Consolidation | Smart path-based | No consolidation |
| Performance | Optimized for specific paths | All changes |
| Use Case | Watch specific data | Track all changes |
Use Matcher when you want to watch specific parts of your state tree.
Use OnChange when you need to track all state changes.
See the root LICENSE file for license information.