Leaving a view is a state change too
A request can settle after its UI is gone. A timer can retain a callback. A repeated setup can register the same action several times. These are lifecycle problems: something starts, but nothing owns when it ends.
A controller can expose dispose(), mark itself unavailable, invalidate outstanding tokens, and abort active work. Its completion paths check the disposed flag before rendering. After disposal, calling load again should either do nothing or fail explicitly according to the contract. Our project chooses no further work.
DOM listeners can be grouped using an AbortSignal: element.addEventListener("click", handler, { signal }). Aborting that signal removes the registered listeners. This is separate from the signal for a particular request; one owns the UI subscription lifetime, the other owns one attempt. The { once: true } option removes a listener after its first invocation.
Use clearTimeout for a timer you no longer need. For page-level code, pagehide reports leaving the page. There is an important distinction: when event.persisted is true, the browser is keeping the page in its back/forward cache so it can resume later. Our local demo preserves its controller in that case; disposing it would leave a restored page with dead controls. It cleans up only when the page is not being retained. Do not attach that conditional cleanup with once: true, because an earlier cached visit could otherwise consume the only listener. In a component system, use that system’s lifecycle boundary instead. Cleanup is not an excuse to abandon rejected promises unobserved: asynchronous tasks still need their rejections handled.
Predict and trace
Write the output before running. Follow the values through each call; if your prediction differs, identify the first step that surprised you.
function createView() {
let disposed = false;
const messages = [];
return {
show(text) {
if (!disposed) {
messages.push(text);
}
},
dispose() {
disposed = true;
},
snapshot() {
return [...messages];
}
};
}
const view = createView();
view.show("Ready");
view.dispose();
view.show("Too late");
console.log(view.snapshot().join(", "));Check your prediction
ReadyA copied snapshot avoids letting the caller mutate the internal array.
Dispose prevents future effects, not just future clicks
A view can disappear while work is pending. Removing an event listener stops future events but does not erase callbacks already waiting on a promise. The lifetime guard must be checked when the result is about to be applied.
Cleanup should be safe to call twice, because callers may share responsibility for ending a lifetime. Choose who owns timers, listeners and pending operations, and make each cleanup path explicit. Do not use a disposed flag as a substitute for releasing resources that can actually be released.
async function main() {
let release;
const pending = new Promise(resolve => {
release = resolve;
});
let disposed = false;
let visible = "before";
const work = pending.then(value => {
if (!disposed) {
visible = value;
}
});
disposed = true;
release("too late");
await work;
console.log(visible);
}
main();Check your prediction
before The guard runs after the pending value is available. Checking only before starting the work would not protect this schedule.
function createOwner() {
let disposed = false;
let cleanups = 0;
return {
dispose() {
if (!disposed) {
disposed = true;
cleanups++;
}
},
count() {
return cleanups;
}
};
}
const owner = createOwner();
owner.dispose();
owner.dispose();
console.log(owner.count());Check your prediction
1 An idempotent operation makes repeated cleanup harmless. The counter stands in for releasing an owned resource exactly once, not just setting an unrelated boolean.
Try the rule on new inputs
Hold a request, dispose the owner, then resolve and reject controlled attempts. Neither should update visible data. List which resources need removal as well as guards, and who is responsible for calling cleanup.
Build it yourself
Lifecycle & Cleanup: practise
Write createSession(), with active() and dispose(). active starts true and stays false after disposal, including repeated calls. Print the three observed states.
// Keep the flag private to one closure.Hint 1: the idea
Disposal should be safe to call again.
Hint 2: a first step
Use an assignment to false, not a toggle.
Show one solution
function createSession() {
let available = true;
return {
active() {
return available;
},
dispose() {
available = false;
}
};
}
const session = createSession();
console.log(session.active());
session.dispose();
console.log(session.active());
session.dispose();
console.log(session.active());true
false
falseReplace the assignment with a toggle deliberately. The repeated-disposal case now exposes the defect. In an async test, resolve pending work after dispose and assert no render occurred.
Test a changed requirement
Replace the assignment with a toggle deliberately. The repeated-disposal case now exposes the defect. In an async test, resolve pending work after dispose and assert no render occurred.
Explain before moving on
Why should listeners and request attempts have different lifetimes? What should a pending rejection do after disposal?
Going further
MDN explains pagehide and its persisted flag. The important distinction for this demo is pausing a cached page versus discarding it; you do not need to implement a general page cache.
Completion is saved in this browser only. It does not measure mastery.