You schedule a recurring task with setInterval, then realise you have nowhere clean to keep the returned id so you can stop it later. The usual mess is a global let intervalId floating somewhere far from where the interval was started.
Implement a cancellableInterval function that takes a callback fn and a delay ms, starts a repeating interval, and returns a cancel function. Calling cancel() stops the interval. Calling it again does nothing.
function cancellableInterval(fn, ms) {
// starts an interval that calls `fn` every `ms` milliseconds
// returns a function that, when called, stops the interval
}
let count = 0;
const cancel = cancellableInterval(() => count++, 100);
// After 350ms: count === 3
cancel();
// No further ticks
const cancel = cancellableInterval(() => console.log('tick'), 50);
cancel();
cancel(); // safe — second call is a no-op
// 'tick' is never logged
setInterval and clearInterval — no setTimeout recursion.fn should never run.You'll wrap setInterval so the caller gets a single button to press — a cancel function — instead of a raw interval id to track.
setInterval returns a numeric id. To stop the interval later you have to call clearInterval(thatId). Keeping the id around — usually as a let in module scope — is awkward and easy to lose track of. Bundle "start this interval" and "here's how to stop it" into one return value, so the caller never sees the id.
setInterval schedules ticks ms apart that fire until you stop them. clearInterval is the off-switch. You're handing the caller that off-switch, wrapped in a closure that remembers which interval it belongs to.
Without a cancel handle, an interval runs for the lifetime of the page. With one, you decide when it stops:
A reasonable first try just returns the raw id:
function cancellableInterval(fn, ms) {
return setInterval(fn, ms); // caller has to clearInterval(id)
}
This doesn't match the spec — the caller wants to call cancel(), not remember clearInterval. It also leaks the id, so the caller can pass it around, log it, or confuse it with another id.
Capture the id in a closure and return a function that clears it. A closure — a function that remembers variables from its surrounding scope (MDN) — is what lets the returned cancel reach back into cancellableInterval's locals every time it runs:
function cancellableInterval(fn, ms) {
// `id` lives in this outer scope, captured by the closure below.
// It's `let`, not `const`, because cancel() reassigns it to null.
let id = setInterval(fn, ms);
return function cancel() {
if (id === null) return; // already cancelled — bail before clearInterval
clearInterval(id); // stop the interval
id = null; // flip the flag so a second cancel() is a no-op
};
}
module.exports = { cancellableInterval };
Three things are doing real work here.
The closure over id. id is declared inside cancellableInterval, but cancel references it. When cancellableInterval returns, the engine keeps that local alive for as long as cancel is reachable. Every call to cancel sees and updates the same id — that's how the second call can detect that the first one already cleaned up.
The double-cancel guard (if (id === null) return). The contract says calling cancel() twice should be a no-op, not a throw. The guard checks the "already cancelled" flag before doing anything else, so a second cancel() returns immediately without touching clearInterval.
Setting id = null after clearInterval. This is the flag the guard reads. After the first cancel, clearInterval has stopped the interval, but id would still hold a stale number like 7. Nulling it tells the next call "there's nothing to clear." Without this line, the guard never trips and cancel() would call clearInterval(7) over and over — harmless to the runtime, but a sign the lifecycle isn't actually tracked.
Say ms = 100:
setInterval returns id 7, stored in id. The function returns cancel.fn fires three times.cancel(). id is 7, so the guard does not trip; clearInterval(7) stops the interval; id is set to null.fn is never called again.cancel() again. id is null, the guard returns immediately, clearInterval is not called a second time.return setInterval(fn, ms) forces every caller to remember clearInterval and to keep the id alive themselves. The whole point of this wrapper is that they don't have to. Return a function instead.id = null line — if you call clearInterval(id) but never null id, calling cancel() twice runs clearInterval(7) twice. The runtime ignores the second call (the timer is already gone), so nothing visibly breaks — but the moment you add an onCancel callback or a "was-cancelled" counter, you'll fire it twice on a single cancel. The null flag is what makes "already cancelled" detectable.id === null guard — without it, cancel() after the first cancel calls clearInterval(null) (or clearInterval(undefined) if you set id = undefined). Both are harmless at runtime, but the guard is what gives cancel an explicit "I'm already done" branch — without it the second cancel quietly does work that shouldn't happen.setTimeout recursion — function tick() { setTimeout(() => { fn(); tick(); }, ms); } works and avoids setInterval's drift, but you'd then need to track a different kind of state (a cancelled boolean) and the cancel function would only take effect at the next scheduled timeout, not instantly. setInterval + clearInterval is the direct tool for this question.fn synchronously at t=0 — setInterval(fn, 100) waits 100ms before the first call. If your test expects count === 1 immediately after starting, that's a misunderstanding of setInterval, not a bug in your wrapper. Don't paper over it by calling fn() yourself before the first tick.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
You schedule a recurring task with setInterval, then realise you have nowhere clean to keep the returned id so you can stop it later. The usual mess is a global let intervalId floating somewhere far from where the interval was started.
Implement a cancellableInterval function that takes a callback fn and a delay ms, starts a repeating interval, and returns a cancel function. Calling cancel() stops the interval. Calling it again does nothing.
function cancellableInterval(fn, ms) {
// starts an interval that calls `fn` every `ms` milliseconds
// returns a function that, when called, stops the interval
}
let count = 0;
const cancel = cancellableInterval(() => count++, 100);
// After 350ms: count === 3
cancel();
// No further ticks
const cancel = cancellableInterval(() => console.log('tick'), 50);
cancel();
cancel(); // safe — second call is a no-op
// 'tick' is never logged
setInterval and clearInterval — no setTimeout recursion.fn should never run.You'll wrap setInterval so the caller gets a single button to press — a cancel function — instead of a raw interval id to track.
setInterval returns a numeric id. To stop the interval later you have to call clearInterval(thatId). Keeping the id around — usually as a let in module scope — is awkward and easy to lose track of. Bundle "start this interval" and "here's how to stop it" into one return value, so the caller never sees the id.
setInterval schedules ticks ms apart that fire until you stop them. clearInterval is the off-switch. You're handing the caller that off-switch, wrapped in a closure that remembers which interval it belongs to.
Without a cancel handle, an interval runs for the lifetime of the page. With one, you decide when it stops:
A reasonable first try just returns the raw id:
function cancellableInterval(fn, ms) {
return setInterval(fn, ms); // caller has to clearInterval(id)
}
This doesn't match the spec — the caller wants to call cancel(), not remember clearInterval. It also leaks the id, so the caller can pass it around, log it, or confuse it with another id.
Capture the id in a closure and return a function that clears it. A closure — a function that remembers variables from its surrounding scope (MDN) — is what lets the returned cancel reach back into cancellableInterval's locals every time it runs:
function cancellableInterval(fn, ms) {
// `id` lives in this outer scope, captured by the closure below.
// It's `let`, not `const`, because cancel() reassigns it to null.
let id = setInterval(fn, ms);
return function cancel() {
if (id === null) return; // already cancelled — bail before clearInterval
clearInterval(id); // stop the interval
id = null; // flip the flag so a second cancel() is a no-op
};
}
module.exports = { cancellableInterval };
Three things are doing real work here.
The closure over id. id is declared inside cancellableInterval, but cancel references it. When cancellableInterval returns, the engine keeps that local alive for as long as cancel is reachable. Every call to cancel sees and updates the same id — that's how the second call can detect that the first one already cleaned up.
The double-cancel guard (if (id === null) return). The contract says calling cancel() twice should be a no-op, not a throw. The guard checks the "already cancelled" flag before doing anything else, so a second cancel() returns immediately without touching clearInterval.
Setting id = null after clearInterval. This is the flag the guard reads. After the first cancel, clearInterval has stopped the interval, but id would still hold a stale number like 7. Nulling it tells the next call "there's nothing to clear." Without this line, the guard never trips and cancel() would call clearInterval(7) over and over — harmless to the runtime, but a sign the lifecycle isn't actually tracked.
Say ms = 100:
setInterval returns id 7, stored in id. The function returns cancel.fn fires three times.cancel(). id is 7, so the guard does not trip; clearInterval(7) stops the interval; id is set to null.fn is never called again.cancel() again. id is null, the guard returns immediately, clearInterval is not called a second time.return setInterval(fn, ms) forces every caller to remember clearInterval and to keep the id alive themselves. The whole point of this wrapper is that they don't have to. Return a function instead.id = null line — if you call clearInterval(id) but never null id, calling cancel() twice runs clearInterval(7) twice. The runtime ignores the second call (the timer is already gone), so nothing visibly breaks — but the moment you add an onCancel callback or a "was-cancelled" counter, you'll fire it twice on a single cancel. The null flag is what makes "already cancelled" detectable.id === null guard — without it, cancel() after the first cancel calls clearInterval(null) (or clearInterval(undefined) if you set id = undefined). Both are harmless at runtime, but the guard is what gives cancel an explicit "I'm already done" branch — without it the second cancel quietly does work that shouldn't happen.setTimeout recursion — function tick() { setTimeout(() => { fn(); tick(); }, ms); } works and avoids setInterval's drift, but you'd then need to track a different kind of state (a cancelled boolean) and the cancel function would only take effect at the next scheduled timeout, not instantly. setInterval + clearInterval is the direct tool for this question.fn synchronously at t=0 — setInterval(fn, 100) waits 100ms before the first call. If your test expects count === 1 immediately after starting, that's a misunderstanding of setInterval, not a bug in your wrapper. Don't paper over it by calling fn() yourself before the first tick.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.