Legitimate Use Cases

We’ve said it before: most components don’t need $effect. But “most” isn’t “all.” Here are the legitimate cases where $effect is the right tool.

The test: “Am I reaching outside the component to interact with the world?” If yes, you probably need an effect.

DOM Manipulation

When you need to work with the actual DOM, not Svelte’s abstraction:

<script>
  let canvas;
  let width = $state(400);
  let height = $state(300);
  let color = $state('#3498db');
  
  $effect(() => {
    const ctx = canvas.getContext('2d');
    ctx.fillStyle = color;
    ctx.fillRect(0, 0, width, height);
  });
</script>

<canvas bind:this={canvas} {width} {height}></canvas>
<input type="color" bind:value={color} />

The effect runs whenever color, width, or height changes. Canvas isn’t reactive—you have to imperatively draw.

Third-Party Libraries

Libraries that manage their own DOM need initialization:

<script>
  import Chart from 'chart.js/auto';
  
  let canvas;
  let data = $state([12, 19, 3, 5, 2, 3]);
  
  $effect(() => {
    const chart = new Chart(canvas, {
      type: 'bar',
      data: {
        labels: data.map((_, i) => `Item ${i + 1}`),
        datasets: [{ data }]
      }
    });
    
    return () => chart.destroy();
  });
</script>

<canvas bind:this={canvas}></canvas>

The cleanup function (chart.destroy()) runs before the effect re-runs and when the component unmounts.

Event Listeners

For events that aren’t on elements you render:

<script>
  let windowWidth = $state(0);
  let scrollY = $state(0);
  
  $effect(() => {
    function handleResize() {
      windowWidth = window.innerWidth;
    }
    
    function handleScroll() {
      scrollY = window.scrollY;
    }
    
    // Set initial values
    handleResize();
    handleScroll();
    
    window.addEventListener('resize', handleResize);
    window.addEventListener('scroll', handleScroll);
    
    return () => {
      window.removeEventListener('resize', handleResize);
      window.removeEventListener('scroll', handleScroll);
    };
  });
</script>

<div class="scroll-indicator" style="width: {(scrollY / document.body.scrollHeight) * 100}%">
</div>

WebSocket/Real-time Connections

<script>
  let messages = $state([]);
  let connected = $state(false);
  let roomId = $state('general');
  
  $effect(() => {
    const ws = new WebSocket(`wss://chat.example.com/${roomId}`);
    
    ws.onopen = () => {
      connected = true;
      messages = []; // Clear messages when switching rooms
    };
    
    ws.onmessage = (event) => {
      messages.push(JSON.parse(event.data));
    };
    
    ws.onclose = () => {
      connected = false;
    };
    
    return () => ws.close();
  });
</script>

When roomId changes, the old WebSocket closes and a new one opens to the new room.

Document/Browser APIs

<script>
  let title = $state('My App');
  let favicon = $state('/favicon.ico');
  
  // Update document title
  $effect(() => {
    document.title = title;
  });
  
  // Update favicon
  $effect(() => {
    const link = document.querySelector("link[rel~='icon']");
    if (link) {
      link.href = favicon;
    }
  });
</script>

Keep effects focused. One effect per concern.

Timers and Intervals

<script>
  let running = $state(false);
  let seconds = $state(0);
  
  $effect(() => {
    if (!running) return;
    
    const id = setInterval(() => {
      seconds++;
    }, 1000);
    
    return () => clearInterval(id);
  });
</script>

<p>{seconds} seconds</p>
<button onclick={() => running = !running}>
  {running ? 'Stop' : 'Start'}
</button>
<button onclick={() => seconds = 0}>Reset</button>

The effect only sets up the interval when running is true.

Local Storage Sync

<script>
  let theme = $state('light');
  
  // Load from localStorage on mount
  $effect(() => {
    const saved = localStorage.getItem('theme');
    if (saved) {
      theme = saved;
    }
  });
  
  // Save to localStorage on change
  $effect(() => {
    localStorage.setItem('theme', theme);
  });
</script>

Or combined:

<script>
  let theme = $state(
    typeof localStorage !== 'undefined' 
      ? localStorage.getItem('theme') || 'light'
      : 'light'
  );
  
  $effect(() => {
    localStorage.setItem('theme', theme);
  });
</script>

Focus Management

<script>
  let input;
  let shouldFocus = $state(false);
  
  $effect(() => {
    if (shouldFocus && input) {
      input.focus();
    }
  });
</script>

<input bind:this={input} />
<button onclick={() => shouldFocus = true}>Focus Input</button>

The Checklist

Before writing $effect, ask:

  1. Can I use $derived? If you’re computing a value, probably yes.
  2. Can I use an event handler? If this happens in response to user action, probably yes.
  3. Can I use a load function? If you’re fetching data, probably yes.
  4. Am I interacting with external APIs, the DOM, or the browser? If yes, $effect is likely correct.

Effect Best Practices

Keep effects small. One effect, one concern.

<script>
  // Good: separate effects for separate concerns
  $effect(() => {
    document.title = pageTitle;
  });
  
  $effect(() => {
    analytics.trackPage(currentPage);
  });
</script>

Always clean up. If you create something, destroy it.

<script>
  $effect(() => {
    const subscription = store.subscribe(handler);
    return () => subscription.unsubscribe();
  });
</script>

Don’t set state that triggers the same effect. This creates infinite loops.

<script>
  // BAD: infinite loop
  let count = $state(0);
  $effect(() => {
    count = count + 1; // Effect runs, sets count, effect runs again...
  });
</script>

Effects are powerful. Use them only where they’re the right tool—and when they are, they work beautifully.

Track complete
You've finished Calm Systems with Svelte 5.