on-change: Proxy traps on every write, deep or shallow
Watch an object or array for changes
At a glance
- What is it?
- on-change watches an object graph through `Proxy` traps and calls your function on every write, down to `a.b.0.c`. It is an ESM package for Node 20 and up, built for code where a save or a render has to follow a mutation, and it stops short of batching, per-key Map paths and previous values for WeakMap writes.
- Who is it for?
- Adopt on-change when a small object or array has to notify your code on every write and your handler can afford one call per assignment. Skip it when writes arrive in bursts, when you need the key of a Map entry, or when a WeakMap diff matters, because none of those are options in the list.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 35 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Star history
- sindresorhus/on-change2,031 ★ · +1
| Repository | 2026-10-14 | 2026-10-23 |
|---|---|---|
| sindresorhus/on-change | 2k | 2k |
1 Stars in the last 9 days, measured from the daily GitHub snapshot. Compare with other projects
Editorial analysis
Proxy traps turn plain assignments into callbacks
on-change covers one narrow job: a mutation has to be followed by a side effect, a save, a re-render, a log line, and the same call ends up written after every assignment. The package description is a single line, Watch an object or array for changes, and the implementation uses the `Proxy` API rather than polling or diffing snapshots on a timer.
The object you pass in comes back wrapped in traps, recursively, so a write to `obj.a.b[0].c` is reported exactly like a write to a top level key. Repository topics are change-detection and es2015-proxy, which is the whole design in two words.
Firing is immediate: the callback runs inside the trap rather than on a later tick, so calls arrive in assignment order. In the usage example the log lines sit directly under `watchedObject.foo = true`, reporting the path as foo and the previous value as false. The object handed to `onChange` is never watched. Only the returned value is.
Four callback arguments and a context that already holds the change
The callback receives four arguments. The first is a path built from the keys walked to reach the value, and a change to c in the worked example returns `a.b.0.c`, with array indexes appearing as bare numbers inside that string. The second is the new value, the third is the previous value, and the fourth describes the method that produced the change when the write came from one.
watchedObject.a.b.push(3);
//=> 'path: "a.b"'
//=> 'value: [{c: true}, 3]'
//=> 'previousValue: [{c: true}]'
//=> 'applyData: {
// name: "push",
// args: [3],
// result: 2,
// }'For a push onto a one item array the reported path is the collection, not the new slot, the result is 2, and both the value and the previous value are copied arrays. Nothing in that record names the index that was written, which is the gap the `details` option exists to fill.
`this` inside the callback is the watched object, and the write has already landed on it. Reading `this` gives post-change state, and the third argument is the only route back to the old one. Writes that set a property to the value it already holds do not call back, because the comparison defaults to `Object.is`.
npm install on-change on Node 20 and above
Installation is one command, and it has to arrive through npm rather than a script tag, because the package ships as an ES module.
npm install on-changeThree fields in package.json decide what you can do afterwards. `type` is `module`, and the exports map points `types` at `source/index.d.ts` and `default` at `source/index.js` with no CommonJS entry beside them, so a Node script or bundler has to load it as ESM. `sideEffects` is `false`, which lets a bundler drop the import when nothing calls it. The published `files` array holds `source` and nothing else, so the `bench/` and `tests/` directories sitting at the top level of the repository never reach an install.
Development tooling is separate from that: the `test` script runs `xo && ava`, and `bench` runs `node bench/index.bench.js`, with `tinybench` at `^5.0.1`, `typescript` at `^5.9.2` and `xo` at `^1.2.2` in the dev dependencies.
The published package.json sits at version 6.0.2, the build recorded as the v6.0.2 release dated 2026-02-01, with v6.0.0 following on 2025-09-23.
Nine options decide what counts as a change
Nine options decide whether a write produces a callback. `isShallow` defaults to `false`, and setting it to `true` limits callbacks to immediate properties, so deep edits go quiet. `equals` defaults to `Object.is`, so a write that sets the same value is dropped, and replacing the function changes what counts as unchanged.
Key filtering comes next. `ignoreSymbols` silences `Symbol` properties, `ignoreUnderscores` skips names that begin with leading underscores, and `ignoreKeys` takes an `Array<string | symbol>`, which makes it the general form of the other two filters.
`pathAsArray` swaps the first callback argument from a delimited string into an array of keys, and it is the documented advice for Sets, Maps and Symbol keys. `ignoreDetached` ignores writes to objects that have left the watched graph, and without it those writes still call back on a stale reference.
`details` set to `true` expands mutating array and collection methods into one callback per item, with paths such as `selectedFeatures.0`, and `details: ['forEach']` narrows that to named methods. `onValidate` is the only hook that can say no: it receives the same four arguments and the same context, returning `true` lets the write through and runs the callback, and any other return value blocks the write so the object stays as it was.
WeakMap writes arrive with no previous value to compare
The third argument is the weak point. For WeakSets and WeakMaps it returns `undefined`, since entries in those collections cannot be read back out, so a callback that keeps previous values to build a diff or a revert has a permanent hole in it. The write itself is still reported, with its path and its new value, but the old state is gone.
Path granularity is the second weak point. With `details` off, which is the default, a push onto a watched array is reported at the collection path, and the same rule covers other mutating collection methods. The documented fix is `details` set to `true`, which emits per-item paths such as `selectedFeatures.0`, or an array naming the methods worth expanding.
Turning on `pathAsArray` does not solve it. That option changes the format of the path, not the number of callbacks, so you receive an array of keys pointing at the collection that changed and still have to work out which entry moved.
The simplified example closes over its own binding
The README's own simplified example hides a timing detail.
const foo = onChange({
a: 0,
b: 0
}, () => save(foo));That arrow function reads the variable while its const binding is still being initialised, which is a temporal dead zone reference that stays legal only because the callback runs after the assignment finishes. Anything that calls back during the `onChange` call itself would hit the dead zone. The shape that avoids the question is to build the object first, pass it in, and have the handler take the returned value as a parameter.
The API text carries a second tension. It calls the returned value the exact same object, just with some `Proxy` traps, while the module ships `onChange.target` precisely to hand back the original unwatched object. Both hold in different directions. Reads through the returned value see the same data, and writes that travel through the target are invisible to the watcher.
One trap per assignment, and no history to roll back
The counter in the usage example settles how many callbacks a burst of work produces. `++index` prints 1, then 2, then 3 across three separate statements: one top level write, one deep write to `a.b[0].c`, one push. Every trap fires on its own, so a loop that writes ten properties in a single pass produces ten callbacks. The README does not document a debounce or batch option among the nine.
History is thin for the same reason. `previousValue` exists only at the instant of one change, nothing accumulates a log of edits, and the README does not document a rollback helper, so undo has to be written by the caller.
The two escape hatches sit at the end of the API.
onChange.target(watchedObject).foo = false;
onChange.unsubscribe(watchedObject);
watchedObject.foo = 'bar';The first line writes to the unwatched original and produces no callback, and that is the trap for other code: any module holding a reference from before you wrapped the object keeps mutating data with no notification at all. `onChange.unsubscribe(watchedObject)` cancels every future callback and returns the unwatched object, so unsubscribing is a real teardown rather than a pause.
introspected keeps observers where on-change hands back the original
Several sibling packages in the Related list also build on `Proxy`, and one of them takes the opposite position on lifetime. `introspected` is described as a never-ending Proxy with multiple observers, so a single wrapped object can feed several listeners and nothing is unwrapped afterwards. on-change attaches one callback per watched object, and `onChange.unsubscribe` deliberately returns the original unwatched object. That keeps object identity clean for code that compares references, at the cost of losing the observers you had once unsubscribed.
The other Proxy-based siblings use the same trap mechanism for a different job. `known` allows only access to known object properties, `negative-array` adds negative array index support with `array[-1]`, and `atama` is a state manager. None of them report writes, which is a fair signal that `Proxy` is the shared part and change detection is only one way to spend it.
Editorial conclusion
Adopt on-change when a small object or array has to notify your code on every write and your handler can afford one call per assignment. Skip it when writes arrive in bursts, when you need the key of a Map entry, or when a WeakMap diff matters, because none of those are options in the list. Before installing, check that your toolchain runs Node `>=20` as `engines` requires, and audit every writer so it holds the value `onChange` returns, since writes through `onChange.target` are never seen. The MIT license covers use and the last push was on 2026-09-18.
Frequently asked questions
Does on-change work with require in CommonJS on Node 20?
The package is published as an ES module: `type` is `module`, and the exports map carries `types` at `source/index.d.ts` and `default` at `source/index.js` with no CommonJS entry. The `engines` field requires Node `>=20`.
How do I stop on-change from firing for keys I do not want to track?
Three filters work by key: `ignoreSymbols` for `Symbol` properties, `ignoreUnderscores` for names that begin with leading underscores, and `ignoreKeys` for an `Array<string | symbol>` list. Set `isShallow` to `true` if only immediate properties should call back at all.
What does the callback receive when I push onto a watched array?
For `watchedObject.a.b.push(3)` the reported path is a.b, the value is `[{c: true}, 3]`, the previous value is `[{c: true}]`, and the fourth argument holds the method name `push` together with the arguments passed and the value the method returned.
Will on-change notice a change made to the original object?
No, only the value returned by `onChange` is watched. Writing through `onChange.target(watchedObject)` produces no callback, and `onChange.unsubscribe(watchedObject)` cancels all future callbacks while returning the unwatched object.
Does on-change batch rapid updates or support undo?
Neither is available. Every trap fires its own callback, the nine documented options contain no debounce or batch switch, and `previousValue` covers only the instant of a single change, so a history you can roll back has to be built in your own handler.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/sindresorhus-on-change)Star history chart for your README
[](https://hysenlabs.com/projects/sindresorhus-on-change)Was this useful?