male_avatar puruvj.dev
blog

Building Smart Intervals with Svelte 5

5 min read

Building Smart Intervals with Svelte 5

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

  1. #duration_input stores the raw input: Either a number or function
  2. #duration resolves to a number: Uses $derived to evaluate functions or pass through numbers
  3. #interval creates the actual interval: Depends on #duration and recreates when it changes
  4. createSubscriber handles updates: Stores the update function and sets up cleanup
  5. get current triggers subscription: Accessing #interval subscribes to the derived chain
  6. set duration enables 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 speed changes
  • 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

  • $derived handles reactivity: Automatically recreates intervals when dependencies change
  • createSubscriber manages 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.