Implement a Model class in the style of Backbone.js — an object that stores a bag of attributes and lets other code subscribe to changes. When an attribute's value changes, the model notifies its subscribers; when nothing changed, it stays silent. This is the observer pattern at the heart of classic MVC front ends: a view registers a listener on the model, and re-renders itself whenever the data it displays actually changes — without the model needing to know the view exists.
class Model {
constructor(attrs?: Record<string, unknown>); // no change events fire here
get(key: string): unknown; // value, or undefined if absent
set(key: string, value: unknown, options?: { silent?: boolean }): this;
set(attrs: Record<string, unknown>, options?: { silent?: boolean }): this;
on(event: string, callback: Function): void; // subscribe
off(event?: string, callback?: Function): void; // unsubscribe
trigger(event: string, ...args: unknown[]): void; // fire manually
previous(key: string): unknown; // value before the last change
changedAttributes(): Record<string, unknown> | false; // keys changed in the last set
toJSON(): Record<string, unknown>; // shallow copy of attributes
}
For each key whose value actually changes, set fires a change:<key> event with (model, newValue). After all per-key events, it fires one general change event with (model). A change:<key> or change event fires only when at least one value changed. set returns this so calls chain.
const m = new Model({ name: 'Ada' });
m.on('change:name', (model, value) => console.log('name ->', value));
m.on('change', (model) => console.log('something changed'));
m.set('name', 'Grace');
// logs: "name -> Grace"
// logs: "something changed"
// Setting a key to the value it already holds fires NOTHING.
const m = new Model({ count: 0 });
m.on('change', () => console.log('changed'));
m.set('count', 0); // (no output — 0 === 0)
m.set('count', 1); // logs: "changed"
// The object form sets many keys but fires the general change only once.
const m = new Model({ a: 1, b: 2 });
m.on('change:a', () => console.log('a'));
m.on('change:b', () => console.log('b'));
m.on('change', () => console.log('general'));
m.set({ a: 10, b: 20 });
// logs: "a", "b", "general" — one general event, not two
// Silent set updates state but stays quiet.
m.set({ a: 99 }, { silent: true }); // (no output); m.get('a') === 99
attrs to new Model(attrs) seeds the values directly. It must not fire any change event — there are no listeners yet, and seeding is not a change.===). A key "changed" only when its new value is not === to its current value. set('count', 0) when count is already 0 fires nothing. Object identity counts: setting a key to a different object with the same contents does fire, because the references differ.change:<key> listener that calls model.get(key) must read the new value, not the old one. Apply the change to the internal store first, then invoke listeners.change. Each changed key gets its own change:<key>, but the whole set produces exactly one general change, regardless of how many keys changed.off has three modes. off(event, cb) removes one listener; off(event) removes every listener for that event; off() removes everything.You'll build a small data object that other code can subscribe to — when an attribute changes, the object tells its subscribers; when nothing changed, it stays quiet.
Imagine a settings panel. Somewhere you hold the user's data — their name, their theme, a notification toggle. Several parts of the screen depend on that data: a header shows the name, a preview pane reflects the theme, a "Save" button lights up when anything is dirty. You don't want each of those pieces to poll the data on a timer, and you don't want the data to hard-code "after I change, go re-render the header and the preview and the button." That coupling rots fast.
The observer pattern inverts it. The data object — the model — keeps a list of interested parties (the observers) and exposes a way to subscribe. When the data changes, the model walks its subscriber list and notifies each one. The model never names a view; it just announces "this changed." That's exactly what a Backbone-style Model does, and what you're implementing here.
A Model is really just two maps wearing a class:
attributes — the actual data. get(key) reads from it; set(key, value) writes to it._listeners — an event registry: a map from an event name ("change:name", "change", or any custom string) to an array of callbacks. on appends a callback; off removes; trigger calls them all.Everything else is plumbing connecting those two. set is the only interesting method: it writes to attributes and, as a side effect, fires events through _listeners.
The contract has one subtle rule that drives the whole design: an event fires only when a value actually changes. Setting count to 0 when it's already 0 must fire nothing. That single requirement is why set can't just write-and-notify — it has to diff first.
The obvious version writes the value and fires the events. Here's the shape most people reach for:
class Model {
constructor(attrs = {}) {
this.attributes = { ...attrs };
this._listeners = {};
}
get(key) {
return this.attributes[key];
}
set(key, value) {
// fire first, then store
this.trigger('change:' + key, this, value);
this.trigger('change', this);
this.attributes[key] = value;
}
on(event, cb) {
(this._listeners[event] ||= []).push(cb);
}
trigger(event, ...args) {
(this._listeners[event] || []).forEach((cb) => cb(...args));
}
}
This is wrong in two independent ways, and both are worth seeing.
Problem one: it fires on every set, even when nothing changed. Call set('count', 0) on a model whose count is already 0 and the change listeners run anyway. A view that re-renders on change now re-renders on a non-change — the exact waste the observer pattern was supposed to eliminate. There's no diff, so there's no way to stay silent.
Problem two: it fires before it writes. Look at the order: trigger('change:count', ...) runs before this.attributes[key] = value. So a listener that calls model.get('count') reads the OLD value. That's a classic, maddening bug — the event says "count changed" but the model still reports the old number. State must be committed before any callback runs.
There's also a missing feature — set only handles set(key, value), not the set({ a: 1, b: 2 }) object form — but the two ordering/diffing bugs are the conceptual core. Fixing them is the whole question.
class Model {
constructor(attrs = {}) {
// Seed attributes directly — no events here. Spread so the caller's
// object isn't aliased by our internal store.
this.attributes = { ...attrs };
this._previous = {}; // snapshot of attributes before the last set
this._changed = {}; // keys (and new values) changed in the last set
this._listeners = {}; // event name -> array of callbacks
}
get(key) {
return this.attributes[key];
}
set(key, value, options) {
// Normalise the two call shapes into a single { k: v } object + options.
let attrs;
if (key !== null && typeof key === 'object') {
attrs = key;
options = value;
} else {
attrs = { [key]: value };
}
const opts = options || {};
// Diff: collect only the keys whose value actually changes (strict ===).
const changed = {};
let any = false;
for (const k of Object.keys(attrs)) {
if (attrs[k] !== this.attributes[k]) {
changed[k] = attrs[k];
any = true;
}
}
if (!any) return this; // nothing changed — stay completely silent
// Snapshot the previous values for previous(), then commit the new state
// BEFORE firing any listener, so a listener calling get() sees the new value.
this._previous = { ...this.attributes };
this._changed = changed;
for (const k of Object.keys(changed)) {
this.attributes[k] = changed[k];
}
if (opts.silent) return this; // state updated, but fire nothing
// Per-key events first, then exactly one general change.
for (const k of Object.keys(changed)) {
this.trigger('change:' + k, this, changed[k]);
}
this.trigger('change', this);
return this;
}
on(event, callback) {
(this._listeners[event] || (this._listeners[event] = [])).push(callback);
}
off(event, callback) {
if (event === undefined) {
this._listeners = {}; // remove everything
} else if (callback === undefined) {
delete this._listeners[event]; // remove all listeners for this event
} else if (this._listeners[event]) {
this._listeners[event] = this._listeners[event].filter((cb) => cb !== callback);
}
}
trigger(event, ...args) {
const cbs = this._listeners[event];
if (!cbs) return;
// Iterate a COPY so a listener that calls off() mid-fire can't corrupt
// the loop or skip a sibling.
for (const cb of cbs.slice()) {
cb(...args);
}
}
previous(key) {
return this._previous[key];
}
changedAttributes() {
const keys = Object.keys(this._changed);
if (keys.length === 0) return false;
return { ...this._changed };
}
toJSON() {
return { ...this.attributes };
}
}
module.exports = { Model };
The key shifts from the naive version: set now diffs before doing anything, commits state before firing, and handles both call shapes. Walk the non-obvious choices.
Normalising the two call shapes. set('name', 'Grace') and set({ name: 'Grace' }) should behave identically. The branch key !== null && typeof key === 'object' detects the object form (and guards against null, since typeof null === 'object'). When it's the object form, the second positional argument is the options object, not a value — so we shift options = value. After this block, the rest of the method only ever deals with one shape: an attrs object and an opts object.
The diff is the heart of the contract. We loop the incoming keys and keep only the ones where attrs[k] !== this.attributes[k] — strict inequality. Strict === is deliberate: 0 !== 0 is false so setting count to 0 again is dropped, but {} !== {} is true (two different object references), so reassigning an object always counts as a change even if its contents match. If changed ends up empty, we return this immediately — no state write, no events, nothing. That early return is what makes "no change, no event" true.
Commit state, then fire. We snapshot the old attributes into _previous (so previous(key) can answer later), record _changed (so changedAttributes() can answer later), and only then copy the new values into this.attributes. All of that happens before the first trigger call. So when a change:name listener calls model.get('name'), the store already holds the new value. This is the fix for the naive version's second bug.
Silent short-circuits after the write. { silent: true } means "update the data but don't tell anyone." Note where the check sits: after the state has been committed, before any trigger. So a silent set genuinely changes the model — get reflects it, previous and changedAttributes are updated — it just skips the notifications.
Per-key first, then one general change. We fire one change:<key> per changed key (passing (this, newValue) — the Backbone signature), then a single change (passing just (this)). One set produces at most one general change, no matter how many keys changed. Putting the per-key events first matches Backbone and is the more useful order: a key-specific listener has run before the broad "something changed" listener.
trigger iterates a copy. cbs.slice() makes a shallow copy of the callback array before looping. If a listener calls off() (or on()) while it's running — common when a one-shot subscriber unsubscribes itself — mutating the live array mid-iteration would skip the next listener or throw. Iterating the snapshot makes the fire-set stable for the duration of the trigger.
off's three modes fall out of which arguments are undefined: no args wipes the whole registry; an event with no callback deletes that event's array; an event plus callback filters out just that one function (by reference — the same function object you passed to on).
Trace 1 — a single set with a listener attached. Start with const m = new Model({ name: 'Ada' }) and a listener:
m.on('change:name', (model, value) => {
console.log('event value:', value);
console.log('get() reads:', model.get('name'));
});
m.set('name', 'Grace');
Step by step inside set('name', 'Grace'):
normalise key is a string -> attrs = { name: 'Grace' }, opts = {}
diff 'Grace' !== 'Ada' -> changed = { name: 'Grace' }, any = true
not empty skip the early return
snapshot _previous = { name: 'Ada' }
_changed = { name: 'Grace' }
commit state attributes.name = 'Grace' <- store updated NOW
not silent skip the silent return
fire change:name trigger('change:name', m, 'Grace')
listener logs "event value: Grace"
listener logs "get() reads: Grace" <- reads the NEW value
fire change trigger('change', m)
return this
The pivotal line is get() reads: Grace. Because we committed attributes.name before firing, the listener sees the new value through both channels — the value argument and a fresh get. In the naive version this line would have logged Ada.
Trace 2 — a multi-key set, showing the batching. Same model, now with three listeners:
m.on('change:name', () => log('change:name'));
m.on('change:role', () => log('change:role'));
m.on('change', () => log('change'));
m.set({ name: 'Grace', role: 'admin' });
normalise key is an object -> attrs = { name: 'Grace', role: 'admin' }, opts = {}
diff 'Grace' !== 'Grace'? name is already 'Grace' from Trace 1 -> NOT changed
'admin' !== undefined -> role changed
changed = { role: 'admin' }, any = true
commit state attributes.role = 'admin'
fire change:role log('change:role') <- name did NOT change, so no change:name
fire change log('change') <- exactly one general event
Two things to notice. First, name was already 'Grace', so the diff drops it — no change:name fires even though we passed name to set. Second, only role actually changed, but the general change fires exactly once regardless. Had both keys changed, you'd see change:name, then change:role, then a single change — never two generals.
trigger('change', this) on every set. A view subscribed to change then re-renders on set('count', 0) even though count was already 0. Fix: build the changed object first, and if it's empty, return this before touching state or firing anything.trigger before writing this.attributes, any listener that calls model.get(key) reads the stale value. The event claims the data changed while the model still reports the old number. Fix: write every changed key into attributes first, then fire. Never interleave.set. A set that only handles set(key, value) silently does nothing useful for set({ a: 1, b: 2 }) — it treats the object as a key. Fix: branch on typeof key === 'object' up front (guarding null), and remember the options argument shifts to the second position in that form.off() with no arguments not clearing everything. It's easy to write off(event, cb) and forget the bare off() and off(event) modes. A view that tears down with model.off() then leaks if off ignores the no-arg call — the model keeps the dead callbacks and keeps invoking them. Fix: branch explicitly on event === undefined (wipe all) and callback === undefined (wipe the event).toJSON. If toJSON returns this.attributes directly, a caller doing const j = m.toJSON(); j.name = 'x' mutates the model's internal store with no event ever firing — a silent corruption. Fix: return a shallow copy ({ ...this.attributes }). The same reasoning applies to the constructor: spread attrs so the caller's object isn't aliased.silent updates state. A silent set is not a no-op — it still changes the data, it just doesn't announce it. Putting the silent check before the state write would make silent sets do nothing at all. Fix: commit state, then check silent, then (if not silent) fire.change listener calls model.off('change', itself) while trigger is iterating the live array, the loop can skip the next listener or throw. Fix: iterate a copy — for (const cb of cbs.slice()) — so the set of callbacks for one trigger is frozen at the moment it starts.validate(attrs) that runs inside set; if it returns an error, the set is aborted and an "invalid" event fires instead of change. You'd call this.validate({ ...this.attributes, ...changed }) after the diff and before the commit, bailing out if it fails.once(event, cb). A subscription that fires at most once, then unsubscribes itself. Implement it by wrapping the callback: register a wrapper that calls cb(...args) and then this.off(event, wrapper). This is exactly the case the cbs.slice() copy in trigger protects — the wrapper unsubscribes mid-fire.Collection (an ordered list of models) that re-emits its children's events, so a listener on the collection hears "change" when any member changes. The pattern is the same observer wiring one level up: the collection subscribes to each model it holds and re-triggers.fullName from firstName + lastName. You'd subscribe internally to change:firstName and change:lastName, recompute, and fire change:fullName when the result differs — the diff discipline keeps it from firing on a recompute that lands on the same value.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement a Model class in the style of Backbone.js — an object that stores a bag of attributes and lets other code subscribe to changes. When an attribute's value changes, the model notifies its subscribers; when nothing changed, it stays silent. This is the observer pattern at the heart of classic MVC front ends: a view registers a listener on the model, and re-renders itself whenever the data it displays actually changes — without the model needing to know the view exists.
class Model {
constructor(attrs?: Record<string, unknown>); // no change events fire here
get(key: string): unknown; // value, or undefined if absent
set(key: string, value: unknown, options?: { silent?: boolean }): this;
set(attrs: Record<string, unknown>, options?: { silent?: boolean }): this;
on(event: string, callback: Function): void; // subscribe
off(event?: string, callback?: Function): void; // unsubscribe
trigger(event: string, ...args: unknown[]): void; // fire manually
previous(key: string): unknown; // value before the last change
changedAttributes(): Record<string, unknown> | false; // keys changed in the last set
toJSON(): Record<string, unknown>; // shallow copy of attributes
}
For each key whose value actually changes, set fires a change:<key> event with (model, newValue). After all per-key events, it fires one general change event with (model). A change:<key> or change event fires only when at least one value changed. set returns this so calls chain.
const m = new Model({ name: 'Ada' });
m.on('change:name', (model, value) => console.log('name ->', value));
m.on('change', (model) => console.log('something changed'));
m.set('name', 'Grace');
// logs: "name -> Grace"
// logs: "something changed"
// Setting a key to the value it already holds fires NOTHING.
const m = new Model({ count: 0 });
m.on('change', () => console.log('changed'));
m.set('count', 0); // (no output — 0 === 0)
m.set('count', 1); // logs: "changed"
// The object form sets many keys but fires the general change only once.
const m = new Model({ a: 1, b: 2 });
m.on('change:a', () => console.log('a'));
m.on('change:b', () => console.log('b'));
m.on('change', () => console.log('general'));
m.set({ a: 10, b: 20 });
// logs: "a", "b", "general" — one general event, not two
// Silent set updates state but stays quiet.
m.set({ a: 99 }, { silent: true }); // (no output); m.get('a') === 99
attrs to new Model(attrs) seeds the values directly. It must not fire any change event — there are no listeners yet, and seeding is not a change.===). A key "changed" only when its new value is not === to its current value. set('count', 0) when count is already 0 fires nothing. Object identity counts: setting a key to a different object with the same contents does fire, because the references differ.change:<key> listener that calls model.get(key) must read the new value, not the old one. Apply the change to the internal store first, then invoke listeners.change. Each changed key gets its own change:<key>, but the whole set produces exactly one general change, regardless of how many keys changed.off has three modes. off(event, cb) removes one listener; off(event) removes every listener for that event; off() removes everything.You'll build a small data object that other code can subscribe to — when an attribute changes, the object tells its subscribers; when nothing changed, it stays quiet.
Imagine a settings panel. Somewhere you hold the user's data — their name, their theme, a notification toggle. Several parts of the screen depend on that data: a header shows the name, a preview pane reflects the theme, a "Save" button lights up when anything is dirty. You don't want each of those pieces to poll the data on a timer, and you don't want the data to hard-code "after I change, go re-render the header and the preview and the button." That coupling rots fast.
The observer pattern inverts it. The data object — the model — keeps a list of interested parties (the observers) and exposes a way to subscribe. When the data changes, the model walks its subscriber list and notifies each one. The model never names a view; it just announces "this changed." That's exactly what a Backbone-style Model does, and what you're implementing here.
A Model is really just two maps wearing a class:
attributes — the actual data. get(key) reads from it; set(key, value) writes to it._listeners — an event registry: a map from an event name ("change:name", "change", or any custom string) to an array of callbacks. on appends a callback; off removes; trigger calls them all.Everything else is plumbing connecting those two. set is the only interesting method: it writes to attributes and, as a side effect, fires events through _listeners.
The contract has one subtle rule that drives the whole design: an event fires only when a value actually changes. Setting count to 0 when it's already 0 must fire nothing. That single requirement is why set can't just write-and-notify — it has to diff first.
The obvious version writes the value and fires the events. Here's the shape most people reach for:
class Model {
constructor(attrs = {}) {
this.attributes = { ...attrs };
this._listeners = {};
}
get(key) {
return this.attributes[key];
}
set(key, value) {
// fire first, then store
this.trigger('change:' + key, this, value);
this.trigger('change', this);
this.attributes[key] = value;
}
on(event, cb) {
(this._listeners[event] ||= []).push(cb);
}
trigger(event, ...args) {
(this._listeners[event] || []).forEach((cb) => cb(...args));
}
}
This is wrong in two independent ways, and both are worth seeing.
Problem one: it fires on every set, even when nothing changed. Call set('count', 0) on a model whose count is already 0 and the change listeners run anyway. A view that re-renders on change now re-renders on a non-change — the exact waste the observer pattern was supposed to eliminate. There's no diff, so there's no way to stay silent.
Problem two: it fires before it writes. Look at the order: trigger('change:count', ...) runs before this.attributes[key] = value. So a listener that calls model.get('count') reads the OLD value. That's a classic, maddening bug — the event says "count changed" but the model still reports the old number. State must be committed before any callback runs.
There's also a missing feature — set only handles set(key, value), not the set({ a: 1, b: 2 }) object form — but the two ordering/diffing bugs are the conceptual core. Fixing them is the whole question.
class Model {
constructor(attrs = {}) {
// Seed attributes directly — no events here. Spread so the caller's
// object isn't aliased by our internal store.
this.attributes = { ...attrs };
this._previous = {}; // snapshot of attributes before the last set
this._changed = {}; // keys (and new values) changed in the last set
this._listeners = {}; // event name -> array of callbacks
}
get(key) {
return this.attributes[key];
}
set(key, value, options) {
// Normalise the two call shapes into a single { k: v } object + options.
let attrs;
if (key !== null && typeof key === 'object') {
attrs = key;
options = value;
} else {
attrs = { [key]: value };
}
const opts = options || {};
// Diff: collect only the keys whose value actually changes (strict ===).
const changed = {};
let any = false;
for (const k of Object.keys(attrs)) {
if (attrs[k] !== this.attributes[k]) {
changed[k] = attrs[k];
any = true;
}
}
if (!any) return this; // nothing changed — stay completely silent
// Snapshot the previous values for previous(), then commit the new state
// BEFORE firing any listener, so a listener calling get() sees the new value.
this._previous = { ...this.attributes };
this._changed = changed;
for (const k of Object.keys(changed)) {
this.attributes[k] = changed[k];
}
if (opts.silent) return this; // state updated, but fire nothing
// Per-key events first, then exactly one general change.
for (const k of Object.keys(changed)) {
this.trigger('change:' + k, this, changed[k]);
}
this.trigger('change', this);
return this;
}
on(event, callback) {
(this._listeners[event] || (this._listeners[event] = [])).push(callback);
}
off(event, callback) {
if (event === undefined) {
this._listeners = {}; // remove everything
} else if (callback === undefined) {
delete this._listeners[event]; // remove all listeners for this event
} else if (this._listeners[event]) {
this._listeners[event] = this._listeners[event].filter((cb) => cb !== callback);
}
}
trigger(event, ...args) {
const cbs = this._listeners[event];
if (!cbs) return;
// Iterate a COPY so a listener that calls off() mid-fire can't corrupt
// the loop or skip a sibling.
for (const cb of cbs.slice()) {
cb(...args);
}
}
previous(key) {
return this._previous[key];
}
changedAttributes() {
const keys = Object.keys(this._changed);
if (keys.length === 0) return false;
return { ...this._changed };
}
toJSON() {
return { ...this.attributes };
}
}
module.exports = { Model };
The key shifts from the naive version: set now diffs before doing anything, commits state before firing, and handles both call shapes. Walk the non-obvious choices.
Normalising the two call shapes. set('name', 'Grace') and set({ name: 'Grace' }) should behave identically. The branch key !== null && typeof key === 'object' detects the object form (and guards against null, since typeof null === 'object'). When it's the object form, the second positional argument is the options object, not a value — so we shift options = value. After this block, the rest of the method only ever deals with one shape: an attrs object and an opts object.
The diff is the heart of the contract. We loop the incoming keys and keep only the ones where attrs[k] !== this.attributes[k] — strict inequality. Strict === is deliberate: 0 !== 0 is false so setting count to 0 again is dropped, but {} !== {} is true (two different object references), so reassigning an object always counts as a change even if its contents match. If changed ends up empty, we return this immediately — no state write, no events, nothing. That early return is what makes "no change, no event" true.
Commit state, then fire. We snapshot the old attributes into _previous (so previous(key) can answer later), record _changed (so changedAttributes() can answer later), and only then copy the new values into this.attributes. All of that happens before the first trigger call. So when a change:name listener calls model.get('name'), the store already holds the new value. This is the fix for the naive version's second bug.
Silent short-circuits after the write. { silent: true } means "update the data but don't tell anyone." Note where the check sits: after the state has been committed, before any trigger. So a silent set genuinely changes the model — get reflects it, previous and changedAttributes are updated — it just skips the notifications.
Per-key first, then one general change. We fire one change:<key> per changed key (passing (this, newValue) — the Backbone signature), then a single change (passing just (this)). One set produces at most one general change, no matter how many keys changed. Putting the per-key events first matches Backbone and is the more useful order: a key-specific listener has run before the broad "something changed" listener.
trigger iterates a copy. cbs.slice() makes a shallow copy of the callback array before looping. If a listener calls off() (or on()) while it's running — common when a one-shot subscriber unsubscribes itself — mutating the live array mid-iteration would skip the next listener or throw. Iterating the snapshot makes the fire-set stable for the duration of the trigger.
off's three modes fall out of which arguments are undefined: no args wipes the whole registry; an event with no callback deletes that event's array; an event plus callback filters out just that one function (by reference — the same function object you passed to on).
Trace 1 — a single set with a listener attached. Start with const m = new Model({ name: 'Ada' }) and a listener:
m.on('change:name', (model, value) => {
console.log('event value:', value);
console.log('get() reads:', model.get('name'));
});
m.set('name', 'Grace');
Step by step inside set('name', 'Grace'):
normalise key is a string -> attrs = { name: 'Grace' }, opts = {}
diff 'Grace' !== 'Ada' -> changed = { name: 'Grace' }, any = true
not empty skip the early return
snapshot _previous = { name: 'Ada' }
_changed = { name: 'Grace' }
commit state attributes.name = 'Grace' <- store updated NOW
not silent skip the silent return
fire change:name trigger('change:name', m, 'Grace')
listener logs "event value: Grace"
listener logs "get() reads: Grace" <- reads the NEW value
fire change trigger('change', m)
return this
The pivotal line is get() reads: Grace. Because we committed attributes.name before firing, the listener sees the new value through both channels — the value argument and a fresh get. In the naive version this line would have logged Ada.
Trace 2 — a multi-key set, showing the batching. Same model, now with three listeners:
m.on('change:name', () => log('change:name'));
m.on('change:role', () => log('change:role'));
m.on('change', () => log('change'));
m.set({ name: 'Grace', role: 'admin' });
normalise key is an object -> attrs = { name: 'Grace', role: 'admin' }, opts = {}
diff 'Grace' !== 'Grace'? name is already 'Grace' from Trace 1 -> NOT changed
'admin' !== undefined -> role changed
changed = { role: 'admin' }, any = true
commit state attributes.role = 'admin'
fire change:role log('change:role') <- name did NOT change, so no change:name
fire change log('change') <- exactly one general event
Two things to notice. First, name was already 'Grace', so the diff drops it — no change:name fires even though we passed name to set. Second, only role actually changed, but the general change fires exactly once regardless. Had both keys changed, you'd see change:name, then change:role, then a single change — never two generals.
trigger('change', this) on every set. A view subscribed to change then re-renders on set('count', 0) even though count was already 0. Fix: build the changed object first, and if it's empty, return this before touching state or firing anything.trigger before writing this.attributes, any listener that calls model.get(key) reads the stale value. The event claims the data changed while the model still reports the old number. Fix: write every changed key into attributes first, then fire. Never interleave.set. A set that only handles set(key, value) silently does nothing useful for set({ a: 1, b: 2 }) — it treats the object as a key. Fix: branch on typeof key === 'object' up front (guarding null), and remember the options argument shifts to the second position in that form.off() with no arguments not clearing everything. It's easy to write off(event, cb) and forget the bare off() and off(event) modes. A view that tears down with model.off() then leaks if off ignores the no-arg call — the model keeps the dead callbacks and keeps invoking them. Fix: branch explicitly on event === undefined (wipe all) and callback === undefined (wipe the event).toJSON. If toJSON returns this.attributes directly, a caller doing const j = m.toJSON(); j.name = 'x' mutates the model's internal store with no event ever firing — a silent corruption. Fix: return a shallow copy ({ ...this.attributes }). The same reasoning applies to the constructor: spread attrs so the caller's object isn't aliased.silent updates state. A silent set is not a no-op — it still changes the data, it just doesn't announce it. Putting the silent check before the state write would make silent sets do nothing at all. Fix: commit state, then check silent, then (if not silent) fire.change listener calls model.off('change', itself) while trigger is iterating the live array, the loop can skip the next listener or throw. Fix: iterate a copy — for (const cb of cbs.slice()) — so the set of callbacks for one trigger is frozen at the moment it starts.validate(attrs) that runs inside set; if it returns an error, the set is aborted and an "invalid" event fires instead of change. You'd call this.validate({ ...this.attributes, ...changed }) after the diff and before the commit, bailing out if it fails.once(event, cb). A subscription that fires at most once, then unsubscribes itself. Implement it by wrapping the callback: register a wrapper that calls cb(...args) and then this.off(event, wrapper). This is exactly the case the cbs.slice() copy in trigger protects — the wrapper unsubscribes mid-fire.Collection (an ordered list of models) that re-emits its children's events, so a listener on the collection hears "change" when any member changes. The pattern is the same observer wiring one level up: the collection subscribes to each model it holds and re-triggers.fullName from firstName + lastName. You'd subscribe internally to change:firstName and change:lastName, recompute, and fire change:fullName when the result differs — the diff discipline keeps it from firing on a recompute that lands on the same value.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.