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? See docs/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 and pause/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 a test hook (flushCleanup()) to run pending cleanups immediately - TypeScript declarations included (
observable-slim.d.ts)
Browser (UMD):
<script src="https://unpkg.com/observable-slim"></script>
<script>
const state = { hello: "world" };
const proxy = ObservableSlim.create(state, false, (changes) => console.log(changes));
</script>NPM (CommonJS):
npm install observable-slim --saveconst ObservableSlim = require('observable-slim');ES Modules (via bundlers that can import CJS):
import ObservableSlim from 'observable-slim';const state = { user: { name: 'Ada' }, todos: [] };
const p = ObservableSlim.create(state, true, (changes) => {
// Array of change records batched on a small timeout when domDelay === true
console.log(changes);
});
p.todos.push({ title: 'Write tests', done: false });
p.user.name = 'Ada Lovelace';Create a new Proxy that mirrors target 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 an array 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/enable observer callbacks for the given proxy (no changes are blocked).
Disable/enable writes 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.
Return true 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 (climb depth levels; default 1).
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 changed
proxy: object, // proxy for the target
newValue: any,
previousValue?: any
}Below, every mutation is followed by the array that your observer handler function receives:
const test = {};
const p = 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"]
// }]
delete p.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":[]
// }]const p = 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).
const data = { foo: { bar: "bar" } };
const p1 = ObservableSlim.create(data, false, (c) => console.log("p1", c));
ObservableSlim.observe(p1, (c) => console.log("p1-second", c));
const p2 = ObservableSlim.create(data, false, (c) => console.log("p2", c));
p2.foo.bar = "baz"; // triggers both observers on p1 and p2const p = ObservableSlim.create({ x: 0 }, false, (c) => console.log("obs", c));
ObservableSlim.pause(p);
p.x = 1; // no observer callbacks
ObservableSlim.resume(p);
ObservableSlim.pauseChanges(p);
p.x = 2; // observer fires, but underlying target is NOT updated
console.log(p.x); // still 0
ObservableSlim.resumeChanges(p);
p.x = 3; // observer fires and target is updatedconst state = { a: { b: 1 } };
const proxy = ObservableSlim.create(state, false, () => {});
console.log(ObservableSlim.isProxy(proxy)); // true
console.log(ObservableSlim.getTarget(proxy) === state); // true
const child = proxy.a;
console.log(ObservableSlim.getParent(child) === proxy); // parent proxy
console.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]); // true
console.log(proxy[TARGET] === state); // true
console.log(child[PARENT](1) === proxy); // true
console.log(child[PATH]); // 'a'const p = ObservableSlim.create({ y: 1 }, false, () => console.log('called'));
ObservableSlim.remove(p);
p.y = 2; // no callbacks after removalThis library requires native ES2015 Proxy, WeakMap and Symbol 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 exposed string-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) or proxy[ObservableSlim.symbols.IS_PROXY] |
proxy.__getTarget |
ObservableSlim.getTarget(proxy) or proxy[ObservableSlim.symbols.TARGET] |
proxy.__getParent(depth?) |
ObservableSlim.getParent(proxy, depth) or proxy[ObservableSlim.symbols.PARENT](depth) |
proxy.__getPath |
ObservableSlim.getPath(proxy) or proxy[ObservableSlim.symbols.PATH] |
The helpers are preferred for readability and to avoid re-entering traps unnecessarily.