You're staring at a bug. Some property is wrong. You have no idea who changed it, when, or from where.
console.log everywhere. Breakpoints. git blame on a file that touches the object in 6 places. Thirty minutes later you find it.
There's a better way.
import { track, why } from "whynotjs";
const user = track({ name: "Hritik", age: 25 });
// …mutations happen across your entire codebase…
why(user, "name");
// Changed 2 times
//
// 1. Old → Hritik New → John
// Source: ProfileForm.tsx:24 (handleSubmit)
// Time: 10:22 PM
//
// 2. Old → John New → Jane
// Source: UserSettings.tsx:87 (onSave)
// Time: 10:45 PMOne line to opt in. No store. No actions. No boilerplate. Works on any plain JavaScript object.
npm install whynotjsWhyNotJS wraps your object in a native Proxy. Every mutation fires the set trap, which captures an Error stack trace at that exact moment, parses out the call-site, and stores a ChangeRecord in a WeakMap keyed to your object.
track(obj)
└─ new Proxy(obj, { set, deleteProperty })
└─ mutation fires → new Error().stack
└─ parse call-site (file · line · col · fn)
└─ push ChangeRecord into WeakMap<target, Map<prop, history>>
└─ why(obj, prop) reads it back
Four deliberate design decisions:
WeakMapas the store — your object can be garbage-collected normally. No leaks, no cleanup required (unless you want it withuntrack).Error.stackfor call-sites — no bundler plugin, no source-map server, no build step. It's just a standard V8/SpiderMonkey stack trace, parsed at runtime.ReflectalongsideProxy— every trap delegates throughReflectso prototype chains, getters, and class instances all behave correctly.- No-op on identical values —
obj.x = obj.xrecords nothing. The guard isoldValue !== newValuebefore any write.
This is the same primitive Vue 3's reactive(), MobX, and Immer are built on. WhyNotJS just exposes the audit trail instead of hiding it.
Wrap an object. Returns a Proxy — use it everywhere you'd use the original.
const user = track({ name: "Hritik", age: 25 });
const config = track({ theme: "dark" }, { verbose: true });| Option | Type | Default | Description |
|---|---|---|---|
maxHistory |
number |
50 |
Sliding-window cap per property. Oldest records are evicted. |
onchange |
(record: ChangeRecord) => void |
noop | Fires on every mutation. |
verbose |
boolean |
false |
Logs every change to the console automatically. |
ignore |
Array<string | RegExp> |
[] |
Properties to skip. |
watch |
Array<string | symbol> |
[] |
When set, only these properties are tracked. |
Full change history for one property.
const report = why(user, "name");
report.count // 2
report.changes[0].oldValue // "Hritik"
report.changes[0].newValue // "John"
report.changes[0].source // { file: "ProfileForm.tsx", line: 24, col: 14, fn: "handleSubmit" }
report.changes[0].time // "10:22 PM"
report.changes[0].timestamp // "2024-01-15T22:22:00.000Z"History for every property at once.
const all = whyAll(user);
// { name: WhyReport, age: WhyReport }Pretty-prints to the console using console.group.
print(user, "name");
// [WhyNotJS] .name changed 2 times
// 1. 10:22 PM
// Old → Hritik / New → John
// ProfileForm.tsx:24 (handleSubmit)
// 2. 10:45 PM
// Old → John / New → Jane
// UserSettings.tsx:87 (onSave)Clear history for one property, or all of them.
reset(user, "name"); // clears .name only
reset(user); // clears everythingStop tracking entirely and free the WeakMap entry.
untrack(user);import { useRef } from "react";
import { track, print } from "whynotjs";
function ProfileForm() {
const user = useRef(track({ name: "", email: "" })).current;
return (
<input
onChange={e => {
user.name = e.target.value; // tracked
print(user, "name"); // log it any time
}}
/>
);
}const store = track(
{ status: "idle", retries: 0 },
{
onchange({ property, oldValue, newValue, source, time }) {
logger.info(`[${time}] ${String(property)}: ${oldValue} → ${newValue}`, source);
},
}
);const order = track({ status: "pending", total: 0 });
await processPayment(order); // black box
print(order, "status");
// Changed 3 times: pending → validating → charging → complete
// Each with source file + lineconst sensor = track({ temp: 0 }, { maxHistory: 10 });
// Stream in 1000 readings — only the last 10 are kept
readings.forEach(r => (sensor.temp = r));
why(sensor, "temp").changes.length // 10const model = track(obj, { ignore: [/^_/, /^__/] });
// _id, __proto__, _rev — all ignored
// Only public properties are trackedWritten in TypeScript. Full types included, no @types/ package needed.
import type { ChangeRecord, WhyReport, WhyNotOptions, SourceInfo } from "whynotjs";Requires native Proxy (ES2015+). Cannot be polyfilled.
| Runtime | Minimum version |
|---|---|
| Node.js | 14 |
| Chrome / Edge | 49 |
| Firefox | 18 |
| Safari | 10 |
MIT