How to build an interval that automatically adapts its timing when your app state changes—no manual cleanup required.
The Wrong Way: Using $effect
Your first instinct might be to use $effect to manage the interval:
// DON'T DO THIS
export class Interval {
#duration;
#callback;
#intervalId;
constructor(duration, callback) {
this.#duration = duration;
this.#callback = callback;
$effect(() => {
this.#intervalId = setInterval(this.#callback, this.#duration);
return () => clearInterval(this.#intervalId);
});
}
}
This approach has problems. The $effect runs in the component context, and managing intervals this way leads to memory leaks and timing issues when components mount/unmount.
Step 1: Basic Non-Reactive Version
Let’s start with a simpler approach using createSubscriber:
import { createSubscriber } from 'svelte/reactivity';
export class Interval {
#duration;
#subscribe;
constructor(duration) {
this.#duration = duration;
this.#subscribe = createSubscriber((update) => {
const intervalId = setInterval(() => update(), this.#duration);
return () => clearInterval(intervalId);
});
}
get current() {
this.#subscribe();
return new Date();
}
}
This works! The createSubscriber sets up the interval when a component subscribes, and automatically cleans up when the component unmounts.
<script>
let interval = new Interval(1000);
let time = $state(interval.current);
// Update time when interval ticks
$effect(() => {
time = interval.current;
});
</script>
<p>Time: {time.toLocaleTimeString()}</p>
Step 2: Making Duration Flexible
Now let’s allow both numbers and functions for duration:
export class Interval {
#duration_fn;
#subscribe;
constructor(duration) {
this.#duration_fn = duration;
this.#subscribe = createSubscriber((update) => {
const actualDuration =
typeof this.#duration_fn === 'function'
? this.#duration_fn()
: this.#duration_fn;
const intervalId = setInterval(() => update(), actualDuration);
return () => clearInterval(intervalId);
});
}
get current() {
this.#subscribe();
return new Date();
}
}
This lets us pass reactive functions:
<script>
let speed = $state(1000);
let interval = new Interval(() => speed);
let time = $state(interval.current);
$effect(() => {
time = interval.current;
});
</script>
<input type="range" min="100" max="2000" bind:value={speed} />
<p>Speed: {speed}ms</p>
<p>Time: {time.toLocaleTimeString()}</p>
The Problem: No Reactivity Inside createSubscriber
Here’s the issue: createSubscriber runs once when the component first subscribes. Even if speed changes, the interval duration stays the same because this.#duration_fn() was only called once.
The subscriber doesn’t know that speed changed, so it never recreates the interval with the new duration.
Step 3: Making It Truly Reactive
We need the interval to recreate itself when the duration changes. Enter $derived:
import { createSubscriber } from 'svelte/reactivity';
export class Interval {
#duration_fn = () => 0;
#subscribe;
#update;
#interval = $derived(
setInterval(
() => this.#update?.(),
typeof this.#duration_fn === 'function'
? this.#duration_fn()
: this.#duration_fn,
),
);
constructor(duration) {
this.#duration_fn = duration;
this.#subscribe = createSubscriber((update) => {
this.#update = update;
return () => clearInterval(this.#interval);
});
}
get current() {
this.#interval; // Access the derived value to subscribe to changes
this.#subscribe();
return new Date();
}
get duration() {
return typeof this.#duration_fn === 'function'
? this.#duration_fn()
: this.#duration_fn;
}
set duration(value) {
clearInterval(this.#interval);
this.#duration_fn = value;
}
}
Step 4: Clean Abstraction with Separate Duration Derived
Let’s clean this up by separating the duration calculation into its own $derived:
import { createSubscriber } from 'svelte/reactivity';
export class Interval {
#duration_input = () => 0;
#subscribe;
#update;
#duration = $derived(
typeof this.#duration_input === 'function'
? this.#duration_input()
: this.#duration_input,
);
#interval = $derived(setInterval(() => this.#update?.(), this.#duration));
constructor(duration) {
this.#duration_input = duration;
this.#subscribe = createSubscriber((update) => {
this.#update = update;
return () => clearInterval(this.#interval);
});
}
get current() {
this.#interval;
this.#subscribe();
return new Date();
}
get duration() {
return this.#duration;
}
set duration(value) {
clearInterval(this.#interval);
this.#duration_input = value;
}
}
This is much cleaner! Now we have:
#duration_input: The raw input (function or number)#duration: A derived value that resolves the input to a number#interval: A derived value that depends on#duration
The separation makes the reactivity chain crystal clear: #duration_input → #duration → #interval.
How The Final Solution Works
#duration_inputstores the raw input: Either a number or function#durationresolves to a number: Uses$derivedto evaluate functions or pass through numbers#intervalcreates the actual interval: Depends on#durationand recreates when it changescreateSubscriberhandles updates: Stores the update function and sets up cleanupget currenttriggers subscription: Accessing#intervalsubscribes to the derived chainset durationenables dynamic changes: Updates the input and the reactivity chain handles the rest
The reactivity flows like this: #duration_input → #duration → #interval → component updates
Live Example
<script>
import { Interval } from './interval.js';
let speed = $state(1000);
let interval = new Interval(() => speed);
let time = $state(interval.current);
$effect(() => {
time = interval.current;
});
function setFast() {
interval.duration = 100;
}
function setSlow() {
interval.duration = 2000;
}
function setReactive() {
interval.duration = () => speed;
}
</script>
<div>
<h2>Current time: {time.toLocaleTimeString()}</h2>
<button onclick={setFast}>Fast (100ms)</button>
<button onclick={setSlow}>Slow (2000ms)</button>
<button onclick={setReactive}>Reactive to slider</button>
<label>
Speed: <input type="range" min="100" max="2000" bind:value={speed} />
{speed}ms
</label>
</div>
The Magic in Action
- Reactive mode: Duration changes automatically when
speedchanges - Fixed mode: Set a static duration that stays constant
- Dynamic switching: Change between reactive and fixed modes anytime
More Dynamic Examples
// Set a fixed duration
interval.duration = 500;
// Make it reactive to user input
interval.duration = () => userSpeed;
// Dynamic based on conditions
interval.duration = () => (isActive ? 100 : 1000);
// Even reactive to other state
interval.duration = () => items.length * 200;
Why This Approach Works
$derivedhandles reactivity: Automatically recreates intervals when dependencies changecreateSubscribermanages lifecycle: Proper cleanup when components unmount- Explicit control: The setter gives you full control over when and how duration changes
- Performance: Only updates when actually needed
This pattern shows the power of Svelte 5 runes: combining $derived for reactive calculations with createSubscriber for component lifecycle management. The result is an interval that just works, reactively.