Unlearning Immutability

If you’re coming from React, you’ve been trained to fear mutation.

Never mutate state directly. Always create new objects. Spread operators everywhere. Immutability is safety.

// The React way
setItems([...items, newItem]);
setUser({ ...user, name: newName });
setTodos(todos.map(t => t.id === id ? { ...t, done: true } : t));

This isn’t wrong in React. React’s rendering model depends on reference equality checks. Mutate an object, and React doesn’t know it changed. Bugs follow.

But the lesson many developers learned wasn’t “React needs immutability.” It was “mutation is dangerous.” That lesson is wrong.

Mutation isn’t dangerous. Untracked mutation is dangerous. Svelte 5 tracks mutation. So mutation is fine.

Why Immutability Became Dogma

React popularized the virtual DOM. The virtual DOM diffing algorithm needs to know what changed. The simplest way to know if something changed is to check if it’s the same object.

// Reference equality check
if (prevState !== nextState) {
  // Something changed, re-render
}

If you mutate an object, it’s still the same object. The reference hasn’t changed. React thinks nothing happened.

const user = { name: 'Alice' };
user.name = 'Bob'; // Mutation!

// But...
user === user // true - same reference
// React: "Nothing changed, skip re-render"

The solution: never mutate. Always create new objects. New objects have new references. React sees the change.

This works. It’s also tedious, error-prone, and creates a lot of garbage for the JavaScript engine to collect. But it works.

Redux doubled down on this. Reducers must return new state objects. Immer became popular specifically to make “immutable updates” less painful. The ecosystem built itself around avoiding mutation.

And developers internalized: mutation bad, immutability good.

What Actually Causes Bugs

The problem was never mutation. The problem was untracked mutation—changes that happen without the framework knowing.

When you mutate state and the framework doesn’t notice:

  • The UI shows stale data
  • Derived values aren’t recalculated
  • Effects don’t re-run
  • Components don’t re-render

These bugs are nasty because the state is correct but the UI is wrong. You look at the data, it’s fine. You look at the screen, it’s not. Something didn’t update somewhere, and good luck finding where.

React’s solution: make mutation impossible, so you can’t have untracked mutation.

Svelte’s solution: track mutation, so all mutation is tracked.

Svelte Tracks Mutation

When you declare state with $state, Svelte wraps it in a reactive proxy. The proxy intercepts all reads and writes. Every mutation is tracked.

<script>
  let user = $state({ name: 'Alice', age: 30 });
  
  function birthday() {
    user.age += 1; // Direct mutation - Svelte sees this
  }
</script>

<p>{user.name} is {user.age} years old</p>
<button onclick={birthday}>Birthday!</button>

No spread operators. No new objects. Just user.age += 1. Svelte sees the mutation and updates the UI.

This works for arrays too:

<script>
  let items = $state(['Apple', 'Banana']);
  
  function addItem() {
    items.push('Cherry'); // Direct mutation - Svelte sees this
  }
</script>

<ul>
  {#each items as item}
    <li>{item}</li>
  {/each}
</ul>

<button onclick={addItem}>Add Cherry</button>

push, pop, splice, sort—all the array methods that mutate in place work exactly as you’d expect. Svelte tracks them all.

A Shopping Cart Example

Let’s build something real. A shopping cart where you can add items, remove items, and change quantities.

<script>
  let cart = $state([
    { id: 1, name: 'Widget', price: 10, quantity: 1 },
    { id: 2, name: 'Gadget', price: 25, quantity: 2 }
  ]);
  
  let total = $derived(
    cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  
  function addItem(item) {
    cart.push({ ...item, quantity: 1 });
  }
  
  function removeItem(id) {
    const index = cart.findIndex(item => item.id === id);
    if (index !== -1) {
      cart.splice(index, 1);
    }
  }
  
  function updateQuantity(id, delta) {
    const item = cart.find(item => item.id === id);
    if (item) {
      item.quantity = Math.max(0, item.quantity + delta);
      if (item.quantity === 0) {
        removeItem(id);
      }
    }
  }
</script>

<h2>Shopping Cart</h2>

{#each cart as item (item.id)}
  <div class="cart-item">
    <span>{item.name} - ${item.price}</span>
    <button onclick={() => updateQuantity(item.id, -1)}>-</button>
    <span>{item.quantity}</span>
    <button onclick={() => updateQuantity(item.id, 1)}>+</button>
    <button onclick={() => removeItem(item.id)}>Remove</button>
  </div>
{/each}

<p><strong>Total: ${total}</strong></p>

Look at updateQuantity. We find an item and mutate its quantity directly. No spreading. No mapping over the array to create a new one. Just item.quantity = newValue.

The derived total recalculates automatically. The {#each} block updates to show the new quantity. Everything stays in sync because Svelte tracks everything.

This is what UI code should look like. Direct. Obvious. Easy to read.

The One Exception: Untracked Objects

There’s one way to create untracked mutation in Svelte: work with objects that aren’t wrapped in $state.

<script>
  // This is tracked
  let user = $state({ name: 'Alice' });
  
  // This is NOT tracked
  let config = { theme: 'dark' };
  
  function changeTheme() {
    config.theme = 'light'; // Untracked mutation!
    // UI won't update
  }
</script>

If something needs to be reactive, wrap it in $state. If it doesn’t change, or you don’t need the UI to react to changes, you can use plain objects. But be intentional about it.

The rule is simple: $state for reactive, plain for static.

When Not to Mutate

Mutation being safe doesn’t mean mutation is always the right choice. There are cases where creating new objects makes sense:

When you need to preserve history. Undo/redo systems need previous states. Mutating destroys history.

When you’re passing data to external code. Libraries that don’t expect proxies might behave unexpectedly. Use $state.snapshot() to get a plain copy.

When you’re comparing values. If you need to check whether something changed (not just what it is now), you need references to compare.

But these are specific needs, not general rules. For most UI state, direct mutation is simpler and clearer.

The Freedom of Mutation

There’s a psychological benefit to mutation being safe: you stop fighting your instincts.

JavaScript developers know how to work with objects and arrays. push, splice, delete, direct assignment—these are natural operations. Wrapping them in immutable patterns adds friction.

In Svelte, you write JavaScript the way JavaScript was designed to be written. The code you’d write to solve the problem is the code that works.

<script>
  let todos = $state([]);
  
  function addTodo(text) {
    todos.push({ id: Date.now(), text, done: false });
  }
  
  function toggleTodo(todo) {
    todo.done = !todo.done;
  }
  
  function deleteTodo(todo) {
    const index = todos.indexOf(todo);
    todos.splice(index, 1);
  }
</script>

No libraries. No special update patterns. Just JavaScript.

That’s the freedom of tracked mutation. The code you want to write is the code that works.


Next up: If $derived is so useful, when should you use it? We’ll explore derived state as the backbone of predictable UI—and why reaching for it first prevents synchronization bugs.