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

Svelte 5 runes explained: a hands-on guide for engineers

Svelte 5 runes replace implicit reactivity with explicit signals — state, derived values, effects and props explained.

Expona Admin6 min read

Understanding Svelte 5's shift to explicit reactivity

Svelte 5 runes replaced the framework's magic implicit reactivity with explicit signals — a fundamental shift that gives engineers more control and clarity over state management. If you've built production applications in React or solid-js, this pattern will feel familiar: you declare reactive variables, compute derived values, wire up effects, and pass props with intention. In this hands-on guide for engineers, we'll walk through each rune and show you how they work in real scenarios.

The move to runes was necessary. As Svelte codebases grew, implicit reactivity — where assignments magically triggered updates — became harder to reason about, especially in team environments. Runes make the contract explicit: you opt into reactivity by wrapping values, and the compiler can optimize more aggressively.

$state — Reactive state

The $state rune declares a reactive variable. When you assign to it, Svelte schedules a re-render of any component that reads that variable.

typescript
<script lang="ts">
  let count = $state(0);
  let user = $state({ name: "Alice", age: 30 });

  function increment() {
    count++;
  }

  function updateUser() {
    user.name = "Bob"; // Reactive update
  }
</script>

<button onclick={increment}>{count}</button>
<p>{user.name} is {user.age}</p>
<button onclick={updateUser}>Change name</button>

Key behaviors:

  • Primitives (numbers, strings, booleans) trigger re-renders on reassignment.
  • Objects and arrays are deeply reactive — mutating nested properties also triggers updates.
  • The initial value passed to $state() can be any JavaScript value, including complex objects or arrays.

In a real e-commerce checkout flow, you might track form state like this:

typescript
<script lang="ts">
  let form = $state({
    email: "",
    cardNumber: "",
    cvv: "",
    errors: {} as Record<string, string>
  });

  function validate() {
    form.errors = {};
    if (!form.email.includes("@")) {
      form.errors.email = "Invalid email";
    }
    // validation logic
  }
</script>

Notice we're mutating form.errors directly — Svelte detects the change and updates the UI. This beats the ceremony of spread operators or state setters.

$derived — Values computed from state

The $derived rune creates a variable that recalculates whenever its dependencies change. Use it for computed values, filtered lists, or formatted output.

typescript
<script lang="ts">
  let items = $state([
    { id: 1, price: 29.99, quantity: 2 },
    { id: 2, price: 15.50, quantity: 3 }
  ]);

  let total = $derived(
    items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

  let itemCount = $derived(items.reduce((sum, item) => sum + item.quantity, 0));
</script>

<p>Total: ${total.toFixed(2)}</p>
<p>Items: {itemCount}</p>

The compiler tracks that total depends on items, and only recalculates when items changes. This is more efficient than computing the value in every render pass or using a watcher. It's also more explicit than implicit getter functions.

Real-world example: A ride-hailing app calculating fare based on distance and time:

typescript
<script lang="ts">
  let distance = $state(5.2); // km
  let duration = $state(18); // minutes
  let surgeMultiplier = $state(1.0);

  let basefare = 2.50;
  let perKm = 1.25;
  let perMin = 0.35;

  let fare = $derived(
    (basefare + distance * perKm + duration * perMin) * surgeMultiplier
  );
</script>

<p>Fare: ${fare.toFixed(2)}</p>

Every time the driver moves or the surge multiplier updates, fare recalculates automatically. You can read fare in your template or pass it to child components without worrying about staleness.

$effect — Side effects when dependencies change

The $effect rune runs a function whenever its tracked dependencies change. Use it to sync state with external systems: API calls, local storage, analytics, or third-party libraries.

typescript
<script lang="ts">
  let query = $state("");
  let results = $state([]);
  let isLoading = $state(false);

  $effect(() => {
    if (query.length < 2) {
      results = [];
      return;
    }

    isLoading = true;
    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then(r => r.json())
      .then(data => {
        results = data;
        isLoading = false;
      });
  });
</script>

<input bind:value={query} placeholder="Search..." />
{#if isLoading}
  <p>Loading...</p>
{:else}
  <ul>
    {#each results as result}
      <li>{result.name}</li>
    {/each}
  </ul>
{/if}

Svelte automatically tracks which state variables the effect function reads. If query changes, the effect runs. If query doesn't change, the effect doesn't run — avoiding redundant API calls.

Cleanup with effects: Return a cleanup function to cancel requests or unsubscribe from listeners:

typescript
$effect(() => {
  const controller = new AbortController();

  fetch(`/api/data`, { signal: controller.signal })
    .then(r => r.json())
    .then(data => { /* ... */ });

  return () => controller.abort(); // Cleanup runs before next effect or unmount
});

This prevents memory leaks and ensures old requests don't overwrite newer results.

$props — Declare component props

The $props rune replaces the older export let syntax. It provides better TypeScript inference and a clearer intent: this component accepts input from its parent.

typescript
<!-- Button.svelte -->
<script lang="ts">
  interface Props {
    label: string;
    disabled?: boolean;
    onclick?: () => void;
  }

  let { label, disabled = false, onclick } = $props() as Props;
</script>

<button {disabled} onclick={onclick}>
  {label}
</button>

In the parent component:

typescript
<script lang="ts">
  import Button from "./Button.svelte";
  let isSubmitting = $state(false);
</script>

<Button
  label="Submit"
  disabled={isSubmitting}
  onclick={() => { isSubmitting = true; }}
/>

Using $props() with destructuring is cleaner than multiple export let statements and works seamlessly with TypeScript interfaces. The compiler understands your prop contract and will warn if you pass the wrong types.

Default values and optional props: You can set defaults inline, as we did with disabled = false, or use nullish coalescing for more complex logic.

Common mistakes

  • Forgetting that $derived is not a function: Writing let total = $derived.by(() => { ... }) is correct if you need a block, but $derived(...) works fine for simple expressions. Don't overthink it.
  • Creating effects without cleanup: If your effect subscribes to an event listener or starts a timer, it will leak memory. Always return a cleanup function.
  • Over-using effects: Derived values are often better than effects. Use $derived first; reach for $effect only when you need to interact with external systems.
  • Mutating props directly: Props are meant to be read. If you need to modify a prop, create a local $state variable and initialize it from the prop. Use an effect to sync changes back if needed.
  • Accessing props outside a component file: Props only work inside <script> blocks of Svelte components. You can't use $props in JavaScript files.

Key takeaways

  • $state creates reactive variables; mutations trigger re-renders automatically.
  • $derived computes values from state and recalculates efficiently when dependencies change.
  • $effect runs side effects when dependencies change; always return a cleanup function for subscriptions or timers.
  • $props declares component inputs explicitly with full TypeScript support.
  • Runes make reactivity explicit and give the compiler better information for optimization.
  • Prefer $derived over $effect whenever possible — effects are for side effects, not computation.
  • In team codebases, runes reduce ambiguity about what triggers updates and what data flows where.
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