Version: 2.0.0+ Status: Stable
The plugin system lets you extend Lume state with custom behaviors — logging, validation, persistence, transformation, and more. Plugins intercept state operations through a small set of hooks.
Plugins are applied via withPlugins(), an addon wrapper. The core state() function has no plugin support — this keeps the core minimal and fast.
- Quick Start
- Plugin Interface
- Hook Execution Order
- Chain Pattern
- Best Practices
- Common Patterns
- Performance
import { state } from 'lume-js';
import { withPlugins } from 'lume-js/addons';
const debugPlugin = {
name: 'debug',
onGet: (key, value) => {
console.log(`GET ${key}:`, value);
return value;
},
onSet: (key, newValue, oldValue) => {
console.log(`SET ${key}:`, oldValue, '→', newValue);
return newValue;
}
};
const store = withPlugins(state({ count: 0 }), [debugPlugin]);A plugin is a plain object with a name property and optional hook functions:
interface Plugin {
name: string; // Required: unique identifier
onInit?: () => void; // Called when state is created
onGet?: (key: string, value: any) => any; // Intercept reads
onSet?: (key: string, newValue: any, oldValue: any) => any; // Intercept writes
onSubscribe?: (key: string) => void; // Called when a subscriber is added
onNotify?: (key: string, value: any) => void; // Before subscribers are notified
}Called synchronously when the state object is created.
Use cases: initialize plugin state, set up external connections, register global handlers.
const plugin = {
name: 'logger',
onInit: () => {
console.log('State initialized at', new Date());
}
};Called when a property is accessed, before the value is returned.
Parameters:
key— Property key being accessed.value— Current value (possibly transformed by earlier plugins in the chain).
Returns: Transformed value, or the original value to pass it through unchanged.
Use cases: transform values on read, log access, implement computed properties, lazy loading.
const transformPlugin = {
name: 'transform',
onGet: (key, value) => {
if (key === 'name' && typeof value === 'string') {
return value.toUpperCase();
}
return value;
}
};Called when a property is updated, before subscribers are notified.
Parameters:
key— Property key being updated.newValue— New value (possibly transformed by earlier plugins).oldValue— Previous value.
Returns: The value to store. Return oldValue to reject the change.
Use cases: validate input, transform values on write, prevent updates, track history.
const validationPlugin = {
name: 'validation',
onSet: (key, newValue, oldValue) => {
if (key === 'age' && (newValue < 0 || newValue > 150)) {
console.warn('Invalid age, keeping old value');
return oldValue; // Reject change
}
return newValue; // Accept change
}
};Called when a new subscriber is added to a property.
Parameters:
key— Property key being subscribed to.
Use cases: track active subscriptions, lazy load data, initialize watchers.
const trackPlugin = {
name: 'tracker',
onSubscribe: (key) => {
console.log(`New subscriber for "${key}"`);
}
};Called during microtask flush, before subscribers are notified. This is the right place for side effects.
Parameters:
key— Property key that changed.value— New value being notified.
Use cases: log notifications, sync with external systems, trigger side effects.
const syncPlugin = {
name: 'sync',
onNotify: (key, value) => {
localStorage.setItem(key, JSON.stringify(value));
}
};1. Property accessed: store.count
2. onGet hooks run (all plugins, in registration order)
3. Transformed value returned to caller
1. Property set: store.count = 5
2. onSet hooks run (all plugins, in registration order)
3. Final value stored
4. Change queued for microtask
--- microtask boundary ---
5. onNotify hooks run (all plugins, in registration order)
6. Subscribers notified
7. Effects re-run
store.count = 5
↓
onSet (plugin1)
↓
onSet (plugin2)
↓
value stored
↓
microtask scheduled
↓
--- wait for microtask ---
↓
onNotify (plugin1)
↓
onNotify (plugin2)
↓
subscribers called
↓
effects run
When multiple plugins are registered, each plugin receives the output of the previous one.
const plugin1 = {
name: 'double',
onGet: (key, value) => typeof value === 'number' ? value * 2 : value
};
const plugin2 = {
name: 'add10',
onGet: (key, value) => typeof value === 'number' ? value + 10 : value
};
const store = withPlugins(state({ count: 5 }), [plugin1, plugin2]);
console.log(store.count); // (5 * 2) + 10 = 20const plugin1 = {
name: 'clamp',
onSet: (key, value) => {
if (key === 'age') return Math.max(0, Math.min(150, value));
return value;
}
};
const plugin2 = {
name: 'round',
onSet: (key, value) => {
if (key === 'age') return Math.round(value);
return value;
}
};
const store = withPlugins(state({ age: 0 }), [plugin1, plugin2]);
store.age = 175.7;
console.log(store.age); // Math.round(Math.min(150, 175.7)) = 150Each plugin should do one thing well.
// Good: Single responsibility
const validationPlugin = { name: 'validation', onSet: validateData };
const loggingPlugin = { name: 'logging', onGet: logAccess };
// Avoid: Mixed responsibilities in one plugin
const godPlugin = {
name: 'everything',
onGet: (key, value) => { /* validate + log + transform + … */ }
};const ageValidator = {
name: 'age-validator',
onSet: (key, newValue, oldValue) => {
if (key !== 'age') return newValue;
if (newValue < 0 || newValue > 150) return oldValue;
return newValue;
}
};Lume catches errors in plugin hooks and logs them, but your plugin should handle expected errors:
const safePlugin = {
name: 'safe',
onGet: (key, value) => {
try {
return JSON.parse(value);
} catch (e) {
console.warn(`Failed to parse ${key}:`, e);
return value;
}
}
};onGet runs on every property read. Side effects here (fetch calls, writes to other keys) will fire far more often than you expect. Use onNotify for side effects instead.
// Good: pure transformation
const plugin = {
name: 'format',
onGet: (key, value) => typeof value === 'string' ? value.toUpperCase() : value
};
// Avoid: side effects in onGet
const plugin = {
name: 'bad',
onGet: (key, value) => {
fetch('/api/log', { method: 'POST', body: key }); // fires on every read!
return value;
}
};const persistPlugin = {
name: 'persist',
onNotify: (key, value) => {
localStorage.setItem(key, JSON.stringify(value));
}
};const validationPlugin = {
name: 'validation',
onSet: (key, newValue, oldValue) => {
const rules = {
email: (v) => typeof v === 'string' && v.includes('@'),
age: (v) => typeof v === 'number' && v >= 0 && v <= 150
};
if (rules[key] && !rules[key](newValue)) {
console.error(`Validation failed for ${key}`);
return oldValue;
}
return newValue;
}
};const historyPlugin = {
name: 'history',
onSet: (key, newValue, oldValue) => {
if (!historyPlugin.stack) historyPlugin.stack = [];
historyPlugin.stack.push({ key, value: oldValue });
return newValue;
}
};
function undo(store) {
if (historyPlugin.stack.length > 0) {
const { key, value } = historyPlugin.stack.pop();
store[key] = value;
}
}const persistPlugin = {
name: 'persist',
onInit: () => {
persistPlugin.loaded = true;
},
onGet: (key, value) => {
if (persistPlugin.loaded && value === undefined) {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : value;
}
return value;
},
onNotify: (key, value) => {
localStorage.setItem(key, JSON.stringify(value));
}
};const normalizePlugin = {
name: 'normalize',
onSet: (key, newValue) => {
if (key === 'email' && typeof newValue === 'string') {
return newValue.toLowerCase().trim();
}
if (key === 'phone' && typeof newValue === 'string') {
return newValue.replace(/\D/g, ''); // digits only
}
return newValue;
}
};Plugin hooks add minimal overhead — typically 10–30% per operation depending on complexity. Each hook call is synchronous, and multiple plugins execute sequentially.
Tips:
- Filter early: return immediately for non-applicable keys.
- Keep hook logic lightweight.
- Cache computed results when needed.
- For typical apps, 1–3 plugins is plenty.
Typical overhead on a modern system (M1 Mac):
| Operation | No Plugins | 1 Plugin | 3 Plugins | 10 Plugins |
|---|---|---|---|---|
| Get | 0.001 ms | 0.002 ms | 0.004 ms | 0.010 ms |
| Set | 0.002 ms | 0.003 ms | 0.006 ms | 0.015 ms |
For the vast majority of applications, plugin overhead is negligible.
Plugin errors are caught and logged but do not crash the application:
const buggyPlugin = {
name: 'buggy',
onGet: (key, value) => {
throw new Error('Oops!');
}
};
const store = withPlugins(state({ count: 0 }), [buggyPlugin]);
store.count; // Logs error, returns original value
// Console: [Lume.js] Plugin "buggy" error in onGet: Error: Oops!Full TypeScript definitions are provided:
import { state } from 'lume-js';
import { withPlugins, Plugin } from 'lume-js/addons';
const myPlugin: Plugin = {
name: 'example',
onGet: (key: string, value: unknown): unknown => {
return value;
}
};
const store = withPlugins(state({ count: 0 }), [myPlugin]);- Design Decisions — Why plugins work this way
- State API — Core state documentation
- addons/debug — Built-in debug plugin using withPlugins
← Previous: Handlers API | Next: watch() →