- Notifications
You must be signed in to change notification settings - Fork55
Observable Slim is a singleton that utilizes ES6 Proxies to observe changes made to an object and any nested children of that object. It is intended to assist with state management and one-way data binding.
License
ElliotNB/observable-slim
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
https://github.com/elliotnb/observable-slim
A small, dependency-free library that watches deep changes in plain JavaScript objects/arrays and tells you exactly what changed.
Observable Slim mirrors JavaScript objects and arrays using ES2015 Proxy, letting you observe deeply nested changes with clear notifications using dotted paths and RFC-6901 JSON Pointers. It safely handles circular references, ensures fast O(1) performance through WeakMap/WeakSet identity tables, and keeps paths accurate even when arrays reorder. You can send updates immediately or batch them for smoother UI rendering. It also efficiently manages proxies, lets you pause or validate updates, and cleans up memory safely when data is removed. Lightweight (~6 KB minified), dependency-free, and memory-safe, it's well-suited for UI data binding, state management, audit trails, debugging tools, telemetry/profiling, or collaborative editing using CRDT or OT algorithms.
- Design (Deep Dive)
- Features
- Installation
- Quick Start
- API
- Change Record Shape
- Usage Examples
- Limitations and Browser Support
- TypeScript
- Development
- Contributing
Curious about the underlying architecture and implementation? Seedocs/design.md for the problem model, core algorithms, complexity, invariants, cycle-safe instrumentation, cross-proxy fan-out, reachability-based teardown, and correctness arguments. It's optional reading, but helpful if you want to understand how the internals stay fast and memory-safe.
- Deep observation of objects and arrays (all nested children)
- Structured change records with:
type,property,currentPath(dot notation),jsonPointer(RFC6901),previousValue,newValue,target,proxy - Batched notifications with
domDelay(boolean or ms number) - Multiple proxies per target with safe cross-proxy propagation
- Pause/resume observers andpause/resume changes (dry-run validation)
- Accurate array length tracking using WeakMap bookkeeping
- Introspection helpers:
isProxy,getTarget,getParent,getPath - Advanced symbol capabilities for collision-free internals
- Configurable orphan-cleanup scheduler (
configure({ cleanupDelayMs })) and atest hook (flushCleanup()) to run pending cleanups immediately - TypeScript declarations included (
observable-slim.d.ts)
Browser (UMD):
<scriptsrc="https://unpkg.com/observable-slim"></script><script>conststate={hello:"world"};constproxy=ObservableSlim.create(state,false,(changes)=>console.log(changes));</script>
NPM (CommonJS):
npm install observable-slim --save
constObservableSlim=require('observable-slim');
ES Modules (via bundlers that can import CJS):
importObservableSlimfrom'observable-slim';
conststate={user:{name:'Ada'},todos:[]};constp=ObservableSlim.create(state,true,(changes)=>{// Array of change records batched on a small timeout when domDelay === trueconsole.log(changes);});p.todos.push({title:'Write tests',done:false});p.user.name='Ada Lovelace';
Create a new Proxy that mirrorstarget and observes all deep changes.
target:object (required) – plain object/array to observe.domDelay:boolean|number (required) –trueto batch notifications on ~10ms timeout;falseto notify synchronously; number> 0to use a custom delay (ms).observer(changes):function (optional) – receives anarray of change records.- Returns: the Proxy.
Note: Passing an existing Proxy produced by Observable Slim is supported; the underlying original target will be used to avoid nested proxying.
Attach an additional observer to an existing proxy. Observers are called with arrays of change records.
Temporarily disable/enableobserver callbacks for the given proxy (no changes are blocked).
Disable/enablewrites to the underlying target while still issuing change records. Useful for approval flows or validations.
Detach all observers and bookkeeping for the given proxy and its nested proxies created for the same root observable.
Returntrue if the argument is a Proxy created by Observable Slim.
Return the original target object behind a Proxy created by Observable Slim.
Return the parent object of a proxy relative to the top-level observable (climbdepth levels; default1).
Return the path string of a proxy relative to its root observable.
options = { jsonPointer?: boolean }– whentrue, return RFC6901 pointer (e.g.,/foo/0/bar); otherwise dot path (e.g.,foo.0.bar).
For advanced users who need capability-style access without relying on public helpers, the library exposes collision-free Symbols:
ObservableSlim.symbols.IS_PROXY– brand symbol;proxy[IS_PROXY] === trueObservableSlim.symbols.TARGET– unwrap symbol;proxy[TARGET] === originalObjectObservableSlim.symbols.PARENT– function symbol;proxy[PARENT](depth)returns the ancestorObservableSlim.symbols.PATH– path symbol;proxy[PATH]returns the dot path
Symbols are not enumerable and won’t collide with user properties. Prefer the public helpers for most use cases.
Configure behavior that affects all observables created in this runtime.
options.cleanupDelayMs: number– delay (ms) used by the orphan-cleanup scheduler. Set to0to run cleanups eagerly; increase to coalesce more work.
ObservableSlim.configure({cleanupDelayMs:25});
Force any pending orphan cleanups to run immediately. Useful in tests for deterministic timing; safe to call in production if you need to flush scheduled cleanups now.
ObservableSlim.flushCleanup();
Every notification contains an array of objects like:
{type:'add'|'update'|'delete',property:string,currentPath:string,// e.g. "foo.0.bar"jsonPointer:string,// e.g. "/foo/0/bar"target:object,// the concrete target that changedproxy:object,// proxy for the targetnewValue:any,previousValue?:any}
Below, every mutation is followed by the array that your observer handler function receives:
consttest={};constp=ObservableSlim.create(test,false,(changes)=>{console.log(JSON.stringify(changes));});p.hello="world";// => [{// "type":"add","target":{"hello":"world"},"property":"hello",// "newValue":"world","currentPath":"hello","jsonPointer":"/hello",// "proxy":{"hello":"world"}// }]p.hello="WORLD";// => [{// "type":"update","target":{"hello":"WORLD"},"property":"hello",// "newValue":"WORLD","previousValue":"world",// "currentPath":"hello","jsonPointer":"/hello",// "proxy":{"hello":"WORLD"}// }]p.testing={};// => [{// "type":"add","target":{"hello":"WORLD","testing":{}},// "property":"testing","newValue":{},// "currentPath":"testing","jsonPointer":"/testing",// "proxy":{"hello":"WORLD","testing":{}}// }]p.testing.blah=42;// => [{// "type":"add","target":{"blah":42},"property":"blah","newValue":42,// "currentPath":"testing.blah","jsonPointer":"/testing/blah",// "proxy":{"blah":42}// }]p.arr=[];// => [{// "type":"add","target":{"hello":"WORLD","testing":{"blah":42},"arr":[]},// "property":"arr","newValue":[],// "currentPath":"arr","jsonPointer":"/arr",// "proxy":{"hello":"WORLD","testing":{"blah":42},"arr":[]}// }]p.arr.push("hello world");// => [{// "type":"add","target":["hello world"],"property":"0",// "newValue":"hello world","currentPath":"arr.0","jsonPointer":"/arr/0",// "proxy":["hello world"]// }]deletep.hello;// => [{// "type":"delete","target":{"testing":{"blah":42},"arr":["hello world"]},// "property":"hello","newValue":null,"previousValue":"WORLD",// "currentPath":"hello","jsonPointer":"/hello",// "proxy":{"testing":{"blah":42},"arr":["hello world"]}// }]p.arr.splice(0,1);// => [{// "type":"delete","target":[],"property":"0","newValue":null,// "previousValue":"hello world","currentPath":"arr.0","jsonPointer":"/arr/0",// "proxy":[]// },{// "type":"update","target":[],"property":"length","newValue":0,// "previousValue":1,"currentPath":"arr.length","jsonPointer":"/arr/length",// "proxy":[]// }]
constp=ObservableSlim.create({arr:["foo","bar"]},false,(c)=>console.log(JSON.stringify(c)));p.arr.unshift("hello");// 1) add index 2 moved -> implementation may record reindexes; canonical signal is:// [{"type":"add","target":["hello","foo","bar"],"property":"0","newValue":"hello",// "currentPath":"arr.0","jsonPointer":"/arr/0","proxy":["hello","foo","bar"]}]p.arr.pop();// Deleting last element and updating length; commonly two records over one or two callbacks:// [{"type":"delete","target":["hello","foo"],"property":"2","newValue":null,// "previousValue":"bar","currentPath":"arr.2","jsonPointer":"/arr/2","proxy":["hello","foo"]}]// [{"type":"update","target":["hello","foo"],"property":"length","newValue":2,// "previousValue":3,"currentPath":"arr.length","jsonPointer":"/arr/length","proxy":["hello","foo"]}]p.arr.splice(1,0,"X");// Insert at index 1 and reindex subsequent items:// [{"type":"add","target":["hello","X","foo"],"property":"1","newValue":"X",// "currentPath":"arr.1","jsonPointer":"/arr/1","proxy":["hello","X","foo"]}]p.arr.shift();// Move index 1 down to 0, delete old 1, update length. Typical sequence:// [{"type":"update","target":["X","foo"],"property":"0","newValue":"X",// "previousValue":"hello","currentPath":"arr.0","jsonPointer":"/arr/0","proxy":["X","foo"]}]// [{"type":"delete","target":["X","foo"],"property":"1","newValue":null,// "previousValue":"foo","currentPath":"arr.1","jsonPointer":"/arr/1","proxy":["X","foo"]}]// [{"type":"update","target":["X","foo"],"property":"length","newValue":2,// "previousValue":3,"currentPath":"arr.length","jsonPointer":"/arr/length","proxy":["X","foo"]}]
Notes: Exact batching of array index/length signals can vary by engine and call path. The shapes above are representative and covered by the test suite (push, unshift, pop, shift, splice, and length tracking).
constdata={foo:{bar:"bar"}};constp1=ObservableSlim.create(data,false,(c)=>console.log("p1",c));ObservableSlim.observe(p1,(c)=>console.log("p1-second",c));constp2=ObservableSlim.create(data,false,(c)=>console.log("p2",c));p2.foo.bar="baz";// triggers both observers on p1 and p2
constp=ObservableSlim.create({x:0},false,(c)=>console.log("obs",c));ObservableSlim.pause(p);p.x=1;// no observer callbacksObservableSlim.resume(p);ObservableSlim.pauseChanges(p);p.x=2;// observer fires, but underlying target is NOT updatedconsole.log(p.x);// still 0ObservableSlim.resumeChanges(p);p.x=3;// observer fires and target is updated
conststate={a:{b:1}};constproxy=ObservableSlim.create(state,false,()=>{});console.log(ObservableSlim.isProxy(proxy));// trueconsole.log(ObservableSlim.getTarget(proxy)===state);// trueconstchild=proxy.a;console.log(ObservableSlim.getParent(child)===proxy);// parent proxyconsole.log(ObservableSlim.getPath(child));// 'a'console.log(ObservableSlim.getPath(child,{jsonPointer:true}));// '/a'const{TARGET,IS_PROXY,PARENT,PATH}=ObservableSlim.symbols;console.log(proxy[IS_PROXY]);// trueconsole.log(proxy[TARGET]===state);// trueconsole.log(child[PARENT](1)===proxy);// trueconsole.log(child[PATH]);// 'a'
constp=ObservableSlim.create({y:1},false,()=>console.log('called'));ObservableSlim.remove(p);p.y=2;// no callbacks after removal
This library requires native ES2015Proxy,WeakMap andSymbol support.
- ✅ Chrome 49+, Edge 12+, Firefox 18+, Opera 36+, Safari 10+ (per MDN guidance)
- ❌ Internet Explorer: not supported
Polyfills cannot fully emulate
Proxy; features like property addition/deletion and.lengthinterception will not work under a polyfill.
Type declarations are published with the package (observable-slim.d.ts). Observer callbacks are strongly typed with the change record shape described above.
- Install deps:
npm ci - Run tests:
npm run test - Lint:
npm run lint/npm run lint:fixto identify and correct code formatting. - Type declarations:
npm run typegenerates thed.tsfile for TypeScript declarations. - Build (minified):
npm run buildemits.cjs,.mjs,.jsand.d.tsartifacts into thedistfolder.
Issues and PRs are welcome! Please:
- Write tests for behavioral changes.
- Keep the API surface small and predictable.
- Run
npm run lintandnpm run testbefore submitting.
Earlier versions exposedstring-named magic fields (e.g.,__isProxy,__getTarget,__getParent(),__getPath). These have been replaced by safer helpers and Symbols:
| Legacy (deprecated) | New API |
|---|---|
proxy.__isProxy | ObservableSlim.isProxy(proxy) orproxy[ObservableSlim.symbols.IS_PROXY] |
proxy.__getTarget | ObservableSlim.getTarget(proxy) orproxy[ObservableSlim.symbols.TARGET] |
proxy.__getParent(depth?) | ObservableSlim.getParent(proxy, depth) orproxy[ObservableSlim.symbols.PARENT](depth) |
proxy.__getPath | ObservableSlim.getPath(proxy) orproxy[ObservableSlim.symbols.PATH] |
The helpers are preferred for readability and to avoid re-entering traps unnecessarily.
About
Observable Slim is a singleton that utilizes ES6 Proxies to observe changes made to an object and any nested children of that object. It is intended to assist with state management and one-way data binding.
Topics
Resources
License
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Packages0
Uh oh!
There was an error while loading.Please reload this page.
Contributors8
Uh oh!
There was an error while loading.Please reload this page.